@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
@@ -4,28 +4,53 @@ import { type ChildProcess, spawn } from "node:child_process";
4
4
  import { existsSync, readdirSync, readFileSync } from "node:fs";
5
5
  import { writeFile } from "node:fs/promises";
6
6
  import { createServer } from "node:net";
7
- import { basename, dirname, join, relative, resolve } from "node:path";
7
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
 
10
10
  import * as acorn from "acorn";
11
+ import type TS from "typescript";
11
12
  import { z } from "zod";
12
13
 
13
14
  import packageJson from "../package.json";
14
15
  import {
15
- formatPromptAssetIssues,
16
- verifyPromptAssets,
17
- } from "../src/cli/prompt-assets.js";
18
- import type { ProviderDefinition } from "../src/index.js";
16
+ LOCAL_CONNECTION_SECRETS_ENV,
17
+ providerRequiresConnection,
18
+ resolveLocalConnection,
19
+ } from "../src/cli/local-connection.js";
20
+ import { formatPromptAssetIssues, verifyPromptAssets } from "../src/cli/prompt-assets.js";
19
21
  import {
20
22
  loadProviderLocaleCatalogs,
21
23
  type ProviderLocale,
22
24
  validateProviderLocaleCatalogs,
23
25
  } from "../src/i18n/index.js";
24
- import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema.js";
25
- import { safeParseSchemaSync } from "../src/schema.js";
26
+ import type { ProviderDefinition } from "../src/index.js";
27
+ import { APIFUSE_DESCRIPTION_KEY_META_KEY, safeParseSchemaSync } from "../src/schema.js";
28
+ import {
29
+ findStreamCaptureGroup,
30
+ hasStreamEvidenceMarker,
31
+ parseStreamEvidenceRecord,
32
+ } from "../src/stream-evidence.js";
33
+ import { getTypeScript } from "../src/typescript-module.js";
26
34
  import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
35
+ import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
27
36
  import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
28
37
 
38
+ const ts: typeof import("typescript") = loadTypeScript();
39
+
40
+ // Shares the lint rules' loader so a workspace whose `typescript` is the 7.x
41
+ // native shell (no compiler API) falls back to `@typescript/typescript6`
42
+ // instead of failing inside the first parse.
43
+ function loadTypeScript(): typeof import("typescript") {
44
+ try {
45
+ return getTypeScript();
46
+ } catch (error) {
47
+ console.error(
48
+ `apifuse submit-check requires the TypeScript compiler API; install it in the workspace running the CLI (bun add -d typescript). ${error instanceof Error ? error.message : String(error)}`,
49
+ );
50
+ process.exit(1);
51
+ }
52
+ }
53
+
29
54
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
30
55
  const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
31
56
  type BountyTier = (typeof TIERS)[number];
@@ -105,16 +130,38 @@ export type SmokeResult = {
105
130
  healthOk: boolean;
106
131
  bootError?: string;
107
132
  operations: SmokeOperationOutcome[];
133
+ /**
134
+ * Whether the provider's declared auth mode needs a gateway-injected
135
+ * credential, and whether this run had one. Recorded so a provider that
136
+ * could not be exercised at all says so, instead of scoring as "ran, but
137
+ * saw no live success" — the two are not the same evidence.
138
+ */
139
+ credential?: {
140
+ required: boolean;
141
+ supplied: boolean;
142
+ mode: string;
143
+ /**
144
+ * Why the local credential could not be resolved — a malformed
145
+ * `APIFUSE__LOCAL_CONNECTION__SECRETS`, a non-string value, a bad
146
+ * `--credential`. Distinct from "not configured": a misconfiguration
147
+ * reported as an absence sends the author looking for a key they already
148
+ * set.
149
+ */
150
+ error?: string;
151
+ };
108
152
  };
109
153
 
110
154
  type SourceFinding = {
111
155
  file: string;
112
156
  line: number;
157
+ detail?: string;
113
158
  };
114
159
 
115
160
  const SDK_NATIVE_CATEGORY = "sdk-native";
116
161
  const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
117
162
  const MAX_SOURCE_FINDING_EVIDENCE = 5;
163
+ const MAX_LOAD_ERROR_EVIDENCE = 5;
164
+ const MAX_LOAD_ERROR_MESSAGE_LENGTH = 500;
118
165
 
119
166
  const CATEGORY_MAX_POINTS = {
120
167
  definition: 15,
@@ -133,7 +180,9 @@ const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold
133
180
  Alias: apifuse bounty-check [path]
134
181
  Default: apifuse submit-check .
135
182
 
136
- Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring.`;
183
+ Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring.
184
+
185
+ A provider whose auth.mode is not "none" reads a credential the gateway injects at invocation time; smoke attaches the same connection from ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}', keyed as the provider reads them through ctx.credential.get(...). Without it no operation reaches the upstream, and the smoke check says so rather than scoring it as a missing live success.`;
137
186
 
138
187
  export async function main() {
139
188
  try {
@@ -269,7 +318,20 @@ export async function buildSubmitCheckReport(
269
318
  ): Promise<SubmitCheckReport> {
270
319
  const checks: SubmitCheck[] = [];
271
320
  const baseChecks = await safeRunChecks(providerRoot);
272
- const provider = await safeLoadProvider(providerRoot);
321
+ const loadResult = await safeLoadProvider(providerRoot);
322
+ const provider = loadResult.ok ? loadResult.provider : undefined;
323
+ let indexParseError: string | undefined;
324
+ if (!provider) {
325
+ const indexPath = resolve(providerRoot, "index.ts");
326
+ if (existsSync(indexPath)) {
327
+ const relPath = toRelativeProviderPath(providerRoot, indexPath);
328
+ try {
329
+ maskCommentsAndStrings(readFileSync(indexPath, "utf8"), relPath);
330
+ } catch (error) {
331
+ indexParseError = error instanceof Error ? error.message : String(error);
332
+ }
333
+ }
334
+ }
273
335
 
274
336
  // Prompt-asset freshness is reported by its own dedicated zero-point
275
337
  // blocker below; filter the base-check duplicate so it is not double
@@ -280,17 +342,20 @@ export async function buildSubmitCheckReport(
280
342
  ),
281
343
  );
282
344
  checks.push(scorePromptAssetFreshness(providerRoot));
283
- checks.push(scoreProviderIdSlug(providerRoot, provider));
284
- checks.push(scoreNoVendorShim(providerRoot));
285
- checks.push(scoreNoVendorImport(providerRoot));
286
- checks.push(scoreDescribeKey(providerRoot));
287
- checks.push(scoreNoRawFetch(providerRoot));
288
- checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
289
- checks.push(scoreManagedBrowserRuntime(providerRoot));
290
- checks.push(scoreAsAssertionCount(providerRoot));
291
- checks.push(scoreUnsafeInputPassthrough(providerRoot));
292
- checks.push(scoreUnjustifiedLooseSchema(providerRoot));
293
- checks.push(scoreFlatOperationComposition(providerRoot));
345
+ if (!indexParseError) {
346
+ checks.push(scoreProviderIdSlug(providerRoot, provider));
347
+ checks.push(scoreNoVendorShim(providerRoot));
348
+ checks.push(scoreNoVendorImport(providerRoot));
349
+ checks.push(scoreDescribeKey(providerRoot));
350
+ checks.push(scoreNoRawFetch(providerRoot));
351
+ checks.push(scoreNoDynamicCode(providerRoot));
352
+ checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
353
+ checks.push(scoreManagedBrowserRuntime(providerRoot));
354
+ checks.push(scoreAsAssertionCount(providerRoot));
355
+ checks.push(scoreUnsafeInputPassthrough(providerRoot));
356
+ checks.push(scoreUnjustifiedLooseSchema(providerRoot));
357
+ checks.push(scoreFlatOperationComposition(providerRoot));
358
+ }
294
359
 
295
360
  if (provider) {
296
361
  const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
@@ -309,15 +374,38 @@ export async function buildSubmitCheckReport(
309
374
  checks.push(scoreRepositoryDx(providerRoot));
310
375
  checks.push(scoreSecrets(providerRoot, provider));
311
376
  } else {
377
+ const loadEvidence = indexParseError
378
+ ? undefined
379
+ : loadResult.ok
380
+ ? undefined
381
+ : formatLoadErrorEvidence(loadResult.error, providerRoot);
382
+ const loadRemediation = indexParseError
383
+ ? "Fix the syntax error reported by the provider-load-parse blocker before apifuse check can load the provider."
384
+ : !loadResult.ok
385
+ ? "Fix the import/initialization failure shown in evidence so apifuse check can load the provider."
386
+ : "Fix index.ts so it default-exports defineProvider(...).";
312
387
  checks.push(
313
388
  blocker(
314
389
  "provider-load",
315
390
  "definition",
316
391
  "Provider could not be loaded.",
317
- "Fix index.ts so it default-exports defineProvider(...).",
392
+ loadRemediation,
318
393
  CATEGORY_MAX_POINTS.definition,
394
+ loadEvidence,
319
395
  ),
320
396
  );
397
+ if (indexParseError) {
398
+ checks.push(
399
+ blocker(
400
+ "provider-load-parse",
401
+ "definition",
402
+ "Provider index.ts could not be parsed safely.",
403
+ "Fix the syntax error in index.ts before submitting.",
404
+ 0,
405
+ [indexParseError],
406
+ ),
407
+ );
408
+ }
321
409
  }
322
410
 
323
411
  const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
@@ -445,7 +533,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
445
533
  }
446
534
 
447
535
  function scoreNoRawFetch(providerRoot: string): SubmitCheck {
448
- const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
536
+ const findings = findGlobalSinkCalls(providerRoot, FETCH_SINKS);
449
537
  if (findings.length > 0) {
450
538
  const evidence = formatSourceFindings(findings);
451
539
  return blocker(
@@ -461,12 +549,441 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
461
549
  return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
462
550
  }
463
551
 
552
+ const DYNAMIC_CODE_SINKS: ReadonlySet<string> = new Set(["eval", "Function"]);
553
+ const FETCH_SINKS: ReadonlySet<string> = new Set(["fetch"]);
554
+ const DYNAMIC_CODE_GLOBAL_OBJECTS: ReadonlySet<string> = new Set([
555
+ "globalThis",
556
+ "window",
557
+ "self",
558
+ "global",
559
+ ]);
560
+
561
+ function unwrapLocalExpression(expression: TS.Expression): TS.Expression {
562
+ let current = expression;
563
+ while (
564
+ ts.isParenthesizedExpression(current) ||
565
+ ts.isAsExpression(current) ||
566
+ ts.isSatisfiesExpression(current) ||
567
+ ts.isNonNullExpression(current)
568
+ ) {
569
+ current = current.expression;
570
+ }
571
+ if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) {
572
+ return unwrapLocalExpression(current.right);
573
+ }
574
+ return current;
575
+ }
576
+
577
+ type LocalBinding = {
578
+ declaration: TS.Identifier;
579
+ initializer?: TS.Expression;
580
+ destructuredProperty?: string;
581
+ mutable: boolean;
582
+ reassigned: boolean;
583
+ };
584
+
585
+ type LocalBindings = {
586
+ scopes: Map<TS.Node, Map<string, LocalBinding[]>>;
587
+ };
588
+
589
+ function isLexicalScope(node: TS.Node): boolean {
590
+ return (
591
+ ts.isSourceFile(node) ||
592
+ ts.isFunctionLike(node) ||
593
+ ts.isBlock(node) ||
594
+ ts.isModuleBlock(node) ||
595
+ ts.isCaseBlock(node) ||
596
+ ts.isCatchClause(node) ||
597
+ ts.isForStatement(node) ||
598
+ ts.isForInStatement(node) ||
599
+ ts.isForOfStatement(node)
600
+ );
601
+ }
602
+
603
+ function enclosingScope(node: TS.Node, functionScoped: boolean): TS.Node {
604
+ let current: TS.Node | undefined = node.parent;
605
+ while (current !== undefined) {
606
+ if (
607
+ ts.isSourceFile(current) ||
608
+ (functionScoped ? ts.isFunctionLike(current) : isLexicalScope(current))
609
+ ) {
610
+ return current;
611
+ }
612
+ current = current.parent;
613
+ }
614
+ return node.getSourceFile();
615
+ }
616
+
617
+ function collectLocalBindings(sourceFile: TS.SourceFile): LocalBindings {
618
+ const bindings: LocalBindings = { scopes: new Map() };
619
+ const addBinding = (
620
+ scope: TS.Node,
621
+ name: TS.Identifier,
622
+ binding: Omit<LocalBinding, "declaration" | "reassigned">,
623
+ ): void => {
624
+ let scopeBindings = bindings.scopes.get(scope);
625
+ if (scopeBindings === undefined) {
626
+ scopeBindings = new Map();
627
+ bindings.scopes.set(scope, scopeBindings);
628
+ }
629
+ const existing = scopeBindings.get(name.text);
630
+ const localBinding: LocalBinding = { ...binding, declaration: name, reassigned: false };
631
+ if (existing === undefined) {
632
+ scopeBindings.set(name.text, [localBinding]);
633
+ } else {
634
+ existing.push(localBinding);
635
+ }
636
+ };
637
+ const addBindingName = (
638
+ bindingName: TS.BindingName,
639
+ scope: TS.Node,
640
+ binding: Omit<LocalBinding, "declaration" | "reassigned">,
641
+ ): void => {
642
+ if (ts.isIdentifier(bindingName)) {
643
+ addBinding(scope, bindingName, binding);
644
+ return;
645
+ }
646
+ for (const element of bindingName.elements) {
647
+ if (ts.isOmittedExpression(element)) {
648
+ continue;
649
+ }
650
+ if (ts.isObjectBindingPattern(bindingName) && !element.dotDotDotToken) {
651
+ const property = element.propertyName ?? element.name;
652
+ const propertyName = propertyNameText(property);
653
+ if (propertyName !== undefined && ts.isIdentifier(element.name)) {
654
+ addBinding(scope, element.name, {
655
+ initializer: binding.initializer,
656
+ destructuredProperty: propertyName,
657
+ mutable: binding.mutable,
658
+ });
659
+ continue;
660
+ }
661
+ }
662
+ addBindingName(element.name, scope, { mutable: binding.mutable });
663
+ }
664
+ };
665
+ const visit = (node: TS.Node): void => {
666
+ if (ts.isVariableDeclaration(node)) {
667
+ const declarationList = ts.isVariableDeclarationList(node.parent) ? node.parent : undefined;
668
+ const isConstBinding =
669
+ declarationList !== undefined && (declarationList.flags & ts.NodeFlags.Const) !== 0;
670
+ const isBlockScoped =
671
+ declarationList !== undefined && (declarationList.flags & ts.NodeFlags.BlockScoped) !== 0;
672
+ addBindingName(node.name, enclosingScope(node, !isBlockScoped), {
673
+ initializer: node.initializer,
674
+ mutable: !isConstBinding,
675
+ });
676
+ } else if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
677
+ addBinding(enclosingScope(node, false), node.name, { mutable: false });
678
+ } else if (ts.isFunctionExpression(node) && node.name !== undefined) {
679
+ addBinding(node, node.name, { mutable: false });
680
+ } else if (ts.isClassDeclaration(node) && node.name !== undefined) {
681
+ addBinding(enclosingScope(node, false), node.name, { mutable: false });
682
+ } else if (ts.isClassExpression(node) && node.name !== undefined) {
683
+ addBinding(node, node.name, { mutable: false });
684
+ } else if (ts.isParameter(node)) {
685
+ addBindingName(node.name, enclosingScope(node, true), { mutable: true });
686
+ } else if (ts.isImportClause(node) && node.name !== undefined) {
687
+ addBinding(sourceFile, node.name, { mutable: false });
688
+ } else if (ts.isNamespaceImport(node) || ts.isImportSpecifier(node)) {
689
+ addBinding(sourceFile, node.name, { mutable: false });
690
+ }
691
+ ts.forEachChild(node, visit);
692
+ };
693
+ visit(sourceFile);
694
+
695
+ const markReassigned = (identifier: TS.Identifier): void => {
696
+ for (const binding of lookupLocalBindings(identifier, bindings) ?? []) {
697
+ binding.reassigned = true;
698
+ }
699
+ };
700
+ const markAssignmentTarget = (node: TS.Node): void => {
701
+ const target = ts.isExpression(node) ? unwrapLocalExpression(node) : node;
702
+ if (ts.isIdentifier(target)) {
703
+ markReassigned(target);
704
+ return;
705
+ }
706
+ if (ts.isObjectLiteralExpression(target)) {
707
+ for (const element of target.properties) {
708
+ if (ts.isSpreadAssignment(element)) {
709
+ markAssignmentTarget(element.expression);
710
+ } else if (ts.isPropertyAssignment(element)) {
711
+ markAssignmentTarget(element.initializer);
712
+ } else if (ts.isShorthandPropertyAssignment(element)) {
713
+ markAssignmentTarget(element.name);
714
+ }
715
+ }
716
+ } else if (ts.isArrayLiteralExpression(target)) {
717
+ for (const element of target.elements) {
718
+ if (!ts.isOmittedExpression(element)) {
719
+ markAssignmentTarget(ts.isSpreadElement(element) ? element.expression : element);
720
+ }
721
+ }
722
+ }
723
+ };
724
+ const visitAssignments = (node: TS.Node): void => {
725
+ if (
726
+ ts.isBinaryExpression(node) &&
727
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
728
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
729
+ ) {
730
+ markAssignmentTarget(node.left);
731
+ } else if (
732
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
733
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
734
+ node.operator === ts.SyntaxKind.MinusMinusToken)
735
+ ) {
736
+ markAssignmentTarget(node.operand);
737
+ } else if (
738
+ (ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
739
+ !ts.isVariableDeclarationList(node.initializer)
740
+ ) {
741
+ markAssignmentTarget(node.initializer);
742
+ }
743
+ ts.forEachChild(node, visitAssignments);
744
+ };
745
+ visitAssignments(sourceFile);
746
+ return bindings;
747
+ }
748
+
749
+ function lookupLocalBindings(
750
+ reference: TS.Identifier,
751
+ bindings: LocalBindings,
752
+ ): readonly LocalBinding[] | undefined {
753
+ let current: TS.Node | undefined = reference;
754
+ while (current !== undefined) {
755
+ const localBindings = bindings.scopes.get(current)?.get(reference.text);
756
+ if (localBindings !== undefined && localBindings.length > 0) {
757
+ return localBindings;
758
+ }
759
+ current = current.parent;
760
+ }
761
+ return undefined;
762
+ }
763
+
764
+ function propertyNameText(name: TS.PropertyName | TS.BindingName): string | undefined {
765
+ if (
766
+ ts.isIdentifier(name) ||
767
+ ts.isStringLiteral(name) ||
768
+ ts.isNumericLiteral(name) ||
769
+ ts.isNoSubstitutionTemplateLiteral(name)
770
+ ) {
771
+ return name.text;
772
+ }
773
+ if (ts.isComputedPropertyName(name)) {
774
+ const expression = unwrapLocalExpression(name.expression);
775
+ if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
776
+ return expression.text;
777
+ }
778
+ }
779
+ return undefined;
780
+ }
781
+
782
+ function isUnshadowedGlobalObject(reference: TS.Expression, bindings: LocalBindings): boolean {
783
+ const receiver = unwrapLocalExpression(reference);
784
+ return (
785
+ ts.isIdentifier(receiver) &&
786
+ DYNAMIC_CODE_GLOBAL_OBJECTS.has(receiver.text) &&
787
+ lookupLocalBindings(receiver, bindings) === undefined
788
+ );
789
+ }
790
+
791
+ function isGlobalMemberSink(
792
+ expression: TS.Expression,
793
+ sinkNames: ReadonlySet<string>,
794
+ bindings: LocalBindings,
795
+ ): boolean {
796
+ const callee = unwrapLocalExpression(expression);
797
+ let sinkName: string | undefined;
798
+ let receiver: TS.Expression | undefined;
799
+ if (ts.isPropertyAccessExpression(callee)) {
800
+ sinkName = callee.name.text;
801
+ receiver = callee.expression;
802
+ } else if (ts.isElementAccessExpression(callee)) {
803
+ const argument = callee.argumentExpression;
804
+ if (
805
+ argument !== undefined &&
806
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
807
+ ) {
808
+ sinkName = argument.text;
809
+ receiver = callee.expression;
810
+ }
811
+ }
812
+ if (sinkName === undefined || receiver === undefined || !sinkNames.has(sinkName)) {
813
+ return false;
814
+ }
815
+ return isUnshadowedGlobalObject(receiver, bindings);
816
+ }
817
+
818
+ function isGlobalSinkReference(
819
+ expression: TS.Expression,
820
+ sinkNames: ReadonlySet<string>,
821
+ bindings: LocalBindings,
822
+ resolving: ReadonlySet<LocalBinding>,
823
+ ): boolean {
824
+ const reference = unwrapLocalExpression(expression);
825
+ if (isGlobalMemberSink(reference, sinkNames, bindings)) {
826
+ return true;
827
+ }
828
+ if (!ts.isIdentifier(reference)) {
829
+ return false;
830
+ }
831
+ return isGlobalSinkIdentifier(reference, sinkNames, bindings, resolving);
832
+ }
833
+
834
+ function isGlobalSinkIdentifier(
835
+ reference: TS.Identifier,
836
+ sinkNames: ReadonlySet<string>,
837
+ bindings: LocalBindings,
838
+ resolving: ReadonlySet<LocalBinding>,
839
+ ): boolean {
840
+ const localBindings = lookupLocalBindings(reference, bindings);
841
+ if (localBindings === undefined || localBindings.length === 0) {
842
+ return sinkNames.has(reference.text);
843
+ }
844
+ return localBindings.every((binding) => {
845
+ if (resolving.has(binding) || binding.initializer === undefined) {
846
+ return false;
847
+ }
848
+ // A mutable alias is safe to resolve only when no assignment targeting
849
+ // this lexical binding exists in its scope (including nested closures).
850
+ // Reassigned let/var aliases remain unresolved to avoid false positives.
851
+ if (binding.mutable && binding.reassigned) {
852
+ return false;
853
+ }
854
+ const nextResolving = new Set(resolving);
855
+ nextResolving.add(binding);
856
+ if (binding.destructuredProperty !== undefined) {
857
+ return (
858
+ sinkNames.has(binding.destructuredProperty) &&
859
+ isUnshadowedGlobalObject(binding.initializer, bindings)
860
+ );
861
+ }
862
+ return isGlobalSinkReference(binding.initializer, sinkNames, bindings, nextResolving);
863
+ });
864
+ }
865
+
866
+ function isGlobalSinkCallee(
867
+ expression: TS.Expression,
868
+ sinkNames: ReadonlySet<string>,
869
+ bindings: LocalBindings,
870
+ ): boolean {
871
+ const callee = unwrapLocalExpression(expression);
872
+ if (isGlobalMemberSink(callee, sinkNames, bindings)) {
873
+ return true;
874
+ }
875
+ if (!ts.isIdentifier(callee)) {
876
+ return false;
877
+ }
878
+ return isGlobalSinkIdentifier(callee, sinkNames, bindings, new Set());
879
+ }
880
+
881
+ function resolveConstStringIdentifier(
882
+ reference: TS.Identifier,
883
+ bindings: LocalBindings,
884
+ resolving: ReadonlySet<LocalBinding> = new Set(),
885
+ ): string | undefined {
886
+ const localBindings = lookupLocalBindings(reference, bindings);
887
+ if (localBindings === undefined || localBindings.length === 0) {
888
+ return undefined;
889
+ }
890
+ let resolved: string | undefined;
891
+ for (const binding of localBindings) {
892
+ if (
893
+ resolving.has(binding) ||
894
+ binding.mutable ||
895
+ binding.initializer === undefined ||
896
+ binding.destructuredProperty !== undefined
897
+ ) {
898
+ return undefined;
899
+ }
900
+ const nextResolving = new Set(resolving);
901
+ nextResolving.add(binding);
902
+ const initializer = unwrapLocalExpression(binding.initializer);
903
+ let value: string | undefined;
904
+ if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
905
+ value = initializer.text;
906
+ } else if (ts.isIdentifier(initializer)) {
907
+ value = resolveConstStringIdentifier(initializer, bindings, nextResolving);
908
+ }
909
+ if (value === undefined || (resolved !== undefined && resolved !== value)) {
910
+ return undefined;
911
+ }
912
+ resolved = value;
913
+ }
914
+ return resolved;
915
+ }
916
+
917
+ function findGlobalSinkCalls(
918
+ providerRoot: string,
919
+ sinkNames: ReadonlySet<string>,
920
+ includeConstructors = false,
921
+ ): SourceFinding[] {
922
+ const findings: SourceFinding[] = [];
923
+ const seen = new Set<string>();
924
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
925
+ const source = readFileSync(filePath, "utf8");
926
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
927
+ const sourceFile = ts.createSourceFile(
928
+ relPath,
929
+ source,
930
+ ts.ScriptTarget.Latest,
931
+ true,
932
+ ts.ScriptKind.TS,
933
+ );
934
+ const bindings = collectLocalBindings(sourceFile);
935
+ const visit = (node: TS.Node): void => {
936
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
937
+ return;
938
+ }
939
+ if (
940
+ (ts.isCallExpression(node) || (includeConstructors && ts.isNewExpression(node))) &&
941
+ isGlobalSinkCallee(node.expression, sinkNames, bindings)
942
+ ) {
943
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
944
+ const key = `${relPath}:${line}`;
945
+ if (!seen.has(key)) {
946
+ seen.add(key);
947
+ findings.push({ file: relPath, line });
948
+ }
949
+ }
950
+ ts.forEachChild(node, visit);
951
+ };
952
+ visit(sourceFile);
953
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
954
+ break;
955
+ }
956
+ }
957
+ return findings;
958
+ }
959
+
960
+ function scoreNoDynamicCode(providerRoot: string): SubmitCheck {
961
+ const findings = findGlobalSinkCalls(providerRoot, DYNAMIC_CODE_SINKS, true);
962
+ if (findings.length > 0) {
963
+ return blocker(
964
+ "no-dynamic-code",
965
+ SDK_NATIVE_CATEGORY,
966
+ "Dynamic code evaluation is not permitted in provider source.",
967
+ "Remove eval() and Function constructor calls. Provider HTTP access must go through ctx.http.",
968
+ 0,
969
+ formatSourceFindings(findings),
970
+ );
971
+ }
972
+
973
+ return pass(
974
+ "no-dynamic-code",
975
+ SDK_NATIVE_CATEGORY,
976
+ "Provider source avoids dynamic code evaluation.",
977
+ 0,
978
+ );
979
+ }
980
+
464
981
  const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
