@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/dist/lint.js CHANGED
@@ -1,6 +1,105 @@
1
+ import { SDK_CANONICAL_ERROR_CODE_RETRYABILITY, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "./error-resolution.js";
2
+ import { APIFUSE_HANDLE_META_KEY, handleFieldDescription, handleFieldNameFor, handleHasDirectionalFieldNames, handleIssuerList, isHandleFieldMeta, isHandleIssuedBy, } from "./handle-meta.js";
3
+ import { PROVIDER_ERROR_CATALOG_NAMESPACE } from "./i18n/error-messages.js";
4
+ import { getProviderLocaleSegments, isProviderLocaleKey, } from "./i18n/keys.js";
1
5
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
6
+ import { isStealthOwnedHeaderName, SDK_OWNED_CHROME_HEADER_PREFIX, } from "./runtime/stealth-owned-headers.js";
7
+ import { lintRuntimeBoundary,
8
+ // This module already has a narrower TEST_SOURCE_FILE_PATTERN of its own,
9
+ // used by the older source rules; it does not cover a plain `tests/`
10
+ // directory. Aliased rather than merged because consolidating the two would
11
+ // silently change what those rules scan.
12
+ TEST_SOURCE_FILE_PATTERN as SHARED_TEST_SOURCE_FILE_PATTERN, scriptKindForSourceFile, } from "./runtime-boundary-lint.js";
2
13
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
14
+ import { getTypeScript } from "./typescript-module.js";
15
+ const CREDENTIAL_BEARING_AUTH_MODES = [
16
+ "credentials",
17
+ "oauth2",
18
+ "oauth2_proxied",
19
+ ];
20
+ function isCredentialBearingAuthMode(mode) {
21
+ return CREDENTIAL_BEARING_AUTH_MODES.some((credentialMode) => credentialMode === mode);
22
+ }
23
+ function defaultApprovalPolicy(riskClass) {
24
+ if (riskClass === "read")
25
+ return "never";
26
+ if (riskClass === "write")
27
+ return "risk-based";
28
+ return "always";
29
+ }
30
+ // Operations that perform an auth-lifecycle action belong on the single
31
+ // `auth.flow` interface, never on a provider operation:
32
+ // - entry (login / signin / authenticate) => auth.flow.start/continue
33
+ // - exit (logout / signout / disconnect) => auth.flow.abort
34
+ //
35
+ // Matching works on `-`/`_` separated segments rather than a raw substring or a
36
+ // leading anchor, so `shop-logout`, `shop_logout` and `user-sign-out-everywhere`
37
+ // are all recognised: a domain prefix does not make the operation any less of an
38
+ // auth-lifecycle action, and operation ids may use either separator.
39
+ //
40
+ // Vocabulary is split into two tiers because auth words collide with ordinary
41
+ // domain verbs. Measured against the live fleet plus synthetic domain ids:
42
+ // - `authorize-payment`, `revoke-invitation`, `unlink-record`,
43
+ // `disconnect-device` are domain actions that never touch the connection
44
+ // credential, so these verbs are NOT matched as segments;
45
+ // - the same verbs as a complete operation id (`authorize`, `revoke`) do
46
+ // refer to the credential itself, so they are matched only in that form.
47
+ // `exchange`, `callback`, `connect`, `session`, `token`, `credential`,
48
+ // `password` and `otp` stay out entirely for the same reason.
49
+ const AUTH_LIFECYCLE_SEGMENT_WORDS = new Set([
50
+ "login",
51
+ "logout",
52
+ "signin",
53
+ "signout",
54
+ "signup",
55
+ "authenticate",
56
+ "reauth",
57
+ "auth",
58
+ ]);
59
+ // Ambiguous as a prefix, unambiguous when they are the whole operation id.
60
+ const AUTH_LIFECYCLE_WHOLE_ID_WORDS = new Set(["authorize", "revoke", "unlink", "disconnect"]);
61
+ // A verb stem followed by a direction word across two segments: `sign-out`,
62
+ // `user_sign_up_flow`, and the spelled-out `log-in` / `shop-log-out` forms
63
+ // (their fused equivalents `login`/`logout` live in the segment set above).
64
+ // `sign` pairs match anywhere; `log` pairs match only at the END of the id,
65
+ // because mid-id `log` is the noun in domain phrases measured against real
66
+ // fleets (`audit-log-in-range`, `change-log-out-of-band` are reads of a log,
67
+ // while `shop-log-out` is a logout).
68
+ const AUTH_DIRECTION_PAIRS = new Map([
69
+ ["sign", { directions: new Set(["in", "out", "up"]), endOnly: false }],
70
+ ["log", { directions: new Set(["in", "out"]), endOnly: true }],
71
+ ]);
72
+ // Legacy anchored form kept for token-plumbing words whose bare use is only
73
+ // auth-related when it leads the operation id (`exchange-code`, `refresh`).
3
74
  const AUTH_OPERATION_ID_PATTERN = /^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
75
+ function isAuthLifecycleOperationId(operationId, authMode) {
76
+ const segments = operationId.toLowerCase().split(/[-_]+/).filter(Boolean);
77
+ if (segments.some((segment) => AUTH_LIFECYCLE_SEGMENT_WORDS.has(segment)))
78
+ return true;
79
+ // A verb stem + direction spread across two segments (`sign-out`,
80
+ // `sign_up`, `shop-log-out`); see AUTH_DIRECTION_PAIRS for positioning.
81
+ if (segments.some((segment, index) => {
82
+ const pair = AUTH_DIRECTION_PAIRS.get(segment);
83
+ if (pair === undefined || index + 1 >= segments.length)
84
+ return false;
85
+ if (!pair.directions.has(segments[index + 1]))
86
+ return false;
87
+ return pair.endOnly ? index + 2 === segments.length : true;
88
+ })) {
89
+ return true;
90
+ }
91
+ if (segments.length === 1 && AUTH_LIFECYCLE_WHOLE_ID_WORDS.has(segments[0])) {
92
+ return true;
93
+ }
94
+ // The legacy anchored pattern keeps its original scope. It matches ordinary
95
+ // domain ids such as `exchange-rates` and `refresh-catalog`, so extending it
96
+ // to `oauth2_proxied` would spread that behavior to providers it never
97
+ // applied to; proxied providers are covered by the segment tiers above.
98
+ if (authMode === "credentials" || authMode === "oauth2") {
99
+ return AUTH_OPERATION_ID_PATTERN.test(operationId);
100
+ }
101
+ return false;
102
+ }
4
103
  function lintAllowedHosts(providerId, allowedHosts) {
5
104
  const prefix = providerId ? `Provider "${providerId}"` : "Provider";
6
105
  if (!allowedHosts) {
@@ -143,7 +242,7 @@ function lintAuthModel(provider) {
143
242
  rule: "platform-managed-no-credential-keys",
144
243
  level: "error",
145
244
  field: "credential.keys",
146
- message: `${providerLabel} must not declare credential.keys for platform-managed auth mode.`,
245
+ message: `${providerLabel} must not declare credential.keys because platform-managed auth mode already implies the unfiltered credential capability.`,
147
246
  });
148
247
  }
149
248
  const authFlowSource = getAuthFlowSource(provider);
@@ -201,15 +300,18 @@ function getObjectShape(schema) {
201
300
  }
202
301
  function getChildSchemas(schema) {
203
302
  const seen = new Map();
303
+ const origins = new Map();
204
304
  const def = getSchemaDef(schema);
205
- const add = (key, value) => {
305
+ const add = (key, value, origin = "structural") => {
206
306
  if (!isSchema(value)) {
207
307
  return;
208
308
  }
209
- seen.set(`${key}:${seen.size}`, value);
309
+ const id = `${key}:${seen.size}`;
310
+ seen.set(id, value);
311
+ origins.set(id, origin);
210
312
  };
211
313
  for (const [key, value] of Object.entries(getObjectShape(schema))) {
212
- add(key, value);
314
+ add(key, value, "shape");
213
315
  }
214
316
  add("element", schema.element);
215
317
  add("innerType", schema.innerType);
@@ -249,6 +351,9 @@ function getChildSchemas(schema) {
249
351
  "schema",
250
352
  "innerType",
251
353
  "type",
354
+ // zod v4 intersections keep their branches on the definition.
355
+ "left",
356
+ "right",
252
357
  "valueType",
253
358
  "keyType",
254
359
  "item",
@@ -272,22 +377,34 @@ function getChildSchemas(schema) {
272
377
  return Array.from(seen.entries()).map(([entryKey, child]) => ({
273
378
  key: entryKey.split(":")[0] ?? entryKey,
274
379
  schema: child,
380
+ origin: origins.get(entryKey) ?? "structural",
275
381
  }));
276
382
  }
277
383
  function uniqueFields(fields) {
278
384
  return Array.from(new Set(fields));
279
385
  }
280
- function isSensitiveSchema(schema) {
281
- if (!schema || typeof schema !== "object" || !("meta" in schema)) {
282
- return false;
386
+ function readSensitivityDeclaration(schema, seen = new Set()) {
387
+ // Declarations sit on the leaf; same-path wrappers such as .optional(),
388
+ // .nullable(), .pipe() or z.lazy() carry no metadata of their own, so look
389
+ // through them.
390
+ if (!isSchema(schema) || seen.has(schema))
391
+ return undefined;
392
+ seen.add(schema);
393
+ const value = Reflect.get(getSchemaMetadata(schema), APIFUSE_SENSITIVE_META_KEY);
394
+ if (typeof value === "boolean")
395
+ return value;
396
+ const def = getSchemaDef(schema);
397
+ const children = def.type === "pipe"
398
+ ? [def.in, def.out]
399
+ : def.type === "lazy" && typeof def.getter === "function"
400
+ ? [def.getter()]
401
+ : [def.innerType];
402
+ for (const child of children) {
403
+ const declaration = readSensitivityDeclaration(child, seen);
404
+ if (declaration !== undefined)
405
+ return declaration;
283
406
  }
284
- const meta = schema.meta;
285
- if (typeof meta !== "function")
286
- return false;
287
- const metadata = meta.call(schema);
288
- return (!!metadata &&
289
- typeof metadata === "object" &&
290
- Reflect.get(metadata, APIFUSE_SENSITIVE_META_KEY) === true);
407
+ return undefined;
291
408
  }
292
409
  function getSchemaMetadata(schema) {
293
410
  return schema.meta?.() ?? {};
@@ -313,9 +430,91 @@ const SENSITIVE_FIELD_NAMES = new Set([
313
430
  "phonenumber",
314
431
  "paymenturl",
315
432
  ]);
433
+ // Only phone-shaped keys can hold reviewed public data (facility phones); the
434
+ // rest are credentials, which a public declaration must not launder.
435
+ const PUBLIC_DECLARABLE_FIELD_NAMES = new Set(["phone", "phonenumber"]);
436
+ function normalizeFieldName(name) {
437
+ return name.toLowerCase().replace(/[-_\s]/g, "");
438
+ }
316
439
  function isSensitiveFieldName(name) {
317
- const normalized = name.toLowerCase().replace(/[-_\s]/g, "");
318
- return SENSITIVE_FIELD_NAMES.has(normalized);
440
+ return SENSITIVE_FIELD_NAMES.has(normalizeFieldName(name));
441
+ }
442
+ function isDeclaredSensitiveField(key, schema) {
443
+ const declaration = readSensitivityDeclaration(schema);
444
+ return (declaration === true ||
445
+ (declaration === false && PUBLIC_DECLARABLE_FIELD_NAMES.has(normalizeFieldName(key))));
446
+ }
447
+ // Child keys produced by getChildSchemas() that wrap a schema without adding a
448
+ // property path segment (optional/nullable/default/pipe/effects/etc.).
449
+ const SCHEMA_WRAPPER_NODE_KEYS = [
450
+ "unwrap",
451
+ "innerType",
452
+ "sourceType",
453
+ "schema",
454
+ "type",
455
+ "in",
456
+ "out",
457
+ "option",
458
+ "pipe",
459
+ "payload",
460
+ "item",
461
+ "rest",
462
+ "catchall",
463
+ "keyType",
464
+ "valueType",
465
+ // zod v4 intersection branches: transparent, the enclosing property key applies.
466
+ "left",
467
+ "right",
468
+ ];
469
+ function isSchemaWrapperNodeKey(key) {
470
+ return SCHEMA_WRAPPER_NODE_KEYS.includes(key) || key.startsWith("pipe.");
471
+ }
472
+ function getOwnHandleFieldMeta(schema) {
473
+ const value = Reflect.get(getSchemaMetadata(schema), APIFUSE_HANDLE_META_KEY);
474
+ return isHandleFieldMeta(value) ? value : undefined;
475
+ }
476
+ /**
477
+ * Handle meta placed by `kind.field()` lives on the string schema itself; zod
478
+ * wrappers (`.optional()`, `.nullable()`, `.default()`, pipes) do not inherit
479
+ * registry metadata, so look through wrapper nodes until a non-wrapper is hit.
480
+ */
481
+ function getHandleFieldMeta(schema, seen = new Set()) {
482
+ if (!isSchema(schema) || seen.has(schema))
483
+ return undefined;
484
+ seen.add(schema);
485
+ const own = getOwnHandleFieldMeta(schema);
486
+ if (own)
487
+ return own;
488
+ for (const child of getChildSchemas(schema)) {
489
+ if (child.origin === "shape" || !isSchemaWrapperNodeKey(child.key))
490
+ continue;
491
+ const found = getHandleFieldMeta(child.schema, seen);
492
+ if (found)
493
+ return found;
494
+ }
495
+ return undefined;
496
+ }
497
+ /**
498
+ * True when `schema` is a handle field (directly or through zod wrappers) whose
499
+ * description is still the SDK-generated wording and no wrapper adds its own
500
+ * description.
501
+ */
502
+ function isSdkOwnedHandleField(schema, seen = new Set()) {
503
+ if (!isSchema(schema) || seen.has(schema))
504
+ return false;
505
+ seen.add(schema);
506
+ const own = getOwnHandleFieldMeta(schema);
507
+ if (own)
508
+ return schema.description === handleFieldDescription(own);
509
+ if (schema.description)
510
+ return false;
511
+ for (const child of getChildSchemas(schema)) {
512
+ if (child.origin === "shape" || !isSchemaWrapperNodeKey(child.key))
513
+ continue;
514
+ if (isSdkOwnedHandleField(child.schema, seen))
515
+ return true;
516
+ }
517
+ return false;
319
518
  }
320
519
  function collectUnmarkedSensitiveFields(schema, basePath, seen = new Set()) {
321
520
  if (!isSchema(schema) || seen.has(schema)) {
@@ -325,7 +524,7 @@ function collectUnmarkedSensitiveFields(schema, basePath, seen = new Set()) {
325
524
  const out = [];
326
525
  for (const [key, child] of Object.entries(getObjectShape(schema))) {
327
526
  const childPath = basePath ? `${basePath}.${key}` : key;
328
- if (isSensitiveFieldName(key) && !isSensitiveSchema(child)) {
527
+ if (isSensitiveFieldName(key) && !isDeclaredSensitiveField(key, child)) {
329
528
  out.push(childPath);
330
529
  }
331
530
  out.push(...collectUnmarkedSensitiveFields(child, childPath, seen));
@@ -333,23 +532,7 @@ function collectUnmarkedSensitiveFields(schema, basePath, seen = new Set()) {
333
532
  for (const child of getChildSchemas(schema)) {
334
533
  if (Object.hasOwn(getObjectShape(schema), child.key))
335
534
  continue;
336
- const isWrapperNode = [
337
- "unwrap",
338
- "innerType",
339
- "sourceType",
340
- "schema",
341
- "type",
342
- "in",
343
- "out",
344
- "option",
345
- "pipe",
346
- "payload",
347
- "item",
348
- "rest",
349
- "catchall",
350
- "keyType",
351
- "valueType",
352
- ].includes(child.key);
535
+ const isWrapperNode = SCHEMA_WRAPPER_NODE_KEYS.includes(child.key);
353
536
  const childPath = child.key === "element" || child.key.startsWith("element.")
354
537
  ? `${basePath}[]`
355
538
  : isWrapperNode || child.key.startsWith("pipe.")
@@ -369,6 +552,14 @@ function collectSchemaDescriptionKeyDiagnostics(schema, basePath, seen = new Set
369
552
  const diagnostics = [];
370
553
  const currentPath = basePath || "schema";
371
554
  const hasDescriptionKey = getSchemaDescriptionKey(schema) !== undefined;
555
+ // Handle fields (`kind.field()`, ADR-0012) carry SDK-owned wording via
556
+ // .describe() plus x-apifuse-handle meta; they count as described and are
557
+ // leaves, so neither this node nor its wrapper chain needs a describeKey.
558
+ // A provider override of that wording (`.describe("…")` on the field or on
559
+ // a wrapper) is provider prose again and stays subject to the rules below.
560
+ if (isSdkOwnedHandleField(schema)) {
561
+ return diagnostics;
562
+ }
372
563
  if (schema.description && !hasDescriptionKey) {
373
564
  diagnostics.push({
374
565
  rule: "schema-description-raw-prose",
@@ -388,23 +579,7 @@ function collectSchemaDescriptionKeyDiagnostics(schema, basePath, seen = new Set
388
579
  });
389
580
  }
390
581
  for (const child of getChildSchemas(schema)) {
391
- const isWrapperNode = [
392
- "unwrap",
393
- "innerType",
394
- "sourceType",
395
- "schema",
396
- "type",
397
- "in",
398
- "out",
399
- "option",
400
- "pipe",
401
- "payload",
402
- "item",
403
- "rest",
404
- "catchall",
405
- "keyType",
406
- "valueType",
407
- ].includes(child.key);
582
+ const isWrapperNode = SCHEMA_WRAPPER_NODE_KEYS.includes(child.key);
408
583
  const isStructuralNode = isWrapperNode ||
409
584
  child.key.startsWith("pipe.") ||
410
585
  child.key === "element" ||
@@ -422,18 +597,6 @@ function collectSchemaDescriptionKeyDiagnostics(schema, basePath, seen = new Set
422
597
  }
423
598
  return diagnostics;
424
599
  }
425
- function isComplexSchema(schema, seen = new Set()) {
426
- if (!isSchema(schema) || seen.has(schema)) {
427
- return false;
428
- }
429
- seen.add(schema);
430
- const children = getChildSchemas(schema);
431
- const hasNestedComposite = children.some(({ schema: child }) => {
432
- const childChildren = getChildSchemas(child);
433
- return childChildren.length > 0;
434
- });
435
- return hasNestedComposite || children.some(({ schema: child }) => isComplexSchema(child, seen));
436
- }
437
600
  function hasBidirectionalFixtures(fixtures) {
438
601
  if (!fixtures || typeof fixtures !== "object") {
439
602
  return true;
@@ -587,136 +750,2308 @@ function lintSelfHostedBrowserPatterns(provider, options) {
587
750
  }
588
751
  return diagnostics;
589
752
  }
590
- export function lintOperation(op) {
591
- const diagnostics = [];
592
- const description = op.description ?? "";
593
- const hasDescriptionKey = typeof op.descriptionKey === "string" && op.descriptionKey.length > 0;
594
- if (description.trim().length > 0 && !hasDescriptionKey) {
595
- diagnostics.push({
596
- rule: "operation-description-raw-prose",
597
- level: "error",
598
- field: "description",
599
- message: "Operation description must use descriptionKey instead of raw static prose.",
600
- });
753
+ // Captures the constructor name so each rule can pick its own scope: the
754
+ // undeclared-code rule keeps its historical ProviderError/ValidationError
755
+ // surface, while the localization rules also cover AuthError.
756
+ const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(ProviderError|AuthError|ValidationError)\s*\(/g;
757
+ const UNDECLARED_CODE_ERROR_CLASSES = new Set([
758
+ "ProviderError",
759
+ "ValidationError",
760
+ ]);
761
+ const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
762
+ const RECORDED_FIXTURE_SOURCE_FILE_PATTERN = /(?:^|\/)__fixtures__(?:\/|$)|(?:^|\/)__tests__\/fixtures(?:\/|$)/;
763
+ const JAVASCRIPT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
764
+ const VERSIONED_PROFILE_LITERAL_PATTERN = /\b(?:chrome|chromium|firefox|safari|edge|opera|ios[-_]safari)[-_]\d+(?:[._-]\d+)*(?=$|[^A-Za-z0-9])/i;
765
+ const VERSIONED_USER_AGENT_PATTERN = /\b(?:Chrome|CriOS|Firefox|FxiOS|EdgA?|OPR)\/\d+(?:\.\d+)*/i;
766
+ const VERSIONED_SAFARI_USER_AGENT_PATTERN = /\bVersion\/(\d+(?:\.\d+)*)(?=[\s\S]*\bSafari\/\d)/i;
767
+ const VERSIONED_CLIENT_HINT_PATTERN = /(?:^|[;,\s])v\s*=\s*["']?\d+/i;
768
+ // Calls that write a header entry as (name, value): Headers/Map `set` and
769
+ // `append`, Node's `setHeader`. Predicates, replacements, and logging calls
770
+ // take string arguments too and must not count as header writes.
771
+ const HEADER_SETTER_CALLEE_PATTERN = /^(?:set|append|setHeader|addHeader)$/;
772
+ const OWNED_CLIENT_HINT_HEADER_PREFIX = "sec-ch-ua";
773
+ function staticStringText(node) {
774
+ if (!node)
775
+ return undefined;
776
+ const ts = getTypeScript();
777
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
778
+ return node.text;
779
+ return undefined;
780
+ }
781
+ function staticPropertyName(node) {
782
+ const ts = getTypeScript();
783
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) {
784
+ return node.text;
601
785
  }
602
- if (!hasDescriptionKey && description.length < 150) {
603
- diagnostics.push({
604
- rule: "description-min-length",
605
- level: "error",
606
- field: "description",
607
- message: "Operation description must be at least 150 characters.",
608
- });
786
+ if (ts.isComputedPropertyName(node))
787
+ return staticStringText(node.expression);
788
+ return undefined;
789
+ }
790
+ function isSecChUaHeaderName(value) {
791
+ return value?.toLowerCase() === "sec-ch-ua";
792
+ }
793
+ /**
794
+ * True for the code paths that reach the stealth transport: `ctx.stealth`,
795
+ * `ctx["stealth"]`, and `const { stealth } = ctx`. Read from the AST so a
796
+ * comment or string that mentions ctx.stealth does not scope a ctx.http file
797
+ * into this rule.
798
+ */
799
+ function isStealthContextAccess(node) {
800
+ const ts = getTypeScript();
801
+ if (ts.isPropertyAccessExpression(node)) {
802
+ return (ts.isIdentifier(node.expression) &&
803
+ node.expression.text === "ctx" &&
804
+ node.name.text === "stealth");
609
805
  }
610
- if ((op.whenToUse?.length ?? 0) > 0 && !(op.whenToUseKeys?.length ?? 0)) {
611
- diagnostics.push({
612
- rule: "operation-when-to-use-raw-prose",
613
- level: "error",
614
- field: "whenToUse",
615
- message: "Operation whenToUse must use whenToUseKeys instead of raw static prose.",
616
- });
806
+ if (ts.isElementAccessExpression(node)) {
807
+ return (ts.isIdentifier(node.expression) &&
808
+ node.expression.text === "ctx" &&
809
+ staticStringText(node.argumentExpression) === "stealth");
617
810
  }
618
- if ((op.whenNotToUse?.length ?? 0) > 0 && !(op.whenNotToUseKeys?.length ?? 0)) {
619
- diagnostics.push({
620
- rule: "operation-when-not-to-use-raw-prose",
621
- level: "error",
622
- field: "whenNotToUse",
623
- message: "Operation whenNotToUse must use whenNotToUseKeys instead of raw static prose.",
624
- });
811
+ if (ts.isBindingElement(node)) {
812
+ const key = node.propertyName ?? node.name;
813
+ if (!ts.isIdentifier(key) || key.text !== "stealth")
814
+ return false;
815
+ const declaration = node.parent?.parent;
816
+ return (declaration !== undefined &&
817
+ ts.isVariableDeclaration(declaration) &&
818
+ declaration.initializer !== undefined &&
819
+ ts.isIdentifier(declaration.initializer) &&
820
+ declaration.initializer.text === "ctx");
625
821
  }
626
- const lowerDescription = description.toLowerCase();
627
- if (!hasDescriptionKey &&
628
- !(lowerDescription.includes("use") && lowerDescription.includes("when"))) {
629
- diagnostics.push({
630
- rule: "description-has-when-clause",
631
- level: "warn",
632
- field: "description",
633
- message: 'Operation description should include both "use" and "when".',
634
- });
822
+ return false;
823
+ }
824
+ function isTupleTypeNode(type) {
825
+ if (!type)
826
+ return false;
827
+ const ts = getTypeScript();
828
+ if (ts.isTypeOperatorNode(type))
829
+ return isTupleTypeNode(type.type);
830
+ return ts.isTupleTypeNode(type);
831
+ }
832
+ /**
833
+ * True where a two-element array is consumed as a `[name, value]` header entry:
834
+ * inside an entries list (`new Headers([[name, value]])`,
835
+ * `Object.fromEntries([...])`), passed to a call such as `tuples.push(...)`, or
836
+ * declared with a tuple type. A two-name list is bound, iterated, or handed to
837
+ * a constructor instead — `const STRIP = ["sec-fetch-dest", "referer"]`,
838
+ * `for (const name of [...])`, `new Set([...])` — and is not an entry. The
839
+ * position decides because a Sec-Fetch-* value ("document", "navigate") is
840
+ * token-shaped exactly like a header name.
841
+ */
842
+ function isHeaderEntryPosition(node) {
843
+ const ts = getTypeScript();
844
+ let child = node;
845
+ let parent = node.parent;
846
+ while (parent &&
847
+ (ts.isParenthesizedExpression(parent) ||
848
+ ts.isAsExpression(parent) ||
849
+ ts.isSatisfiesExpression(parent) ||
850
+ ts.isTypeAssertionExpression(parent))) {
851
+ if (!ts.isParenthesizedExpression(parent) && isTupleTypeNode(parent.type))
852
+ return true;
853
+ child = parent;
854
+ parent = parent.parent;
635
855
  }
636
- diagnostics.push(...collectSchemaDescriptionKeyDiagnostics(op.input, "input"), ...collectSchemaDescriptionKeyDiagnostics(op.output, "output"));
637
- if (!hasBidirectionalFixtures(op.fixtures)) {
638
- diagnostics.push({
639
- rule: "fixtures-both-directions",
640
- level: "error",
641
- field: "fixtures",
642
- message: "Fixtures must include both request and response.",
643
- });
856
+ if (!parent)
857
+ return false;
858
+ if (ts.isArrayLiteralExpression(parent))
859
+ return true;
860
+ if (ts.isCallExpression(parent))
861
+ return parent.arguments.some((argument) => argument === child);
862
+ if (ts.isVariableDeclaration(parent)) {
863
+ return parent.initializer === child && isTupleTypeNode(parent.type);
644
864
  }
645
- if (isComplexSchema(op.input) && (op.inputExamples?.length ?? 0) < 2) {
646
- diagnostics.push({
647
- rule: "complex-input-has-examples",
648
- level: "warn",
649
- field: "inputExamples",
650
- message: "Complex input schemas should provide at least 2 input examples.",
651
- });
865
+ return false;
866
+ }
867
+ function isStaticUndefined(node) {
868
+ if (!node)
869
+ return false;
870
+ const ts = getTypeScript();
871
+ return (ts.isIdentifier(node) && node.text === "undefined") || ts.isVoidExpression(node);
872
+ }
873
+ function calleeName(node) {
874
+ const ts = getTypeScript();
875
+ if (ts.isPropertyAccessExpression(node.expression))
876
+ return node.expression.name.text;
877
+ if (ts.isIdentifier(node.expression))
878
+ return node.expression.text;
879
+ return undefined;
880
+ }
881
+ /**
882
+ * Header names this rule reports in ctx.stealth files: the Sec-Fetch-* and
883
+ * client-hint (sec-ch-ua*) families, restricted to what the stealth runtime
884
+ * really rejects (isStealthOwnedHeaderName). Deliberately narrower than the
885
+ * runtime rejection: host, connection, and accept-encoding are left alone
886
+ * because ctx.http callers set them legitimately in the same files, and
887
+ * user-agent only reaches a finding through the "user-agent" kind, which
888
+ * matches a versioned literal value rather than the name.
889
+ *
890
+ * This rule is the early, same-file signal only. The complete check is the
891
+ * offline test harness: `runStandardTests` runs every `ctx.stealth` request
892
+ * through the runtime's own `assertCallerHeadersSupported`, so a header spread
893
+ * from another module, or a user-agent read from a variable or
894
+ * `getStealthProfile(...)`, fails the provider's tests exactly as it would fail
895
+ * in production.
896
+ */
897
+ function isReportedOwnedHeaderName(value) {
898
+ if (value === undefined)
899
+ return false;
900
+ const name = value.toLowerCase();
901
+ if (!isStealthOwnedHeaderName(name))
902
+ return false;
903
+ if (name.startsWith(SDK_OWNED_CHROME_HEADER_PREFIX)) {
904
+ return name.length > SDK_OWNED_CHROME_HEADER_PREFIX.length;
652
905
  }
653
- for (const field of uniqueFields(collectUnmarkedSensitiveFields(op.input, "input"))) {
654
- diagnostics.push({
655
- rule: "sensitive-field-unmarked",
656
- level: "warn",
657
- field,
658
- message: `Schema field "${field}" looks sensitive; mark it with fields.*(), field(..., { sensitive: true }), or sensitive(...).`,
659
- });
906
+ return name.startsWith(OWNED_CLIENT_HINT_HEADER_PREFIX);
907
+ }
908
+ function collectBrowserVersionLiteralFindings(fileName, source) {
909
+ const ts = getTypeScript();
910
+ const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindForSourceFile(ts, fileName));
911
+ let findings = [];
912
+ const seen = new Set();
913
+ // Owned-header entries only count once the file is known to reach
914
+ // ctx.stealth, which may appear after the entry; collect them and decide
915
+ // after the walk.
916
+ let stealthAccess = false;
917
+ const ownedHeaderEntries = [];
918
+ const addFinding = (kind, literal, position) => {
919
+ const key = `${kind}:${position}:${literal}`;
920
+ if (seen.has(key))
921
+ return;
922
+ seen.add(key);
923
+ findings.push({ kind, literal, position });
924
+ };
925
+ const inspectLiteral = (text, position) => {
926
+ const profile = text.match(VERSIONED_PROFILE_LITERAL_PATTERN)?.[0];
927
+ if (profile)
928
+ addFinding("profile", profile, position);
929
+ const userAgent = text.match(VERSIONED_USER_AGENT_PATTERN)?.[0] ??
930
+ text.match(VERSIONED_SAFARI_USER_AGENT_PATTERN)?.[0];
931
+ if (userAgent)
932
+ addFinding("user-agent", userAgent, position);
933
+ };
934
+ const inspectSecChUaValue = (node) => {
935
+ const text = staticStringText(node);
936
+ if (text && VERSIONED_CLIENT_HINT_PATTERN.test(text)) {
937
+ addFinding("sec-ch-ua", "sec-ch-ua", node.getStart(sourceFile));
938
+ }
939
+ };
940
+ // A header entry `name: value` in any of the shapes below. In a ctx.stealth
941
+ // file an owned name is the finding (the runtime rejects it whatever the
942
+ // value); elsewhere only a versioned sec-ch-ua value is. A statically
943
+ // undefined value is exempt only in the "record" shapes, the ones the
944
+ // runtime drops before the ownership check (`normalizedHeaderEntries`):
945
+ // `Headers.set(name, undefined)` stringifies to "undefined" and still
946
+ // throws, so it stays a finding.
947
+ const inspectHeaderEntry = (nameNode, name, valueNode, ownedHeaderShape) => {
948
+ if (name === undefined)
949
+ return;
950
+ if (ownedHeaderShape !== false &&
951
+ isReportedOwnedHeaderName(name) &&
952
+ !(ownedHeaderShape === "record" && isStaticUndefined(valueNode))) {
953
+ ownedHeaderEntries.push({
954
+ name,
955
+ position: nameNode.getStart(sourceFile),
956
+ valuePosition: valueNode?.getStart(sourceFile),
957
+ });
958
+ }
959
+ if (isSecChUaHeaderName(name))
960
+ inspectSecChUaValue(valueNode);
961
+ };
962
+ const visit = (node) => {
963
+ const text = staticStringText(node);
964
+ if (text !== undefined)
965
+ inspectLiteral(text, node.getStart(sourceFile));
966
+ if (isStealthContextAccess(node))
967
+ stealthAccess = true;
968
+ if (ts.isPropertyAssignment(node)) {
969
+ inspectHeaderEntry(node.name, staticPropertyName(node.name), node.initializer, "record");
970
+ }
971
+ if (ts.isCallExpression(node) && node.arguments[0]) {
972
+ // Only `headers.set(name, value)`-style calls write a header;
973
+ // `name.startsWith("sec-fetch-")` or `log("sec-fetch-dest", x)` do not.
974
+ inspectHeaderEntry(node.arguments[0], staticStringText(node.arguments[0]), node.arguments[1], node.arguments.length >= 2 && HEADER_SETTER_CALLEE_PATTERN.test(calleeName(node) ?? "")
975
+ ? "entry"
976
+ : false);
977
+ }
978
+ if (ts.isArrayLiteralExpression(node) && node.elements[0]) {
979
+ // Only a `[name, value]` tuple is a header: a two-element array in an
980
+ // entry position (isHeaderEntryPosition) whose second element is not
981
+ // itself an owned name.
982
+ const second = node.elements[1];
983
+ inspectHeaderEntry(node.elements[0], staticStringText(node.elements[0]), second, node.elements.length === 2 &&
984
+ isHeaderEntryPosition(node) &&
985
+ !isStealthOwnedHeaderName(staticStringText(second) ?? "")
986
+ ? "entry"
987
+ : false);
988
+ }
989
+ if (ts.isBinaryExpression(node) &&
990
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
991
+ ts.isElementAccessExpression(node.left)) {
992
+ const nameNode = node.left.argumentExpression;
993
+ inspectHeaderEntry(nameNode, staticStringText(nameNode), node.right, "record");
994
+ }
995
+ ts.forEachChild(node, visit);
996
+ };
997
+ visit(sourceFile);
998
+ if (stealthAccess) {
999
+ // The owned name is the finding; do not also report its sec-ch-ua value.
1000
+ const shadowedValues = new Set(ownedHeaderEntries.map((entry) => entry.valuePosition));
1001
+ findings = findings.filter((finding) => !(finding.kind === "sec-ch-ua" && shadowedValues.has(finding.position)));
1002
+ for (const entry of ownedHeaderEntries) {
1003
+ addFinding("owned-header", entry.name, entry.position);
1004
+ }
660
1005
  }
661
- for (const field of uniqueFields(collectUnmarkedSensitiveFields(op.output, "output"))) {
662
- diagnostics.push({
663
- rule: "sensitive-field-unmarked",
664
- level: "warn",
665
- field,
666
- message: `Schema field "${field}" looks sensitive; mark it with fields.*(), field(..., { sensitive: true }), or sensitive(...).`,
667
- });
1006
+ return findings.sort((left, right) => left.position - right.position);
1007
+ }
1008
+ function browserVersionLiteralMessage(finding) {
1009
+ switch (finding.kind) {
1010
+ case "profile":
1011
+ 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" }.`;
1012
+ case "user-agent":
1013
+ 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.`;
1014
+ case "sec-ch-ua":
1015
+ 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.';
1016
+ case "owned-header":
1017
+ 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.`;
1018
+ }
1019
+ }
1020
+ function lintBrowserVersionLiterals(provider) {
1021
+ const sources = [];
1022
+ const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(([filePath]) => JAVASCRIPT_SOURCE_FILE_PATTERN.test(filePath) &&
1023
+ !TEST_SOURCE_FILE_PATTERN.test(filePath) &&
1024
+ !RECORDED_FIXTURE_SOURCE_FILE_PATTERN.test(filePath));
1025
+ if (sourceFiles.length > 0) {
1026
+ for (const [filePath, source] of sourceFiles) {
1027
+ sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
1028
+ }
1029
+ }
1030
+ else {
1031
+ if (provider.authFlowSource)
1032
+ sources.push({
1033
+ field: "auth.flow",
1034
+ fileName: "auth-flow.ts",
1035
+ source: provider.authFlowSource,
1036
+ });
1037
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1038
+ const source = getOperationSource(operation);
1039
+ if (source)
1040
+ sources.push({
1041
+ field: `operations.${operationKey}.handler`,
1042
+ fileName: `${operationKey}.ts`,
1043
+ source,
1044
+ });
1045
+ }
1046
+ }
1047
+ return sources.flatMap(({ field, fileName, source }) => collectBrowserVersionLiteralFindings(fileName, source).map((finding) => ({
1048
+ rule: "browser-version-literal",
1049
+ level: "error",
1050
+ field,
1051
+ message: browserVersionLiteralMessage(finding),
1052
+ })));
1053
+ }
1054
+ /**
1055
+ * Skips a string literal starting at `startIndex` (which must point at the
1056
+ * opening quote). Returns the index of the closing quote, or -1 when the
1057
+ * literal is unterminated. Template literals handle nested `${...}`
1058
+ * expressions, including strings inside them.
1059
+ */
1060
+ function skipStringLiteral(source, startIndex) {
1061
+ const quote = source[startIndex];
1062
+ for (let index = startIndex + 1; index < source.length; index++) {
1063
+ const char = source[index];
1064
+ if (char === "\\") {
1065
+ index++;
1066
+ continue;
1067
+ }
1068
+ if (quote === "`" && char === "$" && source[index + 1] === "{") {
1069
+ index = skipTemplateExpression(source, index + 2);
1070
+ if (index < 0) {
1071
+ return -1;
1072
+ }
1073
+ continue;
1074
+ }
1075
+ if (char === quote) {
1076
+ return index;
1077
+ }
1078
+ if (quote !== "`" && char === "\n") {
1079
+ return -1;
1080
+ }
1081
+ }
1082
+ return -1;
1083
+ }
1084
+ function skipTemplateExpression(source, startIndex) {
1085
+ let depth = 1;
1086
+ for (let index = startIndex; index < source.length; index++) {
1087
+ const char = source[index];
1088
+ if (char === '"' || char === "'" || char === "`") {
1089
+ index = skipStringLiteral(source, index);
1090
+ if (index < 0) {
1091
+ return -1;
1092
+ }
1093
+ continue;
1094
+ }
1095
+ if (char === "{") {
1096
+ depth++;
1097
+ }
1098
+ else if (char === "}") {
1099
+ depth--;
1100
+ if (depth === 0) {
1101
+ return index;
1102
+ }
1103
+ }
1104
+ }
1105
+ return -1;
1106
+ }
1107
+ /**
1108
+ * Extracts the argument text of a call whose opening paren has already been
1109
+ * consumed (`startIndex` points just past it). Returns undefined when the
1110
+ * call never closes in this source, which the caller treats as "skip
1111
+ * silently" — this scanner is conservative by design.
1112
+ */
1113
+ function extractBalancedCallArguments(source, startIndex) {
1114
+ let depth = 1;
1115
+ for (let index = startIndex; index < source.length; index++) {
1116
+ const char = source[index];
1117
+ if (char === '"' || char === "'" || char === "`") {
1118
+ index = skipStringLiteral(source, index);
1119
+ if (index < 0) {
1120
+ return undefined;
1121
+ }
1122
+ continue;
1123
+ }
1124
+ if (char === "/" && source[index + 1] === "/") {
1125
+ const newline = source.indexOf("\n", index);
1126
+ if (newline === -1) {
1127
+ return undefined;
1128
+ }
1129
+ index = newline;
1130
+ continue;
1131
+ }
1132
+ if (char === "/" && source[index + 1] === "*") {
1133
+ const end = source.indexOf("*/", index + 2);
1134
+ if (end === -1) {
1135
+ return undefined;
1136
+ }
1137
+ index = end + 1;
1138
+ continue;
1139
+ }
1140
+ if (char === "(") {
1141
+ depth++;
1142
+ }
1143
+ else if (char === ")") {
1144
+ depth--;
1145
+ if (depth === 0) {
1146
+ return source.slice(startIndex, index);
1147
+ }
1148
+ }
1149
+ }
1150
+ return undefined;
1151
+ }
1152
+ /**
1153
+ * Collects literal string values of the named top-level properties inside a
1154
+ * ProviderError/AuthError/ValidationError options object. Only plain `"..."` /
1155
+ * `'...'` literals at options-object depth count; computed values (identifiers,
1156
+ * ternaries, template substitutions, concatenations, escapes) are skipped
1157
+ * silently so the rules never guess. The first literal wins per property name.
1158
+ */
1159
+ function collectLiteralErrorOptionValues(args, optionNames) {
1160
+ const collected = {};
1161
+ let braceDepth = 0;
1162
+ let parenDepth = 0;
1163
+ let bracketDepth = 0;
1164
+ let previousSignificantChar = "";
1165
+ for (let index = 0; index < args.length; index++) {
1166
+ const char = args[index] ?? "";
1167
+ if (char === '"' || char === "'" || char === "`") {
1168
+ const end = skipStringLiteral(args, index);
1169
+ if (end < 0) {
1170
+ return collected;
1171
+ }
1172
+ index = end;
1173
+ previousSignificantChar = char;
1174
+ continue;
1175
+ }
1176
+ if (char === "/" && args[index + 1] === "/") {
1177
+ const newline = args.indexOf("\n", index);
1178
+ if (newline === -1) {
1179
+ return collected;
1180
+ }
1181
+ index = newline;
1182
+ continue;
1183
+ }
1184
+ if (char === "/" && args[index + 1] === "*") {
1185
+ const end = args.indexOf("*/", index + 2);
1186
+ if (end === -1) {
1187
+ return collected;
1188
+ }
1189
+ index = end + 1;
1190
+ continue;
1191
+ }
1192
+ if (/\s/.test(char)) {
1193
+ continue;
1194
+ }
1195
+ if (char === "{") {
1196
+ braceDepth++;
1197
+ }
1198
+ else if (char === "}") {
1199
+ braceDepth--;
1200
+ }
1201
+ else if (char === "(") {
1202
+ parenDepth++;
1203
+ }
1204
+ else if (char === ")") {
1205
+ parenDepth--;
1206
+ }
1207
+ else if (char === "[") {
1208
+ bracketDepth++;
1209
+ }
1210
+ else if (char === "]") {
1211
+ bracketDepth--;
1212
+ }
1213
+ else if (braceDepth === 1 &&
1214
+ parenDepth === 0 &&
1215
+ bracketDepth === 0 &&
1216
+ (previousSignificantChar === "{" || previousSignificantChar === ",")) {
1217
+ const optionName = optionNames.find((name) => startsWithOptionName(args, index, name));
1218
+ if (optionName === undefined) {
1219
+ previousSignificantChar = char;
1220
+ continue;
1221
+ }
1222
+ let cursor = index + optionName.length;
1223
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
1224
+ cursor++;
1225
+ }
1226
+ if (args[cursor] === ":") {
1227
+ cursor++;
1228
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
1229
+ cursor++;
1230
+ }
1231
+ const quote = args[cursor];
1232
+ if (quote === '"' || quote === "'") {
1233
+ const end = skipStringLiteral(args, cursor);
1234
+ if (end > cursor) {
1235
+ const value = args.slice(cursor + 1, end);
1236
+ let after = end + 1;
1237
+ while (after < args.length && /\s/.test(args[after] ?? "")) {
1238
+ after++;
1239
+ }
1240
+ const nextChar = after < args.length ? (args[after] ?? "") : "";
1241
+ if (!value.includes("\\") &&
1242
+ (nextChar === "," || nextChar === "}" || nextChar === "")) {
1243
+ collected[optionName] ??= value;
1244
+ }
1245
+ index = end;
1246
+ previousSignificantChar = quote;
1247
+ continue;
1248
+ }
1249
+ return collected;
1250
+ }
1251
+ }
1252
+ }
1253
+ previousSignificantChar = char;
1254
+ }
1255
+ return collected;
1256
+ }
1257
+ /** True when `name` starts at `index` and is not the prefix of a longer identifier. */
1258
+ function startsWithOptionName(args, index, name) {
1259
+ if (!args.startsWith(name, index))
1260
+ return false;
1261
+ const next = args[index + name.length] ?? "";
1262
+ return !/[A-Za-z0-9_$]/.test(next);
1263
+ }
1264
+ const THROWN_ERROR_LOCALE_OPTION_NAMES = ["code", "messageKey", "fixKey"];
1265
+ function collectThrownErrorConstructionSites(source) {
1266
+ const sites = [];
1267
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = 0;
1268
+ for (let match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source); match; match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source)) {
1269
+ const argsStart = match.index + match[0].length;
1270
+ const args = extractBalancedCallArguments(source, argsStart);
1271
+ if (args !== undefined) {
1272
+ const values = collectLiteralErrorOptionValues(args, THROWN_ERROR_LOCALE_OPTION_NAMES);
1273
+ sites.push({
1274
+ errorClass: match[1] ?? "",
1275
+ ...(values.code === undefined ? {} : { code: values.code }),
1276
+ ...(values.messageKey === undefined ? {} : { messageKey: values.messageKey }),
1277
+ ...(values.fixKey === undefined ? {} : { fixKey: values.fixKey }),
1278
+ });
1279
+ }
1280
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = argsStart;
1281
+ }
1282
+ return sites;
1283
+ }
1284
+ /**
1285
+ * The provider sources a throw-site rule scans, with the diagnostic field path
1286
+ * each one reports under. Prefers the CLI-collected source files (whole-repo
1287
+ * coverage) and falls back to the handler/auth-flow function text available
1288
+ * when `lintProvider` is called without them. Test sources are excluded.
1289
+ */
1290
+ function collectThrowSiteLintSources(provider) {
1291
+ const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(([filePath]) => !TEST_SOURCE_FILE_PATTERN.test(filePath));
1292
+ if (sourceFiles.length > 0) {
1293
+ return sourceFiles.map(([filePath, source]) => ({
1294
+ field: `sourceFiles.${filePath}`,
1295
+ source,
1296
+ }));
1297
+ }
1298
+ const sources = [];
1299
+ if (provider.authFlowSource) {
1300
+ sources.push({ field: "auth.flow", source: provider.authFlowSource });
1301
+ }
1302
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1303
+ const source = getOperationSource(operation);
1304
+ if (source) {
1305
+ sources.push({ field: `operations.${operationKey}.handler`, source });
1306
+ }
1307
+ }
1308
+ return sources;
1309
+ }
1310
+ /**
1311
+ * Static counterpart of the runtime `unregistered_provider_error_code`
1312
+ * signal (honest-provider-error-contract Phase 3.5.5): flags
1313
+ * `new ProviderError(...)` / `new ValidationError(...)` constructions whose
1314
+ * literal `code` is neither SDK-registered (SDK_RUNTIME_OWNED_ERROR_CODES
1315
+ * plus the canonical status-mapped codes shared with serve.ts toStatusCode)
1316
+ * nor declared in any operation's errorCodes. At runtime such a code
1317
+ * serves HTTP 500 and emits the signal; this rule surfaces it at check time.
1318
+ *
1319
+ * A throw site cannot be attributed to a specific operation statically —
1320
+ * providers routinely throw from helpers shared across operations — so this
1321
+ * rule matches against the provider-level union of declared codes. That is
1322
+ * the honest scope: it will not catch a code declared only on the "wrong"
1323
+ * operation, and it never claims per-operation attribution it cannot prove.
1324
+ * Only literal string codes are checked; computed/dynamic codes and test
1325
+ * sources are skipped silently. Warning level: the long tail of existing
1326
+ * providers converges gradually, so this must not fail `apifuse check`.
1327
+ */
1328
+ function lintUndeclaredThrownErrorCodes(provider) {
1329
+ const knownCodes = new Set([
1330
+ ...SDK_RUNTIME_OWNED_ERROR_CODES,
1331
+ ...SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.keys(),
1332
+ ]);
1333
+ for (const operation of Object.values(provider.operations ?? {})) {
1334
+ for (const entry of operation.errorCodes ?? []) {
1335
+ if (typeof entry?.code === "string") {
1336
+ knownCodes.add(entry.code);
1337
+ }
1338
+ }
1339
+ }
1340
+ const sources = collectThrowSiteLintSources(provider);
1341
+ const diagnostics = [];
1342
+ for (const { field, source } of sources) {
1343
+ const undeclaredCodes = new Set(collectThrownErrorConstructionSites(source)
1344
+ .filter((site) => UNDECLARED_CODE_ERROR_CLASSES.has(site.errorClass))
1345
+ .flatMap((site) => (site.code === undefined ? [] : [site.code]))
1346
+ .filter((code) => !knownCodes.has(code)));
1347
+ for (const code of undeclaredCodes) {
1348
+ diagnostics.push({
1349
+ rule: "thrown-error-code-undeclared",
1350
+ level: "warn",
1351
+ field,
1352
+ 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.`,
1353
+ });
1354
+ }
668
1355
  }
669
1356
  return diagnostics;
670
1357
  }
671
- export function lintProvider(provider, options = {}) {
672
- const diagnostics = [
673
- ...lintAllowedHosts(provider.id, provider.allowedHosts),
674
- ...lintReviewed(provider.id, provider.reviewed),
675
- ...lintAuthModel(provider),
676
- ...lintStealthTransportUsage(provider),
677
- ...lintCredentialWriteUsage(provider),
678
- ...lintPlaywrightDirectImports(provider),
679
- ...lintSelfHostedBrowserPatterns(provider, options),
680
- ];
681
- if (provider.operations) {
682
- const authMode = provider.auth?.mode;
683
- if (authMode === "credentials" || authMode === "oauth2") {
684
- for (const operationKey of Object.keys(provider.operations)) {
685
- if (AUTH_OPERATION_ID_PATTERN.test(operationKey)) {
1358
+ /**
1359
+ * Flags an operation `errorCodes` entry whose declared `status` or `retryable`
1360
+ * contradicts the SDK's canonical resolution for that code.
1361
+ *
1362
+ * For a code the SDK status-maps but does not runtime-own (UPSTREAM_ERROR,
1363
+ * BLOCKED, and the fleet-consensus codes registered alongside them), the
1364
+ * declaration still wins at runtime — `toStatusCode` reads the declaration
1365
+ * before the canonical map. So registering a code never silently rewrites an
1366
+ * existing declaration's status, and it must not: that would change a served
1367
+ * status behind the author's back. What it does create is a fleet that answers
1368
+ * one code two ways. This rule is where that divergence surfaces, by name, with
1369
+ * the canonical value to converge on.
1370
+ *
1371
+ * Codes the SDK runtime-owns are handled separately: `defineProvider` already
1372
+ * warns there, because for those the declaration really is ignored.
1373
+ *
1374
+ * Warning level, deliberately: the divergent providers are live and converge on
1375
+ * their own schedule, so this must not fail `apifuse check`.
1376
+ */
1377
+ function lintErrorCodeDeclarationConflicts(provider) {
1378
+ const diagnostics = [];
1379
+ const providerId = provider.id ?? "unknown";
1380
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1381
+ for (const [index, entry] of (operation.errorCodes ?? []).entries()) {
1382
+ if (typeof entry?.code !== "string")
1383
+ continue;
1384
+ // SDK-owned codes ignore the declaration outright; defineProvider owns
1385
+ // that message so authors are not told two different things.
1386
+ if (SDK_RUNTIME_OWNED_ERROR_CODES.has(entry.code))
1387
+ continue;
1388
+ const field = `operations.${operationKey}.errorCodes[${index}]`;
1389
+ const canonicalStatus = SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.get(entry.code);
1390
+ if (canonicalStatus !== undefined && entry.status !== undefined) {
1391
+ if (entry.status !== canonicalStatus) {
686
1392
  diagnostics.push({
687
- rule: "auth-operation-unsupported",
688
- level: "error",
689
- field: `operations.${operationKey}`,
690
- 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.`,
1393
+ rule: "error-code-status-conflicts-sdk",
1394
+ level: "warn",
1395
+ field: `${field}.status`,
1396
+ 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.`,
691
1397
  });
692
1398
  }
693
1399
  }
1400
+ const canonicalRetryable = SDK_CANONICAL_ERROR_CODE_RETRYABILITY.get(entry.code);
1401
+ if (canonicalRetryable !== undefined &&
1402
+ entry.retryable !== undefined &&
1403
+ entry.retryable !== canonicalRetryable) {
1404
+ diagnostics.push({
1405
+ rule: "error-code-retryable-conflicts-sdk",
1406
+ level: "warn",
1407
+ field: `${field}.retryable`,
1408
+ 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.`,
1409
+ });
1410
+ }
694
1411
  }
695
1412
  }
696
- if (!provider.operations) {
697
- return diagnostics;
698
- }
699
- diagnostics.push(...Object.entries(provider.operations).flatMap(([operationKey, operation]) => [
700
- ...lintOperation({
701
- description: operation.description ?? "",
702
- descriptionKey: operation.descriptionKey,
703
- whenToUse: operation.whenToUse,
704
- whenToUseKeys: operation.whenToUseKeys,
705
- whenNotToUse: operation.whenNotToUse,
706
- whenNotToUseKeys: operation.whenNotToUseKeys,
707
- input: operation.input,
708
- output: operation.output,
709
- fixtures: operation.fixtures,
710
- inputExamples: operation.inputExamples,
711
- derivations: operation.derivations,
712
- }),
713
- ...lintPublicSchemaFieldNames(provider.id, operationKey, operation.input, operation.output, provider.meta?.contract?.publicSchemaFieldNames === "normalized"),
714
- ].map((diagnostic) => ({
715
- ...diagnostic,
716
- field: diagnostic.field
717
- ? `operations.${operationKey}.${diagnostic.field}`
718
- : `operations.${operationKey}`,
719
- message: `[${operationKey}] ${diagnostic.message}`,
720
- }))));
721
1413
  return diagnostics;
722
1414
  }
1415
+ function errorLocaleKeyStatus(catalog, key) {
1416
+ if (!isProviderLocaleKey(key))
1417
+ return "malformed";
1418
+ const value = getProviderLocaleSegments(catalog, key.split("."));
1419
+ return typeof value === "string" && value.trim().length > 0 ? "ok" : "missing";
1420
+ }
1421
+ function hasDerivedErrorCatalogText(catalog, code, field) {
1422
+ const value = getProviderLocaleSegments(catalog, [PROVIDER_ERROR_CATALOG_NAMESPACE, code, field]);
1423
+ return typeof value === "string" && value.trim().length > 0;
1424
+ }
1425
+ /**
1426
+ * True when a thrown code reaches the caller as a real provider error whose
1427
+ * text the declared/derived catalog path can actually reach.
1428
+ *
1429
+ * A code qualifies either because the provider declares it in an operation's
1430
+ * `errorCodes`, or because the SDK registers a canonical status for it. The
1431
+ * second arm matters because registering a code is precisely what lets a
1432
+ * provider *delete* the declaration — AUTHORING tells it to for
1433
+ * `UPSTREAM_AUTH_ERROR`, `UPSTREAM_SCHEMA_ERROR` and `INVALID_REQUEST` — and an
1434
+ * undeclared throw of a registered code is no longer the HTTP 500 that
1435
+ * `thrown-error-code-undeclared` reports. Without this arm the two rules leave
1436
+ * a silent hole exactly where the fleet is being told to converge: a 502
1437
+ * `UPSTREAM_SCHEMA_ERROR` served untranslated with nothing warning about it.
1438
+ *
1439
+ * Codes the SDK runtime-owns are excluded: `sdkOwnsErrorResolution` skips the
1440
+ * declaration and derived candidates for them at serve time, so the catalog
1441
+ * entry this rule asks for would never take effect.
1442
+ */
1443
+ function isCatalogReachableThrownCode(code, declaredCodes) {
1444
+ if (declaredCodes.has(code))
1445
+ return true;
1446
+ return (!SDK_RUNTIME_OWNED_ERROR_CODES.has(code) && SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.has(code));
1447
+ }
1448
+ /**
1449
+ * Authoring rules for localized provider error text (see
1450
+ * `ProviderErrorOptions.messageKey`).
1451
+ *
1452
+ * - `error-locale-key-missing` / `error-locale-key-malformed` (**error**): a
1453
+ * literal `messageKey`/`fixKey`, whether on a throw site or on an
1454
+ * `errorCodes[]` declaration, that the provider's `en` catalog does not
1455
+ * resolve to non-empty text, or that is not a legal locale dot path. At
1456
+ * runtime such a key is silently skipped and the caller gets the English
1457
+ * literal, so nothing fails loudly without this rule.
1458
+ * - `thrown-error-message-not-localized` (**warn**): a throw site whose literal
1459
+ * `code` is declared in the provider's `errorCodes` — or SDK-registered, and
1460
+ * so servable without a declaration — but that carries no `messageKey`, no
1461
+ * declaration-level `messageKey`, and no derived `errors.<code>.message` in
1462
+ * `en`. Such an error is served untranslated to every caller. Warning while
1463
+ * the ~2,000 existing sites migrate; it is promoted to error per provider as
1464
+ * each migration wave lands.
1465
+ *
1466
+ * Both rules are skipped entirely when the caller did not supply
1467
+ * `localeCatalogEn`: without the catalog they could only guess.
1468
+ */
1469
+ function lintErrorMessageLocalization(provider) {
1470
+ const catalog = provider.localeCatalogEn;
1471
+ if (!catalog)
1472
+ return [];
1473
+ const diagnostics = [];
1474
+ const declaredCodes = new Set();
1475
+ const codesWithDeclaredMessageKey = new Set();
1476
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1477
+ for (const entry of operation.errorCodes ?? []) {
1478
+ if (typeof entry?.code !== "string")
1479
+ continue;
1480
+ declaredCodes.add(entry.code);
1481
+ for (const keyField of ["messageKey", "fixKey"]) {
1482
+ const key = entry[keyField];
1483
+ if (typeof key !== "string")
1484
+ continue;
1485
+ if (keyField === "messageKey")
1486
+ codesWithDeclaredMessageKey.add(entry.code);
1487
+ const status = errorLocaleKeyStatus(catalog, key);
1488
+ if (status === "ok")
1489
+ continue;
1490
+ diagnostics.push(errorLocaleKeyDiagnostic(status, `operations.${operationKey}.errorCodes`, key, `errorCodes entry "${entry.code}" of operation "${operationKey}"`));
1491
+ }
1492
+ }
1493
+ }
1494
+ for (const { field, source } of collectThrowSiteLintSources(provider)) {
1495
+ const reportedKeys = new Set();
1496
+ const reportedCodes = new Set();
1497
+ for (const site of collectThrownErrorConstructionSites(source)) {
1498
+ for (const key of [site.messageKey, site.fixKey]) {
1499
+ if (key === undefined || reportedKeys.has(key))
1500
+ continue;
1501
+ const status = errorLocaleKeyStatus(catalog, key);
1502
+ if (status === "ok")
1503
+ continue;
1504
+ reportedKeys.add(key);
1505
+ diagnostics.push(errorLocaleKeyDiagnostic(status, field, key, `a thrown error in ${field}`));
1506
+ }
1507
+ const code = site.code;
1508
+ if (code === undefined || reportedCodes.has(code))
1509
+ continue;
1510
+ if (site.messageKey !== undefined)
1511
+ continue;
1512
+ if (!isCatalogReachableThrownCode(code, declaredCodes))
1513
+ continue;
1514
+ if (codesWithDeclaredMessageKey.has(code))
1515
+ continue;
1516
+ if (hasDerivedErrorCatalogText(catalog, code, "message"))
1517
+ continue;
1518
+ reportedCodes.add(code);
1519
+ const origin = declaredCodes.has(code)
1520
+ ? `declared code "${code}"`
1521
+ : `SDK-registered code "${code}"`;
1522
+ const declarationClause = declaredCodes.has(code)
1523
+ ? "its errorCodes declaration has none, and"
1524
+ : "no errorCodes declaration supplies one, and";
1525
+ diagnostics.push({
1526
+ rule: "thrown-error-message-not-localized",
1527
+ level: "warn",
1528
+ field,
1529
+ 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).`,
1530
+ });
1531
+ }
1532
+ }
1533
+ return diagnostics;
1534
+ }
1535
+ function errorLocaleKeyDiagnostic(status, field, key, owner) {
1536
+ return status === "malformed"
1537
+ ? {
1538
+ rule: "error-locale-key-malformed",
1539
+ level: "error",
1540
+ field,
1541
+ 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.`,
1542
+ }
1543
+ : {
1544
+ rule: "error-locale-key-missing",
1545
+ level: "error",
1546
+ field,
1547
+ 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.`,
1548
+ };
1549
+ }
1550
+ function collectHandleFields(schema, basePath, key = undefined, seen = new Set()) {
1551
+ if (!isSchema(schema))
1552
+ return [];
1553
+ // Record before the cycle guard so one `kind.field()` instance reused under
1554
+ // two property keys is still reported at both positions.
1555
+ const own = getOwnHandleFieldMeta(schema);
1556
+ if (own && key !== undefined) {
1557
+ return [{ path: basePath, key, meta: own }];
1558
+ }
1559
+ // Path-local cycle guard: the same wrapped schema instance may legitimately
1560
+ // appear under several property keys (`Kind.field().optional()` reused), and
1561
+ // each position must be checked. Only true ancestor cycles are cut.
1562
+ if (seen.has(schema))
1563
+ return [];
1564
+ seen.add(schema);
1565
+ try {
1566
+ return collectHandleFieldChildren(schema, basePath, key, seen);
1567
+ }
1568
+ finally {
1569
+ seen.delete(schema);
1570
+ }
1571
+ }
1572
+ function collectHandleFieldChildren(schema, basePath, key, seen) {
1573
+ const out = [];
1574
+ // zod exposes the same inner schema under several structural edges
1575
+ // (`unwrap`/`innerType`, `element`/`def.element`); traverse each structural
1576
+ // child object once. Shape children are always traversed: the same instance
1577
+ // under two property keys is two fields.
1578
+ const structuralSeen = new Set();
1579
+ for (const child of getChildSchemas(schema)) {
1580
+ if (child.origin !== "shape") {
1581
+ if (structuralSeen.has(child.schema))
1582
+ continue;
1583
+ structuralSeen.add(child.schema);
1584
+ }
1585
+ if (child.origin === "shape") {
1586
+ // Provider-chosen property key: always a real field, even when it is
1587
+ // spelled like a structural edge (`item`, `type`, `schema`, ...).
1588
+ out.push(...collectHandleFields(child.schema, `${basePath}.${child.key}`, child.key, seen));
1589
+ }
1590
+ else if (isSchemaWrapperNodeKey(child.key)) {
1591
+ out.push(...collectHandleFields(child.schema, basePath, key, seen));
1592
+ }
1593
+ else if (child.key === "element" || child.key.startsWith("element.")) {
1594
+ out.push(...collectHandleFields(child.schema, `${basePath}[]`, key, seen));
1595
+ }
1596
+ else if (/^\d+$/.test(child.key)) {
1597
+ out.push(...collectHandleFields(child.schema, `${basePath}[${child.key}]`, key, seen));
1598
+ }
1599
+ else {
1600
+ out.push(...collectHandleFields(child.schema, `${basePath}.${child.key}`, child.key, seen));
1601
+ }
1602
+ }
1603
+ return out;
1604
+ }
1605
+ /**
1606
+ * Reads `provider.handle` structurally. The declaration type is owned by
1607
+ * define.ts/types.ts; lint only needs the HandleKindDeclaration view, so
1608
+ * malformed entries are skipped rather than reported.
1609
+ */
1610
+ function readHandleKindDeclarations(value) {
1611
+ if (!Array.isArray(value))
1612
+ return [];
1613
+ return value.filter((entry) => {
1614
+ if (typeof entry !== "object" || entry === null)
1615
+ return false;
1616
+ const record = entry;
1617
+ return (typeof record.name === "string" &&
1618
+ (record.type === "cursor" || record.type === "draft") &&
1619
+ typeof record.fieldName === "string" &&
1620
+ (record.outputFieldName === undefined || typeof record.outputFieldName === "string") &&
1621
+ (record.issuedBy === undefined || isHandleIssuedBy(record.issuedBy)));
1622
+ });
1623
+ }
1624
+ /**
1625
+ * ADR-0012 handle rules: every `x-apifuse-handle` schema field must belong to
1626
+ * a declared kind under its declared property key; every declared kind must be
1627
+ * both issued (some output) and accepted (some input); every `issuedBy` entry
1628
+ * must be an operation key and at least one of them must output the field; and
1629
+ * `ctx.handle` needs `state`.
1630
+ */
1631
+ function lintHandleDeclarations(provider) {
1632
+ const diagnostics = [];
1633
+ const providerLabel = provider.id ? `Provider "${provider.id}"` : "Provider";
1634
+ const kinds = readHandleKindDeclarations(provider.handle);
1635
+ const kindByName = new Map(kinds.map((kind) => [kind.name, kind]));
1636
+ const operations = provider.operations ?? {};
1637
+ if (kinds.length > 0 && !provider.state) {
1638
+ diagnostics.push({
1639
+ rule: "handle-requires-state",
1640
+ level: "warn",
1641
+ field: "handle",
1642
+ message: `${providerLabel} declares handle kinds but not the state capability; ctx.handle stores records through ctx.state, so declare state: true.`,
1643
+ });
1644
+ }
1645
+ const kindsInInputs = new Set();
1646
+ const kindsInOutputs = new Set();
1647
+ const outputKindsByOperation = new Map();
1648
+ for (const [operationKey, operation] of Object.entries(operations)) {
1649
+ const positions = [
1650
+ { side: "input", fields: collectHandleFields(operation.input, "input") },
1651
+ { side: "output", fields: collectHandleFields(operation.output, "output") },
1652
+ ];
1653
+ for (const { side, fields } of positions) {
1654
+ for (const occurrence of fields) {
1655
+ const field = `operations.${operationKey}.${occurrence.path}`;
1656
+ const { kind } = occurrence.meta;
1657
+ // A kind may name one handle differently in each direction (the canon's
1658
+ // `cursor` in, `next_cursor` out). The rule is still exact: each side
1659
+ // must use the key declared for THAT side, so the handle's identity
1660
+ // stays readable from the property key and the round trip has exactly
1661
+ // one edge — read `output`, send back under `input`.
1662
+ const expected = handleFieldNameFor(occurrence.meta, side);
1663
+ if (!kindByName.has(kind)) {
1664
+ diagnostics.push({
1665
+ rule: "handle-kind-undeclared",
1666
+ level: "error",
1667
+ field,
1668
+ 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.`,
1669
+ });
1670
+ }
1671
+ if (occurrence.key !== expected) {
1672
+ const directional = handleHasDirectionalFieldNames(occurrence.meta);
1673
+ const advice = directional
1674
+ ? `Use the kind's ${side} fieldName on every ${side} schema; its ${side === "input" ? "output" : "input"} fieldName is "${handleFieldNameFor(occurrence.meta, side === "input" ? "output" : "input")}".`
1675
+ : "Use the same key in every schema so the LLM sees one name.";
1676
+ diagnostics.push({
1677
+ rule: "handle-field-name",
1678
+ level: "error",
1679
+ field,
1680
+ message: `${providerLabel} operation "${operationKey}" ${side} declares handle kind "${kind}" under property "${occurrence.key}" but the kind's ${directional ? `${side} ` : ""}fieldName is "${expected}". ${advice}`,
1681
+ });
1682
+ }
1683
+ if (side === "input") {
1684
+ kindsInInputs.add(kind);
1685
+ }
1686
+ else {
1687
+ kindsInOutputs.add(kind);
1688
+ let outputKinds = outputKindsByOperation.get(operationKey);
1689
+ if (!outputKinds) {
1690
+ outputKinds = new Set();
1691
+ outputKindsByOperation.set(operationKey, outputKinds);
1692
+ }
1693
+ outputKinds.add(kind);
1694
+ }
1695
+ }
1696
+ }
1697
+ }
1698
+ for (const kind of kinds) {
1699
+ const field = `handle.${kind.name}`;
1700
+ const issued = kindsInOutputs.has(kind.name);
1701
+ const accepted = kindsInInputs.has(kind.name);
1702
+ if (!issued) {
1703
+ diagnostics.push({
1704
+ rule: "handle-field-parity",
1705
+ level: "error",
1706
+ field,
1707
+ 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.`,
1708
+ });
1709
+ }
1710
+ if (!accepted) {
1711
+ diagnostics.push({
1712
+ rule: "handle-field-parity",
1713
+ level: "error",
1714
+ field,
1715
+ 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.`,
1716
+ });
1717
+ }
1718
+ if (kind.issuedBy === undefined)
1719
+ continue;
1720
+ const issuers = handleIssuerList(kind.issuedBy);
1721
+ const known = issuers.filter((operationKey) => Object.hasOwn(operations, operationKey));
1722
+ for (const operationKey of issuers) {
1723
+ if (known.includes(operationKey))
1724
+ continue;
1725
+ diagnostics.push({
1726
+ rule: "handle-issued-by",
1727
+ level: "error",
1728
+ field,
1729
+ message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${operationKey}", which is not an operation key.`,
1730
+ });
1731
+ }
1732
+ if (known.length === 0)
1733
+ continue;
1734
+ const issuing = known.filter((operationKey) => outputKindsByOperation.get(operationKey)?.has(kind.name));
1735
+ if (issuing.length > 0)
1736
+ continue;
1737
+ if (known.length === 1) {
1738
+ diagnostics.push({
1739
+ rule: "handle-issued-by",
1740
+ level: "error",
1741
+ field,
1742
+ message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${known[0]}", but that operation's output has no "${handleFieldNameFor(kind, "output")}" handle field.`,
1743
+ });
1744
+ }
1745
+ else {
1746
+ diagnostics.push({
1747
+ rule: "handle-issued-by",
1748
+ level: "error",
1749
+ field,
1750
+ message: `${providerLabel} handle kind "${kind.name}" declares issuedBy ${known
1751
+ .map((operationKey) => `"${operationKey}"`)
1752
+ .join(", ")}, but none of those operations' outputs has a "${handleFieldNameFor(kind, "output")}" handle field. At least one listed issuer must output it.`,
1753
+ });
1754
+ }
1755
+ }
1756
+ return diagnostics;
1757
+ }
1758
+ const LEGACY_CHOICE_USAGE_PATTERN = /\bctx\.choice\b|\b(?:createProviderChoiceToken|parseProviderChoiceToken|ProviderChoiceTokenError|createTestProviderChoiceContext)\b/;
1759
+ const LEGACY_CHOICE_USAGE_MESSAGE = "Choice tokens were replaced by handles (ADR-0012). See docs/migrations/handle.md.";
1760
+ /**
1761
+ * The inline choice-token API was removed outright (no aliases). Any surviving
1762
+ * reference in provider source would fail at runtime, so report it at check time.
1763
+ */
1764
+ const LEGACY_CHOICE_IDENTIFIERS = new Set([
1765
+ "createProviderChoiceToken",
1766
+ "parseProviderChoiceToken",
1767
+ "ProviderChoiceTokenError",
1768
+ "createTestProviderChoiceContext",
1769
+ ]);
1770
+ /**
1771
+ * Finds the first legacy choice API reference in executable code. Uses the
1772
+ * TypeScript AST when available so comments, string and template literal text
1773
+ * never count (a migration note such as `// moved from ctx.choice` must not fail
1774
+ * the provider) while code inside `${}` interpolations still does. Falls back
1775
+ * to a raw-source regex when TypeScript is not installed.
1776
+ */
1777
+ function findLegacyChoiceUsage(fileName, source) {
1778
+ let ts;
1779
+ try {
1780
+ ts = getTypeScript();
1781
+ }
1782
+ catch {
1783
+ return LEGACY_CHOICE_USAGE_PATTERN.exec(source)?.[0];
1784
+ }
1785
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, scriptKindForSourceFile(ts, fileName));
1786
+ let found;
1787
+ const visit = (node) => {
1788
+ if (found)
1789
+ return;
1790
+ if (ts.isIdentifier(node) && LEGACY_CHOICE_IDENTIFIERS.has(node.text)) {
1791
+ found = node.text;
1792
+ return;
1793
+ }
1794
+ if (ts.isPropertyAccessExpression(node) &&
1795
+ ts.isIdentifier(node.expression) &&
1796
+ node.expression.text === "ctx" &&
1797
+ node.name.text === "choice") {
1798
+ found = "ctx.choice";
1799
+ return;
1800
+ }
1801
+ ts.forEachChild(node, visit);
1802
+ };
1803
+ visit(file);
1804
+ return found;
1805
+ }
1806
+ function lintLegacyChoiceUsage(provider) {
1807
+ const sources = [];
1808
+ if (provider.authFlowSource) {
1809
+ sources.push({ field: "auth.flow", fileName: "auth-flow.ts", source: provider.authFlowSource });
1810
+ }
1811
+ for (const [filePath, source] of Object.entries(provider.providerSourceFiles ?? {})) {
1812
+ sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
1813
+ }
1814
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1815
+ const source = getOperationSource(operation);
1816
+ if (source) {
1817
+ sources.push({
1818
+ field: `operations.${operationKey}.handler`,
1819
+ fileName: `${operationKey}.ts`,
1820
+ source,
1821
+ });
1822
+ }
1823
+ }
1824
+ return sources.flatMap(({ field, fileName, source }) => {
1825
+ const match = findLegacyChoiceUsage(fileName, source);
1826
+ if (!match)
1827
+ return [];
1828
+ return [
1829
+ {
1830
+ rule: "legacy-choice-usage",
1831
+ level: "error",
1832
+ field,
1833
+ message: `Legacy choice API "${match}" is no longer supported. ${LEGACY_CHOICE_USAGE_MESSAGE}`,
1834
+ },
1835
+ ];
1836
+ });
1837
+ }
1838
+ // ---------------------------------------------------------------------------
1839
+ // meta-requires-as-const
1840
+ // ---------------------------------------------------------------------------
1841
+ const META_AS_CONST_RULE = "meta-requires-as-const";
1842
+ /**
1843
+ * Why this is checked from source and not from the loaded provider.
1844
+ *
1845
+ * `defineProvider` is declared `<const TConfig extends ProviderDeclaration>`, so
1846
+ * an object literal written AT the call keeps its literal types. A `const` type
1847
+ * parameter cannot re-narrow a value that widened somewhere else: when the
1848
+ * declaration — or its `meta` block — lives in its own module and is passed by
1849
+ * name, the call sees that module's declared type. `as const` has no runtime
1850
+ * trace at all (the loaded object is byte-identical with and without it), so
1851
+ * only the syntax carries the narrowing, which is why this rule reads
1852
+ * `providerSourceFiles` rather than the provider value.
1853
+ *
1854
+ * What is actually at stake, measured against this SDK rather than reasoned
1855
+ * about (probe: declare `http` only, then read `ctx.files`):
1856
+ *
1857
+ * | how the extracted `meta` is declared | at the call | ProviderContextOf |
1858
+ * |---------------------------------------------|-------------|-------------------|
1859
+ * | `meta` written inline in `defineProvider({...})` | ok | narrowed |
1860
+ * | `meta = { ... } as const` | ok | narrowed |
1861
+ * | `meta = { ... } satisfies ProviderMeta` | ok | narrowed |
1862
+ * | `meta: ProviderMeta = { ... }` | ok | narrowed |
1863
+ * | `meta = { ... }`, no literal-typed field | ok | narrowed |
1864
+ * | `meta = { ... }` with a literal-typed field | **TS2322** | **degenerate** |
1865
+ * | `decl: ProviderDeclaration = { ... }` | **ok** | **degenerate** |
1866
+ * | `decl: Pick<ProviderDeclaration, …>` | ok | narrowed |
1867
+ * | `decl: ProviderDeclaration & { … }` | **ok** | **degenerate** |
1868
+ * | `defineProvider<ProviderDeclaration>({…})` | **ok** | **degenerate** |
1869
+ *
1870
+ * The last two rows are the rule, and they fail differently.
1871
+ *
1872
+ * LOUD (`warn`). A widened `meta` is one property of the call literal, so it
1873
+ * cannot change `keyof TConfig` by itself — rows 5 and 6 differ only in whether
1874
+ * some widened value is still assignable. Once one is not
1875
+ * (`contract.publicSchemaFieldNames` going from `"normalized"` to `string` is
1876
+ * the one the fleet hits), inference for `TConfig` falls back to the constraint
1877
+ * and the context degenerates — but `tsc` also rejects the call with TS2322, so
1878
+ * nothing ships. It warns because row 5 is one added field away from row 6.
1879
+ *
1880
+ * SILENT (`error`). Annotating the WHOLE declaration
1881
+ * (`const decl: ProviderDeclaration = { ... }; defineProvider(decl)`) is
1882
+ * different in kind. It is perfectly assignable, so the call compiles; but
1883
+ * every capability is an OPTIONAL key of `ProviderDeclaration`, so
1884
+ * `keyof TConfig` is all of them at once. Every branch of `ProviderContext` is
1885
+ * keyed on `"<capability>" extends keyof TConfig`, so the context hands
1886
+ * `ctx.files`, `ctx.browser`, `ctx.state` and the rest to a provider that
1887
+ * declared none of them, and no diagnostic anywhere points at it. `satisfies
1888
+ * ProviderDeclaration` checks the same shape and keeps the key set.
1889
+ *
1890
+ * The rule is scoped to what is mechanically decidable from the provider's own
1891
+ * sources. A `meta` written inline at the call is fine (the `const` type
1892
+ * parameter does the work). A value passed by name is checked at its
1893
+ * declaration, following at most {@link MODULE_HOP_LIMIT} re-export hops. An
1894
+ * indexed access such as `ProviderDeclaration["meta"]` names one member and is
1895
+ * not a key-erasing annotation. A value that cannot be resolved inside the
1896
+ * provider tree (a bare-specifier import, a computed expression, a function
1897
+ * call) is left alone rather than guessed at.
1898
+ */
1899
+ const MODULE_HOP_LIMIT = 4;
1900
+ /**
1901
+ * True for a type node that erases the authored key set to
1902
+ * `keyof ProviderDeclaration` — the declaration type itself, or a mapped type
1903
+ * over it (`Partial<ProviderDeclaration>`, `Omit<ProviderDeclaration, "auth">`).
1904
+ *
1905
+ * An indexed access into it (`ProviderDeclaration["meta"]`) is NOT this: it
1906
+ * names one member's type and leaves the declaration's own key set alone, which
1907
+ * is why `satisfies ProviderDeclaration["meta"]` is a safe and used convention.
1908
+ */
1909
+ function erasesDeclarationKeys(ts, node, declarationTypeNames) {
1910
+ if (ts.isParenthesizedTypeNode(node)) {
1911
+ return erasesDeclarationKeys(ts, node.type, declarationTypeNames);
1912
+ }
1913
+ // `ProviderDeclaration & { http: true }` still carries every optional
1914
+ // capability key, so an intersection is erasing when any constituent is.
1915
+ if (ts.isIntersectionTypeNode(node) || ts.isUnionTypeNode(node)) {
1916
+ return node.types.some((member) => erasesDeclarationKeys(ts, member, declarationTypeNames));
1917
+ }
1918
+ if (ts.isIndexedAccessTypeNode(node))
1919
+ return false;
1920
+ if (ts.isTypeReferenceNode(node)) {
1921
+ const name = ts.isIdentifier(node.typeName) ? node.typeName.text : node.typeName.right.text;
1922
+ if (declarationTypeNames.has(name))
1923
+ return true;
1924
+ // `Pick<ProviderDeclaration, "id" | "http">` names an explicit key subset,
1925
+ // so the authored keys survive and the context stays narrow — measured.
1926
+ // Every other transformation over the declaration (`Omit` keeps all the
1927
+ // keys but one, `Partial`/`Readonly`/`Required` keep them all) leaves the
1928
+ // optional capability keys in place and is erasing.
1929
+ if (name === "Pick")
1930
+ return false;
1931
+ return (node.typeArguments ?? []).some((argument) => erasesDeclarationKeys(ts, argument, declarationTypeNames));
1932
+ }
1933
+ return false;
1934
+ }
1935
+ /** True for an `@apifuse/provider-sdk` (or subpath) module specifier. */
1936
+ function isSdkSpecifier(specifier) {
1937
+ return specifier === "@apifuse/provider-sdk" || specifier.startsWith("@apifuse/provider-sdk/");
1938
+ }
1939
+ /**
1940
+ * Local names that denote `ProviderDeclaration` inside one file.
1941
+ *
1942
+ * The type is usually imported under its own name, but `import type
1943
+ * { ProviderDeclaration as Decl }` and `type Decl = ProviderDeclaration` are
1944
+ * the same type wearing another word, and annotating with either erases the
1945
+ * key set just as thoroughly. Comparing the written name alone would miss both.
1946
+ */
1947
+ function declarationTypeNamesIn(ts, file) {
1948
+ const names = new Set(["ProviderDeclaration"]);
1949
+ for (const statement of file.statements) {
1950
+ if (ts.isImportDeclaration(statement)) {
1951
+ const bindings = statement.importClause?.namedBindings;
1952
+ if (!bindings || !ts.isNamedImports(bindings))
1953
+ continue;
1954
+ if (!ts.isStringLiteral(statement.moduleSpecifier))
1955
+ continue;
1956
+ if (!isSdkSpecifier(statement.moduleSpecifier.text))
1957
+ continue;
1958
+ for (const element of bindings.elements) {
1959
+ if ((element.propertyName?.text ?? element.name.text) === "ProviderDeclaration") {
1960
+ names.add(element.name.text);
1961
+ }
1962
+ }
1963
+ continue;
1964
+ }
1965
+ if (ts.isTypeAliasDeclaration(statement) &&
1966
+ ts.isTypeReferenceNode(statement.type) &&
1967
+ ts.isIdentifier(statement.type.typeName) &&
1968
+ names.has(statement.type.typeName.text)) {
1969
+ names.add(statement.name.text);
1970
+ }
1971
+ }
1972
+ return names;
1973
+ }
1974
+ /**
1975
+ * Local names bound to the SDK's `defineProvider` inside one file.
1976
+ *
1977
+ * Restricting the callee match to these keeps an unrelated local helper called
1978
+ * `defineProvider` out of the rule, and picks the SDK call up when it is
1979
+ * imported under another name. A file with no SDK import at all falls back to
1980
+ * the bare name, which is what an in-repo fixture looks like.
1981
+ */
1982
+ function defineProviderNamesIn(ts, file) {
1983
+ const names = new Set();
1984
+ for (const statement of file.statements) {
1985
+ if (!ts.isImportDeclaration(statement))
1986
+ continue;
1987
+ if (!ts.isStringLiteral(statement.moduleSpecifier))
1988
+ continue;
1989
+ if (!isSdkSpecifier(statement.moduleSpecifier.text))
1990
+ continue;
1991
+ const bindings = statement.importClause?.namedBindings;
1992
+ if (!bindings)
1993
+ continue;
1994
+ if (ts.isNamedImports(bindings)) {
1995
+ for (const element of bindings.elements) {
1996
+ if ((element.propertyName?.text ?? element.name.text) === "defineProvider") {
1997
+ names.add(element.name.text);
1998
+ }
1999
+ }
2000
+ }
2001
+ // `import * as sdk from "@apifuse/provider-sdk"` reaches the call as
2002
+ // `sdk.defineProvider`, and calleeName() already reduces that to the
2003
+ // member name.
2004
+ if (ts.isNamespaceImport(bindings))
2005
+ names.add("defineProvider");
2006
+ }
2007
+ if (names.size > 0)
2008
+ return names;
2009
+ // No SDK import in this file. The bare name is the right fallback for an
2010
+ // in-repo fixture, but not when the file declares or imports a
2011
+ // `defineProvider` of its own — passing a ProviderDeclaration to an
2012
+ // unrelated local helper is not an SDK call and must not fail a check.
2013
+ for (const statement of file.statements) {
2014
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === "defineProvider") {
2015
+ return names;
2016
+ }
2017
+ if (ts.isVariableStatement(statement)) {
2018
+ for (const declaration of statement.declarationList.declarations) {
2019
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === "defineProvider") {
2020
+ return names;
2021
+ }
2022
+ }
2023
+ }
2024
+ if (ts.isImportDeclaration(statement)) {
2025
+ const bindings = statement.importClause?.namedBindings;
2026
+ if (bindings && ts.isNamedImports(bindings)) {
2027
+ for (const element of bindings.elements) {
2028
+ if (element.name.text === "defineProvider")
2029
+ return names;
2030
+ }
2031
+ }
2032
+ }
2033
+ }
2034
+ return new Set(["defineProvider"]);
2035
+ }
2036
+ function normalizeSourcePath(path) {
2037
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
2038
+ }
2039
+ /** Resolves `./meta` (or `./meta.js`) from `index.ts` against the source map. */
2040
+ function resolveProviderModule(fromFile, specifier, files) {
2041
+ if (!specifier.startsWith("."))
2042
+ return undefined;
2043
+ const fromSegments = normalizeSourcePath(fromFile).split("/");
2044
+ fromSegments.pop();
2045
+ for (const segment of specifier.split("/")) {
2046
+ if (segment === "" || segment === ".")
2047
+ continue;
2048
+ if (segment === "..")
2049
+ fromSegments.pop();
2050
+ else
2051
+ fromSegments.push(segment);
2052
+ }
2053
+ const joined = fromSegments.join("/");
2054
+ // An ESM provider writes `./meta.js` and ships `meta.mts`; the extension in
2055
+ // the specifier is the EMITTED one, so every TypeScript counterpart of the
2056
+ // scan set has to be tried, not just `.ts`.
2057
+ const withoutJs = joined.replace(/\.(js|mjs|cjs)$/, "");
2058
+ const candidates = [
2059
+ joined,
2060
+ ...["ts", "tsx", "mts", "cts"].flatMap((extension) => [
2061
+ `${withoutJs}.${extension}`,
2062
+ `${withoutJs}/index.${extension}`,
2063
+ ]),
2064
+ ];
2065
+ const normalized = new Map(Object.keys(files).map((key) => [normalizeSourcePath(key), key]));
2066
+ for (const candidate of candidates) {
2067
+ const match = normalized.get(candidate);
2068
+ if (match !== undefined)
2069
+ return match;
2070
+ }
2071
+ return undefined;
2072
+ }
2073
+ /** True for `<expr> as const`. */
2074
+ function isConstAssertion(ts, node) {
2075
+ return (ts.isAsExpression(node) &&
2076
+ ts.isTypeReferenceNode(node.type) &&
2077
+ ts.isIdentifier(node.type.typeName) &&
2078
+ node.type.typeName.text === "const");
2079
+ }
2080
+ /**
2081
+ * Decides whether `name`, as declared in `fileName`, still carries its literal
2082
+ * types when it is read from another module. Follows `import`/`export ... from`
2083
+ * re-exports up to {@link MODULE_HOP_LIMIT} hops.
2084
+ */
2085
+ function narrowingOfExportedConst(ts, files, fileName, name, hops = 0) {
2086
+ if (hops > MODULE_HOP_LIMIT)
2087
+ return { kind: "unresolved" };
2088
+ const source = files[fileName];
2089
+ if (source === undefined)
2090
+ return { kind: "unresolved" };
2091
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindForSourceFile(ts, fileName));
2092
+ const declarationTypeNames = declarationTypeNamesIn(ts, file);
2093
+ for (const statement of file.statements) {
2094
+ if (ts.isVariableStatement(statement)) {
2095
+ for (const declaration of statement.declarationList.declarations) {
2096
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name)
2097
+ continue;
2098
+ // An annotation (`: ProviderMeta`) and a `satisfies` check both give
2099
+ // the literal a contextual type whose literal-typed members stay
2100
+ // literal. Measured: neither degenerates ProviderContextOf — UNLESS
2101
+ // the type named is the declaration itself, which replaces the
2102
+ // authored key set with every optional capability key.
2103
+ if (declaration.type) {
2104
+ if (erasesDeclarationKeys(ts, declaration.type, declarationTypeNames)) {
2105
+ return {
2106
+ kind: "widened",
2107
+ silent: true,
2108
+ reason: `it is annotated \`: ${declaration.type.getText(file)}\`, and every capability is an OPTIONAL key of ProviderDeclaration, so \`keyof TConfig\` becomes all of them`,
2109
+ };
2110
+ }
2111
+ return { kind: "narrowed" };
2112
+ }
2113
+ let initializer = declaration.initializer;
2114
+ while (initializer && ts.isParenthesizedExpression(initializer)) {
2115
+ initializer = initializer.expression;
2116
+ }
2117
+ if (!initializer)
2118
+ return { kind: "unresolved" };
2119
+ if (ts.isSatisfiesExpression(initializer))
2120
+ return { kind: "narrowed" };
2121
+ if (isConstAssertion(ts, initializer))
2122
+ return { kind: "narrowed" };
2123
+ if (ts.isAsExpression(initializer)) {
2124
+ if (erasesDeclarationKeys(ts, initializer.type, declarationTypeNames)) {
2125
+ return {
2126
+ kind: "widened",
2127
+ silent: true,
2128
+ 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`,
2129
+ };
2130
+ }
2131
+ return { kind: "narrowed" };
2132
+ }
2133
+ if (ts.isObjectLiteralExpression(initializer)) {
2134
+ return {
2135
+ kind: "widened",
2136
+ silent: false,
2137
+ reason: "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",
2138
+ };
2139
+ }
2140
+ return { kind: "unresolved" };
2141
+ }
2142
+ }
2143
+ // `export { providerMeta } from "./meta";` and
2144
+ // `import { providerMeta } from "./meta"; export { providerMeta };`
2145
+ if (ts.isExportDeclaration(statement) && statement.exportClause) {
2146
+ if (!ts.isNamedExports(statement.exportClause))
2147
+ continue;
2148
+ for (const element of statement.exportClause.elements) {
2149
+ if (element.name.text !== name)
2150
+ continue;
2151
+ const origin = element.propertyName?.text ?? name;
2152
+ const specifier = statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
2153
+ ? statement.moduleSpecifier.text
2154
+ : undefined;
2155
+ if (specifier === undefined) {
2156
+ return narrowingOfImportedName(ts, files, file, fileName, origin, hops + 1);
2157
+ }
2158
+ const target = resolveProviderModule(fileName, specifier, files);
2159
+ if (target === undefined)
2160
+ return { kind: "unresolved" };
2161
+ return narrowingOfExportedConst(ts, files, target, origin, hops + 1);
2162
+ }
2163
+ }
2164
+ }
2165
+ return narrowingOfImportedName(ts, files, file, fileName, name, hops + 1);
2166
+ }
2167
+ /** Follows `import { name } from "./x"` declared in `file` to its definition. */
2168
+ function narrowingOfImportedName(ts, files, file, fileName, name, hops) {
2169
+ if (hops > MODULE_HOP_LIMIT)
2170
+ return { kind: "unresolved" };
2171
+ for (const statement of file.statements) {
2172
+ if (!ts.isImportDeclaration(statement))
2173
+ continue;
2174
+ const bindings = statement.importClause?.namedBindings;
2175
+ if (!bindings || !ts.isNamedImports(bindings))
2176
+ continue;
2177
+ for (const element of bindings.elements) {
2178
+ if (element.name.text !== name)
2179
+ continue;
2180
+ if (!ts.isStringLiteral(statement.moduleSpecifier))
2181
+ return { kind: "unresolved" };
2182
+ const target = resolveProviderModule(fileName, statement.moduleSpecifier.text, files);
2183
+ if (target === undefined)
2184
+ return { kind: "unresolved" };
2185
+ return narrowingOfExportedConst(ts, files, target, element.propertyName?.text ?? name, hops);
2186
+ }
2187
+ }
2188
+ return { kind: "unresolved" };
2189
+ }
2190
+ /**
2191
+ * Finds every `defineProvider(...)` in one file and reports the arguments that
2192
+ * reach it already widened.
2193
+ */
2194
+ function findWidenedDefineProviderArguments(ts, files, fileName) {
2195
+ const source = files[fileName];
2196
+ if (source === undefined)
2197
+ return [];
2198
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindForSourceFile(ts, fileName));
2199
+ const findings = [];
2200
+ const declarationTypeNames = declarationTypeNamesIn(ts, file);
2201
+ const calleeNames = defineProviderNamesIn(ts, file);
2202
+ const check = (subject, expression) => {
2203
+ let current = expression;
2204
+ while (ts.isParenthesizedExpression(current))
2205
+ current = current.expression;
2206
+ // `satisfies` and `as const` narrow a literal written HERE; neither can
2207
+ // re-narrow a binding that widened at its own declaration, and neither
2208
+ // re-narrows a value spread in from one. Unwrap and keep looking rather
2209
+ // than treating the wrapper as proof.
2210
+ while (ts.isSatisfiesExpression(current) || isConstAssertion(ts, current)) {
2211
+ current = current.expression;
2212
+ while (ts.isParenthesizedExpression(current))
2213
+ current = current.expression;
2214
+ }
2215
+ // `defineProvider({...} as ProviderDeclaration)` and
2216
+ // `defineProvider(declaration as ProviderDeclaration)` erase the key set
2217
+ // at the call itself, without any widened binding to resolve to.
2218
+ if (ts.isAsExpression(current)) {
2219
+ if (erasesDeclarationKeys(ts, current.type, declarationTypeNames)) {
2220
+ findings.push({
2221
+ subject,
2222
+ name: current.expression.getText(file).slice(0, 60),
2223
+ 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`,
2224
+ silent: subject === "declaration",
2225
+ });
2226
+ return;
2227
+ }
2228
+ current = current.expression;
2229
+ while (ts.isParenthesizedExpression(current))
2230
+ current = current.expression;
2231
+ }
2232
+ if (ts.isObjectLiteralExpression(current)) {
2233
+ // Written at the call: the `const` type parameter narrows it. Only a
2234
+ // spread of an already-widened binding can still lose the literals.
2235
+ for (const property of current.properties) {
2236
+ if (ts.isSpreadAssignment(property) && ts.isIdentifier(property.expression)) {
2237
+ check(subject, property.expression);
2238
+ }
2239
+ }
2240
+ return;
2241
+ }
2242
+ if (!ts.isIdentifier(current))
2243
+ return;
2244
+ const verdict = narrowingOfImportedName(ts, files, file, fileName, current.text, 1);
2245
+ const resolved = verdict.kind === "unresolved"
2246
+ ? narrowingOfExportedConst(ts, files, fileName, current.text, 1)
2247
+ : verdict;
2248
+ if (resolved.kind === "widened") {
2249
+ // A widened `meta` is only ever the loud row: it is one property of the
2250
+ // call literal, so it cannot change `keyof TConfig` by itself.
2251
+ const silent = resolved.silent && subject === "declaration";
2252
+ findings.push({ subject, name: current.text, reason: resolved.reason, silent });
2253
+ }
2254
+ };
2255
+ const visit = (node) => {
2256
+ const callee = ts.isCallExpression(node) ? calleeName(node) : undefined;
2257
+ if (ts.isCallExpression(node) && callee !== undefined && calleeNames.has(callee)) {
2258
+ // `defineProvider<ProviderDeclaration>({...})` pins TConfig explicitly,
2259
+ // so the `const` type parameter never infers anything and even an inline
2260
+ // literal gets the full optional-key set. The value argument looks
2261
+ // perfect; the type argument is the whole defect.
2262
+ const pinned = (node.typeArguments ?? []).find((argument) => erasesDeclarationKeys(ts, argument, declarationTypeNames));
2263
+ if (pinned) {
2264
+ findings.push({
2265
+ subject: "declaration",
2266
+ name: `${callee}<${pinned.getText(file)}>`,
2267
+ 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`,
2268
+ silent: true,
2269
+ });
2270
+ ts.forEachChild(node, visit);
2271
+ return;
2272
+ }
2273
+ const argument = node.arguments[0];
2274
+ if (argument) {
2275
+ let current = argument;
2276
+ while (ts.isParenthesizedExpression(current))
2277
+ current = current.expression;
2278
+ if (ts.isAsExpression(current)) {
2279
+ // `defineProvider(x as T)`: check() decides whether T erases the
2280
+ // key set, and unwraps to x when it does not.
2281
+ check("declaration", current);
2282
+ ts.forEachChild(node, visit);
2283
+ return;
2284
+ }
2285
+ if (ts.isObjectLiteralExpression(current)) {
2286
+ for (const property of current.properties) {
2287
+ if (ts.isPropertyAssignment(property) && staticPropertyName(property.name) === "meta") {
2288
+ check("meta", property.initializer);
2289
+ }
2290
+ else if (ts.isShorthandPropertyAssignment(property) &&
2291
+ property.name.text === "meta") {
2292
+ check("meta", property.name);
2293
+ }
2294
+ else if (ts.isSpreadAssignment(property)) {
2295
+ check("declaration", property.expression);
2296
+ }
2297
+ }
2298
+ }
2299
+ else {
2300
+ check("declaration", argument);
2301
+ }
2302
+ }
2303
+ }
2304
+ ts.forEachChild(node, visit);
2305
+ };
2306
+ visit(file);
2307
+ return findings;
2308
+ }
2309
+ /**
2310
+ * `meta-requires-as-const`: the provider declaration, and its `meta` block in
2311
+ * particular, should reach `defineProvider` with its literal types intact.
2312
+ *
2313
+ * See the table above {@link MODULE_HOP_LIMIT} for the measured consequences
2314
+ * and for why this warns rather than errors.
2315
+ */
2316
+ function lintProviderMetaNarrowing(provider) {
2317
+ const files = provider.providerSourceFiles ?? {};
2318
+ if (Object.keys(files).length === 0)
2319
+ return [];
2320
+ let ts;
2321
+ try {
2322
+ ts = getTypeScript();
2323
+ }
2324
+ catch {
2325
+ // No TypeScript in the consumer's tree: the other AST rules degrade the
2326
+ // same way rather than guessing from raw text.
2327
+ return [];
2328
+ }
2329
+ const diagnostics = [];
2330
+ for (const fileName of Object.keys(files)) {
2331
+ const normalized = normalizeSourcePath(fileName);
2332
+ if (!/\.(ts|tsx|mts|cts)$/.test(normalized) || /\.d\.(ts|mts|cts)$/.test(normalized))
2333
+ continue;
2334
+ // Test sources routinely build throwaway declarations to exercise a
2335
+ // capability shape, and several do it with a `ProviderDeclaration`
2336
+ // annotation on purpose. Reporting those would fail `apifuse check` over
2337
+ // code that is not the provider's runtime contract.
2338
+ if (SHARED_TEST_SOURCE_FILE_PATTERN.test(normalized))
2339
+ continue;
2340
+ for (const finding of findWidenedDefineProviderArguments(ts, files, fileName)) {
2341
+ const what = finding.subject === "meta"
2342
+ ? `The \`meta\` block passed to defineProvider (\`${finding.name}\`)`
2343
+ : `The declaration passed to defineProvider (\`${finding.name}\`)`;
2344
+ const consequence = finding.silent
2345
+ ? "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."
2346
+ : '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.';
2347
+ diagnostics.push({
2348
+ rule: META_AS_CONST_RULE,
2349
+ level: finding.silent ? "error" : "warn",
2350
+ field: finding.subject,
2351
+ message: `${what} reaches the call already widened: ${finding.reason}. ${consequence}`,
2352
+ });
2353
+ }
2354
+ }
2355
+ return diagnostics;
2356
+ }
2357
+ const STALE_IF_ERROR_OPTION = "staleIfErrorMs";
2358
+ const STALE_IF_ERROR_PATTERN = /\bstaleIfErrorMs\b/;
2359
+ /**
2360
+ * Whether a property assignment's initializer leaves no stale window.
2361
+ * `staleIfErrorMs: 0` makes `staleUntil` equal `freshUntil` in
2362
+ * `runtime/cache.ts`, so a loader failure propagates instead of being answered
2363
+ * from cache — a provider deliberately turning stale serving OFF must not be
2364
+ * told its probes conceal outages.
2365
+ */
2366
+ function disablesStaleWindow(ts, initializer) {
2367
+ if (ts.isNumericLiteral(initializer))
2368
+ return Number(initializer.text) === 0;
2369
+ return ts.isIdentifier(initializer) && initializer.text === "undefined";
2370
+ }
2371
+ /**
2372
+ * Whether executable source configures a stale-if-error window.
2373
+ *
2374
+ * Parsed, not matched: comments and unrelated string literals must not count,
2375
+ * and a regex cannot tell them apart (an `Accept: "*​/*"` header literal opens a
2376
+ * block comment to a stripping regex and swallows the cache call after it).
2377
+ * This is the approach `findLegacyChoiceUsage` already uses here. All three
2378
+ * spellings of the option count — `staleIfErrorMs: n`, the shorthand
2379
+ * `{ ttlMs, staleIfErrorMs }`, and the quoted `"staleIfErrorMs": n` — because
2380
+ * refactoring between them must not change the verdict. Falls back to the raw
2381
+ * identifier match when TypeScript is not installed, which is the fail-closed
2382
+ * direction for this rule: it warns rather than goes quiet.
2383
+ */
2384
+ function servesStaleIfError(source, fileName = "provider.ts") {
2385
+ if (!source.includes(STALE_IF_ERROR_OPTION))
2386
+ return false;
2387
+ let ts;
2388
+ try {
2389
+ ts = getTypeScript();
2390
+ }
2391
+ catch {
2392
+ return STALE_IF_ERROR_PATTERN.test(source);
2393
+ }
2394
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, scriptKindForSourceFile(ts, fileName));
2395
+ /** The statically known name of a property, however it is written. */
2396
+ const propertyName = (name) => {
2397
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name))
2398
+ return name.text;
2399
+ if (ts.isComputedPropertyName(name) && ts.isStringLiteral(name.expression)) {
2400
+ return name.expression.text;
2401
+ }
2402
+ return undefined;
2403
+ };
2404
+ /**
2405
+ * A type declaring the option is not a cache call setting it. Skipping these
2406
+ * whole subtrees also stops a `CachePolicy` interface from overriding the
2407
+ * disabled-window exemption for a provider whose only real call passes 0.
2408
+ */
2409
+ const isTypeOnly = (node) => ts.isInterfaceDeclaration(node) ||
2410
+ ts.isTypeAliasDeclaration(node) ||
2411
+ ts.isTypeLiteralNode(node) ||
2412
+ ts.isPropertySignature(node) ||
2413
+ ts.isTypeReferenceNode(node) ||
2414
+ ts.isTypeParameterDeclaration(node);
2415
+ /**
2416
+ * The cache write options that travel with `staleIfErrorMs`
2417
+ * (`ProviderCacheWriteOptions`). An object literal carrying one of these
2418
+ * alongside the option is a cache configuration wherever it is declared.
2419
+ */
2420
+ const siblingCacheOptions = new Set(["ttlMs", "jitterPct"]);
2421
+ /**
2422
+ * A callee that writes the provider cache: `ctx.cache.getOrSet(...)`,
2423
+ * `ctx.cache.set(...)`, and the `cached*` helpers built on them. An unrelated
2424
+ * call receiving an object that happens to carry the name is not cache
2425
+ * configuration. Every one of these takes its options LAST.
2426
+ */
2427
+ const isCacheCallee = (expression) => {
2428
+ if (ts.isIdentifier(expression))
2429
+ return /^cached[A-Z]/.test(expression.text);
2430
+ if (!ts.isPropertyAccessExpression(expression))
2431
+ return false;
2432
+ if (expression.name.text === "getOrSet")
2433
+ return true;
2434
+ return ((expression.name.text === "set" || expression.name.text === "write") &&
2435
+ ts.isPropertyAccessExpression(expression.expression) &&
2436
+ expression.expression.name.text === "cache");
2437
+ };
2438
+ /**
2439
+ * `const POLICY = { ... }` object literals, so `{ ...POLICY }` can be judged.
2440
+ * Keyed by name file-wide: a shadowed name may resolve to the wrong literal,
2441
+ * which at warning level is a mis-nudge rather than a wrong build — but the
2442
+ * expansion below still has to be cycle-safe, because shadowing can make the
2443
+ * resolution circular in valid source.
2444
+ */
2445
+ const literalsByName = new Map();
2446
+ const collect = (node) => {
2447
+ if (ts.isVariableDeclaration(node) &&
2448
+ ts.isIdentifier(node.name) &&
2449
+ node.initializer !== undefined &&
2450
+ ts.isObjectLiteralExpression(node.initializer)) {
2451
+ literalsByName.set(node.name.text, node.initializer);
2452
+ }
2453
+ ts.forEachChild(node, collect);
2454
+ };
2455
+ collect(file);
2456
+ /**
2457
+ * The EFFECTIVE stale-window setting of an object literal, spreads expanded
2458
+ * and later properties overriding earlier ones the way JavaScript does — so
2459
+ * `{ ...policy, staleIfErrorMs: 0 }` reads as disabled even when `policy`
2460
+ * enables it.
2461
+ */
2462
+ const readOptions = (literal, seen) => {
2463
+ if (seen.has(literal))
2464
+ return { window: undefined, hasSibling: false };
2465
+ seen.add(literal);
2466
+ let window;
2467
+ let hasSibling = false;
2468
+ for (const property of literal.properties) {
2469
+ if (ts.isSpreadAssignment(property)) {
2470
+ const target = ts.isIdentifier(property.expression)
2471
+ ? literalsByName.get(property.expression.text)
2472
+ : ts.isObjectLiteralExpression(property.expression)
2473
+ ? property.expression
2474
+ : undefined;
2475
+ if (target === undefined)
2476
+ continue;
2477
+ const inner = readOptions(target, seen);
2478
+ if (inner.window !== undefined)
2479
+ window = inner.window;
2480
+ hasSibling ||= inner.hasSibling;
2481
+ continue;
2482
+ }
2483
+ if (ts.isShorthandPropertyAssignment(property)) {
2484
+ if (property.name.text === STALE_IF_ERROR_OPTION)
2485
+ window = "enabled";
2486
+ else if (siblingCacheOptions.has(property.name.text))
2487
+ hasSibling = true;
2488
+ continue;
2489
+ }
2490
+ if (!ts.isPropertyAssignment(property))
2491
+ continue;
2492
+ const name = propertyName(property.name);
2493
+ if (name === STALE_IF_ERROR_OPTION) {
2494
+ window = disablesStaleWindow(ts, property.initializer) ? "disabled" : "enabled";
2495
+ }
2496
+ else if (name !== undefined && siblingCacheOptions.has(name)) {
2497
+ hasSibling = true;
2498
+ }
2499
+ }
2500
+ seen.delete(literal);
2501
+ return { window, hasSibling };
2502
+ };
2503
+ let found = false;
2504
+ /**
2505
+ * `inOptionsArgument` is the second way to recognise a cache configuration:
2506
+ * the literal is the options argument of a cache call. Together with the
2507
+ * sibling-option test this covers the real shapes, while an upstream payload
2508
+ * that merely carries a field of the same name does not count.
2509
+ */
2510
+ const visit = (node, inOptionsArgument) => {
2511
+ if (found || isTypeOnly(node))
2512
+ return;
2513
+ if (ts.isObjectLiteralExpression(node)) {
2514
+ const { window, hasSibling } = readOptions(node, new Set());
2515
+ if (window === "enabled" && (hasSibling || inOptionsArgument)) {
2516
+ found = true;
2517
+ return;
2518
+ }
2519
+ }
2520
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
2521
+ const argumentList = node.arguments ?? [];
2522
+ // Options come last on every cache API; the earlier arguments are the
2523
+ // key, the loader, or the value being cached.
2524
+ const optionsIndex = isCacheCallee(node.expression) ? argumentList.length - 1 : -1;
2525
+ visit(node.expression, false);
2526
+ for (const [index, argument] of [...argumentList].entries()) {
2527
+ visit(argument, index === optionsIndex);
2528
+ }
2529
+ return;
2530
+ }
2531
+ ts.forEachChild(node, (child) => visit(child, false));
2532
+ };
2533
+ visit(file, false);
2534
+ return found;
2535
+ }
2536
+ /** The operation-step field that distinguishes a stale-if-error serve. */
2537
+ const SERVED_STALE_CACHE_OPERAND = "served_stale_cache";
2538
+ /**
2539
+ * Sources that describe behaviour rather than serve it. Mirrors the broadest
2540
+ * test-source predicate in the repository (`runtime-boundary-lint.ts:82`, which
2541
+ * covers a plain `tests/` directory and `__mocks__/` as well as `__tests__/`)
2542
+ * and adds recorded-fixture directories, so a mock that mentions
2543
+ * `staleIfErrorMs` cannot raise a warning about code the runtime never serves.
2544
+ */
2545
+ const NON_SERVING_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__|__fixtures__|tests|fixtures)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
2546
+ /**
2547
+ * Operator tooling and bootstrap entrypoints, mirroring
2548
+ * `runtime-boundary-lint.ts`: a cache option used by a fixture recorder or a
2549
+ * start script is not the request path a probe reads through.
2550
+ */
2551
+ const NON_SERVING_TOOLING_DIRECTORY_PATTERN = /^(?:scripts|tools|bin)\//;
2552
+ const NON_SERVING_BOOTSTRAP_FILE_PATTERN = /^(?:dev|start|deploy)\.[cm]?[jt]sx?$/;
2553
+ function isServingProviderSourcePath(relativePath) {
2554
+ return (JAVASCRIPT_SOURCE_FILE_PATTERN.test(relativePath) &&
2555
+ !NON_SERVING_SOURCE_FILE_PATTERN.test(relativePath) &&
2556
+ !NON_SERVING_TOOLING_DIRECTORY_PATTERN.test(relativePath) &&
2557
+ !NON_SERVING_BOOTSTRAP_FILE_PATTERN.test(relativePath));
2558
+ }
2559
+ function isLintRecord(value) {
2560
+ return !!value && typeof value === "object" && !Array.isArray(value);
2561
+ }
2562
+ /**
2563
+ * Reads `healthCheck.cases` structurally. The lint input is the loaded provider
2564
+ * declaration, which `defineProvider` has already validated, but this rule must
2565
+ * not throw on a shape it did not expect.
2566
+ */
2567
+ function readHealthCheckCases(healthCheck) {
2568
+ if (!isLintRecord(healthCheck))
2569
+ return [];
2570
+ const cases = healthCheck.cases;
2571
+ if (!Array.isArray(cases))
2572
+ return [];
2573
+ return cases.filter(isLintRecord);
2574
+ }
2575
+ /**
2576
+ * The `result` bindings of the `operation` steps that invoke `operationId`.
2577
+ *
2578
+ * Scoped to the probe's own operation on purpose: a scenario may invoke a
2579
+ * preparatory read before the operation the case is about, and a guard on the
2580
+ * preparation's result says nothing about whether the checked operation was
2581
+ * answered from a stale cache.
2582
+ */
2583
+ function operationStepBindings(steps, operationId) {
2584
+ const bindings = new Set();
2585
+ for (const step of steps) {
2586
+ if (isLintRecord(step) &&
2587
+ step.kind === "operation" &&
2588
+ step.operationId === operationId &&
2589
+ typeof step.result === "string") {
2590
+ bindings.add(step.result);
2591
+ }
2592
+ }
2593
+ return bindings;
2594
+ }
2595
+ /**
2596
+ * Whether an operand is the operand: the runtime's `served_stale_cache`
2597
+ * projection on an operation step result for the probe's own operation.
2598
+ *
2599
+ * Exact on three counts, because each looser form is a way to silence the rule
2600
+ * without checking cache provenance: a literal `"served_stale_cache"` in an
2601
+ * `inputTemplate` or a `reasonKey` is not a reference at all;
2602
+ * `["data", "served_stale_cache"]` is an upstream payload field the provider
2603
+ * happens to have named that way; and a reference bound to an `extract` result
2604
+ * is not the operation step this probe invoked.
2605
+ */
2606
+ function isStaleCacheOperand(operand, bindings) {
2607
+ if (!isLintRecord(operand))
2608
+ return false;
2609
+ const reference = operand.ref;
2610
+ return (isLintRecord(reference) &&
2611
+ reference.namespace === "steps" &&
2612
+ typeof reference.binding === "string" &&
2613
+ bindings.has(reference.binding) &&
2614
+ Array.isArray(reference.path) &&
2615
+ reference.path.length === 1 &&
2616
+ reference.path[0] === SERVED_STALE_CACHE_OPERAND);
2617
+ }
2618
+ /**
2619
+ * Whether a single predicate over the operand is FALSE for a stale serve, under
2620
+ * `negated` negation depth. `served_stale_cache` is always present and always a
2621
+ * boolean, so `exists` and `type_is: "boolean"` pass for a stale serve too —
2622
+ * reading the operand is not the same as rejecting it.
2623
+ */
2624
+ function predicateRejectsStaleServe(predicate, bindings, negated) {
2625
+ // `equals`/`not_equals` are symmetric and the schema allows a reference on
2626
+ // either side, so `equals(actual: false, expected: <ref>)` is the same check
2627
+ // written the other way round.
2628
+ const onActual = isStaleCacheOperand(predicate.actual, bindings);
2629
+ const onExpected = isStaleCacheOperand(predicate.expected, bindings);
2630
+ if (!onActual && !onExpected)
2631
+ return false;
2632
+ const { operator } = predicate;
2633
+ const literal = onActual ? predicate.expected : predicate.actual;
2634
+ const failsWhenStale = (operator === "not_equals" && literal === true) || (operator === "equals" && literal === false);
2635
+ const holdsWhenStale = (operator === "is_true" && onActual) ||
2636
+ (operator === "equals" && literal === true) ||
2637
+ (operator === "not_equals" && literal === false);
2638
+ return negated ? holdsWhenStale : failsWhenStale;
2639
+ }
2640
+ /**
2641
+ * Whether an assertion expression evaluates FALSE when the operation step was
2642
+ * answered from a stale cache — the only thing that makes a guard degrade or an
2643
+ * assert fail. A clause under `any` only counts if every sibling branch also
2644
+ * rejects the stale serve, because `any` is satisfied by the branch that still
2645
+ * passes (`status_2xx` on a stale 200 being the obvious one).
2646
+ */
2647
+ function rejectsStaleServe(node, bindings, negated = false) {
2648
+ if (!isLintRecord(node))
2649
+ return false;
2650
+ if (node.kind === "not")
2651
+ return rejectsStaleServe(node.clause, bindings, !negated);
2652
+ if (node.kind === "all" || node.kind === "any") {
2653
+ if (!Array.isArray(node.clauses))
2654
+ return false;
2655
+ // `all` fails if ANY clause fails; `any` fails only if EVERY clause fails.
2656
+ // Negation swaps which of the two the operator behaves like.
2657
+ const conjunctive = negated ? node.kind === "any" : node.kind === "all";
2658
+ return conjunctive
2659
+ ? node.clauses.some((clause) => rejectsStaleServe(clause, bindings, negated))
2660
+ : node.clauses.every((clause) => rejectsStaleServe(clause, bindings, negated));
2661
+ }
2662
+ if (node.kind === "predicate")
2663
+ return predicateRejectsStaleServe(node, bindings, negated);
2664
+ return false;
2665
+ }
2666
+ /**
2667
+ * Whether a guard's degrade lands on the operation the case is about. A guard
2668
+ * whose condition reads the checked operation's freshness but attributes the
2669
+ * failure to a different operation leaves the checked one green through the
2670
+ * stale serve — covered on paper, not on the status page.
2671
+ */
2672
+ /**
2673
+ * Whether an assert records its verdict against the checked operation. Same
2674
+ * concern as `guardAttributesTo`: an assertion that covers only some other
2675
+ * operation fails that one, not this one.
2676
+ */
2677
+ function assertCoversOperation(coversOperations, operationId) {
2678
+ return Array.isArray(coversOperations) && coversOperations.includes(operationId);
2679
+ }
2680
+ function guardAttributesTo(onFail, operationId) {
2681
+ if (!isLintRecord(onFail) || !Array.isArray(onFail.attribute))
2682
+ return false;
2683
+ return onFail.attribute.some((entry) => isLintRecord(entry) && entry.operationId === operationId);
2684
+ }
2685
+ /**
2686
+ * Whether a scenario reaches a verdict on freshness: a `guard` condition
2687
+ * (degraded, the shape this rule recommends) or an `assert` expression (down,
2688
+ * harsher but still real coverage). Any other position — an extract selector,
2689
+ * an operation input — is not a verdict.
2690
+ *
2691
+ * Order matters, and this is the rule the guidance states: the freshness
2692
+ * verdict must come before any OTHER guard. Every guard stops the scenario when
2693
+ * it fires, so a row or emptiness guard placed first answers a stale serve that
2694
+ * happens to be empty with its own reason and the freshness guard never runs —
2695
+ * the probe degrades for the wrong cause and the tenant reads "no rows" during
2696
+ * an upstream outage. Asserts before it are fine; they are the evidence clauses
2697
+ * and only stop the scenario by failing it outright.
2698
+ */
2699
+ function scenarioChecksFreshness(scenario, operationId) {
2700
+ if (!isLintRecord(scenario) || !Array.isArray(scenario.steps))
2701
+ return false;
2702
+ const bindings = [...operationStepBindings(scenario.steps, operationId)];
2703
+ if (bindings.length === 0)
2704
+ return false;
2705
+ // EVERY read of the operation must be checked. A scenario that invokes it
2706
+ // twice with different inputs has two chances to serve stale, and one fresh
2707
+ // read must not conceal the other.
2708
+ return bindings.every((binding) => resultBindingChecksFreshness(scenario.steps, binding, operationId, bindings));
2709
+ }
2710
+ function resultBindingChecksFreshness(steps, binding, operationId, siblingBindings) {
2711
+ const operand = new Set([binding]);
2712
+ let produced = false;
2713
+ for (const step of steps) {
2714
+ if (!isLintRecord(step))
2715
+ continue;
2716
+ if (!produced) {
2717
+ // Nothing before the read can have checked its freshness.
2718
+ produced = step.kind === "operation" && step.result === binding;
2719
+ continue;
2720
+ }
2721
+ if (step.kind === "assert" &&
2722
+ rejectsStaleServe(step.expression, operand) &&
2723
+ assertCoversOperation(step.coversOperations, operationId)) {
2724
+ return true;
2725
+ }
2726
+ if (step.kind !== "guard")
2727
+ continue;
2728
+ if (rejectsStaleServe(step.condition, operand)) {
2729
+ return guardAttributesTo(step.onFail, operationId);
2730
+ }
2731
+ // A freshness guard for ANOTHER read of the same operation is not a row or
2732
+ // emptiness guard masking the cause: it degrades on exactly this concern,
2733
+ // so `read A, read B, guard A, guard B` covers both. Anything else stops
2734
+ // the scenario first and shadows a later freshness guard.
2735
+ const coversSibling = siblingBindings.some((sibling) => sibling !== binding &&
2736
+ rejectsStaleServe(step.condition, new Set([sibling])) &&
2737
+ guardAttributesTo(step.onFail, operationId));
2738
+ if (!coversSibling)
2739
+ return false;
2740
+ }
2741
+ return false;
2742
+ }
2743
+ /**
2744
+ * Whether a freshness guard for this operation still attributes
2745
+ * `expected_absence` — the only code the attribution schema admitted before
2746
+ * `served_stale_cache` existed, so earlier guidance recommended it. It IS
2747
+ * freshness coverage (the guard runs and stops the scenario), which is why
2748
+ * `scenarioChecksFreshness` accepts it; but the platform publishes
2749
+ * `expected_absence` as a non-incident status, so the stale serve it catches
2750
+ * would read as "nothing to return" instead of `degraded`.
2751
+ */
2752
+ function freshnessGuardAttributesExpectedAbsence(scenario, operationId) {
2753
+ if (!isLintRecord(scenario) || !Array.isArray(scenario.steps))
2754
+ return false;
2755
+ const bindings = operationStepBindings(scenario.steps, operationId);
2756
+ if (bindings.size === 0)
2757
+ return false;
2758
+ return scenario.steps.some((step) => isLintRecord(step) &&
2759
+ step.kind === "guard" &&
2760
+ rejectsStaleServe(step.condition, bindings) &&
2761
+ isLintRecord(step.onFail) &&
2762
+ Array.isArray(step.onFail.attribute) &&
2763
+ step.onFail.attribute.some((entry) => isLintRecord(entry) &&
2764
+ entry.operationId === operationId &&
2765
+ entry.reasonCode === "expected_absence"));
2766
+ }
2767
+ /** How many uncovered probes the stale-serve message names before eliding. */
2768
+ const STALE_SERVE_MESSAGE_PROBE_LIMIT = 8;
2769
+ /**
2770
+ * Health-check cases the platform monitor cannot execute, and stale-if-error
2771
+ * serves no probe can see.
2772
+ *
2773
+ * The monitor runs a case's SERIALIZED `scenario` and nothing else — a closure
2774
+ * cannot cross the registry's serialization boundary. A case carrying only
2775
+ * `assertions` is still published as a probe, but the registry projects it as
2776
+ * `legacy_function_assertions_not_serializable` and the monitor answers
2777
+ * `unknown` / `monitoring_unavailable` for it forever: the operation reads as
2778
+ * monitored on the status page while nothing is checked.
2779
+ *
2780
+ * A provider that reads its upstream through `ctx.cache.getOrSet(..., {
2781
+ * staleIfErrorMs })` answers an upstream outage with HTTP 200 and a
2782
+ * schema-valid body for the whole stale window, so every clause over
2783
+ * `status_code` and `data` passes and the probe rolls up `ok` through the
2784
+ * outage. `served_stale_cache` on the operation step result is the only operand
2785
+ * that separates that from a live read.
2786
+ *
2787
+ * Both are warnings while the fleet migrates; they become errors once no
2788
+ * repository carries an unmonitored case.
2789
+ */
2790
+ function lintHealthCheckMonitorability(provider) {
2791
+ const diagnostics = [];
2792
+ const providerLabel = provider.id ? `Provider "${provider.id}"` : "Provider";
2793
+ // Freshness is judged per CASE. Operation-level bookkeeping would let one
2794
+ // guarded case silence its unguarded siblings, which still report `ok`
2795
+ // through a stale serve — the same fail-open this rule exists to close.
2796
+ const uncoveredByOperation = new Map();
2797
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
2798
+ const cases = readHealthCheckCases(operation.healthCheck);
2799
+ const unmonitored = [];
2800
+ const absenceAttributed = [];
2801
+ for (const [caseIndex, healthCase] of cases.entries()) {
2802
+ const caseLabel = typeof healthCase.name === "string" && healthCase.name.length > 0
2803
+ ? `"${healthCase.name}"`
2804
+ : `[${caseIndex}]`;
2805
+ if (healthCase.scenario === undefined) {
2806
+ unmonitored.push(caseLabel);
2807
+ }
2808
+ if (!scenarioChecksFreshness(healthCase.scenario, operationKey)) {
2809
+ const uncovered = uncoveredByOperation.get(operationKey) ?? [];
2810
+ uncovered.push(`${operationKey} ${caseLabel}`);
2811
+ uncoveredByOperation.set(operationKey, uncovered);
2812
+ }
2813
+ if (freshnessGuardAttributesExpectedAbsence(healthCase.scenario, operationKey)) {
2814
+ absenceAttributed.push(caseLabel);
2815
+ }
2816
+ }
2817
+ if (absenceAttributed.length > 0) {
2818
+ diagnostics.push({
2819
+ rule: "health-check-stale-serve-reason",
2820
+ level: "warn",
2821
+ field: `operations.${operationKey}.healthCheck.cases`,
2822
+ 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.`,
2823
+ });
2824
+ }
2825
+ if (unmonitored.length > 0) {
2826
+ diagnostics.push({
2827
+ rule: "health-check-assertions-not-monitored",
2828
+ level: "warn",
2829
+ field: `operations.${operationKey}.healthCheck.cases`,
2830
+ 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({ ... }).`,
2831
+ });
2832
+ }
2833
+ }
2834
+ if (uncoveredByOperation.size === 0)
2835
+ return diagnostics;
2836
+ // Attribution. A handler that carries the cache call in its OWN source names
2837
+ // exactly the operation that serves stale. A file does not: the linter cannot
2838
+ // follow the call graph, and it cannot reliably tell a handler's own file
2839
+ // from a shared helper either — `getOperationSource` returns the runtime's
2840
+ // `handler.toString()`, which type erasure and reformatting make textually
2841
+ // unequal to the TypeScript on disk. So any file-level evidence degrades the
2842
+ // verdict to "somewhere in this provider", and the message says so instead of
2843
+ // implying every listed probe reads through that cache.
2844
+ const sources = new Set();
2845
+ const operationsServingStale = new Set();
2846
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
2847
+ if (servesStaleIfError(getOperationSource(operation), `${operationKey}.ts`)) {
2848
+ sources.add(`operations.${operationKey} handler`);
2849
+ operationsServingStale.add(operationKey);
2850
+ }
2851
+ }
2852
+ let unattributed = false;
2853
+ for (const [filePath, source] of Object.entries(provider.providerSourceFiles ?? {})) {
2854
+ if (!isServingProviderSourcePath(filePath) || !servesStaleIfError(source, filePath))
2855
+ continue;
2856
+ sources.add(filePath);
2857
+ unattributed = true;
2858
+ }
2859
+ if (sources.size === 0)
2860
+ return diagnostics;
2861
+ const attributable = !unattributed;
2862
+ const uncovered = [...uncoveredByOperation]
2863
+ .filter(([operationKey]) => !attributable || operationsServingStale.has(operationKey))
2864
+ .flatMap(([, probes]) => probes);
2865
+ if (uncovered.length === 0)
2866
+ return diagnostics;
2867
+ const elided = uncovered.length - STALE_SERVE_MESSAGE_PROBE_LIMIT;
2868
+ const named = uncovered
2869
+ .slice(0, STALE_SERVE_MESSAGE_PROBE_LIMIT)
2870
+ .join(", ")
2871
+ .concat(elided > 0 ? `, and ${elided} more` : "");
2872
+ const caveat = attributable
2873
+ ? ""
2874
+ : ` 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.`;
2875
+ diagnostics.push({
2876
+ rule: "health-check-stale-serve-unguarded",
2877
+ level: "warn",
2878
+ field: "healthCheck",
2879
+ 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}`,
2880
+ });
2881
+ return diagnostics;
2882
+ }
2883
+ export function lintOperation(op) {
2884
+ const diagnostics = [];
2885
+ const hasDescriptionKey = typeof op.descriptionKey === "string" && op.descriptionKey.length > 0;
2886
+ if (!hasDescriptionKey) {
2887
+ diagnostics.push({
2888
+ rule: "description-key-required",
2889
+ level: "error",
2890
+ field: "descriptionKey",
2891
+ message: "Operation must declare a locale-backed descriptionKey.",
2892
+ });
2893
+ }
2894
+ diagnostics.push(...collectSchemaDescriptionKeyDiagnostics(op.input, "input"), ...collectSchemaDescriptionKeyDiagnostics(op.output, "output"));
2895
+ if (!hasBidirectionalFixtures(op.fixtures)) {
2896
+ diagnostics.push({
2897
+ rule: "fixtures-both-directions",
2898
+ level: "error",
2899
+ field: "fixtures",
2900
+ message: "Fixtures must include both request and response.",
2901
+ });
2902
+ }
2903
+ for (const field of uniqueFields(collectUnmarkedSensitiveFields(op.input, "input"))) {
2904
+ diagnostics.push({
2905
+ rule: "sensitive-field-unmarked",
2906
+ level: "warn",
2907
+ field,
2908
+ message: `Schema field "${field}" looks sensitive; mark it with fields.*() or sensitive(...); publicField(...) is accepted only for reviewed public phone-shaped fields.`,
2909
+ });
2910
+ }
2911
+ for (const field of uniqueFields(collectUnmarkedSensitiveFields(op.output, "output"))) {
2912
+ diagnostics.push({
2913
+ rule: "sensitive-field-unmarked",
2914
+ level: "warn",
2915
+ field,
2916
+ message: `Schema field "${field}" looks sensitive; mark it with fields.*() or sensitive(...); publicField(...) is accepted only for reviewed public phone-shaped fields.`,
2917
+ });
2918
+ }
2919
+ return diagnostics;
2920
+ }
2921
+ function declaredPinnedWireFieldPaths(provider) {
2922
+ const pins = provider.meta?.contract?.pinnedWireFieldPaths;
2923
+ if (!Array.isArray(pins))
2924
+ return [];
2925
+ return pins.filter((pin) => pin !== null &&
2926
+ typeof pin === "object" &&
2927
+ typeof pin.path === "string" &&
2928
+ pin.path.trim().length > 0 &&
2929
+ typeof pin.reason === "string" &&
2930
+ pin.reason.trim().length > 0);
2931
+ }
2932
+ function applyPinnedWireFieldPaths(provider, diagnostics) {
2933
+ const pins = declaredPinnedWireFieldPaths(provider);
2934
+ if (pins.length === 0) {
2935
+ return { diagnostics: [...diagnostics], information: [] };
2936
+ }
2937
+ const pinsByPath = new Map(pins.map((pin) => [pin.path, pin]));
2938
+ const matchedPaths = new Set();
2939
+ const remainingDiagnostics = diagnostics.filter((diagnostic) => {
2940
+ if (diagnostic.rule !== "public-schema-upstream-field" ||
2941
+ diagnostic.field === undefined ||
2942
+ !pinsByPath.has(diagnostic.field)) {
2943
+ return true;
2944
+ }
2945
+ matchedPaths.add(diagnostic.field);
2946
+ return false;
2947
+ });
2948
+ const information = [];
2949
+ for (const pin of pins) {
2950
+ if (matchedPaths.has(pin.path)) {
2951
+ information.push({
2952
+ rule: "public-schema-pinned-wire-field",
2953
+ field: pin.path,
2954
+ message: `Suppressed exact public-schema-upstream-field diagnostic. Reason: ${pin.reason}`,
2955
+ });
2956
+ continue;
2957
+ }
2958
+ remainingDiagnostics.push({
2959
+ rule: "public-schema-pinned-wire-field-stale",
2960
+ level: "error",
2961
+ field: pin.path,
2962
+ message: `Pinned wire field path ${JSON.stringify(pin.path)} matches no current public-schema-upstream-field diagnostic; remove the stale declaration.`,
2963
+ });
2964
+ }
2965
+ return { diagnostics: remainingDiagnostics, information };
2966
+ }
2967
+ export function lintProvider(provider, options = {}) {
2968
+ return lintProviderWithInformation(provider, options).diagnostics;
2969
+ }
2970
+ /** Internal detailed result used by the CLI to render suppression audits. */
2971
+ export function lintProviderWithInformation(provider, options = {}) {
2972
+ const diagnostics = [
2973
+ ...lintAllowedHosts(provider.id, provider.allowedHosts),
2974
+ ...lintReviewed(provider.id, provider.reviewed),
2975
+ ...lintAuthModel(provider),
2976
+ ...lintStealthTransportUsage(provider),
2977
+ ...lintCredentialWriteUsage(provider),
2978
+ ...lintPlaywrightDirectImports(provider),
2979
+ ...lintSelfHostedBrowserPatterns(provider, options),
2980
+ ...lintBrowserVersionLiterals(provider),
2981
+ ...lintUndeclaredThrownErrorCodes(provider),
2982
+ ...lintErrorCodeDeclarationConflicts(provider),
2983
+ ...lintErrorMessageLocalization(provider),
2984
+ ...lintLegacyChoiceUsage(provider),
2985
+ ...lintProviderMetaNarrowing(provider),
2986
+ ...lintHandleDeclarations(provider),
2987
+ ...lintHealthCheckMonitorability(provider),
2988
+ ];
2989
+ // Runtime boundary rules (process.env, node:fs/net/child_process, raw
2990
+ // fetch) also yield information entries for `@apifuse-allow` acknowledgements.
2991
+ const runtimeBoundary = lintRuntimeBoundary(getTypeScript(), provider, getOperationSource);
2992
+ diagnostics.push(...runtimeBoundary.diagnostics);
2993
+ if (provider.operations) {
2994
+ const authMode = provider.auth?.mode;
2995
+ for (const [operationKey, operation] of Object.entries(provider.operations)) {
2996
+ if (isCredentialBearingAuthMode(authMode) && operation.connectionMode === undefined) {
2997
+ diagnostics.push({
2998
+ rule: "mixed-auth-connection-mode-required",
2999
+ level: "error",
3000
+ field: `operations.${operationKey}.connectionMode`,
3001
+ message: `Provider "${provider.id ?? "unknown"}" uses credential-bearing auth.mode "${authMode}"; operation "${operationKey}" must declare connectionMode explicitly.`,
3002
+ });
3003
+ }
3004
+ if (operation.riskClass !== undefined &&
3005
+ operation.approval !== undefined &&
3006
+ operation.approval === defaultApprovalPolicy(operation.riskClass)) {
3007
+ diagnostics.push({
3008
+ rule: "redundant-approval",
3009
+ level: "error",
3010
+ field: `operations.${operationKey}.approval`,
3011
+ message: `Operation "${operationKey}" approval "${operation.approval}" repeats the default for riskClass "${operation.riskClass}"; omit approval unless it is a deliberate override.`,
3012
+ });
3013
+ }
3014
+ }
3015
+ // Every authenticated mode owns an auth.flow; `oauth2_proxied` was
3016
+ // previously exempt, which let auth-lifecycle operations ship on
3017
+ // proxied providers unchecked.
3018
+ if (authMode === "credentials" || authMode === "oauth2" || authMode === "oauth2_proxied") {
3019
+ for (const operationKey of Object.keys(provider.operations)) {
3020
+ if (isAuthLifecycleOperationId(operationKey, authMode)) {
3021
+ diagnostics.push({
3022
+ rule: "auth-operation-unsupported",
3023
+ level: "error",
3024
+ field: `operations.${operationKey}`,
3025
+ 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.`,
3026
+ });
3027
+ }
3028
+ }
3029
+ }
3030
+ }
3031
+ if (!provider.operations) {
3032
+ return withInformation(applyPinnedWireFieldPaths(provider, diagnostics), runtimeBoundary.information);
3033
+ }
3034
+ diagnostics.push(...Object.entries(provider.operations).flatMap(([operationKey, operation]) => [
3035
+ ...lintOperation({
3036
+ descriptionKey: operation.descriptionKey,
3037
+ whenToUseKeys: operation.whenToUseKeys,
3038
+ whenNotToUseKeys: operation.whenNotToUseKeys,
3039
+ input: operation.input,
3040
+ output: operation.output,
3041
+ fixtures: operation.fixtures,
3042
+ }),
3043
+ ...lintPublicSchemaFieldNames(provider.id, operationKey, operation.input, operation.output, provider.meta?.contract?.publicSchemaFieldNames === "normalized"),
3044
+ ].map((diagnostic) => ({
3045
+ ...diagnostic,
3046
+ field: diagnostic.field
3047
+ ? `operations.${operationKey}.${diagnostic.field}`
3048
+ : `operations.${operationKey}`,
3049
+ message: `[${operationKey}] ${diagnostic.message}`,
3050
+ }))));
3051
+ return withInformation(applyPinnedWireFieldPaths(provider, diagnostics), runtimeBoundary.information);
3052
+ }
3053
+ function withInformation(result, information) {
3054
+ if (information.length === 0)
3055
+ return result;
3056
+ return { diagnostics: result.diagnostics, information: [...information, ...result.information] };
3057
+ }