465
- /\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\?\./,
982
+ /\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|handle)\?\./,
466
983
  ];
467
984
 
468
985
  const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
469
- /\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\.(?:\w+)/;
986
+ /\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|handle)\.(?:\w+)/;
470
987
 
471
988
  function hasRedundantRuntimeGuard(line: string, remainingLines: readonly string[]): boolean {
472
989
  if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
@@ -516,7 +1033,9 @@ function countAsAssertions(providerRoot: string): {
516
1033
 
517
1034
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
518
1035
  const content = readFileSync(filePath, "utf8");
519
- const lines = content.split(/\r?\n/);
1036
+ const lines = maskCommentsAndStrings(content, toRelativeProviderPath(providerRoot, filePath), {
1037
+ blankPropertyKeys: true,
1038
+ }).split(/\r?\n/);
520
1039
  for (let index = 0; index < lines.length; index += 1) {
521
1040
  const line = lines[index];
522
1041
  if (
@@ -707,18 +1226,44 @@ function offsetToLine(source: string, offset: number): number {
707
1226
  // between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
708
1227
  const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
709
1228
 
1229
+ type ExpressionSlice = {
1230
+ raw: string;
1231
+ masked: string;
1232
+ };
1233
+
1234
+ function trimExpressionSlice(expr: ExpressionSlice): ExpressionSlice {
1235
+ const start = expr.raw.length - expr.raw.trimStart().length;
1236
+ const end = expr.raw.trimEnd().length;
1237
+ return {
1238
+ raw: expr.raw.slice(start, end),
1239
+ masked: expr.masked.slice(start, end),
1240
+ };
1241
+ }
1242
+
1243
+ function balancedExpressionSlice(
1244
+ source: string,
1245
+ valueStart: number,
1246
+ fileName?: string,
1247
+ ): ExpressionSlice {
1248
+ const raw = balancedValueExpression(source, valueStart, fileName);
1249
+ return trimExpressionSlice({
1250
+ raw,
1251
+ masked: maskCommentsAndStrings(source, fileName).slice(valueStart, valueStart + raw.length),
1252
+ });
1253
+ }
1254
+
710
1255
  // Strips redundant wrapping parentheses from an expression so that a value like
711
1256
  // `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
712
1257
  // Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
713
1258
  // the whole expression is parenthesized), preserving call expressions such as
714
1259
  // `makeOperations()` whose first `(` is not a wrapper.
715
- function unwrapParens(expr: string): string {
716
- let value = expr.trim();
717
- while (value.startsWith("(")) {
1260
+ function unwrapParens(expr: ExpressionSlice): ExpressionSlice {
1261
+ let value = trimExpressionSlice(expr);
1262
+ while (value.raw.startsWith("(")) {
718
1263
  let depth = 0;
719
1264
  let matchIndex = -1;
720
- for (let i = 0; i < value.length; i += 1) {
721
- const ch = value[i];
1265
+ for (let i = 0; i < value.raw.length; i += 1) {
1266
+ const ch = value.masked[i];
722
1267
  if (ch === "(") {
723
1268
  depth += 1;
724
1269
  } else if (ch === ")") {
@@ -731,8 +1276,11 @@ function unwrapParens(expr: string): string {
731
1276
  }
732
1277
  // Only a true wrapper spans the entire expression (closing paren is the
733
1278
  // last char). Otherwise the leading `(` belongs to a sub-expression.
734
- if (matchIndex === value.length - 1) {
735
- value = value.slice(1, -1).trim();
1279
+ if (matchIndex === value.raw.length - 1) {
1280
+ value = trimExpressionSlice({
1281
+ raw: value.raw.slice(1, -1),
1282
+ masked: value.masked.slice(1, -1),
1283
+ });
736
1284
  } else {
737
1285
  break;
738
1286
  }
@@ -744,8 +1292,8 @@ function unwrapParens(expr: string): string {
744
1292
  // across (){}[] and stopping at the first top-level `,`/`;` or unmatched
745
1293
  // closing bracket. This lets a property value be read across newlines, so a
746
1294
  // multi-line `input: z.object({...})\n.passthrough()` is captured whole.
747
- function balancedValueExpression(source: string, valueStart: number): string {
748
- const masked = maskCommentsAndStrings(source);
1295
+ function balancedValueExpression(source: string, valueStart: number, fileName?: string): string {
1296
+ const masked = maskCommentsAndStrings(source, fileName);
749
1297
  let depth = 0;
750
1298
  let index = valueStart;
751
1299
  for (; index < source.length; index += 1) {
@@ -769,14 +1317,14 @@ function balancedValueExpression(source: string, valueStart: number): string {
769
1317
  // nested deeper than the outer object (inside handler bodies, nested objects,
770
1318
  // or arrays) are ignored, so only a factory composition of the object itself
771
1319
  // is detected. Input is expected to start at the outer `{`.
772
- function hasTopLevelFactorySpread(expr: string): boolean {
773
- const open = expr.indexOf("{");
1320
+ function hasTopLevelFactorySpread(expr: ExpressionSlice): boolean {
1321
+ const open = expr.masked.indexOf("{");
774
1322
  if (open === -1) {
775
1323
  return false;
776
1324
  }
777
1325
  let depth = 0;
778
- for (let i = open; i < expr.length; i += 1) {
779
- const ch = expr[i];
1326
+ for (let i = open; i < expr.raw.length; i += 1) {
1327
+ const ch = expr.masked[i];
780
1328
  if (ch === "{" || ch === "(" || ch === "[") {
781
1329
  depth += 1;
782
1330
  } else if (ch === "}" || ch === ")" || ch === "]") {
@@ -784,11 +1332,11 @@ function hasTopLevelFactorySpread(expr: string): boolean {
784
1332
  if (depth === 0) {
785
1333
  break;
786
1334
  }
787
- } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
1335
+ } else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
788
1336
  // A spread at the object's own level. Check whether the spread
789
1337
  // argument is a call expression (factory) rather than a plain
790
1338
  // identifier/member spread of an already-built object.
791
- const rest = expr.slice(i + 3);
1339
+ const rest = expr.raw.slice(i + 3);
792
1340
  if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
793
1341
  return true;
794
1342
  }
@@ -803,15 +1351,15 @@ function hasTopLevelFactorySpread(expr: string): boolean {
803
1351
  // hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
804
1352
  // resolved to their declarations: `const hidden = makeOperations()` spread as
805
1353
  // `{ ...hidden }` is still a factory-composed map and must block.
806
- function topLevelSpreadIdentifiers(expr: string): string[] {
807
- const open = expr.indexOf("{");
1354
+ function topLevelSpreadIdentifiers(expr: ExpressionSlice): string[] {
1355
+ const open = expr.masked.indexOf("{");
808
1356
  if (open === -1) {
809
1357
  return [];
810
1358
  }
811
1359
  const names: string[] = [];
812
1360
  let depth = 0;
813
- for (let i = open; i < expr.length; i += 1) {
814
- const ch = expr[i];
1361
+ for (let i = open; i < expr.raw.length; i += 1) {
1362
+ const ch = expr.masked[i];
815
1363
  if (ch === "{" || ch === "(" || ch === "[") {
816
1364
  depth += 1;
817
1365
  } else if (ch === "}" || ch === ")" || ch === "]") {
@@ -819,8 +1367,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
819
1367
  if (depth === 0) {
820
1368
  break;
821
1369
  }
822
- } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
823
- const rest = expr.slice(i + 3);
1370
+ } else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
1371
+ const rest = expr.raw.slice(i + 3);
824
1372
  // Bare identifier spread (no call parens) -> needs declaration
825
1373
  // resolution. `...obj.prop` member spreads are treated as already
826
1374
  // built and ignored (the leading identifier is captured).
@@ -853,8 +1401,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
853
1401
  // `makeOperations()` — stays classified as factory composition.
854
1402
  const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
855
1403
  const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
856
- function isTransparentObjectReshape(expr: string): boolean {
857
- const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
1404
+ function isTransparentObjectReshape(expr: ExpressionSlice): boolean {
1405
+ const head = TRANSPARENT_RESHAPE_HEAD.exec(expr.masked);
858
1406
  if (!head) {
859
1407
  return false;
860
1408
  }
@@ -862,7 +1410,7 @@ function isTransparentObjectReshape(expr: string): boolean {
862
1410
  // transparent only when that argument's root callee is `Object.entries(`
863
1411
  // (optionally chained: `Object.entries(obj).filter(...)`), so the source
864
1412
  // object is enumerable from source rather than produced by an opaque call.
865
- const firstArg = expr.slice(head[0].length).trimStart();
1413
+ const firstArg = expr.masked.slice(head[0].length).trimStart();
866
1414
  return OBJECT_ENTRIES_HEAD.test(firstArg);
867
1415
  }
868
1416
 
@@ -996,7 +1544,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
996
1544
  continue;
997
1545
  }
998
1546
  const valueStart = match.index + match[0].length;
999
- const value = balancedValueExpression(source, valueStart);
1547
+ const value = balancedValueExpression(source, valueStart, relPath);
1000
1548
  if (PASSTHROUGH_CALL.test(value)) {
1001
1549
  const site: ConstSite = {
1002
1550
  file: relPath,
@@ -1025,8 +1573,8 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1025
1573
  // that is itself a passthrough expression, or that references a passthrough
1026
1574
  // const by name (resolved against the provider-wide map), is a violation.
1027
1575
  for (const filePath of files) {
1028
- const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
1029
1576
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1577
+ const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
1030
1578
 
1031
1579
  const inputProp = /\binput\s*:\s*/g;
1032
1580
  for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
@@ -1037,7 +1585,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1037
1585
  continue;
1038
1586
  }
1039
1587
  const valueStart = match.index + match[0].length;
1040
- const value = balancedValueExpression(source, valueStart);
1588
+ const value = balancedValueExpression(source, valueStart, relPath);
1041
1589
  if (PASSTHROUGH_CALL.test(value)) {
1042
1590
  push({ file: relPath, line: offsetToLine(source, valueStart) });
1043
1591
  continue;
@@ -1148,13 +1696,19 @@ function spreadIdentifierResolvesToFactory(
1148
1696
  if (!existsSync(filePath)) {
1149
1697
  continue;
1150
1698
  }
1699
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
1151
1700
  const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1701
+ const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
1702
+ blankPropertyKeys: true,
1703
+ });
1152
1704
  const re = new RegExp(declRe.source, "g");
1153
- for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
1705
+ for (let m = re.exec(maskedFileSource); m !== null; m = re.exec(maskedFileSource)) {
1154
1706
  sawDeclaration = true;
1155
- const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
1707
+ const expr = unwrapParens(
1708
+ balancedExpressionSlice(fileSource, m.index + m[0].length, relPath),
1709
+ );
1156
1710
  const isFactory =
1157
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1711
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
1158
1712
  !isTransparentObjectReshape(expr);
1159
1713
  if (isFactory) {
1160
1714
  return true;
@@ -1163,12 +1717,232 @@ function spreadIdentifierResolvesToFactory(
1163
1717
  }
1164
1718
  // No local declaration anywhere but imported into index.ts => constructed
1165
1719
  // out of view; treat as factory (conservative, false-negative-safe).
1166
- if (!sawDeclaration && fileImportsBinding(indexSource, name)) {
1720
+ if (
1721
+ !sawDeclaration &&
1722
+ fileImportsBinding(
1723
+ maskCommentsAndStrings(indexSource, "index.ts", { blankPropertyKeys: true }),
1724
+ name,
1725
+ )
1726
+ ) {
1167
1727
  return true;
1168
1728
  }
1169
1729
  return false;
1170
1730
  }
1171
1731
 
1732
+ function isDefineProviderCall(node: TS.CallExpression): boolean {
1733
+ return ts.isIdentifier(node.expression) && node.expression.text === "defineProvider";
1734
+ }
1735
+
1736
+ function resolveDefineProviderImplementationCall(
1737
+ candidate: TS.CallExpression,
1738
+ ): TS.CallExpression | undefined {
1739
+ let root = candidate;
1740
+ while (ts.isCallExpression(root.expression)) {
1741
+ root = root.expression;
1742
+ }
1743
+ return isDefineProviderCall(root) ? candidate : undefined;
1744
+ }
1745
+
1746
+ function findProviderImplementationCall(sourceFile: TS.SourceFile): TS.CallExpression | undefined {
1747
+ const defaultExports = sourceFile.statements.filter(
1748
+ (statement): statement is TS.ExportAssignment =>
1749
+ ts.isExportAssignment(statement) && !statement.isExportEquals,
1750
+ );
1751
+
1752
+ // Phase-separated providers export a builder invocation directly. Preserve
1753
+ // the previous locator's preference for that call over every defineProvider
1754
+ // declaration that may also appear in the file.
1755
+ for (const assignment of defaultExports) {
1756
+ const expression = unwrapImplementationExpression(assignment.expression);
1757
+ if (!ts.isCallExpression(expression)) {
1758
+ continue;
1759
+ }
1760
+ if (resolveDefineProviderImplementationCall(expression) !== undefined) {
1761
+ continue;
1762
+ }
1763
+ if (ts.isIdentifier(expression.expression)) {
1764
+ return expression;
1765
+ }
1766
+ }
1767
+
1768
+ // For `defineProvider(metadata)(implementation)`, select the outer call whose
1769
+ // argument is the implementation. A plain `defineProvider(implementation)`
1770
+ // selects the single call itself.
1771
+ for (const assignment of defaultExports) {
1772
+ const defaultExpression = unwrapImplementationExpression(assignment.expression);
1773
+ if (!ts.isCallExpression(defaultExpression)) {
1774
+ continue;
1775
+ }
1776
+ const implementationCall = resolveDefineProviderImplementationCall(defaultExpression);
1777
+ if (implementationCall !== undefined) {
1778
+ return implementationCall;
1779
+ }
1780
+ }
1781
+
1782
+ // Resolve `export default provider` to a top-level variable initialized by
1783
+ // defineProvider, including its curried implementation call when present.
1784
+ for (const assignment of defaultExports) {
1785
+ if (!ts.isIdentifier(assignment.expression)) {
1786
+ continue;
1787
+ }
1788
+ const exportedName = assignment.expression.text;
1789
+ for (const statement of sourceFile.statements) {
1790
+ if (!ts.isVariableStatement(statement)) {
1791
+ continue;
1792
+ }
1793
+ for (const declaration of statement.declarationList.declarations) {
1794
+ if (
1795
+ !ts.isIdentifier(declaration.name) ||
1796
+ declaration.name.text !== exportedName ||
1797
+ declaration.initializer === undefined
1798
+ ) {
1799
+ continue;
1800
+ }
1801
+ if (!ts.isCallExpression(declaration.initializer)) {
1802
+ continue;
1803
+ }
1804
+ const implementationCall = resolveDefineProviderImplementationCall(declaration.initializer);
1805
+ if (implementationCall !== undefined) {
1806
+ return implementationCall;
1807
+ }
1808
+ }
1809
+ }
1810
+ }
1811
+
1812
+ // Structural fixtures without a recognizable default export retain the
1813
+ // previous fallback to the first defineProvider call in source order.
1814
+ let firstCall: TS.CallExpression | undefined;
1815
+ const visit = (node: TS.Node): void => {
1816
+ if (firstCall !== undefined) {
1817
+ return;
1818
+ }
1819
+ if (ts.isCallExpression(node)) {
1820
+ const implementationCall = resolveDefineProviderImplementationCall(node);
1821
+ if (implementationCall !== undefined) {
1822
+ firstCall = implementationCall;
1823
+ return;
1824
+ }
1825
+ }
1826
+ ts.forEachChild(node, visit);
1827
+ };
1828
+ visit(sourceFile);
1829
+ return firstCall;
1830
+ }
1831
+
1832
+ function unwrapImplementationExpression(expression: TS.Expression): TS.Expression {
1833
+ let current = expression;
1834
+ while (
1835
+ ts.isSatisfiesExpression(current) ||
1836
+ ts.isAsExpression(current) ||
1837
+ ts.isParenthesizedExpression(current) ||
1838
+ ts.isNonNullExpression(current)
1839
+ ) {
1840
+ current = current.expression;
1841
+ }
1842
+ return current;
1843
+ }
1844
+
1845
+ function isOperationsProperty(
1846
+ property: TS.ObjectLiteralElementLike,
1847
+ ): property is TS.PropertyAssignment | TS.ShorthandPropertyAssignment {
1848
+ if (ts.isShorthandPropertyAssignment(property)) {
1849
+ return property.name.text === "operations";
1850
+ }
1851
+ if (!ts.isPropertyAssignment(property)) {
1852
+ return false;
1853
+ }
1854
+ return (
1855
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
1856
+ property.name.text === "operations"
1857
+ );
1858
+ }
1859
+
1860
+ function isOperationsAccessorOrMethod(
1861
+ property: TS.ObjectLiteralElementLike,
1862
+ ): property is TS.GetAccessorDeclaration | TS.SetAccessorDeclaration | TS.MethodDeclaration {
1863
+ if (
1864
+ !ts.isGetAccessorDeclaration(property) &&
1865
+ !ts.isSetAccessorDeclaration(property) &&
1866
+ !ts.isMethodDeclaration(property)
1867
+ ) {
1868
+ return false;
1869
+ }
1870
+ return (
1871
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
1872
+ property.name.text === "operations"
1873
+ );
1874
+ }
1875
+
1876
+ function isSourceEnumerableStaticObjectLiteral(expression: TS.Expression): boolean {
1877
+ const unwrapped = unwrapImplementationExpression(expression);
1878
+ if (!ts.isObjectLiteralExpression(unwrapped)) {
1879
+ return false;
1880
+ }
1881
+ return unwrapped.properties.every((property) => {
1882
+ if (ts.isSpreadAssignment(property)) {
1883
+ return isSourceEnumerableStaticObjectLiteral(property.expression);
1884
+ }
1885
+ if (property.name === undefined || ts.isComputedPropertyName(property.name)) {
1886
+ return property.name === undefined;
1887
+ }
1888
+ return property.name.text !== "operations" || isOperationsProperty(property);
1889
+ });
1890
+ }
1891
+
1892
+ function findComputedImplementationProperty(
1893
+ objectLiteral: TS.ObjectLiteralExpression,
1894
+ ): TS.ObjectLiteralElementLike | undefined {
1895
+ for (const property of objectLiteral.properties) {
1896
+ if (property.name !== undefined && ts.isComputedPropertyName(property.name)) {
1897
+ return property;
1898
+ }
1899
+ if (ts.isSpreadAssignment(property)) {
1900
+ const spreadExpression = unwrapImplementationExpression(property.expression);
1901
+ if (ts.isObjectLiteralExpression(spreadExpression)) {
1902
+ const nested = findComputedImplementationProperty(spreadExpression);
1903
+ if (nested !== undefined) {
1904
+ return nested;
1905
+ }
1906
+ }
1907
+ }
1908
+ }
1909
+ return undefined;
1910
+ }
1911
+
1912
+ function findEffectiveOperationsProperty(
1913
+ objectLiteral: TS.ObjectLiteralExpression,
1914
+ ):
1915
+ | TS.PropertyAssignment
1916
+ | TS.ShorthandPropertyAssignment
1917
+ | TS.GetAccessorDeclaration
1918
+ | TS.SetAccessorDeclaration
1919
+ | TS.MethodDeclaration
1920
+ | undefined {
1921
+ let effective:
1922
+ | TS.PropertyAssignment
1923
+ | TS.ShorthandPropertyAssignment
1924
+ | TS.GetAccessorDeclaration
1925
+ | TS.SetAccessorDeclaration
1926
+ | TS.MethodDeclaration
1927
+ | undefined;
1928
+ for (const property of objectLiteral.properties) {
1929
+ if (isOperationsProperty(property) || isOperationsAccessorOrMethod(property)) {
1930
+ effective = property;
1931
+ continue;
1932
+ }
1933
+ if (ts.isSpreadAssignment(property)) {
1934
+ const spreadExpression = unwrapImplementationExpression(property.expression);
1935
+ if (ts.isObjectLiteralExpression(spreadExpression)) {
1936
+ const nested = findEffectiveOperationsProperty(spreadExpression);
1937
+ if (nested !== undefined) {
1938
+ effective = nested;
1939
+ }
1940
+ }
1941
+ }
1942
+ }
1943
+ return effective;
1944
+ }
1945
+
1172
1946
  function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1173
1947
  const indexPath = resolve(providerRoot, "index.ts");
1174
1948
  const ruleId = "flat-operation-composition";
@@ -1182,10 +1956,23 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1182
1956
  }
1183
1957
 
1184
1958
  const source = readFileSync(indexPath, "utf8");
1185
- // Use the same whitespace-tolerant detection as the resolver below, so a
1186
- // `defineProvider (` / `defineProvider\n(` formatting cannot pass the early
1187
- // exit before the real classification runs.
1188
- if (!/\bdefineProvider\s*\(/.test(source)) {
1959
+ const indexRelPath = toRelativeProviderPath(providerRoot, indexPath);
1960
+ // The report preflight already rejects unparseable index.ts sources before
1961
+ // this rule runs, so this local AST parse only replaces property location.
1962
+ const sourceFile = ts.createSourceFile(
1963
+ indexRelPath,
1964
+ source,
1965
+ ts.ScriptTarget.Latest,
1966
+ true,
1967
+ ts.ScriptKind.TS,
1968
+ );
1969
+ const implementationCall = findProviderImplementationCall(sourceFile);
1970
+ const rawImplementationArg = implementationCall?.arguments[0];
1971
+ const implementationArg =
1972
+ rawImplementationArg === undefined
1973
+ ? undefined
1974
+ : unwrapImplementationExpression(rawImplementationArg);
1975
+ if (implementationArg === undefined || !ts.isObjectLiteralExpression(implementationArg)) {
1189
1976
  return pass(
1190
1977
  ruleId,
1191
1978
  SDK_NATIVE_CATEGORY,
@@ -1194,69 +1981,73 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1194
1981
  );
1195
1982
  }
1196
1983
 
1197
- // Scope the scan to the argument of the EXPORTED `defineProvider(...)` call.
1198
- // A provider can contain helper/non-exported defineProvider calls before the
1199
- // real default export (e.g. test scaffolds), so resolve the default export
1200
- // rather than blindly taking the first regex match. Resolution order:
1201
- // 1. `export default defineProvider(` — inline default export
1202
- // 2. `export default <ident>` then `const <ident> = defineProvider(`
1203
- // 3. fallback: first `defineProvider(` in the file
1204
- let defineParenIndex = -1;
1205
- const inlineDefault = /\bexport\s+default\s+defineProvider\s*\(/.exec(source);
1206
- if (inlineDefault) {
1207
- defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
1208
- } else {
1209
- const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
1210
- const exportedName = namedDefault?.[1];
1211
- if (exportedName !== undefined) {
1212
- const namedDecl = new RegExp(
1213
- `(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${exportedName}\\s*(?::[^=\\n]+)?\\s*=\\s*defineProvider\\s*\\(`,
1214
- ).exec(source);
1215
- if (namedDecl) {
1216
- defineParenIndex = namedDecl.index + namedDecl[0].length - 1;
1217
- }
1218
- }
1219
- if (defineParenIndex === -1) {
1220
- const firstCall = /\bdefineProvider\s*\(/.exec(source);
1221
- if (firstCall) {
1222
- defineParenIndex = firstCall.index + firstCall[0].length - 1;
1223
- }
1224
- }
1984
+ // Resolve the top-level operations property from the AST, then feed its raw
1985
+ // initializer offset into the existing expression classifier and alias
1986
+ // resolver unchanged.
1987
+ const opsProp = findEffectiveOperationsProperty(implementationArg);
1988
+ let opsValue: ExpressionSlice | undefined;
1989
+ let opsLine = 1;
1990
+ if (opsProp !== undefined && ts.isPropertyAssignment(opsProp)) {
1991
+ const valueStart = opsProp.initializer.getStart(sourceFile);
1992
+ opsValue = unwrapParens(balancedExpressionSlice(source, valueStart, indexRelPath));
1993
+ opsLine = offsetToLine(source, valueStart);
1225
1994
  }
1226
- if (defineParenIndex === -1) {
1227
- return pass(
1995
+
1996
+ // Property shorthand: `buildProvider({ operations })` — resolve the
1997
+ // local `operations` const initializer.
1998
+ let aliasName: string | undefined;
1999
+ if (opsProp !== undefined && ts.isShorthandPropertyAssignment(opsProp)) {
2000
+ aliasName = "operations";
2001
+ } else if (opsValue !== undefined && /^[A-Za-z_$][\w$]*$/.test(opsValue.masked)) {
2002
+ // `operations: ops` — a bare identifier alias to resolve.
2003
+ aliasName = opsValue.raw;
2004
+ }
2005
+
2006
+ // A computed property might resolve to `operations`, but its composition is
2007
+ // not source-enumerable here. Fail closed, like an unresolved imported alias.
2008
+ const computed = findComputedImplementationProperty(implementationArg);
2009
+ if (computed !== undefined) {
2010
+ const computedLine = offsetToLine(source, computed.getStart(sourceFile));
2011
+ return blocker(
1228
2012
  ruleId,
1229
2013
  SDK_NATIVE_CATEGORY,
1230
- "No defineProvider call to evaluate for operation composition.",
2014
+ "The defineProvider operations property uses a computed property name that cannot be statically resolved.",
2015
+ "Declare operations with the literal property name `operations` and a static object literal value so the provider-registry AST gate can enumerate it.",
1231
2016
  0,
2017
+ [`${indexRelPath}:${computedLine} (computed property name)`],
1232
2018
  );
1233
2019
  }
1234
- const argStart = defineParenIndex + 1;
1235
- const argText = balancedValueExpression(source, argStart);
1236
2020
 
1237
- // Resolve the value passed as `operations:` inside the defineProvider call,
1238
- // following one alias hop. The value is classified as a static object
1239
- // literal (pass) or a factory/call expression (block). The regex index is
1240
- // offset back into the full source so line numbers stay accurate.
1241
- const opsProp = /\boperations\s*:\s*/.exec(argText);
1242
- let opsValue: string | undefined;
1243
- let opsLine = 1;
1244
- if (opsProp) {
1245
- const valueStart = argStart + opsProp.index + opsProp[0].length;
1246
- opsValue = unwrapParens(balancedValueExpression(source, valueStart).trim());
1247
- opsLine = offsetToLine(source, valueStart);
2021
+ if (opsProp !== undefined && isOperationsAccessorOrMethod(opsProp)) {
2022
+ const memberLine = offsetToLine(source, opsProp.getStart(sourceFile));
2023
+ return blocker(
2024
+ ruleId,
2025
+ SDK_NATIVE_CATEGORY,
2026
+ "The defineProvider operations member is an accessor/method whose value is not statically enumerable.",
2027
+ "Declare operations as a property assignment with a static object literal value so the provider-registry AST gate can enumerate it.",
2028
+ 0,
2029
+ [`${indexRelPath}:${memberLine} (operations accessor/method)`],
2030
+ );
1248
2031
  }
1249
2032
 
1250
- // Property shorthand: `defineProvider({ ..., operations })` — resolve the
1251
- // local `operations` const initializer.
1252
- let aliasName: string | undefined;
1253
- if (opsValue === undefined) {
1254
- if (/\boperations\s*[,}]/.test(argText)) {
1255
- aliasName = "operations";
1256
- }
1257
- } else if (/^[A-Za-z_$][\w$]*$/.test(opsValue)) {
1258
- // `operations: ops` — a bare identifier alias to resolve.
1259
- aliasName = opsValue;
2033
+ // A spread can override a preceding literal `operations` property. Only an
2034
+ // inline static object literal (including recursively inline static spreads)
2035
+ // exposes all of its property names to this source-only gate. Identifiers,
2036
+ // calls, and other expressions are opaque and therefore fail closed.
2037
+ const opaqueSpread = implementationArg.properties.find(
2038
+ (property): property is TS.SpreadAssignment =>
2039
+ ts.isSpreadAssignment(property) &&
2040
+ !isSourceEnumerableStaticObjectLiteral(property.expression),
2041
+ );
2042
+ if (opaqueSpread !== undefined) {
2043
+ const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
2044
+ return escapeHatchResult(providerRoot, ruleId, [{ file: indexRelPath, line: spreadLine }], {
2045
+ blockerMessage:
2046
+ "The defineProvider implementation uses a non-enumerable spread that could override operations.",
2047
+ remediation:
2048
+ "Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
2049
+ passMessage: "defineProvider declares operations as a static object literal.",
2050
+ });
1260
2051
  }
1261
2052
 
1262
2053
  // Determine the effective initializer expression to classify. The alias may
@@ -1290,7 +2081,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1290
2081
  // do not resolve the exact import target path; "any same-named factory
1291
2082
  // blocks" is the conservative, false-negative-avoiding choice for a gate.)
1292
2083
  type Candidate = {
1293
- expr: string;
2084
+ expr: ExpressionSlice;
1294
2085
  line: number;
1295
2086
  file: string;
1296
2087
  isFactory: boolean;
@@ -1300,15 +2091,18 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1300
2091
  if (!existsSync(filePath)) {
1301
2092
  continue;
1302
2093
  }
1303
- const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
1304
2094
  const relPath = toRelativeProviderPath(providerRoot, filePath);
2095
+ const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
2096
+ const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
2097
+ blankPropertyKeys: true,
2098
+ });
1305
2099
 
1306
2100
  const declRe = new RegExp(aliasDecl.source, "g");
1307
- for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
2101
+ for (let m = declRe.exec(maskedFileSource); m !== null; m = declRe.exec(maskedFileSource)) {
1308
2102
  const valueStart = m.index + m[0].length;
1309
- const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
2103
+ const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
1310
2104
  const isFactory =
1311
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
2105
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
1312
2106
  !isTransparentObjectReshape(expr);
1313
2107
  candidates.push({
1314
2108
  expr,
@@ -1318,9 +2112,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1318
2112
  });
1319
2113
  }
1320
2114
  const destructRe = new RegExp(destructured.source, "g");
1321
- for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
2115
+ for (
2116
+ let m = destructRe.exec(maskedFileSource);
2117
+ m !== null;
2118
+ m = destructRe.exec(maskedFileSource)
2119
+ ) {
2120
+ const raw = `${m[1]}(`;
1322
2121
  candidates.push({
1323
- expr: `${m[1]}(`,
2122
+ expr: { raw, masked: raw },
1324
2123
  line: offsetToLine(fileSource, m.index),
1325
2124
  file: relPath,
1326
2125
  isFactory: true,
@@ -1346,11 +2145,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1346
2145
  // the unresolved import as a factory-composed (non-static) shape rather
1347
2146
  // than silently passing.
1348
2147
  if (!resolved) {
2148
+ const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
2149
+ blankPropertyKeys: true,
2150
+ });
1349
2151
  const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
1350
- source,
2152
+ maskedIndexSource,
1351
2153
  );
1352
2154
  if (importMatch) {
1353
- effective = `${aliasName}(`;
2155
+ const raw = `${aliasName}(`;
2156
+ effective = { raw, masked: raw };
1354
2157
  effectiveLine = offsetToLine(source, importMatch.index);
1355
2158
  effectiveFile = "index.ts";
1356
2159
  }
@@ -1374,14 +2177,16 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1374
2177
  spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
1375
2178
  );
1376
2179
  const isStaticLiteral =
1377
- effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
2180
+ effective?.masked.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
1378
2181
  // A call expression `ident(...)` (factory) or a factory-spread literal is
1379
2182
  // the rejected, non-static shape — UNLESS it is the stdlib
1380
2183
  // `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
1381
2184
  // whose op set is still enumerable from source (verified golden pattern).
1382
2185
  const isFactoryCall =
1383
2186
  effective !== undefined &&
1384
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
2187
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective.masked) ||
2188
+ hasFactorySpread ||
2189
+ hasFactorySpreadIdentifier) &&
1385
2190
  !isTransparentObjectReshape(effective);
1386
2191
 
1387
2192
  if (isFactoryCall && !isStaticLiteral) {
@@ -1393,7 +2198,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1393
2198
  blockerMessage:
1394
2199
  "defineProvider operations are composed by a factory call instead of a static object literal.",
1395
2200
  remediation:
1396
- "Declare operations as a static literal: defineProvider({ operations: { 'op-id': defineOperation({...}) } }). The provider-registry AST gate requires static runtime/operations; factory composition fails the registry build. If composition is unavoidable, add `// @apifuse-allow flat-operation-composition: <reason>`.",
2201
+ "Declare operations as a static literal in the provider builder call. The provider-registry AST gate requires static runtime/operations; factory composition fails the registry build. If composition is unavoidable, add `// @apifuse-allow flat-operation-composition: <reason>`.",
1397
2202
  passMessage: "defineProvider declares operations as a static object literal.",
1398
2203
  });
1399
2204
  }
@@ -1407,7 +2212,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1407
2212
  }
1408
2213
 
1409
2214
  function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1410
- const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
2215
+ const credentialReferences = findCredentialReferences(providerRoot);
1411
2216
  const authMode = provider.auth?.mode ?? "none";
1412
2217
  const credentialKeys = provider.credential?.keys ?? [];
1413
2218
  const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
@@ -1437,6 +2242,107 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
1437
2242
  );
1438
2243
  }
1439
2244
 
2245
+ function isDirectCredentialReference(expression: TS.Expression): boolean {
2246
+ const reference = unwrapLocalExpression(expression);
2247
+ let receiver: TS.Expression | undefined;
2248
+ let memberName: string | undefined;
2249
+ if (ts.isPropertyAccessExpression(reference)) {
2250
+ receiver = reference.expression;
2251
+ memberName = reference.name.text;
2252
+ } else if (ts.isElementAccessExpression(reference)) {
2253
+ receiver = reference.expression;
2254
+ const argument = reference.argumentExpression;
2255
+ if (
2256
+ argument !== undefined &&
2257
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
2258
+ ) {
2259
+ memberName = argument.text;
2260
+ }
2261
+ }
2262
+ const unwrappedReceiver = receiver === undefined ? undefined : unwrapLocalExpression(receiver);
2263
+ return (
2264
+ memberName === "credential" &&
2265
+ unwrappedReceiver !== undefined &&
2266
+ ts.isIdentifier(unwrappedReceiver) &&
2267
+ unwrappedReceiver.text === "ctx"
2268
+ );
2269
+ }
2270
+
2271
+ function isCredentialReference(
2272
+ expression: TS.Expression,
2273
+ bindings: LocalBindings,
2274
+ resolving: ReadonlySet<LocalBinding> = new Set(),
2275
+ ): boolean {
2276
+ const reference = unwrapLocalExpression(expression);
2277
+ if (isDirectCredentialReference(reference)) {
2278
+ return true;
2279
+ }
2280
+ if (!ts.isIdentifier(reference)) {
2281
+ return false;
2282
+ }
2283
+ const localBindings = lookupLocalBindings(reference, bindings);
2284
+ if (localBindings === undefined || localBindings.length === 0) {
2285
+ return false;
2286
+ }
2287
+ return localBindings.every((binding) => {
2288
+ if (resolving.has(binding) || binding.mutable || binding.initializer === undefined) {
2289
+ return false;
2290
+ }
2291
+ const nextResolving = new Set(resolving);
2292
+ nextResolving.add(binding);
2293
+ if (binding.destructuredProperty !== undefined) {
2294
+ const source = unwrapLocalExpression(binding.initializer);
2295
+ return (
2296
+ binding.destructuredProperty === "credential" &&
2297
+ ts.isIdentifier(source) &&
2298
+ source.text === "ctx"
2299
+ );
2300
+ }
2301
+ return isCredentialReference(binding.initializer, bindings, nextResolving);
2302
+ });
2303
+ }
2304
+
2305
+ function findCredentialReferences(providerRoot: string): SourceFinding[] {
2306
+ const findings: SourceFinding[] = [];
2307
+ const seen = new Set<string>();
2308
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2309
+ const source = readFileSync(filePath, "utf8");
2310
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2311
+ const sourceFile = ts.createSourceFile(
2312
+ relPath,
2313
+ source,
2314
+ ts.ScriptTarget.Latest,
2315
+ true,
2316
+ ts.ScriptKind.TS,
2317
+ );
2318
+ const bindings = collectLocalBindings(sourceFile);
2319
+ const visit = (node: TS.Node): void => {
2320
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2321
+ return;
2322
+ }
2323
+ if (
2324
+ ts.isExpression(node) &&
2325
+ (isDirectCredentialReference(node) ||
2326
+ ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
2327
+ isCredentialReference(node.expression, bindings)))
2328
+ ) {
2329
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
2330
+ const key = `${relPath}:${line}`;
2331
+ if (!seen.has(key)) {
2332
+ seen.add(key);
2333
+ findings.push({ file: relPath, line });
2334
+ }
2335
+ }
2336
+ ts.forEachChild(node, visit);
2337
+ };
2338
+ visit(sourceFile);
2339
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2340
+ break;
2341
+ }
2342
+ }
2343
+ return findings;
2344
+ }
2345
+
1440
2346
  // ---------------------------------------------------------------------------
1441
2347
  // sdk-owned-secret-presence (warn): provider-local double validation of
1442
2348
  // declared env secrets.
@@ -1593,23 +2499,34 @@ function scoreSdkOwnedSecretPresence(
1593
2499
  function findSourceLineMatches(
1594
2500
  providerRoot: string,
1595
2501
  pattern: RegExp | ((line: string) => boolean),
2502
+ useMaskedSource = false,
1596
2503
  ): SourceFinding[] {
1597
- return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
2504
+ return findSourceFindings(
2505
+ providerRoot,
2506
+ (line) => matchesLinePattern(line, pattern),
2507
+ useMaskedSource,
2508
+ );
1598
2509
  }
1599
2510
 
1600
2511
  function findSourceFindings(
1601
2512
  providerRoot: string,
1602
2513
  matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
2514
+ useMaskedSource = false,
1603
2515
  ): SourceFinding[] {
1604
2516
  const findings: SourceFinding[] = [];
1605
2517
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1606
2518
  const content = readFileSync(filePath, "utf8");
1607
- const lines = content.split(/\r?\n/);
2519
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2520
+ const lines = (
2521
+ useMaskedSource
2522
+ ? maskCommentsAndStrings(content, relPath, { blankPropertyKeys: true })
2523
+ : content
2524
+ ).split(/\r?\n/);
1608
2525
  for (let index = 0; index < lines.length; index += 1) {
1609
2526
  const line = lines[index];
1610
2527
  if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
1611
2528
  findings.push({
1612
- file: toRelativeProviderPath(providerRoot, filePath),
2529
+ file: relPath,
1613
2530
  line: index + 1,
1614
2531
  });
1615
2532
  if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
@@ -1689,12 +2606,25 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1689
2606
  );
1690
2607
  }
1691
2608
 
2609
+ const EXCLUDED_SOURCE_DIRECTORY_SEGMENTS = new Set([
2610
+ ".git",
2611
+ ".worktree",
2612
+ "node_modules",
2613
+ "dist",
2614
+ "build",
2615
+ "coverage",
2616
+ ]);
2617
+
1692
2618
  // `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
1693
2619
  // markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
1694
2620
  // directories must not become a scan-exempt hiding place for secrets, raw
1695
- // fetch, or vendor imports.
2621
+ // fetch, or vendor imports. Nested dependency/build output directories under
2622
+ // them use the same segment exclusions; `.worktree` is excluded because task
2623
+ // worktrees are duplicate repository checkouts, not provider source.
1696
2624
  function shouldScanSourceDirectory(relativePath: string): boolean {
1697
- return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
2625
+ return relativePath
2626
+ .split("/")
2627
+ .every((segment) => !EXCLUDED_SOURCE_DIRECTORY_SEGMENTS.has(segment));
1698
2628
  }
1699
2629
 
1700
2630
  function isExcludedTestSource(relativePath: string): boolean {
@@ -1712,7 +2642,10 @@ function toRelativeProviderPath(providerRoot: string, filePath: string): string
1712
2642
  }
1713
2643
 
1714
2644
  function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1715
- return findings.map((finding) => `${finding.file}:${finding.line}`);
2645
+ return findings.map(
2646
+ (finding) =>
2647
+ `${finding.file}:${finding.line}${finding.detail === undefined ? "" : `: ${finding.detail}`}`,
2648
+ );
1716
2649
  }
1717
2650
 
1718
2651
  function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
@@ -1808,7 +2741,7 @@ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
1808
2741
  {
1809
2742
  message: "Base provider checks can run",
1810
2743
  passed: false,
1811
- details: [error instanceof Error ? error.message : String(error)],
2744
+ details: [formatThrownValue(error)],
1812
2745
  },
1813
2746
  ];
1814
2747
  }
@@ -2033,13 +2966,17 @@ function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string
2033
2966
 
2034
2967
  for (const operation of Object.values(provider.operations)) {
2035
2968
  addLocaleKeys(keys, [
2969
+ operation.titleKey,
2036
2970
  operation.descriptionKey,
2037
- operation.docs?.titleKey,
2038
- operation.docs?.descriptionKey,
2039
- operation.docs?.summaryKey,
2040
- operation.docs?.markdownKey,
2971
+ operation.summaryKey,
2972
+ operation.markdownKey,
2041
2973
  ...(operation.whenToUseKeys ?? []),
2042
2974
  ...(operation.whenNotToUseKeys ?? []),
2975
+ ...(operation.normalizationNotesKeys ?? []),
2976
+ ...(operation.examples ?? []).flatMap((example) => [
2977
+ example.scenarioKey,
2978
+ example.rationaleKey,
2979
+ ]),
2043
2980
  ...collectSchemaDescriptionKeys(operation.input),
2044
2981
  ...collectSchemaDescriptionKeys(operation.output),
2045
2982
  ]);
@@ -2106,8 +3043,8 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
2106
3043
  return true;
2107
3044
  })
2108
3045
  .map(([operationId]) => operationId);
2109
- const missingAnnotations = operations
2110
- .filter(([, operation]) => !operation.annotations)
3046
+ const missingRiskClass = operations
3047
+ .filter(([, operation]) => !operation.riskClass)
2111
3048
  .map(([operationId]) => operationId);
2112
3049
 
2113
3050
  if (weakDescriptions.length > 0) {
@@ -2119,30 +3056,30 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
2119
3056
  points: 0,
2120
3057
  maxPoints: CATEGORY_MAX_POINTS.operations,
2121
3058
  message: "One or more operations have weak descriptions.",
2122
- remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json\`, or add a 150+ character \`description\` explaining when to use it, when not to use it, outputs, and caveats.`,
3059
+ remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json\`.`,
2123
3060
  evidence: weakDescriptions,
2124
3061
  };
2125
3062
  }
2126
3063
 
2127
- const points = missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
3064
+ const points = missingRiskClass.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
2128
3065
  return {
2129
3066
  id: "operation-metadata",
2130
3067
  category: "operations",
2131
- level: missingAnnotations.length > 0 ? "warn" : "info",
2132
- status: missingAnnotations.length > 0 ? "warn" : "pass",
3068
+ level: missingRiskClass.length > 0 ? "warn" : "info",
3069
+ status: missingRiskClass.length > 0 ? "warn" : "pass",
2133
3070
  points,
2134
3071
  maxPoints: CATEGORY_MAX_POINTS.operations,
2135
3072
  message:
2136
- missingAnnotations.length > 0
2137
- ? "Operations are described, but some are missing safety annotations."
3073
+ missingRiskClass.length > 0
3074
+ ? "Operations are described, but some are missing a safety classification."
2138
3075
  : "Operation descriptions and metadata are review-ready.",
2139
3076
  remediation:
2140
- missingAnnotations.length > 0
2141
- ? `For ${missingAnnotations.join(", ")}, add \`annotations\` with the applicable safety fields, such as \`readOnly\`, \`destructive\`, \`idempotent\`, \`openWorld\`, \`rateLimit\`, or \`timeoutMs\`.`
3077
+ missingRiskClass.length > 0
3078
+ ? `For ${missingRiskClass.join(", ")}, declare the operation's required \`riskClass\` as \`read\`, \`write\`, \`destructive\`, or \`external-send\`.`
2142
3079
  : undefined,
2143
3080
  evidence:
2144
- missingAnnotations.length > 0
2145
- ? missingAnnotations.map((operationId) => `${operationId}: missing annotations`)
3081
+ missingRiskClass.length > 0
3082
+ ? missingRiskClass.map((operationId) => `${operationId}: missing riskClass`)
2146
3083
  : operations.map(([operationId]) => operationId),
2147
3084
  };
2148
3085
  }
@@ -2174,11 +3111,16 @@ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
2174
3111
  function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
2175
3112
  const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
2176
3113
  let hasRecordedEvidence = false;
3114
+ let fixtureValidationError: string | undefined;
2177
3115
  if (existsSync(rawPath)) {
2178
3116
  try {
2179
- hasRecordedEvidence = hasNonEmptyRecordedFixture(JSON.parse(readFileSync(rawPath, "utf8")));
2180
- } catch {
3117
+ hasRecordedEvidence = recordedFixtureStats(
3118
+ JSON.parse(readFileSync(rawPath, "utf8")),
3119
+ 0,
3120
+ ).hasNestedSubstance;
3121
+ } catch (error) {
2181
3122
  hasRecordedEvidence = false;
3123
+ fixtureValidationError = error instanceof Error ? error.message : String(error);
2182
3124
  }
2183
3125
  }
2184
3126
 
@@ -2190,6 +3132,16 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
2190
3132
  0,
2191
3133
  );
2192
3134
  }
3135
+ if (fixtureValidationError) {
3136
+ return blocker(
3137
+ "fixture-provenance",
3138
+ "fixtures",
3139
+ `Malformed recorded fixture evidence in __fixtures__/raw.json: ${fixtureValidationError}`,
3140
+ "Re-run `bun run record` to replace the malformed stream evidence, or repair the named field using the stream evidence contract.",
3141
+ 0,
3142
+ ["__fixtures__/raw.json"],
3143
+ );
3144
+ }
2193
3145
 
2194
3146
  if (allOperationsAreGeneratedLocalScaffold(provider)) {
2195
3147
  return {
@@ -2217,14 +3169,32 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
2217
3169
  );
2218
3170
  }
2219
3171
 
2220
- function hasNonEmptyRecordedFixture(value: unknown): boolean {
2221
- return recordedFixtureStats(value, 0).hasNestedSubstance;
3172
+ export function hasNonEmptyRecordedFixture(value: unknown): boolean {
3173
+ try {
3174
+ return recordedFixtureStats(value, 0).hasNestedSubstance;
3175
+ } catch {
3176
+ return false;
3177
+ }
2222
3178
  }
2223
3179
 
2224
3180
  function recordedFixtureStats(
2225
3181
  value: unknown,
2226
3182
  depth: number,
2227
3183
  ): { hasNestedSubstance: boolean; leafValues: number } {
3184
+ if (
3185
+ value !== null &&
3186
+ typeof value === "object" &&
3187
+ !Array.isArray(value) &&
3188
+ (value as Record<string, unknown>).__apifuse_capture__ === true
3189
+ ) {
3190
+ const group = findStreamCaptureGroup(value);
3191
+ if (!group) throw new Error("Stream capture envelope is invalid.");
3192
+ return { hasNestedSubstance: true, leafValues: group.items.length };
3193
+ }
3194
+ if (hasStreamEvidenceMarker(value)) {
3195
+ parseStreamEvidenceRecord(value);
3196
+ return { hasNestedSubstance: true, leafValues: 1 };
3197
+ }
2228
3198
  if (value === null || value === undefined) {
2229
3199
  return { hasNestedSubstance: false, leafValues: 0 };
2230
3200
  }
@@ -2259,10 +3229,14 @@ function recordedFixtureStats(
2259
3229
  if (value.length === 0) {
2260
3230
  return { hasNestedSubstance: false, leafValues: 0 };
2261
3231
  }
2262
- // A recorded operation value may be a raw XML success payload; treat a
2263
- // substantive, well-formed one as nested evidence while still counting the
2264
- // string as a leaf so existing JSON provenance heuristics are unchanged.
2265
- return { hasNestedSubstance: hasSubstantiveXmlStructure(value), leafValues: 1 };
3232
+ // A recorded operation value may be a raw XML or delimited-text success
3233
+ // payload. Treat a substantive one as nested evidence while still counting
3234
+ // the string as a leaf so existing JSON provenance heuristics are unchanged.
3235
+ return {
3236
+ hasNestedSubstance:
3237
+ hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
3238
+ leafValues: 1,
3239
+ };
2266
3240
  }
2267
3241
  return { hasNestedSubstance: false, leafValues: 1 };
2268
3242
  }
@@ -2327,19 +3301,32 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
2327
3301
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2328
3302
  const source = readFileSync(filePath, "utf8");
2329
3303
  const relPath = toRelativeProviderPath(providerRoot, filePath);
3304
+ maskCommentsAndStrings(source, relPath);
3305
+ const sourceFile = ts.createSourceFile(
3306
+ relPath,
3307
+ source,
3308
+ ts.ScriptTarget.Latest,
3309
+ true,
3310
+ ts.ScriptKind.TS,
3311
+ );
3312
+ const bindings = collectLocalBindings(sourceFile);
2330
3313
  const upstreamRanges = findUpstreamMarkedConstRanges(source);
2331
3314
  for (const zObject of findZObjectLiterals(source)) {
2332
3315
  if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
2333
3316
  continue;
2334
3317
  }
2335
- if (!zObjectAppearsPublicOutput(source, zObject)) {
3318
+ if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
2336
3319
  continue;
2337
3320
  }
2338
- for (const keyFinding of vendorKeyFindingsForObject(source, zObject)) {
3321
+ for (const keyFinding of vendorKeyFindingsForObject(source, sourceFile, zObject, bindings)) {
2339
3322
  const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
2340
3323
  if (!seen.has(key)) {
2341
3324
  seen.add(key);
2342
- findings.push({ file: relPath, line: keyFinding.line });
3325
+ findings.push({
3326
+ file: relPath,
3327
+ line: keyFinding.line,
3328
+ detail: keyFinding.key,
3329
+ });
2343
3330
  if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2344
3331
  return findings;
2345
3332
  }
@@ -2375,20 +3362,260 @@ function findZObjectLiterals(source: string): ZObjectLiteral[] {
2375
3362
  return literals;
2376
3363
  }
2377
3364
 
2378
- function zObjectAppearsPublicOutput(source: string, zObject: ZObjectLiteral): boolean {
2379
- const enclosingConst = findConstValueRangeContaining(source, zObject.callStart);
2380
- if (enclosingConst && /output|response|result/i.test(enclosingConst.name)) {
3365
+ function zObjectAppearsPublicOutput(
3366
+ sourceFile: TS.SourceFile,
3367
+ zObject: ZObjectLiteral,
3368
+ relPath: string,
3369
+ ): boolean {
3370
+ let zObjectCall: TS.CallExpression | undefined;
3371
+ const findCall = (node: TS.Node): void => {
3372
+ if (
3373
+ zObjectCall === undefined &&
3374
+ ts.isCallExpression(node) &&
3375
+ node.getStart(sourceFile) === zObject.callStart
3376
+ ) {
3377
+ zObjectCall = node;
3378
+ return;
3379
+ }
3380
+ ts.forEachChild(node, findCall);
3381
+ };
3382
+ findCall(sourceFile);
3383
+ if (zObjectCall === undefined) {
3384
+ return true;
3385
+ }
3386
+
3387
+ let expression: TS.Expression = zObjectCall;
3388
+ while (
3389
+ ts.isParenthesizedExpression(expression.parent) ||
3390
+ ts.isAsExpression(expression.parent) ||
3391
+ ts.isSatisfiesExpression(expression.parent) ||
3392
+ ts.isNonNullExpression(expression.parent)
3393
+ ) {
3394
+ expression = expression.parent;
3395
+ }
3396
+ if (
3397
+ ts.isPropertyAssignment(expression.parent) &&
3398
+ isPublicOutputPropertyName(expression.parent.name)
3399
+ ) {
3400
+ return true;
3401
+ }
3402
+
3403
+ const declaration = findEnclosingVariableDeclaration(expression);
3404
+ if (
3405
+ declaration === undefined ||
3406
+ !ts.isIdentifier(declaration.name) ||
3407
+ declaration.initializer === undefined ||
3408
+ !ts.isVariableDeclarationList(declaration.parent) ||
3409
+ (declaration.parent.flags & ts.NodeFlags.Const) === 0
3410
+ ) {
3411
+ return true;
3412
+ }
3413
+
3414
+ const reachability = localSchemaBindingReachability(
3415
+ sourceFile,
3416
+ declaration.name.text,
3417
+ declaration,
3418
+ new Set(),
3419
+ );
3420
+ if (reachability === "public") {
3421
+ return true;
3422
+ }
3423
+ if (reachability === "unknown") {
2381
3424
  return true;
2382
3425
  }
2383
- const before = source.slice(Math.max(0, zObject.callStart - 160), zObject.callStart);
2384
- return /(?:^|[\s,{])(?:output|response)\s*:\s*$/.test(before);
3426
+ // Sibling modules are scanned independently and therefore remain
3427
+ // fail-closed. An exported binding in index.ts is likewise consumable by
3428
+ // another module, so local inertness does not prove it is private.
3429
+ if (relPath !== "index.ts") {
3430
+ return true;
3431
+ }
3432
+ return isExportedVariableBinding(sourceFile, declaration);
3433
+ }
3434
+
3435
+ type SchemaReachability = "public" | "internal" | "unknown";
3436
+
3437
+ const PARSE_LIKE_SCHEMA_METHODS: ReadonlySet<string> = new Set([
3438
+ "parse",
3439
+ "safeParse",
3440
+ "parseAsync",
3441
+ "safeParseAsync",
3442
+ ]);
3443
+
3444
+ function isExportedVariableBinding(
3445
+ sourceFile: TS.SourceFile,
3446
+ declaration: TS.VariableDeclaration,
3447
+ ): boolean {
3448
+ const declarationStatement = declaration.parent.parent;
3449
+ if (
3450
+ ts.isVariableStatement(declarationStatement) &&
3451
+ declarationStatement.modifiers?.some(
3452
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
3453
+ ) === true
3454
+ ) {
3455
+ return true;
3456
+ }
3457
+ if (!ts.isIdentifier(declaration.name)) {
3458
+ return false;
3459
+ }
3460
+ const name = declaration.name.text;
3461
+ let exported = false;
3462
+ const visit = (node: TS.Node): void => {
3463
+ if (exported) {
3464
+ return;
3465
+ }
3466
+ if (ts.isExportSpecifier(node)) {
3467
+ const localName = node.propertyName ?? node.name;
3468
+ if (ts.isIdentifier(localName) && localName.text === name) {
3469
+ exported = true;
3470
+ return;
3471
+ }
3472
+ }
3473
+ if (ts.isExportAssignment(node)) {
3474
+ const expression = unwrapLocalExpression(node.expression);
3475
+ if (ts.isIdentifier(expression) && expression.text === name) {
3476
+ exported = true;
3477
+ return;
3478
+ }
3479
+ }
3480
+ ts.forEachChild(node, visit);
3481
+ };
3482
+ for (const statement of sourceFile.statements) {
3483
+ visit(statement);
3484
+ if (exported) {
3485
+ break;
3486
+ }
3487
+ }
3488
+ return exported;
3489
+ }
3490
+
3491
+ function isPublicOutputPropertyName(name: TS.PropertyName): boolean {
3492
+ const text = propertyNameText(name);
3493
+ return text === "output" || text === "response";
3494
+ }
3495
+
3496
+ function findEnclosingVariableDeclaration(node: TS.Node): TS.VariableDeclaration | undefined {
3497
+ let current: TS.Node | undefined = node;
3498
+ while (current !== undefined && !ts.isSourceFile(current)) {
3499
+ if (ts.isVariableDeclaration(current)) {
3500
+ return current;
3501
+ }
3502
+ current = current.parent;
3503
+ }
3504
+ return undefined;
3505
+ }
3506
+
3507
+ function localSchemaBindingReachability(
3508
+ sourceFile: TS.SourceFile,
3509
+ name: string,
3510
+ declaration: TS.VariableDeclaration,
3511
+ resolving: ReadonlySet<string>,
3512
+ ): SchemaReachability {
3513
+ if (resolving.has(name)) {
3514
+ return "unknown";
3515
+ }
3516
+ const nextResolving = new Set(resolving);
3517
+ nextResolving.add(name);
3518
+ let result: SchemaReachability = "internal";
3519
+ const visit = (node: TS.Node): void => {
3520
+ if (result === "public") {
3521
+ return;
3522
+ }
3523
+ if (ts.isIdentifier(node) && node.text === name && node !== declaration.name) {
3524
+ const parent = node.parent;
3525
+ if (isParseLikeSchemaReceiver(node)) {
3526
+ return;
3527
+ }
3528
+ if (ts.isExportSpecifier(parent)) {
3529
+ const localName = parent.propertyName ?? parent.name;
3530
+ if (localName === node) {
3531
+ result = "public";
3532
+ }
3533
+ return;
3534
+ }
3535
+ if (
3536
+ ts.isPropertyAssignment(parent) &&
3537
+ parent.initializer === node &&
3538
+ isPublicOutputPropertyName(parent.name)
3539
+ ) {
3540
+ result = "public";
3541
+ return;
3542
+ }
3543
+ if (ts.isShorthandPropertyAssignment(parent)) {
3544
+ result = isPublicOutputPropertyName(parent.name) ? "public" : "unknown";
3545
+ return;
3546
+ }
3547
+ if (
3548
+ ts.isVariableDeclaration(parent) &&
3549
+ parent.initializer === node &&
3550
+ ts.isIdentifier(parent.name) &&
3551
+ ts.isVariableDeclarationList(parent.parent) &&
3552
+ (parent.parent.flags & ts.NodeFlags.Const) !== 0
3553
+ ) {
3554
+ const aliasReachability = localSchemaBindingReachability(
3555
+ sourceFile,
3556
+ parent.name.text,
3557
+ parent,
3558
+ nextResolving,
3559
+ );
3560
+ if (aliasReachability === "public" || result === "internal") {
3561
+ result = aliasReachability;
3562
+ }
3563
+ return;
3564
+ }
3565
+ if (ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) {
3566
+ return;
3567
+ }
3568
+ if (
3569
+ (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
3570
+ (ts.isPropertyAssignment(parent) && parent.name === node) ||
3571
+ ts.isBindingElement(parent) ||
3572
+ ts.isImportSpecifier(parent)
3573
+ ) {
3574
+ return;
3575
+ }
3576
+ result = "unknown";
3577
+ }
3578
+ ts.forEachChild(node, visit);
3579
+ };
3580
+ visit(sourceFile);
3581
+ return result;
3582
+ }
3583
+
3584
+ function isParseLikeSchemaReceiver(identifier: TS.Identifier): boolean {
3585
+ const access = identifier.parent;
3586
+ let methodName: string | undefined;
3587
+ if (ts.isPropertyAccessExpression(access) && access.expression === identifier) {
3588
+ methodName = access.name.text;
3589
+ } else if (ts.isElementAccessExpression(access) && access.expression === identifier) {
3590
+ const argument = access.argumentExpression;
3591
+ if (
3592
+ argument !== undefined &&
3593
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
3594
+ ) {
3595
+ methodName = argument.text;
3596
+ }
3597
+ }
3598
+ return (
3599
+ methodName !== undefined &&
3600
+ PARSE_LIKE_SCHEMA_METHODS.has(methodName) &&
3601
+ ts.isCallExpression(access.parent) &&
3602
+ access.parent.expression === access
3603
+ );
2385
3604
  }
2386
3605
 
2387
3606
  function vendorKeyFindingsForObject(
2388
3607
  source: string,
3608
+ sourceFile: TS.SourceFile,
2389
3609
  zObject: ZObjectLiteral,
3610
+ bindings: LocalBindings,
2390
3611
  ): Array<{ key: string; line: number }> {
2391
- const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
3612
+ const keys = collectTopLevelObjectKeys(
3613
+ source,
3614
+ sourceFile,
3615
+ zObject.objectStart,
3616
+ zObject.objectEnd,
3617
+ bindings,
3618
+ );
2392
3619
  const digitFamilies = new Map<string, Set<string>>();
2393
3620
  for (const key of keys) {
2394
3621
  const member = numberedFamilyMember(key.name);
@@ -2444,8 +3671,10 @@ function isAllowedPublicOutputKeyName(name: string): boolean {
2444
3671
 
2445
3672
  function collectTopLevelObjectKeys(
2446
3673
  source: string,
3674
+ sourceFile: TS.SourceFile,
2447
3675
  objectStart: number,
2448
3676
  objectEnd: number,
3677
+ bindings: LocalBindings,
2449
3678
  ): Array<{ name: string; offset: number }> {
2450
3679
  const keys: Array<{ name: string; offset: number }> = [];
2451
3680
  const masked = maskCommentsAndStrings(source);
@@ -2467,17 +3696,16 @@ function collectTopLevelObjectKeys(
2467
3696
  index = endQuote + 1;
2468
3697
  } else if (masked[index] === "[") {
2469
3698
  const computedEnd = findMatchingBracket(masked, index);
2470
- const literalStart = findNextNonWhitespace(masked, index + 1);
2471
- if (computedEnd === -1 || literalStart === -1) {
3699
+ if (computedEnd === -1) {
2472
3700
  break;
2473
3701
  }
2474
- const computedQuote = source[literalStart];
2475
- if (computedQuote === '"' || computedQuote === "'") {
2476
- const literalEnd = findStringEnd(source, literalStart);
2477
- const afterLiteral =
2478
- literalEnd === -1 ? -1 : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
2479
- if (literalEnd !== -1 && afterLiteral === computedEnd) {
2480
- key = source.slice(literalStart + 1, literalEnd);
3702
+ const expression = computedPropertyExpressionAtOffset(sourceFile, index);
3703
+ if (expression !== undefined) {
3704
+ const unwrapped = unwrapLocalExpression(expression);
3705
+ if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
3706
+ key = unwrapped.text;
3707
+ } else if (ts.isIdentifier(unwrapped)) {
3708
+ key = resolveConstStringIdentifier(unwrapped, bindings);
2481
3709
  }
2482
3710
  }
2483
3711
  index = computedEnd + 1;
@@ -2500,8 +3728,27 @@ function collectTopLevelObjectKeys(
2500
3728
  if (masked[index] === ",") {
2501
3729
  index += 1;
2502
3730
  }
2503
- }
2504
- return keys;
3731
+ }
3732
+ return keys;
3733
+ }
3734
+
3735
+ function computedPropertyExpressionAtOffset(
3736
+ sourceFile: TS.SourceFile,
3737
+ offset: number,
3738
+ ): TS.Expression | undefined {
3739
+ let expression: TS.Expression | undefined;
3740
+ const visit = (node: TS.Node): void => {
3741
+ if (expression !== undefined || offset < node.getFullStart() || offset >= node.getEnd()) {
3742
+ return;
3743
+ }
3744
+ if (ts.isComputedPropertyName(node) && node.getStart(sourceFile) === offset) {
3745
+ expression = node.expression;
3746
+ return;
3747
+ }
3748
+ ts.forEachChild(node, visit);
3749
+ };
3750
+ visit(sourceFile);
3751
+ return expression;
2505
3752
  }
2506
3753
 
2507
3754
  function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
@@ -2511,6 +3758,15 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2511
3758
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2512
3759
  const source = readFileSync(filePath, "utf8");
2513
3760
  const relPath = toRelativeProviderPath(providerRoot, filePath);
3761
+ maskCommentsAndStrings(source, relPath);
3762
+ const sourceFile = ts.createSourceFile(
3763
+ relPath,
3764
+ source,
3765
+ ts.ScriptTarget.Latest,
3766
+ true,
3767
+ ts.ScriptKind.TS,
3768
+ );
3769
+ const bindings = collectLocalBindings(sourceFile);
2514
3770
  const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
2515
3771
  start: zObject.callStart,
2516
3772
  end: zObject.objectEnd,
@@ -2523,7 +3779,7 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2523
3779
  ].filter((range) => rangeContainedInRanges(fixtureRanges, range));
2524
3780
 
2525
3781
  for (const range of fixtureResponseRanges) {
2526
- for (const literal of findStringLiteralsInRange(source, range)) {
3782
+ for (const literal of findStringLiteralsInRange(source, range, relPath)) {
2527
3783
  if (
2528
3784
  rangeContainsOffset(zObjectRanges, literal.offset) ||
2529
3785
  rangeContainsOffset(upstreamRanges, literal.offset) ||
@@ -2544,12 +3800,86 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2544
3800
  }
2545
3801
  }
2546
3802
  }
3803
+
3804
+ const visitAliases = (node: TS.Node): void => {
3805
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3806
+ return;
3807
+ }
3808
+ const offset = node.getStart(sourceFile);
3809
+ if (
3810
+ ts.isIdentifier(node) &&
3811
+ isIdentifierValueUse(node) &&
3812
+ offset >= range.start &&
3813
+ offset <= range.end &&
3814
+ !rangeContainsOffset(zObjectRanges, offset) &&
3815
+ !rangeContainsOffset(upstreamRanges, offset)
3816
+ ) {
3817
+ const value = resolveConstStringIdentifier(node, bindings);
3818
+ if (
3819
+ value !== undefined &&
3820
+ isVendorTimestampCandidate(value, propertyNameForValueExpression(node))
3821
+ ) {
3822
+ const line = offsetToLine(source, offset);
3823
+ const key = `${relPath}:${line}:${value}`;
3824
+ if (!seen.has(key)) {
3825
+ seen.add(key);
3826
+ findings.push({ file: relPath, line });
3827
+ }
3828
+ }
3829
+ }
3830
+ ts.forEachChild(node, visitAliases);
3831
+ };
3832
+ visitAliases(sourceFile);
3833
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3834
+ return findings;
3835
+ }
2547
3836
  }
2548
3837
  }
2549
3838
 
2550
3839
  return findings;
2551
3840
  }
2552
3841
 
3842
+ function isIdentifierValueUse(identifier: TS.Identifier): boolean {
3843
+ const parent = identifier.parent;
3844
+ return !(
3845
+ (ts.isPropertyAssignment(parent) && parent.name === identifier) ||
3846
+ (ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
3847
+ ts.isBindingElement(parent) ||
3848
+ ts.isVariableDeclaration(parent) ||
3849
+ ts.isParameter(parent) ||
3850
+ ts.isImportClause(parent) ||
3851
+ ts.isImportSpecifier(parent) ||
3852
+ ts.isNamespaceImport(parent) ||
3853
+ ts.isFunctionDeclaration(parent) ||
3854
+ ts.isFunctionExpression(parent) ||
3855
+ ts.isClassDeclaration(parent) ||
3856
+ ts.isClassExpression(parent) ||
3857
+ ts.isPropertyDeclaration(parent) ||
3858
+ ts.isPropertySignature(parent) ||
3859
+ ts.isMethodDeclaration(parent) ||
3860
+ ts.isMethodSignature(parent)
3861
+ );
3862
+ }
3863
+
3864
+ function propertyNameForValueExpression(expression: TS.Expression): string | undefined {
3865
+ let current: TS.Expression = expression;
3866
+ while (
3867
+ ts.isParenthesizedExpression(current.parent) ||
3868
+ ts.isAsExpression(current.parent) ||
3869
+ ts.isSatisfiesExpression(current.parent) ||
3870
+ ts.isNonNullExpression(current.parent)
3871
+ ) {
3872
+ current = current.parent;
3873
+ }
3874
+ if (ts.isPropertyAssignment(current.parent) && current.parent.initializer === current) {
3875
+ return propertyNameText(current.parent.name);
3876
+ }
3877
+ if (ts.isShorthandPropertyAssignment(current.parent)) {
3878
+ return current.parent.name.text;
3879
+ }
3880
+ return undefined;
3881
+ }
3882
+
2553
3883
  function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
2554
3884
  const ranges: ObjectRange[] = [];
2555
3885
  const masked = maskCommentsAndStrings(source);
@@ -2593,23 +3923,16 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
2593
3923
  return ranges;
2594
3924
  }
2595
3925
 
2596
- function findConstValueRangeContaining(
2597
- source: string,
2598
- offset: number,
2599
- ): NamedObjectRange | undefined {
2600
- return findNamedConstValueRanges(source).find(
2601
- (range) => offset >= range.start && offset <= range.end,
2602
- );
2603
- }
2604
-
2605
3926
  function findStringLiteralsInRange(
2606
3927
  source: string,
2607
3928
  range: ObjectRange,
3929
+ fileName: string,
2608
3930
  ): Array<{ value: string; offset: number }> {
2609
3931
  const literals: Array<{ value: string; offset: number }> = [];
3932
+ const masked = maskCommentsAndStrings(source, fileName);
2610
3933
  let index = range.start;
2611
3934
  while (index <= range.end) {
2612
- const quote = source[index];
3935
+ const quote = masked[index];
2613
3936
  if (quote !== '"' && quote !== "'" && quote !== "`") {
2614
3937
  index += 1;
2615
3938
  continue;
@@ -2719,57 +4042,233 @@ function findStringEnd(source: string, start: number): number {
2719
4042
  return -1;
2720
4043
  }
2721
4044
 
2722
- function maskCommentsAndStrings(source: string): string {
4045
+ // Masking now parses with TypeScript, which is orders of magnitude more
4046
+ // expensive than the character walk it replaced, and six scanners call this
4047
+ // helper once per candidate match — the same file is masked thousands of
4048
+ // times in one run (measured: 1,872 calls for a 3.4k-line provider). Without
4049
+ // memoization that run regresses from ~8s to minutes and can exceed the CI
4050
+ // validation timeout. Successful results are cached by exact source text and
4051
+ // property-key mode; the cache stays small because a run only ever reads a
4052
+ // handful of files.
4053
+ // Parse failures are NOT cached: they throw and abort the check (fail-closed).
4054
+ const MASK_CACHE_LIMIT = 64;
4055
+ const maskCache = new Map<string, string>();
4056
+
4057
+ // Neutralize { } ( ) [ ], backtick, quotes, slash, backslash, comma, and semicolon
4058
+ // because scanners treat these as depth, string/regex delimiters, or top-level stops.
4059
+ const PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS = new Set([
4060
+ "{",
4061
+ "}",
4062
+ "(",
4063
+ ")",
4064
+ "[",
4065
+ "]",
4066
+ "`",
4067
+ '"',
4068
+ "'",
4069
+ "/",
4070
+ "\\",
4071
+ ",",
4072
+ ";",
4073
+ ]);
4074
+
4075
+ type MaskCommentsAndStringsOptions = {
4076
+ blankPropertyKeys?: boolean;
4077
+ };
4078
+
4079
+ // Which literal bodies are blanked alongside comment bodies. "all" blanks
4080
+ // every string/template/regex body; "values" does the same but keeps quoted
4081
+ // property-key text (only its structural characters are blanked); "none"
4082
+ // leaves every literal and all code byte-identical, so only comments go.
4083
+ type LiteralMaskMode = "all" | "values" | "none";
4084
+
4085
+ export function maskCommentsAndStrings(
4086
+ source: string,
4087
+ fileName = "provider.ts",
4088
+ options: MaskCommentsAndStringsOptions = {},
4089
+ ): string {
4090
+ return maskSource(source, fileName, options.blankPropertyKeys === true ? "all" : "values");
4091
+ }
4092
+
4093
+ // Blanks comment bodies only, from the same TypeScript-lexer comment ranges
4094
+ // maskCommentsAndStrings uses: a `//` inside a string, template, or regex is
4095
+ // not a comment, and a block comment spanning lines is blanked whole. Line
4096
+ // terminators survive, so line N of the result is line N of the source.
4097
+ // Throws like maskCommentsAndStrings when the source cannot be parsed.
4098
+ export function maskComments(source: string, fileName = "provider.ts"): string {
4099
+ return maskSource(source, fileName, "none");
4100
+ }
4101
+
4102
+ function maskSource(source: string, fileName: string, literals: LiteralMaskMode): string {
4103
+ const { scriptKind } = maskScriptKind(fileName);
4104
+ const cacheKey = `${literals}\0${scriptKind}\0${source}`;
4105
+ const cached = maskCache.get(cacheKey);
4106
+ if (cached !== undefined) {
4107
+ return cached;
4108
+ }
4109
+ const masked = computeMaskedSource(source, fileName, literals);
4110
+ if (maskCache.size >= MASK_CACHE_LIMIT) {
4111
+ const oldest = maskCache.keys().next();
4112
+ if (!oldest.done) {
4113
+ maskCache.delete(oldest.value);
4114
+ }
4115
+ }
4116
+ maskCache.set(cacheKey, masked);
4117
+ return masked;
4118
+ }
4119
+
4120
+ // The script kind a source is lexed as, from its file name, and the synthetic
4121
+ // implementation file name transpileModule parses it under. Declaration files
4122
+ // trigger an internal TypeScript Debug Failure when passed to transpileModule,
4123
+ // so `.d.ts` (like every other non-JS/JSX/TSX name) parses as `provider.ts`.
4124
+ function maskScriptKind(fileName: string): {
4125
+ scriptKind: TS.ScriptKind;
4126
+ syntheticFileName: string;
4127
+ } {
4128
+ if (fileName.endsWith(".tsx")) {
4129
+ return { scriptKind: ts.ScriptKind.TSX, syntheticFileName: "provider.tsx" };
4130
+ }
4131
+ if (fileName.endsWith(".jsx")) {
4132
+ return { scriptKind: ts.ScriptKind.JSX, syntheticFileName: "provider.jsx" };
4133
+ }
4134
+ if (/\.[cm]?js$/.test(fileName)) {
4135
+ return { scriptKind: ts.ScriptKind.JS, syntheticFileName: "provider.js" };
4136
+ }
4137
+ return { scriptKind: ts.ScriptKind.TS, syntheticFileName: "provider.ts" };
4138
+ }
4139
+
4140
+ function computeMaskedSource(source: string, fileName: string, literals: LiteralMaskMode): string {
4141
+ const blankPropertyKeys = literals === "all";
4142
+ const { scriptKind, syntheticFileName } = maskScriptKind(fileName);
4143
+ const transpiled = ts.transpileModule(source, {
4144
+ // Parsing is all we need here: parse under the synthetic implementation
4145
+ // filename while retaining the real filename for source mapping and
4146
+ // sanitized diagnostics below.
4147
+ fileName: syntheticFileName,
4148
+ reportDiagnostics: true,
4149
+ compilerOptions: { target: ts.ScriptTarget.Latest },
4150
+ });
4151
+ const sourceFile = ts.createSourceFile(
4152
+ fileName,
4153
+ source,
4154
+ ts.ScriptTarget.Latest,
4155
+ true,
4156
+ scriptKind,
4157
+ );
4158
+ const parseDiagnostic = transpiled.diagnostics?.[0];
4159
+ if (parseDiagnostic) {
4160
+ const position = parseDiagnostic.start ?? 0;
4161
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
4162
+ throw new Error(
4163
+ `Cannot safely scan TypeScript source ${sanitizeDiagnosticFileName(fileName)} at ${line + 1}:${character + 1}: ${ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n")}`,
4164
+ );
4165
+ }
4166
+
2723
4167
  const chars = source.split("");
2724
- for (let index = 0; index < source.length; index += 1) {
2725
- if (source.startsWith("//", index)) {
2726
- const bodyStart = index + 2;
2727
- const newline = source.indexOf("\n", bodyStart);
2728
- const end = newline === -1 ? source.length : newline;
2729
- for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2730
- chars[bodyIndex] = " ";
2731
- }
2732
- index = end;
2733
- continue;
2734
- }
2735
- if (source.startsWith("/*", index)) {
2736
- const bodyStart = index + 2;
2737
- const close = source.indexOf("*/", bodyStart);
2738
- const end = close === -1 ? source.length : close;
2739
- for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2740
- if (chars[bodyIndex] !== "\n") {
2741
- chars[bodyIndex] = " ";
2742
- }
4168
+ const maskRange = (start: number, end: number): void => {
4169
+ for (let index = start; index < end; index += 1) {
4170
+ // Preserve the backslash of a string line continuation (\ followed
4171
+ // by LF, CRLF, or lone CR) and every line terminator, so masked
4172
+ // string bodies remain syntactically valid and the mask output can
4173
+ // be re-parsed (mask(mask(x)) === mask(x)).
4174
+ if (source[index] === "\\" && (source[index + 1] === "\n" || source[index + 1] === "\r")) {
4175
+ continue;
2743
4176
  }
2744
- index = close === -1 ? source.length : close + 1;
2745
- continue;
2746
- }
2747
- const quote = source[index];
2748
- if (quote !== '"' && quote !== "'" && quote !== "`") {
2749
- continue;
2750
- }
2751
- const end = findStringEnd(source, index);
2752
- if (end === -1) {
2753
- break;
4177
+ if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
2754
4178
  }
2755
- // Preserve quoted property keys ("response": ...) so range/key scanners
2756
- // can still match them; only string VALUES are blanked.
2757
- let probe = end + 1;
2758
- while (probe < source.length && /\s/.test(source[probe] ?? "")) {
2759
- probe += 1;
4179
+ };
4180
+ const commentRanges = new Map<string, TS.CommentRange>();
4181
+ const addCommentRanges = (ranges: readonly TS.CommentRange[] | undefined): void => {
4182
+ for (const range of ranges ?? []) {
4183
+ commentRanges.set(`${range.pos}:${range.end}`, range);
2760
4184
  }
2761
- if (source[probe] !== ":") {
2762
- for (let bodyIndex = index + 1; bodyIndex < end; bodyIndex += 1) {
2763
- if (chars[bodyIndex] !== "\n") {
2764
- chars[bodyIndex] = " ";
4185
+ };
4186
+
4187
+ const maskLiteralBody = (node: TS.Node): void => {
4188
+ const start = node.getStart(sourceFile);
4189
+ if (ts.isStringLiteral(node)) {
4190
+ // Preserve quoted property keys ("response": ...) by default so range/key
4191
+ // scanners can still match them. Line scanners opt into blanking key bodies
4192
+ // as well, preventing key text from looking like executable source.
4193
+ const isQuotedPropertyKey = ts.isPropertyAssignment(node.parent) && node.parent.name === node;
4194
+ if (blankPropertyKeys || !isQuotedPropertyKey) {
4195
+ maskRange(start + 1, node.end - 1);
4196
+ } else {
4197
+ for (let index = start + 1; index < node.end - 1; index += 1) {
4198
+ if (
4199
+ source[index] === "\\" &&
4200
+ (source[index + 1] === "\n" || source[index + 1] === "\r")
4201
+ ) {
4202
+ continue;
4203
+ }
4204
+ if (PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS.has(source[index] ?? "")) {
4205
+ chars[index] = " ";
4206
+ }
2765
4207
  }
2766
4208
  }
4209
+ } else if (ts.isRegularExpressionLiteral(node)) {
4210
+ let closeDelimiter = node.end - 1;
4211
+ while (closeDelimiter > start && /[A-Za-z]/.test(source[closeDelimiter] ?? "")) {
4212
+ closeDelimiter -= 1;
4213
+ }
4214
+ maskRange(start + 1, closeDelimiter);
4215
+ } else if (ts.isNoSubstitutionTemplateLiteral(node)) {
4216
+ maskRange(start + 1, node.end - 1);
4217
+ } else if (node.kind === ts.SyntaxKind.TemplateHead) {
4218
+ maskRange(start + 1, node.end - 2);
4219
+ } else if (node.kind === ts.SyntaxKind.TemplateMiddle) {
4220
+ maskRange(start + 1, node.end - 2);
4221
+ } else if (node.kind === ts.SyntaxKind.TemplateTail) {
4222
+ maskRange(start + 1, node.end - 1);
2767
4223
  }
2768
- index = end;
4224
+ };
4225
+
4226
+ const visit = (node: TS.Node): void => {
4227
+ addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
4228
+ addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
4229
+ if (literals !== "none") maskLiteralBody(node);
4230
+ for (const child of node.getChildren(sourceFile)) visit(child);
4231
+ };
4232
+
4233
+ visit(sourceFile);
4234
+ for (const comment of commentRanges.values()) {
4235
+ const markerLength = 2;
4236
+ const bodyEnd =
4237
+ comment.kind === ts.SyntaxKind.MultiLineCommentTrivia
4238
+ ? comment.end - markerLength
4239
+ : comment.end;
4240
+ maskRange(comment.pos + markerLength, bodyEnd);
2769
4241
  }
2770
4242
  return chars.join("");
2771
4243
  }
2772
4244
 
4245
+ // Escapes every non-printable or formatting character before a submitter-
4246
+ // controlled filename is interpolated into a diagnostic that the CLI prints.
4247
+ // Category-based on purpose: enumerated code-point lists kept missing members
4248
+ // of the same class (C1 controls, U+061C, U+2028/U+2029 were each found
4249
+ // individually in review), so this escapes the whole Unicode categories —
4250
+ // Cc (controls), Cf (formatting, includes all Bidi_Control), Zl/Zp
4251
+ // (line/paragraph separators). Ordinary letters, digits, spaces, and CJK
4252
+ // filenames pass through unchanged.
4253
+ const DIAGNOSTIC_UNSAFE_CHARACTER = /\p{Cc}|\p{Cf}|\p{Zl}|\p{Zp}/u;
4254
+
4255
+ function sanitizeDiagnosticFileName(fileName: string): string {
4256
+ let sanitized = "";
4257
+ for (const character of fileName) {
4258
+ const codePoint = character.codePointAt(0);
4259
+ if (codePoint === undefined) continue;
4260
+ if (DIAGNOSTIC_UNSAFE_CHARACTER.test(character)) {
4261
+ sanitized +=
4262
+ codePoint <= 0xff
4263
+ ? `\\x${codePoint.toString(16).padStart(2, "0")}`
4264
+ : `\\u{${codePoint.toString(16)}}`;
4265
+ } else {
4266
+ sanitized += character;
4267
+ }
4268
+ }
4269
+ return sanitized;
4270
+ }
4271
+
2773
4272
  function skipWhitespaceAndComments(source: string, start: number, end: number): number {
2774
4273
  let index = start;
2775
4274
  while (index < end) {
@@ -2865,6 +4364,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2865
4364
  const operations = Object.entries(provider.operations);
2866
4365
  const missing: string[] = [];
2867
4366
  const vacuous: string[] = [];
4367
+ const evidenceFreeScenario: string[] = [];
2868
4368
  const placeholder: string[] = [];
2869
4369
  const unsupported: string[] = [];
2870
4370
  const generatedStarter: string[] = [];
@@ -2876,8 +4376,13 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2876
4376
  missing.push(operationId);
2877
4377
  continue;
2878
4378
  }
2879
- if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
2880
- vacuous.push(operationId);
4379
+ if (hasCheck && !hasUnsupported) {
4380
+ const vacuousClass = vacuousHealthCaseClass(operation.healthCheck);
4381
+ if (vacuousClass === "empty-assertions") {
4382
+ vacuous.push(operationId);
4383
+ } else if (vacuousClass === "evidence-free-scenario") {
4384
+ evidenceFreeScenario.push(operationId);
4385
+ }
2881
4386
  }
2882
4387
  if (hasUnsupported) {
2883
4388
  const reason = operation.healthCheckUnsupported?.reason ?? "";
@@ -2904,14 +4409,36 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2904
4409
  );
2905
4410
  }
2906
4411
 
2907
- if (vacuous.length > 0) {
4412
+ if (vacuous.length > 0 || evidenceFreeScenario.length > 0) {
4413
+ const remediationParts: string[] = [];
4414
+ if (vacuous.length > 0) {
4415
+ remediationParts.push(
4416
+ `healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
4417
+ );
4418
+ }
4419
+ if (evidenceFreeScenario.length > 0) {
4420
+ remediationParts.push(
4421
+ `healthCheck scenario for ${evidenceFreeScenario.join(", ")} asserts nothing beyond transport — add an assert step whose expression reads the probe response (a field under data, an extracted value, or a quantifier over response rows), not just status_2xx or a bare response-envelope check.`,
4422
+ );
4423
+ }
4424
+ const message =
4425
+ evidenceFreeScenario.length === 0
4426
+ ? "One or more operations have healthCheck cases with empty assertions."
4427
+ : vacuous.length === 0
4428
+ ? "One or more operations have healthCheck scenarios without response evidence."
4429
+ : "One or more operations have healthCheck cases with empty assertions or evidence-free scenarios.";
2908
4430
  return blocker(
2909
4431
  "health-coverage",
2910
4432
  "health",
2911
- "One or more operations have healthCheck cases with empty assertions.",
2912
- `healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
4433
+ message,
4434
+ remediationParts.join(" "),
2913
4435
  CATEGORY_MAX_POINTS.health,
2914
- vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4436
+ [
4437
+ ...vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4438
+ ...evidenceFreeScenario.map(
4439
+ (operationId) => `${operationId}: healthCheck scenario asserts nothing beyond transport`,
4440
+ ),
4441
+ ],
2915
4442
  );
2916
4443
  }
2917
4444
 
@@ -2966,14 +4493,216 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2966
4493
  );
2967
4494
  }
2968
4495
 
2969
- function hasOnlyVacuousHealthCases(
4496
+ type VacuousHealthCaseClass = "empty-assertions" | "evidence-free-scenario";
4497
+
4498
+ /**
4499
+ * Classify an operation whose every health case is vacuous. Returns undefined
4500
+ * when at least one case carries real coverage: an imperative case with a
4501
+ * non-vacuous assertions function, or a declarative case whose scenario reads
4502
+ * the probe response. When all cases are vacuous, the class picks the blocker
4503
+ * wording — "empty-assertions" whenever any vacuous case is imperative (the
4504
+ * pre-scenario behavior, kept byte-identical), "evidence-free-scenario" when
4505
+ * the suite is scenario-only.
4506
+ */
4507
+ function vacuousHealthCaseClass(
2970
4508
  healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
2971
- ): boolean {
4509
+ ): VacuousHealthCaseClass | undefined {
2972
4510
  const cases = healthCheck?.cases;
2973
4511
  if (!Array.isArray(cases) || cases.length === 0) {
4512
+ return "empty-assertions";
4513
+ }
4514
+ let sawImperativeCase = false;
4515
+ for (const healthCase of cases) {
4516
+ if (healthCase?.scenario !== undefined) {
4517
+ if (scenarioCarriesResponseEvidence(healthCase.scenario)) {
4518
+ return undefined;
4519
+ }
4520
+ continue;
4521
+ }
4522
+ sawImperativeCase = true;
4523
+ if (!isVacuousAssertionFunction(healthCase?.assertions)) {
4524
+ return undefined;
4525
+ }
4526
+ }
4527
+ return sawImperativeCase ? "empty-assertions" : "evidence-free-scenario";
4528
+ }
4529
+
4530
+ /**
4531
+ * A declarative case (`scenario`) carries real coverage when at least one
4532
+ * `assert` step reads the probe response beyond the transport envelope. The
4533
+ * SDK's own definitions drive this judgement:
4534
+ *
4535
+ * - The assert step is the scenario's only verdict-bearing construct
4536
+ * (`AssertResult.passed`). A guard can only attribute
4537
+ * degraded/expected_absence and stop the run (an escape hatch, not a health
4538
+ * verdict), and an operation step does not intrinsically fail on a non-2xx
4539
+ * response — that is what the explicit `status_2xx` predicate exists for. So
4540
+ * a scenario without a semantic assert step proves nothing beyond the
4541
+ * operation merely executing, which is the same emptiness the imperative
4542
+ * vacuity check flags.
4543
+ * - Leaves that cannot observe upstream content carry no evidence: predicates
4544
+ * whose operands read nothing produced by a step (pure literals,
4545
+ * attempt/credential metadata, and the `status_code`/`request_id`/`kind`
4546
+ * envelope fields of a step result — this is what makes the legacy transport
4547
+ * pin `status_2xx` on `["status_code"]` non-evidence), predicates comparing
4548
+ * a reference against an identical copy of itself (`equals(x, x)` holds for
4549
+ * any response), and positive-polarity presence/type pins (`exists`,
4550
+ * `type_is`, `not(not_exists(...))`, zero-bound `array_length_gte`) on the
4551
+ * bare `data` root — the declarative spellings of the legacy transport-only
4552
+ * assertion (`status === 200` + `typeof data === "object"`), satisfied by
4553
+ * any well-formed response body. Polarity matters: `not(exists(data))`
4554
+ * ("this probe must return no body") is falsified by ordinary responses, so
4555
+ * the carve-out only applies where the pin appears un-negated.
4556
+ * - Everything else counts: paths under `data`, extract-step results,
4557
+ * `duration_ms` (parity with imperative `ctx.durationMs` reads),
4558
+ * `status_2xx` applied to a body path (envelope-style upstreams answer HTTP
4559
+ * 200 and carry the real code in the body), and quantifiers, which iterate
4560
+ * response rows by construction.
4561
+ *
4562
+ * Fail-open contract (same as isVacuousAssertionFunction): the provider module
4563
+ * already validated this scenario against HealthScenarioSchema in its own SDK
4564
+ * at import time, so any shape this walk does not recognize (unknown step
4565
+ * kind, expression kind, operator, or reference namespace) indicates a newer
4566
+ * grammar, not an empty probe — treat it as evidence rather than flag it.
4567
+ * The inverse direction is accepted and documented: a determined author can
4568
+ * still fabricate a read that never fails (a decorative deep path inside a
4569
+ * disjunction, a degenerate bound). That effort is comparable to writing the
4570
+ * real one-line assertion — the same accepted limitation as the imperative
4571
+ * check's decorative-ctx-read — and the live `--smoke` probe remains the
4572
+ * runtime defense.
4573
+ */
4574
+ export function scenarioCarriesResponseEvidence(scenario: unknown): boolean {
4575
+ if (!isRecord(scenario) || !Array.isArray(scenario.steps)) {
4576
+ return true;
4577
+ }
4578
+ let sawUnknownStepKind = false;
4579
+ for (const step of scenario.steps) {
4580
+ if (!isRecord(step)) {
4581
+ sawUnknownStepKind = true;
4582
+ continue;
4583
+ }
4584
+ if (step.kind === "assert") {
4585
+ if (expressionReadsResponse(step.expression)) {
4586
+ return true;
4587
+ }
4588
+ continue;
4589
+ }
4590
+ if (step.kind !== "operation" && step.kind !== "extract" && step.kind !== "guard") {
4591
+ sawUnknownStepKind = true;
4592
+ }
4593
+ }
4594
+ return sawUnknownStepKind;
4595
+ }
4596
+
4597
+ /**
4598
+ * Predicate operators of the current scenario grammar. An operator outside
4599
+ * this set indicates a newer grammar and fails open (counts as evidence),
4600
+ * because a future operator may source the response through fields this walk
4601
+ * does not know about.
4602
+ */
4603
+ const KNOWN_SCENARIO_PREDICATE_OPERATORS: ReadonlySet<string> = new Set([
4604
+ "exists",
4605
+ "not_exists",
4606
+ "non_empty",
4607
+ "is_true",
4608
+ "equals",
4609
+ "not_equals",
4610
+ "contains",
4611
+ "matches",
4612
+ "number_gt",
4613
+ "number_gte",
4614
+ "number_lt",
4615
+ "number_lte",
4616
+ "array_length_eq",
4617
+ "array_length_gte",
4618
+ "array_length_lte",
4619
+ "status_2xx",
4620
+ "type_is",
4621
+ ]);
4622
+
4623
+ function expressionReadsResponse(expression: unknown, negationDepth = 0): boolean {
4624
+ if (!isRecord(expression)) {
4625
+ return true;
4626
+ }
4627
+ if (expression.kind === "all" || expression.kind === "any") {
4628
+ return Array.isArray(expression.clauses)
4629
+ ? expression.clauses.some((clause) => expressionReadsResponse(clause, negationDepth))
4630
+ : true;
4631
+ }
4632
+ if (expression.kind === "not") {
4633
+ return expressionReadsResponse(expression.clause, negationDepth + 1);
4634
+ }
4635
+ if (expression.kind === "quantifier") {
4636
+ return true;
4637
+ }
4638
+ if (expression.kind !== "predicate" || typeof expression.operator !== "string") {
4639
+ return true;
4640
+ }
4641
+ const operator = expression.operator;
4642
+ if (!KNOWN_SCENARIO_PREDICATE_OPERATORS.has(operator)) {
4643
+ return true;
4644
+ }
4645
+ const actualReads = operandReadsStepOutput(expression.actual);
4646
+ const expectedReads = operandReadsStepOutput(expression.expected);
4647
+ if (!actualReads && !expectedReads) {
4648
+ return false;
4649
+ }
4650
+ // A predicate comparing a reference against an identical copy of itself
4651
+ // (equals(x, x) and friends) holds or fails identically for every response,
4652
+ // so the reads discriminate nothing.
4653
+ if (
4654
+ actualReads &&
4655
+ expectedReads &&
4656
+ JSON.stringify(expression.actual) === JSON.stringify(expression.expected)
4657
+ ) {
4658
+ return false;
4659
+ }
4660
+ // Presence/type pins on the whole `data` root are satisfied by any
4661
+ // well-formed JSON body — but only in positive polarity. Under an odd
4662
+ // number of `not` wrappers the same pin means "this probe must return no
4663
+ // body / a different shape", which ordinary responses falsify, so it is a
4664
+ // real (if unusual) constraint and stays evidence.
4665
+ const positivePolarity = negationDepth % 2 === 0;
4666
+ const bareEnvelopePin = positivePolarity
4667
+ ? operator === "exists" ||
4668
+ operator === "type_is" ||
4669
+ (operator === "array_length_gte" && expression.expected === 0)
4670
+ : operator === "not_exists";
4671
+ if (bareEnvelopePin && !expectedReads && isBareResponseEnvelope(expression.actual)) {
4672
+ return false;
4673
+ }
4674
+ return true;
4675
+ }
4676
+
4677
+ function operandReadsStepOutput(operand: unknown): boolean {
4678
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4679
+ return false;
4680
+ }
4681
+ const reference = operand.ref;
4682
+ if (reference.namespace === "attempt" || reference.namespace === "credentials") {
4683
+ return false;
4684
+ }
4685
+ if (reference.namespace !== "steps") {
4686
+ return true;
4687
+ }
4688
+ if (!Array.isArray(reference.path) || reference.path.length === 0) {
2974
4689
  return true;
2975
4690
  }
2976
- return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
4691
+ const head = reference.path[0];
4692
+ return head !== "status_code" && head !== "request_id" && head !== "kind";
4693
+ }
4694
+
4695
+ function isBareResponseEnvelope(operand: unknown): boolean {
4696
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4697
+ return false;
4698
+ }
4699
+ const reference = operand.ref;
4700
+ return (
4701
+ reference.namespace === "steps" &&
4702
+ Array.isArray(reference.path) &&
4703
+ reference.path.length === 1 &&
4704
+ reference.path[0] === "data"
4705
+ );
2977
4706
  }
2978
4707
 
2979
4708
  function isVacuousAssertionFunction(assertions: unknown): boolean {
@@ -3341,7 +5070,7 @@ function isAstNode(value: unknown): value is acorn.AnyNode {
3341
5070
  );
3342
5071
  }
3343
5072
 
3344
- function scoreSmoke(
5073
+ export function scoreSmoke(
3345
5074
  smokeResult: SmokeResult | undefined,
3346
5075
  smokeNote: string | undefined,
3347
5076
  ): SubmitCheck {
@@ -3363,8 +5092,22 @@ function scoreSmoke(
3363
5092
  };
3364
5093
  }
3365
5094
 
5095
+ const credential = smokeResult.credential;
5096
+ // An invalid configuration is reported even for a provider that needs no
5097
+ // credential: the operator set something, and silence would read as accepted.
5098
+ const credentialEvidence =
5099
+ credential?.required === true || credential?.error !== undefined
5100
+ ? [
5101
+ credential.supplied
5102
+ ? `credential: supplied for auth.mode "${credential.mode}"`
5103
+ : credential.error
5104
+ ? `credential: CONFIGURED BUT INVALID — ${credential.error}`
5105
+ : `credential: NOT supplied; auth.mode "${credential.mode}" needs one the gateway injects at invocation time, so every credentialed operation failed before reaching the upstream`,
5106
+ ]
5107
+ : [];
3366
5108
  const evidence = [
3367
5109
  `/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
5110
+ ...credentialEvidence,
3368
5111
  ...smokeResult.operations.map(
3369
5112
  (outcome) =>
3370
5113
  `${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
@@ -3388,6 +5131,31 @@ function scoreSmoke(
3388
5131
  };
3389
5132
  }
3390
5133
 
5134
+ // Checked BEFORE the success branch. A provider that needs a
5135
+ // gateway-injected credential and did not get one never exercised its
5136
+ // credentialed path, so a schema-valid success from some other operation --
5137
+ // an unauthenticated one, or a fixture-shaped response -- must not buy full
5138
+ // smoke points. Saying "verified" would credit the run with evidence it does
5139
+ // not have.
5140
+ if (credential?.required === true && !credential.supplied) {
5141
+ return {
5142
+ id: "local-smoke",
5143
+ category: "smoke",
5144
+ level: "warn",
5145
+ status: "warn",
5146
+ points: 7,
5147
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
5148
+ message: credential.error
5149
+ ? `Runtime path was verified, but the configured local credential could not be used, so the provider's credentialed operations never reached the upstream.`
5150
+ : `Runtime path was verified, but auth.mode "${credential.mode}" needs a credential this run did not have, so the provider's credentialed operations never reached the upstream.`,
5151
+ remediation: credential.error
5152
+ ? `Fix the local credential configuration and rerun \`bun run submit-check -- --smoke\`: ${credential.error}`
5153
+ : `Supply the credential the gateway injects and rerun \`bun run submit-check -- --smoke\`: ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}', where the keys are the ones the provider passes to ctx.credential.get(...). For platform-managed providers that value is the platform-owned key an operator holds; it is not in the checkout.`,
5154
+ evidence,
5155
+ details: smokeResult,
5156
+ };
5157
+ }
5158
+
3391
5159
  const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
3392
5160
  if (successes.length > 0) {
3393
5161
  return {
@@ -3458,6 +5226,12 @@ export async function runSubmitCheckSmoke(
3458
5226
  operations: [],
3459
5227
  };
3460
5228
  }
5229
+ // The gateway attaches a resolved credential to every invocation of a
5230
+ // provider whose auth.mode is not "none". Smoke POSTed an envelope with
5231
+ // no `connection` at all, so every platform-managed provider answered
5232
+ // MISSING_SECRET and scored as "runtime verified, no live success" —
5233
+ // a capability gap wearing the costume of an ordinary upstream miss.
5234
+ const connection = resolveLocalConnection(loadedProvider);
3461
5235
  const operations: SmokeOperationOutcome[] = [];
3462
5236
  for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
3463
5237
  operations.push(
@@ -3465,10 +5239,21 @@ export async function runSubmitCheckSmoke(
3465
5239
  requestId: `req_submit_check_smoke_${operationId}`,
3466
5240
  input: operation.fixtures?.request ?? {},
3467
5241
  headers: {},
5242
+ ...(connection.ok && connection.connection ? { connection: connection.connection } : {}),
3468
5243
  }),
3469
5244
  );
3470
5245
  }
3471
- return { measured: true, healthOk: true, operations };
5246
+ return {
5247
+ measured: true,
5248
+ healthOk: true,
5249
+ operations,
5250
+ credential: {
5251
+ required: providerRequiresConnection(loadedProvider),
5252
+ supplied: connection.ok && connection.connection !== undefined,
5253
+ mode: loadedProvider.auth?.mode ?? "none",
5254
+ ...(connection.ok ? {} : { error: connection.reason }),
5255
+ },
5256
+ };
3472
5257
  } finally {
3473
5258
  await stopSmokeServer(server);
3474
5259
  }
@@ -3655,25 +5440,6 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
3655
5440
  };
3656
5441
  }
3657
5442
 
3658
- if (authMode === "none") {
3659
- const securedOperations = Object.entries(provider.operations).filter(
3660
- ([, operation]) => operation.annotations?.openWorld === false,
3661
- );
3662
- if (securedOperations.length > 0) {
3663
- return {
3664
- id: "auth-safety",
3665
- category: "auth",
3666
- level: "warn",
3667
- status: "warn",
3668
- points: 7,
3669
- maxPoints: CATEGORY_MAX_POINTS.auth,
3670
- message: "Provider is no-auth but at least one operation is not marked openWorld.",
3671
- remediation: `Either set \`auth.mode\` to the upstream auth model, or mark these public no-auth operations with \`annotations.openWorld: true\`: ${securedOperations.map(([operationId]) => operationId).join(", ")}.`,
3672
- evidence: securedOperations.map(([operationId]) => operationId),
3673
- };
3674
- }
3675
- }
3676
-
3677
5443
  return pass(
3678
5444
  "auth-safety",
3679
5445
  "auth",
@@ -3875,18 +5641,18 @@ function findEntropySecretFindings(providerRoot: string, providerId: string): Se
3875
5641
  if (isEntropySecretExcludedPath(relativePath)) continue;
3876
5642
  const content = readFileSync(filePath, "utf8");
3877
5643
  const lines = content.split(/\r?\n/);
3878
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
3879
- const line = lines[lineIndex] ?? "";
3880
- for (const candidate of extractStringLiteralCandidates(line)) {
3881
- const finding = classifyEntropyCandidate({
3882
- value: candidate,
3883
- line,
3884
- file: relativePath,
3885
- lineNumber: lineIndex + 1,
3886
- providerId,
3887
- });
3888
- if (finding) findings.push(finding);
3889
- }
5644
+ for (const candidate of extractStringLiteralCandidates(content, relativePath)) {
5645
+ // Context is the whole source line, comment tail included: a
5646
+ // comment that names the value is evidence about it, even though
5647
+ // comment text itself is never a candidate.
5648
+ const finding = classifyEntropyCandidate({
5649
+ value: candidate.value,
5650
+ line: lines[candidate.lineIndex] ?? "",
5651
+ file: relativePath,
5652
+ lineNumber: candidate.lineIndex + 1,
5653
+ providerId,
5654
+ });
5655
+ if (finding) findings.push(finding);
3890
5656
  }
3891
5657
  }
3892
5658
  return findings;
@@ -3902,7 +5668,62 @@ function isEntropySecretExcludedPath(relativePath: string): boolean {
3902
5668
  );
3903
5669
  }
3904
5670
 
3905
- export function extractStringLiteralCandidates(line: string): string[] {
5671
+ export type StringLiteralCandidate = {
5672
+ value: string;
5673
+ // 0-based index of the source line the literal sits on.
5674
+ lineIndex: number;
5675
+ };
5676
+
5677
+ // Every string-literal candidate (a quoted body of at least
5678
+ // ENTROPY_CANDIDATE_MIN_LENGTH characters) in one provider source file.
5679
+ // Candidates come from code only. Comments are prose, not values, so each
5680
+ // file's own comment syntax is removed before a quote is read:
5681
+ // - JS/TS sources: comment trivia is blanked with the TypeScript lexer
5682
+ // (maskComments, the comment ranges the other source lints skip), so `//`
5683
+ // inside a string or regex is not a comment and a `/* … */` block spanning
5684
+ // lines is skipped whole. A backtick in code opens a template literal — a
5685
+ // string value — so it stays a quote form.
5686
+ // - Shell sources (.sh/.bash, Dockerfile, entrypoint): `#` starts a comment
5687
+ // when it begins a word outside quotes, and a backtick is command
5688
+ // substitution (code), not a string delimiter.
5689
+ // Identifiers are therefore never candidates by construction: a bare name is
5690
+ // not a string literal, and a backticked name in a comment is comment text.
5691
+ // A JS/TS source the lexer cannot parse keeps the unmasked scan (every quote
5692
+ // pair is a candidate): when comments cannot be located the scan may
5693
+ // over-report, never under-report.
5694
+ export function extractStringLiteralCandidates(
5695
+ content: string,
5696
+ relativePath: string,
5697
+ ): StringLiteralCandidate[] {
5698
+ const shell = !isJavaScriptFamilySource(relativePath);
5699
+ const codeLines = (shell ? content : maskCommentsForSecretScan(content, relativePath)).split(
5700
+ /\r?\n/,
5701
+ );
5702
+ const candidates: StringLiteralCandidate[] = [];
5703
+ for (let lineIndex = 0; lineIndex < codeLines.length; lineIndex += 1) {
5704
+ const line = codeLines[lineIndex] ?? "";
5705
+ const values = shell ? extractShellLineStrings(line) : extractQuotedLineStrings(line);
5706
+ for (const value of values) candidates.push({ value, lineIndex });
5707
+ }
5708
+ return candidates;
5709
+ }
5710
+
5711
+ function isJavaScriptFamilySource(relativePath: string): boolean {
5712
+ return /\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(relativePath);
5713
+ }
5714
+
5715
+ function maskCommentsForSecretScan(content: string, relativePath: string): string {
5716
+ try {
5717
+ return maskComments(content, relativePath);
5718
+ } catch {
5719
+ // Unparseable: scan the source unmasked rather than skip it.
5720
+ return content;
5721
+ }
5722
+ }
5723
+
5724
+ // Quoted bodies on one comment-free JS/TS line (`"`, `'`, and template
5725
+ // backticks), with backslash escapes.
5726
+ function extractQuotedLineStrings(line: string): string[] {
3906
5727
  const candidates: string[] = [];
3907
5728
  for (let index = 0; index < line.length; index += 1) {
3908
5729
  const quote = line[index];
@@ -3929,6 +5750,51 @@ export function extractStringLiteralCandidates(line: string): string[] {
3929
5750
  return candidates;
3930
5751
  }
3931
5752
 
5753
+ // Characters after which a `#` begins a new shell word (blanks and the
5754
+ // metacharacters `;&|()<>`).
5755
+ const SHELL_WORD_BOUNDARY = /[\s;&|()<>]/;
5756
+
5757
+ // Quoted bodies on one shell line. Single quotes take no escapes; double
5758
+ // quotes and unquoted text honor backslash escapes. A `#` that begins a word
5759
+ // outside quotes ends the line's code (the rest is a comment). A word begins
5760
+ // at the line start or after an UNESCAPED boundary character, so `${#var}`,
5761
+ // `$#`, `"a"#b`, and `word\ #` (escaped blank) keep `#` inside the word.
5762
+ // Backticks are command substitution, so their text is lexed as code. A
5763
+ // quote left open on the line is read as a literal character, as the
5764
+ // line-based scan always has.
5765
+ function extractShellLineStrings(line: string): string[] {
5766
+ const candidates: string[] = [];
5767
+ let index = 0;
5768
+ let atWordStart = true;
5769
+ while (index < line.length) {
5770
+ const char = line[index];
5771
+ if (char === "\\") {
5772
+ index += 2;
5773
+ atWordStart = false;
5774
+ continue;
5775
+ }
5776
+ if (char === "#" && atWordStart) break;
5777
+ if (char === '"' || char === "'") {
5778
+ const contentStart = index + 1;
5779
+ let cursor = contentStart;
5780
+ while (cursor < line.length && line[cursor] !== char) {
5781
+ cursor += char === '"' && line[cursor] === "\\" ? 2 : 1;
5782
+ }
5783
+ if (cursor < line.length) {
5784
+ if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
5785
+ candidates.push(line.slice(contentStart, cursor));
5786
+ }
5787
+ index = cursor + 1;
5788
+ atWordStart = false;
5789
+ continue;
5790
+ }
5791
+ }
5792
+ atWordStart = SHELL_WORD_BOUNDARY.test(char ?? "");
5793
+ index += 1;
5794
+ }
5795
+ return candidates;
5796
+ }
5797
+
3932
5798
  function classifyEntropyCandidate(input: {
3933
5799
  value: string;
3934
5800
  line: string;
@@ -4023,9 +5889,7 @@ type LineStringLiteral = {
4023
5889
  // Stable identity for the container a literal sits in ("top" when the
4024
5890
  // literal is not inside any bracket on the line).
4025
5891
  function literalContainerKey(literal: LineStringLiteral): string {
4026
- return literal.container
4027
- ? `${literal.container.bracket}${literal.container.index}`
4028
- : "top";
5892
+ return literal.container ? `${literal.container.bracket}${literal.container.index}` : "top";
4029
5893
  }
4030
5894
 
4031
5895
  // Single-pass line tokenizer: extracts every string literal with its span and
@@ -4035,7 +5899,7 @@ function literalContainerKey(literal: LineStringLiteral): string {
4035
5899
  // other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
4036
5900
  // elements, call arguments, and assignment right-hand sides, even when a
4037
5901
  // ternary's ":" happens to follow them. Uses the same quote/escape walking as
4038
- // extractStringLiteralCandidates.
5902
+ // extractQuotedLineStrings.
4039
5903
  function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
4040
5904
  const literals: LineStringLiteral[] = [];
4041
5905
  const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
@@ -4166,8 +6030,9 @@ function shannonEntropy(value: string): number {
4166
6030
 
4167
6031
  function guessSecretName(line: string): string {
4168
6032
  const match =
4169
- /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
4170
- /["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
6033
+ /\b[A-Za-z_$][\w$]*(?:\s*\.\s*([A-Za-z_$][\w$]*))+\s*=/.exec(line) ??
6034
+ /["']?([A-Za-z_$][\w$-]*)["']?\s*:\s*["'`]/.exec(line) ??
6035
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line);
4171
6036
  const raw = match?.[1] ?? "SECRET";
4172
6037
  return raw
4173
6038
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
@@ -4182,6 +6047,35 @@ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/
4182
6047
  // the three stay coherent.
4183
6048
  const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
4184
6049
 
6050
+ // A secret-ish name is only supporting evidence. Shorter values must also look
6051
+ // credential-like, and known config tokens/numeric values are never candidates.
6052
+ const SECRET_NAMED_ENV_VALUE_MIN_LENGTH = 4;
6053
+ const SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH = 8;
6054
+ const NON_SECRET_ENV_VALUE_TOKENS = new Set([
6055
+ "true",
6056
+ "false",
6057
+ "yes",
6058
+ "no",
6059
+ "on",
6060
+ "off",
6061
+ "none",
6062
+ "null",
6063
+ "nil",
6064
+ "auto",
6065
+ "default",
6066
+ "local",
6067
+ "debug",
6068
+ "info",
6069
+ "warn",
6070
+ "error",
6071
+ "always",
6072
+ "never",
6073
+ "enabled",
6074
+ "disabled",
6075
+ "0",
6076
+ "1",
6077
+ ]);
6078
+
4185
6079
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
4186
6080
  ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
4187
6081
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
@@ -4193,12 +6087,150 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
4193
6087
  ],
4194
6088
  ];
4195
6089
 
4196
- async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
6090
+ type SafeLoadProviderResult =
6091
+ | { ok: true; provider: ProviderDefinition | undefined }
6092
+ | { ok: false; error: unknown };
6093
+
6094
+ async function safeLoadProvider(providerRoot: string): Promise<SafeLoadProviderResult> {
6095
+ try {
6096
+ return { ok: true, provider: await loadProvider(providerRoot) };
6097
+ } catch (error) {
6098
+ return { ok: false, error };
6099
+ }
6100
+ }
6101
+
6102
+ const UNRENDERABLE_LOAD_ERROR = "<thrown value could not be rendered>";
6103
+
6104
+ function formatLoadErrorEvidence(error: unknown, providerRoot: string): string[] {
6105
+ const evidence: string[] = [];
6106
+ const seen = new Set<object>();
6107
+ let current: unknown = error;
6108
+ let frame = 0;
6109
+ while (frame < MAX_LOAD_ERROR_EVIDENCE) {
6110
+ if (typeof current === "object" || typeof current === "function") {
6111
+ if (current !== null && seen.has(current)) {
6112
+ break;
6113
+ }
6114
+ if (current !== null) seen.add(current);
6115
+ }
6116
+ evidence.push(
6117
+ `${frame === 0 ? "Load error" : "Cause"}: ${sanitizeLoadErrorText(
6118
+ formatThrownValue(current),
6119
+ providerRoot,
6120
+ )}`,
6121
+ );
6122
+ frame += 1;
6123
+ const cause = readThrownValueCause(current);
6124
+ if (!cause.found || cause.value === undefined || cause.value === null) {
6125
+ break;
6126
+ }
6127
+ current = cause.value;
6128
+ }
6129
+ return evidence;
6130
+ }
6131
+
6132
+ function formatThrownValue(value: unknown): string {
4197
6133
  try {
4198
- return await loadProvider(providerRoot);
6134
+ if (value instanceof Error) {
6135
+ return String(value.message);
6136
+ }
6137
+ if (
6138
+ value !== null &&
6139
+ (typeof value === "object" || typeof value === "function") &&
6140
+ "message" in value
6141
+ ) {
6142
+ return String((value as { message?: unknown }).message);
6143
+ }
6144
+ return `<non-Error value thrown: ${String(value)}>`;
4199
6145
  } catch {
4200
- return undefined;
6146
+ return UNRENDERABLE_LOAD_ERROR;
6147
+ }
6148
+ }
6149
+
6150
+ function readThrownValueCause(value: unknown): { found: boolean; value?: unknown } {
6151
+ try {
6152
+ if (
6153
+ value === null ||
6154
+ (typeof value !== "object" && typeof value !== "function") ||
6155
+ !("cause" in value)
6156
+ ) {
6157
+ return { found: false };
6158
+ }
6159
+ return { found: true, value: (value as { cause?: unknown }).cause };
6160
+ } catch {
6161
+ return { found: false };
6162
+ }
6163
+ }
6164
+
6165
+ function isPlausibleSecretNamedEnvValue(value: string): boolean {
6166
+ const normalized = value.trim();
6167
+ if (normalized.length < SECRET_NAMED_ENV_VALUE_MIN_LENGTH) return false;
6168
+ if (NON_SECRET_ENV_VALUE_TOKENS.has(normalized.toLowerCase())) return false;
6169
+ if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return false;
6170
+ if (normalized.length >= SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH) return true;
6171
+
6172
+ const hasLetter = /[A-Za-z]/.test(normalized);
6173
+ const hasDigitOrSymbol = /[^A-Za-z\s]/.test(normalized);
6174
+ return hasLetter && hasDigitOrSymbol;
6175
+ }
6176
+
6177
+ function replaceEnvValue(
6178
+ input: string,
6179
+ value: string,
6180
+ requireIdentifierBoundaries: boolean,
6181
+ ): string {
6182
+ if (!requireIdentifierBoundaries) return input.replaceAll(value, "[REDACTED]");
6183
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6184
+ return input.replace(
6185
+ new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, "g"),
6186
+ "[REDACTED]",
6187
+ );
6188
+ }
6189
+
6190
+ function sanitizeLoadErrorText(value: string, providerRoot: string): string {
6191
+ let output = value.replace(
6192
+ /(^|[^A-Za-z0-9_.])((?:\/[^\s"'`]+)+)/g,
6193
+ (_match, prefix: string, rawPath: string) => {
6194
+ const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
6195
+ const relativePath = relative(providerRoot, resolve(pathCandidate));
6196
+ const insideProvider =
6197
+ relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
6198
+ return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
6199
+ },
6200
+ );
6201
+ output = output.replace(/[A-Za-z]:[\\/][^\s"'`]+/g, "[REDACTED_PATH]");
6202
+ output = output.replace(
6203
+ /((?:password|token|secret|api[_-]?key|credential|authorization)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|["'`]?[^\s,;"'`]+)/gi,
6204
+ "$1[REDACTED]",
6205
+ );
6206
+ const envValues: Array<{ value: string; requireIdentifierBoundaries: boolean }> = [];
6207
+ for (const [name, item] of Object.entries(process.env)) {
6208
+ if (typeof item !== "string") continue;
6209
+ const secretNamed = SECRETISH_IDENTIFIER_PATTERN.test(name);
6210
+ let shouldRedact = secretNamed;
6211
+ if (secretNamed) {
6212
+ shouldRedact = isPlausibleSecretNamedEnvValue(item);
6213
+ } else if (item.length >= ENTROPY_CANDIDATE_MIN_LENGTH && shouldConsiderEntropyValue(item)) {
6214
+ const charset = classifyEntropyCharset(item);
6215
+ const threshold = charset === "hex" ? 3.0 : 4.5;
6216
+ shouldRedact = charset !== undefined && shannonEntropy(item) >= threshold;
6217
+ }
6218
+ if (shouldRedact) {
6219
+ envValues.push({
6220
+ value: item,
6221
+ requireIdentifierBoundaries:
6222
+ secretNamed && item.trim().length < ENTROPY_CANDIDATE_MIN_LENGTH,
6223
+ });
6224
+ }
6225
+ }
6226
+ envValues.sort((a, b) => b.value.length - a.value.length);
6227
+ for (const envValue of envValues) {
6228
+ output = replaceEnvValue(output, envValue.value, envValue.requireIdentifierBoundaries);
4201
6229
  }
6230
+ output = redact(output).replace(/\s+/g, " ");
6231
+ return output.length > MAX_LOAD_ERROR_MESSAGE_LENGTH
6232
+ ? `${output.slice(0, MAX_LOAD_ERROR_MESSAGE_LENGTH - 1)}…`
6233
+ : output;
4202
6234
  }
4203
6235
 
4204
6236
  async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {