@apifuse/provider-sdk 2.2.0-beta.5 → 2.2.0-beta.50

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 (365) hide show
  1. package/AUTHORING.md +537 -33
  2. package/CHANGELOG.md +201 -1
  3. package/README.md +83 -22
  4. package/SUBMISSION.md +2 -2
  5. package/bin/apifuse-check.ts +165 -11
  6. package/bin/apifuse-dev.ts +57 -13
  7. package/bin/apifuse-migrate-operation-declaration.ts +55 -0
  8. package/bin/apifuse-migrate-shape.ts +202 -0
  9. package/bin/apifuse-pack-check.ts +22 -2
  10. package/bin/apifuse-pack-smoke.ts +57 -2
  11. package/bin/apifuse-pack-types.ts +357 -38
  12. package/bin/apifuse-perf.ts +14 -13
  13. package/bin/apifuse-record.ts +709 -72
  14. package/bin/apifuse-submit-check.ts +2344 -324
  15. package/bin/apifuse-sync-assets.ts +117 -0
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/dist/auth-turn/index.d.ts +3 -3
  18. package/dist/auth-turn/index.js +1 -1
  19. package/dist/auth.d.ts +14 -0
  20. package/dist/auth.js +67 -0
  21. package/dist/ceremonies/index.d.ts +16 -0
  22. package/dist/ceremonies/index.js +141 -36
  23. package/dist/cli/commands.d.ts +1 -1
  24. package/dist/cli/commands.js +27 -0
  25. package/dist/cli/create.d.ts +4 -1
  26. package/dist/cli/create.js +42 -37
  27. package/dist/cli/migrate-operation-declaration.d.ts +59 -0
  28. package/dist/cli/migrate-operation-declaration.js +1178 -0
  29. package/dist/cli/migrate-operation-shape.d.ts +44 -0
  30. package/dist/cli/migrate-operation-shape.js +113 -0
  31. package/dist/cli/migrate-provider-shape.d.ts +52 -0
  32. package/dist/cli/migrate-provider-shape.js +578 -0
  33. package/dist/cli/prompt-assets.d.ts +80 -0
  34. package/dist/cli/prompt-assets.js +743 -0
  35. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  36. package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
  37. package/dist/cli/templates/provider/README.md.tpl +5 -5
  38. package/dist/cli/templates/provider/index.ts.tpl +6 -3
  39. package/dist/cli/templates/provider/operations/ping.ts.tpl +4 -1
  40. package/dist/cli/templates/provider/provider.json.tpl +6 -0
  41. package/dist/config/loader.d.ts +179 -16
  42. package/dist/config/loader.js +435 -127
  43. package/dist/contract-serialization.js +4 -8
  44. package/dist/contract-types.d.ts +12 -5
  45. package/dist/contract.js +24 -10
  46. package/dist/declaration-validation.d.ts +33 -0
  47. package/dist/declaration-validation.js +266 -0
  48. package/dist/define.d.ts +66 -29
  49. package/dist/define.js +778 -69
  50. package/dist/dev.d.ts +3 -0
  51. package/dist/dev.js +1 -1
  52. package/dist/engine.d.ts +78 -0
  53. package/dist/engine.js +133 -0
  54. package/dist/error-observability.d.ts +7 -0
  55. package/dist/error-observability.js +61 -0
  56. package/dist/error-resolution.d.ts +4 -0
  57. package/dist/error-resolution.js +122 -0
  58. package/dist/errors.d.ts +33 -0
  59. package/dist/errors.js +40 -0
  60. package/dist/fixture-sanitization.d.ts +28 -0
  61. package/dist/fixture-sanitization.js +227 -0
  62. package/dist/health-scenario.d.ts +1842 -0
  63. package/dist/health-scenario.js +624 -0
  64. package/dist/index.d.ts +21 -10
  65. package/dist/index.js +12 -7
  66. package/dist/lint.d.ts +23 -11
  67. package/dist/lint.js +572 -69
  68. package/dist/native-address.d.ts +43 -0
  69. package/dist/native-address.js +281 -0
  70. package/dist/native-egress-policy.d.ts +31 -0
  71. package/dist/native-egress-policy.js +288 -0
  72. package/dist/observability.d.ts +5 -2
  73. package/dist/observability.js +48 -1
  74. package/dist/provider.d.ts +10 -2
  75. package/dist/provider.js +4 -1
  76. package/dist/runtime/auth-flow.d.ts +5 -1
  77. package/dist/runtime/auth-flow.js +6 -0
  78. package/dist/runtime/browser.d.ts +1 -0
  79. package/dist/runtime/browser.js +492 -49
  80. package/dist/runtime/cache.d.ts +1 -0
  81. package/dist/runtime/cache.js +169 -15
  82. package/dist/runtime/choice-wordlist.d.ts +9 -0
  83. package/dist/runtime/choice-wordlist.js +138 -0
  84. package/dist/runtime/choice.d.ts +13 -1
  85. package/dist/runtime/choice.js +490 -102
  86. package/dist/runtime/chrome149-header-order.d.ts +58 -0
  87. package/dist/runtime/chrome149-header-order.js +289 -0
  88. package/dist/runtime/env.js +12 -0
  89. package/dist/runtime/executor.d.ts +3 -2
  90. package/dist/runtime/executor.js +26 -25
  91. package/dist/runtime/http.d.ts +1 -0
  92. package/dist/runtime/http.js +515 -53
  93. package/dist/runtime/insights.js +2 -2
  94. package/dist/runtime/instrumentation.d.ts +2 -2
  95. package/dist/runtime/instrumentation.js +366 -8
  96. package/dist/runtime/native-network-errors.d.ts +33 -0
  97. package/dist/runtime/native-network-errors.js +69 -0
  98. package/dist/runtime/native-network.d.ts +96 -0
  99. package/dist/runtime/native-network.js +1232 -0
  100. package/dist/runtime/ocr.d.ts +29 -0
  101. package/dist/runtime/ocr.js +440 -0
  102. package/dist/runtime/otlp.d.ts +71 -2
  103. package/dist/runtime/otlp.js +397 -16
  104. package/dist/runtime/proxy-errors.js +6 -2
  105. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  106. package/dist/runtime/proxy-nodemaven.js +146 -0
  107. package/dist/runtime/proxy-telemetry.d.ts +80 -1
  108. package/dist/runtime/proxy-telemetry.js +154 -47
  109. package/dist/runtime/redirects.d.ts +29 -0
  110. package/dist/runtime/redirects.js +36 -0
  111. package/dist/runtime/redis.d.ts +1 -1
  112. package/dist/runtime/redis.js +4 -2
  113. package/dist/runtime/request-options.d.ts +68 -1
  114. package/dist/runtime/request-options.js +548 -0
  115. package/dist/runtime/resolver-config.d.ts +6 -0
  116. package/dist/runtime/resolver-config.js +6 -0
  117. package/dist/runtime/resolver-public.d.ts +1 -0
  118. package/dist/runtime/resolver-public.js +1 -0
  119. package/dist/runtime/resolver-shared.d.ts +14 -0
  120. package/dist/runtime/resolver-shared.js +12 -0
  121. package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
  122. package/dist/runtime/resolver-vendors/bindings.js +40 -0
  123. package/dist/runtime/resolver-vendors/browser.d.ts +22 -0
  124. package/dist/runtime/resolver-vendors/browser.js +377 -0
  125. package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
  126. package/dist/runtime/resolver-vendors/capsolver.js +531 -0
  127. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  128. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  129. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  130. package/dist/runtime/resolver-vendors/twocaptcha.js +408 -0
  131. package/dist/runtime/resolver-vendors/types.d.ts +94 -0
  132. package/dist/runtime/resolver-vendors/types.js +96 -0
  133. package/dist/runtime/resolver.d.ts +72 -0
  134. package/dist/runtime/resolver.js +771 -0
  135. package/dist/runtime/secrets.d.ts +27 -0
  136. package/dist/runtime/secrets.js +51 -0
  137. package/dist/runtime/state.d.ts +3 -0
  138. package/dist/runtime/state.js +277 -71
  139. package/dist/runtime/stealth-cookies.d.ts +20 -0
  140. package/dist/runtime/stealth-cookies.js +111 -0
  141. package/dist/runtime/stealth.d.ts +41 -5
  142. package/dist/runtime/stealth.js +997 -291
  143. package/dist/runtime/stt.js +1 -12
  144. package/dist/runtime/timeout.d.ts +5 -0
  145. package/dist/runtime/timeout.js +12 -0
  146. package/dist/runtime/trace-config.d.ts +12 -0
  147. package/dist/runtime/trace-config.js +62 -0
  148. package/dist/runtime/trace.d.ts +5 -0
  149. package/dist/runtime/trace.js +43 -10
  150. package/dist/serve.d.ts +1 -1
  151. package/dist/serve.js +1 -1
  152. package/dist/server/error-observability.d.ts +1 -0
  153. package/dist/server/error-observability.js +1 -0
  154. package/dist/server/index.d.ts +5 -2
  155. package/dist/server/index.js +2 -2
  156. package/dist/server/self-test-input-tokens.d.ts +2 -1
  157. package/dist/server/self-test-input-tokens.js +18 -14
  158. package/dist/server/self-test.d.ts +14 -3
  159. package/dist/server/self-test.js +129 -58
  160. package/dist/server/serve-implementation.d.ts +230 -0
  161. package/dist/server/serve-implementation.js +2265 -0
  162. package/dist/server/serve.d.ts +1 -70
  163. package/dist/server/serve.js +1 -1131
  164. package/dist/server/trace-output.d.ts +6 -0
  165. package/dist/server/trace-output.js +79 -0
  166. package/dist/server/types.d.ts +30 -5
  167. package/dist/server/types.js +13 -1
  168. package/dist/stateful/errors.d.ts +19 -0
  169. package/dist/stateful/errors.js +24 -0
  170. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  171. package/dist/stateful/http-provider-event-emitter.js +237 -0
  172. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  173. package/dist/stateful/http-session-owner-registry.js +210 -0
  174. package/dist/stateful/index.d.ts +18 -0
  175. package/dist/stateful/index.js +18 -0
  176. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  177. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  178. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  179. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  180. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  181. package/dist/stateful/provider-event-pipeline.js +1 -0
  182. package/dist/stateful/provider-events.d.ts +101 -0
  183. package/dist/stateful/provider-events.js +289 -0
  184. package/dist/stateful/session-key.d.ts +15 -0
  185. package/dist/stateful/session-key.js +86 -0
  186. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  187. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  188. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  189. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  190. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  191. package/dist/stateful/stateful-provider-adapter.js +287 -0
  192. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  193. package/dist/stateful/stateful-provider-observability.js +161 -0
  194. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  195. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  196. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  197. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  198. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  199. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  200. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  201. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  202. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  203. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  204. package/dist/stateful-signing.d.ts +18 -0
  205. package/dist/stateful-signing.js +27 -0
  206. package/dist/stealth/profiles.d.ts +9 -4
  207. package/dist/stealth/profiles.js +160 -211
  208. package/dist/stream-evidence.d.ts +74 -0
  209. package/dist/stream-evidence.js +785 -0
  210. package/dist/stream.js +7 -1
  211. package/dist/testing/index.d.ts +2 -1
  212. package/dist/testing/index.js +2 -1
  213. package/dist/testing/run.d.ts +32 -2
  214. package/dist/testing/run.js +489 -21
  215. package/dist/trace-sanitization.d.ts +5 -0
  216. package/dist/trace-sanitization.js +45 -0
  217. package/dist/types.d.ts +673 -131
  218. package/dist/types.js +1 -0
  219. package/package.json +45 -5
  220. package/src/auth-turn/index.ts +1 -1
  221. package/src/auth.ts +118 -0
  222. package/src/ceremonies/index.ts +189 -46
  223. package/src/cli/__tests__/fixtures/migrate-operation-declaration/approval-override.ts.txt +6 -0
  224. package/src/cli/__tests__/fixtures/migrate-operation-declaration/codemod-syntax.ts.txt +3 -0
  225. package/src/cli/__tests__/fixtures/migrate-operation-declaration/connection-precedence.ts.txt +10 -0
  226. package/src/cli/__tests__/fixtures/migrate-operation-declaration/docs-conflict.ts.txt +8 -0
  227. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-map.ts.txt +5 -0
  228. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-operation.ts.txt +16 -0
  229. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-map.ts.txt +3 -0
  230. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoist-all.ts.txt +31 -0
  231. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoisted-const.ts.txt +11 -0
  232. package/src/cli/__tests__/fixtures/migrate-operation-declaration/imported-spread.ts.txt +11 -0
  233. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-map.ts.txt +11 -0
  234. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-cast-tail.ts.txt +21 -0
  235. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-ekitan.ts.txt +11 -0
  236. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-override.ts.txt +14 -0
  237. package/src/cli/__tests__/fixtures/migrate-operation-declaration/missing-english-locale.ts.txt +7 -0
  238. package/src/cli/__tests__/fixtures/migrate-operation-declaration/no-safety.ts.txt +6 -0
  239. package/src/cli/__tests__/fixtures/migrate-operation-declaration/non-literal.ts.txt +7 -0
  240. package/src/cli/__tests__/fixtures/migrate-operation-declaration/redundant-approval.ts.txt +6 -0
  241. package/src/cli/__tests__/fixtures/migrate-operation-declaration/safety-conflict.ts.txt +7 -0
  242. package/src/cli/__tests__/fixtures/migrate-operation-declaration/stream.ts.txt +7 -0
  243. package/src/cli/__tests__/fixtures/migrate-operation-declaration/tool-router-spread.ts.txt +15 -0
  244. package/src/cli/__tests__/fixtures/migrate-operation-declaration/unparseable.ts.txt +4 -0
  245. package/src/cli/__tests__/fixtures/migrate-operation-declaration/verbatim-template.ts.txt +12 -0
  246. package/src/cli/commands.ts +33 -0
  247. package/src/cli/create.ts +50 -37
  248. package/src/cli/migrate-operation-declaration.ts +1654 -0
  249. package/src/cli/migrate-operation-shape.ts +184 -0
  250. package/src/cli/migrate-provider-shape.ts +772 -0
  251. package/src/cli/prompt-assets.ts +865 -0
  252. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  253. package/src/cli/templates/provider/Dockerfile.tpl +1 -1
  254. package/src/cli/templates/provider/README.md.tpl +5 -5
  255. package/src/cli/templates/provider/index.ts.tpl +6 -3
  256. package/src/cli/templates/provider/operations/ping.ts.tpl +4 -1
  257. package/src/cli/templates/provider/provider.json.tpl +6 -0
  258. package/src/config/loader.ts +690 -163
  259. package/src/contract-serialization.ts +5 -7
  260. package/src/contract-types.ts +12 -5
  261. package/src/contract.ts +24 -10
  262. package/src/declaration-validation.ts +330 -0
  263. package/src/define.ts +1048 -131
  264. package/src/dev.ts +4 -1
  265. package/src/engine.ts +279 -0
  266. package/src/error-observability.ts +64 -0
  267. package/src/error-resolution.ts +127 -0
  268. package/src/errors.ts +68 -0
  269. package/src/fixture-sanitization.ts +264 -0
  270. package/src/health-scenario.ts +875 -0
  271. package/src/index.ts +220 -13
  272. package/src/lint.ts +698 -90
  273. package/src/native-address.ts +340 -0
  274. package/src/native-egress-policy.ts +358 -0
  275. package/src/observability.ts +51 -1
  276. package/src/provider.ts +161 -3
  277. package/src/runtime/auth-flow.ts +12 -0
  278. package/src/runtime/browser.ts +661 -63
  279. package/src/runtime/cache.ts +189 -14
  280. package/src/runtime/choice-wordlist.ts +145 -0
  281. package/src/runtime/choice.ts +631 -120
  282. package/src/runtime/chrome149-header-order.ts +330 -0
  283. package/src/runtime/env.ts +13 -0
  284. package/src/runtime/executor.ts +43 -31
  285. package/src/runtime/http.ts +641 -61
  286. package/src/runtime/insights.ts +2 -2
  287. package/src/runtime/instrumentation.ts +520 -15
  288. package/src/runtime/native-network-errors.ts +99 -0
  289. package/src/runtime/native-network.ts +1605 -0
  290. package/src/runtime/ocr.ts +523 -0
  291. package/src/runtime/otlp.ts +467 -21
  292. package/src/runtime/proxy-errors.ts +12 -4
  293. package/src/runtime/proxy-nodemaven.ts +221 -0
  294. package/src/runtime/proxy-telemetry.ts +244 -75
  295. package/src/runtime/redirects.ts +66 -0
  296. package/src/runtime/redis.ts +7 -2
  297. package/src/runtime/request-options.ts +680 -1
  298. package/src/runtime/resolver-config.ts +6 -0
  299. package/src/runtime/resolver-public.ts +23 -0
  300. package/src/runtime/resolver-shared.ts +33 -0
  301. package/src/runtime/resolver-vendors/bindings.ts +56 -0
  302. package/src/runtime/resolver-vendors/browser.ts +533 -0
  303. package/src/runtime/resolver-vendors/capsolver.ts +707 -0
  304. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  305. package/src/runtime/resolver-vendors/twocaptcha.ts +540 -0
  306. package/src/runtime/resolver-vendors/types.ts +212 -0
  307. package/src/runtime/resolver.ts +1157 -0
  308. package/src/runtime/secrets.ts +64 -0
  309. package/src/runtime/state.ts +394 -77
  310. package/src/runtime/stealth-cookies.ts +132 -0
  311. package/src/runtime/stealth.ts +1280 -336
  312. package/src/runtime/stt.ts +1 -19
  313. package/src/runtime/timeout.ts +18 -0
  314. package/src/runtime/trace-config.ts +78 -0
  315. package/src/runtime/trace.ts +57 -17
  316. package/src/serve.ts +6 -1
  317. package/src/server/error-observability.ts +1 -0
  318. package/src/server/index.ts +34 -2
  319. package/src/server/self-test-input-tokens.ts +29 -14
  320. package/src/server/self-test.ts +191 -68
  321. package/src/server/serve-implementation.ts +3378 -0
  322. package/src/server/serve.ts +1 -1632
  323. package/src/server/trace-output.ts +129 -0
  324. package/src/server/types.ts +13 -1
  325. package/src/stateful/README.md +146 -0
  326. package/src/stateful/errors.ts +35 -0
  327. package/src/stateful/http-provider-event-emitter.ts +314 -0
  328. package/src/stateful/http-session-owner-registry.ts +306 -0
  329. package/src/stateful/index.ts +18 -0
  330. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  331. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  332. package/src/stateful/provider-event-pipeline.ts +61 -0
  333. package/src/stateful/provider-events.ts +462 -0
  334. package/src/stateful/session-key.ts +111 -0
  335. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  336. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  337. package/src/stateful/stateful-provider-adapter.ts +562 -0
  338. package/src/stateful/stateful-provider-observability.ts +261 -0
  339. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  340. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  341. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  342. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  343. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  344. package/src/stateful-signing.ts +46 -0
  345. package/src/stealth/profiles.ts +202 -231
  346. package/src/stream-evidence.ts +988 -0
  347. package/src/stream.ts +8 -1
  348. package/src/testing/index.ts +10 -1
  349. package/src/testing/run.ts +658 -15
  350. package/src/trace-sanitization.ts +63 -0
  351. package/src/types.ts +788 -174
  352. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  353. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  354. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  355. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  356. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  357. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  358. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  359. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  360. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  361. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  362. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  363. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  364. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  365. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -4,24 +4,44 @@ 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
- import type { ProviderDefinition } from "../src/index.js";
15
+ import { formatPromptAssetIssues, verifyPromptAssets } from "../src/cli/prompt-assets.js";
15
16
  import {
16
17
  loadProviderLocaleCatalogs,
17
18
  type ProviderLocale,
18
19
  validateProviderLocaleCatalogs,
19
20
  } from "../src/i18n/index.js";
20
- import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema.js";
21
- import { safeParseSchemaSync } from "../src/schema.js";
22
- import { type CheckResult, runChecks } from "./apifuse-check.js";
21
+ import type { ProviderDefinition } from "../src/index.js";
22
+ import { APIFUSE_DESCRIPTION_KEY_META_KEY, safeParseSchemaSync } from "../src/schema.js";
23
+ import {
24
+ findStreamCaptureGroup,
25
+ hasStreamEvidenceMarker,
26
+ parseStreamEvidenceRecord,
27
+ } from "../src/stream-evidence.js";
28
+ import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
29
+ import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
23
30
  import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
24
31
 
32
+ const ts: typeof import("typescript") = await loadTypeScript();
33
+
34
+ async function loadTypeScript(): Promise<typeof import("typescript")> {
35
+ try {
36
+ return await import("typescript");
37
+ } catch {
38
+ console.error(
39
+ "apifuse submit-check requires typescript; install it in the workspace running the CLI (bun add -d typescript)",
40
+ );
41
+ process.exit(1);
42
+ }
43
+ }
44
+
25
45
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
26
46
  const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
27
47
  type BountyTier = (typeof TIERS)[number];
@@ -106,11 +126,14 @@ export type SmokeResult = {
106
126
  type SourceFinding = {
107
127
  file: string;
108
128
  line: number;
129
+ detail?: string;
109
130
  };
110
131
 
111
132
  const SDK_NATIVE_CATEGORY = "sdk-native";
112
133
  const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
113
134
  const MAX_SOURCE_FINDING_EVIDENCE = 5;
135
+ const MAX_LOAD_ERROR_EVIDENCE = 5;
136
+ const MAX_LOAD_ERROR_MESSAGE_LENGTH = 500;
114
137
 
115
138
  const CATEGORY_MAX_POINTS = {
116
139
  definition: 15,
@@ -265,24 +288,49 @@ export async function buildSubmitCheckReport(
265
288
  ): Promise<SubmitCheckReport> {
266
289
  const checks: SubmitCheck[] = [];
267
290
  const baseChecks = await safeRunChecks(providerRoot);
268
- const provider = await safeLoadProvider(providerRoot);
269
-
270
- checks.push(...scoreBaseChecks(baseChecks));
271
- checks.push(scoreProviderIdSlug(providerRoot, provider));
272
- checks.push(scoreNoVendorShim(providerRoot));
273
- checks.push(scoreNoVendorImport(providerRoot));
274
- checks.push(scoreDescribeKey(providerRoot));
275
- checks.push(scoreNoRawFetch(providerRoot));
276
- checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
277
- checks.push(scoreManagedBrowserRuntime(providerRoot));
278
- checks.push(scoreAsAssertionCount(providerRoot));
279
- checks.push(scoreUnsafeInputPassthrough(providerRoot));
280
- checks.push(scoreUnjustifiedLooseSchema(providerRoot));
281
- checks.push(scoreFlatOperationComposition(providerRoot));
291
+ const loadResult = await safeLoadProvider(providerRoot);
292
+ const provider = loadResult.ok ? loadResult.provider : undefined;
293
+ let indexParseError: string | undefined;
294
+ if (!provider) {
295
+ const indexPath = resolve(providerRoot, "index.ts");
296
+ if (existsSync(indexPath)) {
297
+ const relPath = toRelativeProviderPath(providerRoot, indexPath);
298
+ try {
299
+ maskCommentsAndStrings(readFileSync(indexPath, "utf8"), relPath);
300
+ } catch (error) {
301
+ indexParseError = error instanceof Error ? error.message : String(error);
302
+ }
303
+ }
304
+ }
305
+
306
+ // Prompt-asset freshness is reported by its own dedicated zero-point
307
+ // blocker below; filter the base-check duplicate so it is not double
308
+ // penalized under the definition category.
309
+ checks.push(
310
+ ...scoreBaseChecks(
311
+ baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
312
+ ),
313
+ );
314
+ checks.push(scorePromptAssetFreshness(providerRoot));
315
+ if (!indexParseError) {
316
+ checks.push(scoreProviderIdSlug(providerRoot, provider));
317
+ checks.push(scoreNoVendorShim(providerRoot));
318
+ checks.push(scoreNoVendorImport(providerRoot));
319
+ checks.push(scoreDescribeKey(providerRoot));
320
+ checks.push(scoreNoRawFetch(providerRoot));
321
+ checks.push(scoreNoDynamicCode(providerRoot));
322
+ checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
323
+ checks.push(scoreManagedBrowserRuntime(providerRoot));
324
+ checks.push(scoreAsAssertionCount(providerRoot));
325
+ checks.push(scoreUnsafeInputPassthrough(providerRoot));
326
+ checks.push(scoreUnjustifiedLooseSchema(providerRoot));
327
+ checks.push(scoreFlatOperationComposition(providerRoot));
328
+ }
282
329
 
283
330
  if (provider) {
284
331
  const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
285
332
  checks.push(scoreCredentialUsage(providerRoot, provider));
333
+ checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
286
334
  checks.push(scoreLocaleCatalog(providerRoot, provider));
287
335
  checks.push(scoreOperationMetadata(provider));
288
336
  checks.push(scoreFixtureCoverage(provider));
@@ -296,15 +344,38 @@ export async function buildSubmitCheckReport(
296
344
  checks.push(scoreRepositoryDx(providerRoot));
297
345
  checks.push(scoreSecrets(providerRoot, provider));
298
346
  } else {
347
+ const loadEvidence = indexParseError
348
+ ? undefined
349
+ : loadResult.ok
350
+ ? undefined
351
+ : formatLoadErrorEvidence(loadResult.error, providerRoot);
352
+ const loadRemediation = indexParseError
353
+ ? "Fix the syntax error reported by the provider-load-parse blocker before apifuse check can load the provider."
354
+ : !loadResult.ok
355
+ ? "Fix the import/initialization failure shown in evidence so apifuse check can load the provider."
356
+ : "Fix index.ts so it default-exports defineProvider(...).";
299
357
  checks.push(
300
358
  blocker(
301
359
  "provider-load",
302
360
  "definition",
303
361
  "Provider could not be loaded.",
304
- "Fix index.ts so it default-exports defineProvider(...).",
362
+ loadRemediation,
305
363
  CATEGORY_MAX_POINTS.definition,
364
+ loadEvidence,
306
365
  ),
307
366
  );
367
+ if (indexParseError) {
368
+ checks.push(
369
+ blocker(
370
+ "provider-load-parse",
371
+ "definition",
372
+ "Provider index.ts could not be parsed safely.",
373
+ "Fix the syntax error in index.ts before submitting.",
374
+ 0,
375
+ [indexParseError],
376
+ ),
377
+ );
378
+ }
308
379
  }
309
380
 
310
381
  const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
@@ -432,7 +503,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
432
503
  }
433
504
 
434
505
  function scoreNoRawFetch(providerRoot: string): SubmitCheck {
435
- const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
506
+ const findings = findGlobalSinkCalls(providerRoot, FETCH_SINKS);
436
507
  if (findings.length > 0) {
437
508
  const evidence = formatSourceFindings(findings);
438
509
  return blocker(
@@ -448,6 +519,435 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
448
519
  return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
449
520
  }
450
521
 
522
+ const DYNAMIC_CODE_SINKS: ReadonlySet<string> = new Set(["eval", "Function"]);
523
+ const FETCH_SINKS: ReadonlySet<string> = new Set(["fetch"]);
524
+ const DYNAMIC_CODE_GLOBAL_OBJECTS: ReadonlySet<string> = new Set([
525
+ "globalThis",
526
+ "window",
527
+ "self",
528
+ "global",
529
+ ]);
530
+
531
+ function unwrapLocalExpression(expression: TS.Expression): TS.Expression {
532
+ let current = expression;
533
+ while (
534
+ ts.isParenthesizedExpression(current) ||
535
+ ts.isAsExpression(current) ||
536
+ ts.isSatisfiesExpression(current) ||
537
+ ts.isNonNullExpression(current)
538
+ ) {
539
+ current = current.expression;
540
+ }
541
+ if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) {
542
+ return unwrapLocalExpression(current.right);
543
+ }
544
+ return current;
545
+ }
546
+
547
+ type LocalBinding = {
548
+ declaration: TS.Identifier;
549
+ initializer?: TS.Expression;
550
+ destructuredProperty?: string;
551
+ mutable: boolean;
552
+ reassigned: boolean;
553
+ };
554
+
555
+ type LocalBindings = {
556
+ scopes: Map<TS.Node, Map<string, LocalBinding[]>>;
557
+ };
558
+
559
+ function isLexicalScope(node: TS.Node): boolean {
560
+ return (
561
+ ts.isSourceFile(node) ||
562
+ ts.isFunctionLike(node) ||
563
+ ts.isBlock(node) ||
564
+ ts.isModuleBlock(node) ||
565
+ ts.isCaseBlock(node) ||
566
+ ts.isCatchClause(node) ||
567
+ ts.isForStatement(node) ||
568
+ ts.isForInStatement(node) ||
569
+ ts.isForOfStatement(node)
570
+ );
571
+ }
572
+
573
+ function enclosingScope(node: TS.Node, functionScoped: boolean): TS.Node {
574
+ let current: TS.Node | undefined = node.parent;
575
+ while (current !== undefined) {
576
+ if (
577
+ ts.isSourceFile(current) ||
578
+ (functionScoped ? ts.isFunctionLike(current) : isLexicalScope(current))
579
+ ) {
580
+ return current;
581
+ }
582
+ current = current.parent;
583
+ }
584
+ return node.getSourceFile();
585
+ }
586
+
587
+ function collectLocalBindings(sourceFile: TS.SourceFile): LocalBindings {
588
+ const bindings: LocalBindings = { scopes: new Map() };
589
+ const addBinding = (
590
+ scope: TS.Node,
591
+ name: TS.Identifier,
592
+ binding: Omit<LocalBinding, "declaration" | "reassigned">,
593
+ ): void => {
594
+ let scopeBindings = bindings.scopes.get(scope);
595
+ if (scopeBindings === undefined) {
596
+ scopeBindings = new Map();
597
+ bindings.scopes.set(scope, scopeBindings);
598
+ }
599
+ const existing = scopeBindings.get(name.text);
600
+ const localBinding: LocalBinding = { ...binding, declaration: name, reassigned: false };
601
+ if (existing === undefined) {
602
+ scopeBindings.set(name.text, [localBinding]);
603
+ } else {
604
+ existing.push(localBinding);
605
+ }
606
+ };
607
+ const addBindingName = (
608
+ bindingName: TS.BindingName,
609
+ scope: TS.Node,
610
+ binding: Omit<LocalBinding, "declaration" | "reassigned">,
611
+ ): void => {
612
+ if (ts.isIdentifier(bindingName)) {
613
+ addBinding(scope, bindingName, binding);
614
+ return;
615
+ }
616
+ for (const element of bindingName.elements) {
617
+ if (ts.isOmittedExpression(element)) {
618
+ continue;
619
+ }
620
+ if (ts.isObjectBindingPattern(bindingName) && !element.dotDotDotToken) {
621
+ const property = element.propertyName ?? element.name;
622
+ const propertyName = propertyNameText(property);
623
+ if (propertyName !== undefined && ts.isIdentifier(element.name)) {
624
+ addBinding(scope, element.name, {
625
+ initializer: binding.initializer,
626
+ destructuredProperty: propertyName,
627
+ mutable: binding.mutable,
628
+ });
629
+ continue;
630
+ }
631
+ }
632
+ addBindingName(element.name, scope, { mutable: binding.mutable });
633
+ }
634
+ };
635
+ const visit = (node: TS.Node): void => {
636
+ if (ts.isVariableDeclaration(node)) {
637
+ const declarationList = ts.isVariableDeclarationList(node.parent) ? node.parent : undefined;
638
+ const isConstBinding =
639
+ declarationList !== undefined && (declarationList.flags & ts.NodeFlags.Const) !== 0;
640
+ const isBlockScoped =
641
+ declarationList !== undefined && (declarationList.flags & ts.NodeFlags.BlockScoped) !== 0;
642
+ addBindingName(node.name, enclosingScope(node, !isBlockScoped), {
643
+ initializer: node.initializer,
644
+ mutable: !isConstBinding,
645
+ });
646
+ } else if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
647
+ addBinding(enclosingScope(node, false), node.name, { mutable: false });
648
+ } else if (ts.isFunctionExpression(node) && node.name !== undefined) {
649
+ addBinding(node, node.name, { mutable: false });
650
+ } else if (ts.isClassDeclaration(node) && node.name !== undefined) {
651
+ addBinding(enclosingScope(node, false), node.name, { mutable: false });
652
+ } else if (ts.isClassExpression(node) && node.name !== undefined) {
653
+ addBinding(node, node.name, { mutable: false });
654
+ } else if (ts.isParameter(node)) {
655
+ addBindingName(node.name, enclosingScope(node, true), { mutable: true });
656
+ } else if (ts.isImportClause(node) && node.name !== undefined) {
657
+ addBinding(sourceFile, node.name, { mutable: false });
658
+ } else if (ts.isNamespaceImport(node) || ts.isImportSpecifier(node)) {
659
+ addBinding(sourceFile, node.name, { mutable: false });
660
+ }
661
+ ts.forEachChild(node, visit);
662
+ };
663
+ visit(sourceFile);
664
+
665
+ const markReassigned = (identifier: TS.Identifier): void => {
666
+ for (const binding of lookupLocalBindings(identifier, bindings) ?? []) {
667
+ binding.reassigned = true;
668
+ }
669
+ };
670
+ const markAssignmentTarget = (node: TS.Node): void => {
671
+ const target = ts.isExpression(node) ? unwrapLocalExpression(node) : node;
672
+ if (ts.isIdentifier(target)) {
673
+ markReassigned(target);
674
+ return;
675
+ }
676
+ if (ts.isObjectLiteralExpression(target)) {
677
+ for (const element of target.properties) {
678
+ if (ts.isSpreadAssignment(element)) {
679
+ markAssignmentTarget(element.expression);
680
+ } else if (ts.isPropertyAssignment(element)) {
681
+ markAssignmentTarget(element.initializer);
682
+ } else if (ts.isShorthandPropertyAssignment(element)) {
683
+ markAssignmentTarget(element.name);
684
+ }
685
+ }
686
+ } else if (ts.isArrayLiteralExpression(target)) {
687
+ for (const element of target.elements) {
688
+ if (!ts.isOmittedExpression(element)) {
689
+ markAssignmentTarget(ts.isSpreadElement(element) ? element.expression : element);
690
+ }
691
+ }
692
+ }
693
+ };
694
+ const visitAssignments = (node: TS.Node): void => {
695
+ if (
696
+ ts.isBinaryExpression(node) &&
697
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
698
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
699
+ ) {
700
+ markAssignmentTarget(node.left);
701
+ } else if (
702
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
703
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
704
+ node.operator === ts.SyntaxKind.MinusMinusToken)
705
+ ) {
706
+ markAssignmentTarget(node.operand);
707
+ } else if (
708
+ (ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
709
+ !ts.isVariableDeclarationList(node.initializer)
710
+ ) {
711
+ markAssignmentTarget(node.initializer);
712
+ }
713
+ ts.forEachChild(node, visitAssignments);
714
+ };
715
+ visitAssignments(sourceFile);
716
+ return bindings;
717
+ }
718
+
719
+ function lookupLocalBindings(
720
+ reference: TS.Identifier,
721
+ bindings: LocalBindings,
722
+ ): readonly LocalBinding[] | undefined {
723
+ let current: TS.Node | undefined = reference;
724
+ while (current !== undefined) {
725
+ const localBindings = bindings.scopes.get(current)?.get(reference.text);
726
+ if (localBindings !== undefined && localBindings.length > 0) {
727
+ return localBindings;
728
+ }
729
+ current = current.parent;
730
+ }
731
+ return undefined;
732
+ }
733
+
734
+ function propertyNameText(name: TS.PropertyName | TS.BindingName): string | undefined {
735
+ if (
736
+ ts.isIdentifier(name) ||
737
+ ts.isStringLiteral(name) ||
738
+ ts.isNumericLiteral(name) ||
739
+ ts.isNoSubstitutionTemplateLiteral(name)
740
+ ) {
741
+ return name.text;
742
+ }
743
+ if (ts.isComputedPropertyName(name)) {
744
+ const expression = unwrapLocalExpression(name.expression);
745
+ if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
746
+ return expression.text;
747
+ }
748
+ }
749
+ return undefined;
750
+ }
751
+
752
+ function isUnshadowedGlobalObject(reference: TS.Expression, bindings: LocalBindings): boolean {
753
+ const receiver = unwrapLocalExpression(reference);
754
+ return (
755
+ ts.isIdentifier(receiver) &&
756
+ DYNAMIC_CODE_GLOBAL_OBJECTS.has(receiver.text) &&
757
+ lookupLocalBindings(receiver, bindings) === undefined
758
+ );
759
+ }
760
+
761
+ function isGlobalMemberSink(
762
+ expression: TS.Expression,
763
+ sinkNames: ReadonlySet<string>,
764
+ bindings: LocalBindings,
765
+ ): boolean {
766
+ const callee = unwrapLocalExpression(expression);
767
+ let sinkName: string | undefined;
768
+ let receiver: TS.Expression | undefined;
769
+ if (ts.isPropertyAccessExpression(callee)) {
770
+ sinkName = callee.name.text;
771
+ receiver = callee.expression;
772
+ } else if (ts.isElementAccessExpression(callee)) {
773
+ const argument = callee.argumentExpression;
774
+ if (
775
+ argument !== undefined &&
776
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
777
+ ) {
778
+ sinkName = argument.text;
779
+ receiver = callee.expression;
780
+ }
781
+ }
782
+ if (sinkName === undefined || receiver === undefined || !sinkNames.has(sinkName)) {
783
+ return false;
784
+ }
785
+ return isUnshadowedGlobalObject(receiver, bindings);
786
+ }
787
+
788
+ function isGlobalSinkReference(
789
+ expression: TS.Expression,
790
+ sinkNames: ReadonlySet<string>,
791
+ bindings: LocalBindings,
792
+ resolving: ReadonlySet<LocalBinding>,
793
+ ): boolean {
794
+ const reference = unwrapLocalExpression(expression);
795
+ if (isGlobalMemberSink(reference, sinkNames, bindings)) {
796
+ return true;
797
+ }
798
+ if (!ts.isIdentifier(reference)) {
799
+ return false;
800
+ }
801
+ return isGlobalSinkIdentifier(reference, sinkNames, bindings, resolving);
802
+ }
803
+
804
+ function isGlobalSinkIdentifier(
805
+ reference: TS.Identifier,
806
+ sinkNames: ReadonlySet<string>,
807
+ bindings: LocalBindings,
808
+ resolving: ReadonlySet<LocalBinding>,
809
+ ): boolean {
810
+ const localBindings = lookupLocalBindings(reference, bindings);
811
+ if (localBindings === undefined || localBindings.length === 0) {
812
+ return sinkNames.has(reference.text);
813
+ }
814
+ return localBindings.every((binding) => {
815
+ if (resolving.has(binding) || binding.initializer === undefined) {
816
+ return false;
817
+ }
818
+ // A mutable alias is safe to resolve only when no assignment targeting
819
+ // this lexical binding exists in its scope (including nested closures).
820
+ // Reassigned let/var aliases remain unresolved to avoid false positives.
821
+ if (binding.mutable && binding.reassigned) {
822
+ return false;
823
+ }
824
+ const nextResolving = new Set(resolving);
825
+ nextResolving.add(binding);
826
+ if (binding.destructuredProperty !== undefined) {
827
+ return (
828
+ sinkNames.has(binding.destructuredProperty) &&
829
+ isUnshadowedGlobalObject(binding.initializer, bindings)
830
+ );
831
+ }
832
+ return isGlobalSinkReference(binding.initializer, sinkNames, bindings, nextResolving);
833
+ });
834
+ }
835
+
836
+ function isGlobalSinkCallee(
837
+ expression: TS.Expression,
838
+ sinkNames: ReadonlySet<string>,
839
+ bindings: LocalBindings,
840
+ ): boolean {
841
+ const callee = unwrapLocalExpression(expression);
842
+ if (isGlobalMemberSink(callee, sinkNames, bindings)) {
843
+ return true;
844
+ }
845
+ if (!ts.isIdentifier(callee)) {
846
+ return false;
847
+ }
848
+ return isGlobalSinkIdentifier(callee, sinkNames, bindings, new Set());
849
+ }
850
+
851
+ function resolveConstStringIdentifier(
852
+ reference: TS.Identifier,
853
+ bindings: LocalBindings,
854
+ resolving: ReadonlySet<LocalBinding> = new Set(),
855
+ ): string | undefined {
856
+ const localBindings = lookupLocalBindings(reference, bindings);
857
+ if (localBindings === undefined || localBindings.length === 0) {
858
+ return undefined;
859
+ }
860
+ let resolved: string | undefined;
861
+ for (const binding of localBindings) {
862
+ if (
863
+ resolving.has(binding) ||
864
+ binding.mutable ||
865
+ binding.initializer === undefined ||
866
+ binding.destructuredProperty !== undefined
867
+ ) {
868
+ return undefined;
869
+ }
870
+ const nextResolving = new Set(resolving);
871
+ nextResolving.add(binding);
872
+ const initializer = unwrapLocalExpression(binding.initializer);
873
+ let value: string | undefined;
874
+ if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
875
+ value = initializer.text;
876
+ } else if (ts.isIdentifier(initializer)) {
877
+ value = resolveConstStringIdentifier(initializer, bindings, nextResolving);
878
+ }
879
+ if (value === undefined || (resolved !== undefined && resolved !== value)) {
880
+ return undefined;
881
+ }
882
+ resolved = value;
883
+ }
884
+ return resolved;
885
+ }
886
+
887
+ function findGlobalSinkCalls(
888
+ providerRoot: string,
889
+ sinkNames: ReadonlySet<string>,
890
+ includeConstructors = false,
891
+ ): SourceFinding[] {
892
+ const findings: SourceFinding[] = [];
893
+ const seen = new Set<string>();
894
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
895
+ const source = readFileSync(filePath, "utf8");
896
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
897
+ const sourceFile = ts.createSourceFile(
898
+ relPath,
899
+ source,
900
+ ts.ScriptTarget.Latest,
901
+ true,
902
+ ts.ScriptKind.TS,
903
+ );
904
+ const bindings = collectLocalBindings(sourceFile);
905
+ const visit = (node: TS.Node): void => {
906
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
907
+ return;
908
+ }
909
+ if (
910
+ (ts.isCallExpression(node) || (includeConstructors && ts.isNewExpression(node))) &&
911
+ isGlobalSinkCallee(node.expression, sinkNames, bindings)
912
+ ) {
913
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
914
+ const key = `${relPath}:${line}`;
915
+ if (!seen.has(key)) {
916
+ seen.add(key);
917
+ findings.push({ file: relPath, line });
918
+ }
919
+ }
920
+ ts.forEachChild(node, visit);
921
+ };
922
+ visit(sourceFile);
923
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
924
+ break;
925
+ }
926
+ }
927
+ return findings;
928
+ }
929
+
930
+ function scoreNoDynamicCode(providerRoot: string): SubmitCheck {
931
+ const findings = findGlobalSinkCalls(providerRoot, DYNAMIC_CODE_SINKS, true);
932
+ if (findings.length > 0) {
933
+ return blocker(
934
+ "no-dynamic-code",
935
+ SDK_NATIVE_CATEGORY,
936
+ "Dynamic code evaluation is not permitted in provider source.",
937
+ "Remove eval() and Function constructor calls. Provider HTTP access must go through ctx.http.",
938
+ 0,
939
+ formatSourceFindings(findings),
940
+ );
941
+ }
942
+
943
+ return pass(
944
+ "no-dynamic-code",
945
+ SDK_NATIVE_CATEGORY,
946
+ "Provider source avoids dynamic code evaluation.",
947
+ 0,
948
+ );
949
+ }
950
+
451
951
  const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
452
952
  /\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\?\./,
453
953
  ];
@@ -503,7 +1003,11 @@ function countAsAssertions(providerRoot: string): {
503
1003
 
504
1004
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
505
1005
  const content = readFileSync(filePath, "utf8");
506
- const lines = content.split(/\r?\n/);
1006
+ const lines = maskCommentsAndStrings(
1007
+ content,
1008
+ toRelativeProviderPath(providerRoot, filePath),
1009
+ { blankPropertyKeys: true },
1010
+ ).split(/\r?\n/);
507
1011
  for (let index = 0; index < lines.length; index += 1) {
508
1012
  const line = lines[index];
509
1013
  if (
@@ -694,18 +1198,44 @@ function offsetToLine(source: string, offset: number): number {
694
1198
  // between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
695
1199
  const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
696
1200
 
1201
+ type ExpressionSlice = {
1202
+ raw: string;
1203
+ masked: string;
1204
+ };
1205
+
1206
+ function trimExpressionSlice(expr: ExpressionSlice): ExpressionSlice {
1207
+ const start = expr.raw.length - expr.raw.trimStart().length;
1208
+ const end = expr.raw.trimEnd().length;
1209
+ return {
1210
+ raw: expr.raw.slice(start, end),
1211
+ masked: expr.masked.slice(start, end),
1212
+ };
1213
+ }
1214
+
1215
+ function balancedExpressionSlice(
1216
+ source: string,
1217
+ valueStart: number,
1218
+ fileName?: string,
1219
+ ): ExpressionSlice {
1220
+ const raw = balancedValueExpression(source, valueStart, fileName);
1221
+ return trimExpressionSlice({
1222
+ raw,
1223
+ masked: maskCommentsAndStrings(source, fileName).slice(valueStart, valueStart + raw.length),
1224
+ });
1225
+ }
1226
+
697
1227
  // Strips redundant wrapping parentheses from an expression so that a value like
698
1228
  // `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
699
1229
  // Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
700
1230
  // the whole expression is parenthesized), preserving call expressions such as
701
1231
  // `makeOperations()` whose first `(` is not a wrapper.
702
- function unwrapParens(expr: string): string {
703
- let value = expr.trim();
704
- while (value.startsWith("(")) {
1232
+ function unwrapParens(expr: ExpressionSlice): ExpressionSlice {
1233
+ let value = trimExpressionSlice(expr);
1234
+ while (value.raw.startsWith("(")) {
705
1235
  let depth = 0;
706
1236
  let matchIndex = -1;
707
- for (let i = 0; i < value.length; i += 1) {
708
- const ch = value[i];
1237
+ for (let i = 0; i < value.raw.length; i += 1) {
1238
+ const ch = value.masked[i];
709
1239
  if (ch === "(") {
710
1240
  depth += 1;
711
1241
  } else if (ch === ")") {
@@ -718,8 +1248,11 @@ function unwrapParens(expr: string): string {
718
1248
  }
719
1249
  // Only a true wrapper spans the entire expression (closing paren is the
720
1250
  // last char). Otherwise the leading `(` belongs to a sub-expression.
721
- if (matchIndex === value.length - 1) {
722
- value = value.slice(1, -1).trim();
1251
+ if (matchIndex === value.raw.length - 1) {
1252
+ value = trimExpressionSlice({
1253
+ raw: value.raw.slice(1, -1),
1254
+ masked: value.masked.slice(1, -1),
1255
+ });
723
1256
  } else {
724
1257
  break;
725
1258
  }
@@ -731,8 +1264,8 @@ function unwrapParens(expr: string): string {
731
1264
  // across (){}[] and stopping at the first top-level `,`/`;` or unmatched
732
1265
  // closing bracket. This lets a property value be read across newlines, so a
733
1266
  // multi-line `input: z.object({...})\n.passthrough()` is captured whole.
734
- function balancedValueExpression(source: string, valueStart: number): string {
735
- const masked = maskCommentsAndStrings(source);
1267
+ function balancedValueExpression(source: string, valueStart: number, fileName?: string): string {
1268
+ const masked = maskCommentsAndStrings(source, fileName);
736
1269
  let depth = 0;
737
1270
  let index = valueStart;
738
1271
  for (; index < source.length; index += 1) {
@@ -756,14 +1289,14 @@ function balancedValueExpression(source: string, valueStart: number): string {
756
1289
  // nested deeper than the outer object (inside handler bodies, nested objects,
757
1290
  // or arrays) are ignored, so only a factory composition of the object itself
758
1291
  // is detected. Input is expected to start at the outer `{`.
759
- function hasTopLevelFactorySpread(expr: string): boolean {
760
- const open = expr.indexOf("{");
1292
+ function hasTopLevelFactorySpread(expr: ExpressionSlice): boolean {
1293
+ const open = expr.masked.indexOf("{");
761
1294
  if (open === -1) {
762
1295
  return false;
763
1296
  }
764
1297
  let depth = 0;
765
- for (let i = open; i < expr.length; i += 1) {
766
- const ch = expr[i];
1298
+ for (let i = open; i < expr.raw.length; i += 1) {
1299
+ const ch = expr.masked[i];
767
1300
  if (ch === "{" || ch === "(" || ch === "[") {
768
1301
  depth += 1;
769
1302
  } else if (ch === "}" || ch === ")" || ch === "]") {
@@ -771,11 +1304,11 @@ function hasTopLevelFactorySpread(expr: string): boolean {
771
1304
  if (depth === 0) {
772
1305
  break;
773
1306
  }
774
- } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
1307
+ } else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
775
1308
  // A spread at the object's own level. Check whether the spread
776
1309
  // argument is a call expression (factory) rather than a plain
777
1310
  // identifier/member spread of an already-built object.
778
- const rest = expr.slice(i + 3);
1311
+ const rest = expr.raw.slice(i + 3);
779
1312
  if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
780
1313
  return true;
781
1314
  }
@@ -790,15 +1323,15 @@ function hasTopLevelFactorySpread(expr: string): boolean {
790
1323
  // hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
791
1324
  // resolved to their declarations: `const hidden = makeOperations()` spread as
792
1325
  // `{ ...hidden }` is still a factory-composed map and must block.
793
- function topLevelSpreadIdentifiers(expr: string): string[] {
794
- const open = expr.indexOf("{");
1326
+ function topLevelSpreadIdentifiers(expr: ExpressionSlice): string[] {
1327
+ const open = expr.masked.indexOf("{");
795
1328
  if (open === -1) {
796
1329
  return [];
797
1330
  }
798
1331
  const names: string[] = [];
799
1332
  let depth = 0;
800
- for (let i = open; i < expr.length; i += 1) {
801
- const ch = expr[i];
1333
+ for (let i = open; i < expr.raw.length; i += 1) {
1334
+ const ch = expr.masked[i];
802
1335
  if (ch === "{" || ch === "(" || ch === "[") {
803
1336
  depth += 1;
804
1337
  } else if (ch === "}" || ch === ")" || ch === "]") {
@@ -806,8 +1339,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
806
1339
  if (depth === 0) {
807
1340
  break;
808
1341
  }
809
- } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
810
- const rest = expr.slice(i + 3);
1342
+ } else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
1343
+ const rest = expr.raw.slice(i + 3);
811
1344
  // Bare identifier spread (no call parens) -> needs declaration
812
1345
  // resolution. `...obj.prop` member spreads are treated as already
813
1346
  // built and ignored (the leading identifier is captured).
@@ -840,8 +1373,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
840
1373
  // `makeOperations()` — stays classified as factory composition.
841
1374
  const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
842
1375
  const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
843
- function isTransparentObjectReshape(expr: string): boolean {
844
- const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
1376
+ function isTransparentObjectReshape(expr: ExpressionSlice): boolean {
1377
+ const head = TRANSPARENT_RESHAPE_HEAD.exec(expr.masked);
845
1378
  if (!head) {
846
1379
  return false;
847
1380
  }
@@ -849,7 +1382,7 @@ function isTransparentObjectReshape(expr: string): boolean {
849
1382
  // transparent only when that argument's root callee is `Object.entries(`
850
1383
  // (optionally chained: `Object.entries(obj).filter(...)`), so the source
851
1384
  // object is enumerable from source rather than produced by an opaque call.
852
- const firstArg = expr.slice(head[0].length).trimStart();
1385
+ const firstArg = expr.masked.slice(head[0].length).trimStart();
853
1386
  return OBJECT_ENTRIES_HEAD.test(firstArg);
854
1387
  }
855
1388
 
@@ -983,7 +1516,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
983
1516
  continue;
984
1517
  }
985
1518
  const valueStart = match.index + match[0].length;
986
- const value = balancedValueExpression(source, valueStart);
1519
+ const value = balancedValueExpression(source, valueStart, relPath);
987
1520
  if (PASSTHROUGH_CALL.test(value)) {
988
1521
  const site: ConstSite = {
989
1522
  file: relPath,
@@ -1012,8 +1545,8 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1012
1545
  // that is itself a passthrough expression, or that references a passthrough
1013
1546
  // const by name (resolved against the provider-wide map), is a violation.
1014
1547
  for (const filePath of files) {
1015
- const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
1016
1548
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1549
+ const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
1017
1550
 
1018
1551
  const inputProp = /\binput\s*:\s*/g;
1019
1552
  for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
@@ -1024,7 +1557,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1024
1557
  continue;
1025
1558
  }
1026
1559
  const valueStart = match.index + match[0].length;
1027
- const value = balancedValueExpression(source, valueStart);
1560
+ const value = balancedValueExpression(source, valueStart, relPath);
1028
1561
  if (PASSTHROUGH_CALL.test(value)) {
1029
1562
  push({ file: relPath, line: offsetToLine(source, valueStart) });
1030
1563
  continue;
@@ -1135,13 +1668,19 @@ function spreadIdentifierResolvesToFactory(
1135
1668
  if (!existsSync(filePath)) {
1136
1669
  continue;
1137
1670
  }
1671
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
1138
1672
  const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1673
+ const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
1674
+ blankPropertyKeys: true,
1675
+ });
1139
1676
  const re = new RegExp(declRe.source, "g");
1140
- for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
1677
+ for (let m = re.exec(maskedFileSource); m !== null; m = re.exec(maskedFileSource)) {
1141
1678
  sawDeclaration = true;
1142
- const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
1679
+ const expr = unwrapParens(
1680
+ balancedExpressionSlice(fileSource, m.index + m[0].length, relPath),
1681
+ );
1143
1682
  const isFactory =
1144
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1683
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
1145
1684
  !isTransparentObjectReshape(expr);
1146
1685
  if (isFactory) {
1147
1686
  return true;
@@ -1150,111 +1689,353 @@ function spreadIdentifierResolvesToFactory(
1150
1689
  }
1151
1690
  // No local declaration anywhere but imported into index.ts => constructed
1152
1691
  // out of view; treat as factory (conservative, false-negative-safe).
1153
- if (!sawDeclaration && fileImportsBinding(indexSource, name)) {
1692
+ if (
1693
+ !sawDeclaration &&
1694
+ fileImportsBinding(
1695
+ maskCommentsAndStrings(indexSource, "index.ts", { blankPropertyKeys: true }),
1696
+ name,
1697
+ )
1698
+ ) {
1154
1699
  return true;
1155
1700
  }
1156
1701
  return false;
1157
1702
  }
1158
1703
 
1159
- function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1160
- const indexPath = resolve(providerRoot, "index.ts");
1161
- const ruleId = "flat-operation-composition";
1162
- if (!existsSync(indexPath)) {
1163
- return pass(
1164
- ruleId,
1165
- SDK_NATIVE_CATEGORY,
1166
- "Provider index.ts not found; flat-operation check skipped.",
1167
- 0,
1168
- );
1169
- }
1704
+ function isDefineProviderCall(node: TS.CallExpression): boolean {
1705
+ return ts.isIdentifier(node.expression) && node.expression.text === "defineProvider";
1706
+ }
1170
1707
 
1171
- const source = readFileSync(indexPath, "utf8");
1172
- // Use the same whitespace-tolerant detection as the resolver below, so a
1173
- // `defineProvider (` / `defineProvider\n(` formatting cannot pass the early
1174
- // exit before the real classification runs.
1175
- if (!/\bdefineProvider\s*\(/.test(source)) {
1176
- return pass(
1177
- ruleId,
1178
- SDK_NATIVE_CATEGORY,
1179
- "No defineProvider call to evaluate for operation composition.",
1180
- 0,
1181
- );
1708
+ function resolveDefineProviderImplementationCall(
1709
+ candidate: TS.CallExpression,
1710
+ ): TS.CallExpression | undefined {
1711
+ let root = candidate;
1712
+ while (ts.isCallExpression(root.expression)) {
1713
+ root = root.expression;
1182
1714
  }
1715
+ return isDefineProviderCall(root) ? candidate : undefined;
1716
+ }
1183
1717
 
1184
- // Scope the scan to the argument of the EXPORTED `defineProvider(...)` call.
1185
- // A provider can contain helper/non-exported defineProvider calls before the
1186
- // real default export (e.g. test scaffolds), so resolve the default export
1187
- // rather than blindly taking the first regex match. Resolution order:
1188
- // 1. `export default defineProvider(` — inline default export
1189
- // 2. `export default <ident>` then `const <ident> = defineProvider(`
1190
- // 3. fallback: first `defineProvider(` in the file
1191
- let defineParenIndex = -1;
1192
- const inlineDefault = /\bexport\s+default\s+defineProvider\s*\(/.exec(source);
1193
- if (inlineDefault) {
1194
- defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
1195
- } else {
1196
- const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
1197
- const exportedName = namedDefault?.[1];
1198
- if (exportedName !== undefined) {
1199
- const namedDecl = new RegExp(
1200
- `(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${exportedName}\\s*(?::[^=\\n]+)?\\s*=\\s*defineProvider\\s*\\(`,
1201
- ).exec(source);
1202
- if (namedDecl) {
1203
- defineParenIndex = namedDecl.index + namedDecl[0].length - 1;
1204
- }
1718
+ function findProviderImplementationCall(sourceFile: TS.SourceFile): TS.CallExpression | undefined {
1719
+ const defaultExports = sourceFile.statements.filter(
1720
+ (statement): statement is TS.ExportAssignment =>
1721
+ ts.isExportAssignment(statement) && !statement.isExportEquals,
1722
+ );
1723
+
1724
+ // Phase-separated providers export a builder invocation directly. Preserve
1725
+ // the previous locator's preference for that call over every defineProvider
1726
+ // declaration that may also appear in the file.
1727
+ for (const assignment of defaultExports) {
1728
+ const expression = unwrapImplementationExpression(assignment.expression);
1729
+ if (!ts.isCallExpression(expression)) {
1730
+ continue;
1205
1731
  }
1206
- if (defineParenIndex === -1) {
1207
- const firstCall = /\bdefineProvider\s*\(/.exec(source);
1208
- if (firstCall) {
1209
- defineParenIndex = firstCall.index + firstCall[0].length - 1;
1210
- }
1732
+ if (resolveDefineProviderImplementationCall(expression) !== undefined) {
1733
+ continue;
1734
+ }
1735
+ if (ts.isIdentifier(expression.expression)) {
1736
+ return expression;
1211
1737
  }
1212
1738
  }
1213
- if (defineParenIndex === -1) {
1214
- return pass(
1215
- ruleId,
1216
- SDK_NATIVE_CATEGORY,
1217
- "No defineProvider call to evaluate for operation composition.",
1218
- 0,
1219
- );
1220
- }
1221
- const argStart = defineParenIndex + 1;
1222
- const argText = balancedValueExpression(source, argStart);
1223
1739
 
1224
- // Resolve the value passed as `operations:` inside the defineProvider call,
1225
- // following one alias hop. The value is classified as a static object
1226
- // literal (pass) or a factory/call expression (block). The regex index is
1227
- // offset back into the full source so line numbers stay accurate.
1228
- const opsProp = /\boperations\s*:\s*/.exec(argText);
1229
- let opsValue: string | undefined;
1230
- let opsLine = 1;
1231
- if (opsProp) {
1232
- const valueStart = argStart + opsProp.index + opsProp[0].length;
1233
- opsValue = unwrapParens(balancedValueExpression(source, valueStart).trim());
1234
- opsLine = offsetToLine(source, valueStart);
1740
+ // For `defineProvider(metadata)(implementation)`, select the outer call whose
1741
+ // argument is the implementation. A plain `defineProvider(implementation)`
1742
+ // selects the single call itself.
1743
+ for (const assignment of defaultExports) {
1744
+ const defaultExpression = unwrapImplementationExpression(assignment.expression);
1745
+ if (!ts.isCallExpression(defaultExpression)) {
1746
+ continue;
1747
+ }
1748
+ const implementationCall = resolveDefineProviderImplementationCall(defaultExpression);
1749
+ if (implementationCall !== undefined) {
1750
+ return implementationCall;
1751
+ }
1235
1752
  }
1236
1753
 
1237
- // Property shorthand: `defineProvider({ ..., operations })` resolve the
1238
- // local `operations` const initializer.
1239
- let aliasName: string | undefined;
1240
- if (opsValue === undefined) {
1241
- if (/\boperations\s*[,}]/.test(argText)) {
1242
- aliasName = "operations";
1754
+ // Resolve `export default provider` to a top-level variable initialized by
1755
+ // defineProvider, including its curried implementation call when present.
1756
+ for (const assignment of defaultExports) {
1757
+ if (!ts.isIdentifier(assignment.expression)) {
1758
+ continue;
1759
+ }
1760
+ const exportedName = assignment.expression.text;
1761
+ for (const statement of sourceFile.statements) {
1762
+ if (!ts.isVariableStatement(statement)) {
1763
+ continue;
1764
+ }
1765
+ for (const declaration of statement.declarationList.declarations) {
1766
+ if (
1767
+ !ts.isIdentifier(declaration.name) ||
1768
+ declaration.name.text !== exportedName ||
1769
+ declaration.initializer === undefined
1770
+ ) {
1771
+ continue;
1772
+ }
1773
+ if (!ts.isCallExpression(declaration.initializer)) {
1774
+ continue;
1775
+ }
1776
+ const implementationCall = resolveDefineProviderImplementationCall(declaration.initializer);
1777
+ if (implementationCall !== undefined) {
1778
+ return implementationCall;
1779
+ }
1780
+ }
1243
1781
  }
1244
- } else if (/^[A-Za-z_$][\w$]*$/.test(opsValue)) {
1245
- // `operations: ops` — a bare identifier alias to resolve.
1246
- aliasName = opsValue;
1247
1782
  }
1248
1783
 
1249
- // Determine the effective initializer expression to classify. The alias may
1250
- // be declared in index.ts OR re-exported from a sibling module (the common
1251
- // generated scaffold: `import { operations } from "./operations"` where
1252
- // ./operations.ts builds the map with makeOperations()/Object.fromEntries).
1253
- // Resolve across every provider source file so cross-module factory
1254
- // composition cannot evade the blocker.
1255
- let effective = opsValue;
1256
- let effectiveLine = opsLine;
1257
- let effectiveFile = "index.ts";
1784
+ // Structural fixtures without a recognizable default export retain the
1785
+ // previous fallback to the first defineProvider call in source order.
1786
+ let firstCall: TS.CallExpression | undefined;
1787
+ const visit = (node: TS.Node): void => {
1788
+ if (firstCall !== undefined) {
1789
+ return;
1790
+ }
1791
+ if (ts.isCallExpression(node)) {
1792
+ const implementationCall = resolveDefineProviderImplementationCall(node);
1793
+ if (implementationCall !== undefined) {
1794
+ firstCall = implementationCall;
1795
+ return;
1796
+ }
1797
+ }
1798
+ ts.forEachChild(node, visit);
1799
+ };
1800
+ visit(sourceFile);
1801
+ return firstCall;
1802
+ }
1803
+
1804
+ function unwrapImplementationExpression(expression: TS.Expression): TS.Expression {
1805
+ let current = expression;
1806
+ while (
1807
+ ts.isSatisfiesExpression(current) ||
1808
+ ts.isAsExpression(current) ||
1809
+ ts.isParenthesizedExpression(current) ||
1810
+ ts.isNonNullExpression(current)
1811
+ ) {
1812
+ current = current.expression;
1813
+ }
1814
+ return current;
1815
+ }
1816
+
1817
+ function isOperationsProperty(
1818
+ property: TS.ObjectLiteralElementLike,
1819
+ ): property is TS.PropertyAssignment | TS.ShorthandPropertyAssignment {
1820
+ if (ts.isShorthandPropertyAssignment(property)) {
1821
+ return property.name.text === "operations";
1822
+ }
1823
+ if (!ts.isPropertyAssignment(property)) {
1824
+ return false;
1825
+ }
1826
+ return (
1827
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
1828
+ property.name.text === "operations"
1829
+ );
1830
+ }
1831
+
1832
+ function isOperationsAccessorOrMethod(
1833
+ property: TS.ObjectLiteralElementLike,
1834
+ ): property is TS.GetAccessorDeclaration | TS.SetAccessorDeclaration | TS.MethodDeclaration {
1835
+ if (
1836
+ !ts.isGetAccessorDeclaration(property) &&
1837
+ !ts.isSetAccessorDeclaration(property) &&
1838
+ !ts.isMethodDeclaration(property)
1839
+ ) {
1840
+ return false;
1841
+ }
1842
+ return (
1843
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
1844
+ property.name.text === "operations"
1845
+ );
1846
+ }
1847
+
1848
+ function isSourceEnumerableStaticObjectLiteral(expression: TS.Expression): boolean {
1849
+ const unwrapped = unwrapImplementationExpression(expression);
1850
+ if (!ts.isObjectLiteralExpression(unwrapped)) {
1851
+ return false;
1852
+ }
1853
+ return unwrapped.properties.every((property) => {
1854
+ if (ts.isSpreadAssignment(property)) {
1855
+ return isSourceEnumerableStaticObjectLiteral(property.expression);
1856
+ }
1857
+ if (property.name === undefined || ts.isComputedPropertyName(property.name)) {
1858
+ return property.name === undefined;
1859
+ }
1860
+ return property.name.text !== "operations" || isOperationsProperty(property);
1861
+ });
1862
+ }
1863
+
1864
+ function findComputedImplementationProperty(
1865
+ objectLiteral: TS.ObjectLiteralExpression,
1866
+ ): TS.ObjectLiteralElementLike | undefined {
1867
+ for (const property of objectLiteral.properties) {
1868
+ if (property.name !== undefined && ts.isComputedPropertyName(property.name)) {
1869
+ return property;
1870
+ }
1871
+ if (ts.isSpreadAssignment(property)) {
1872
+ const spreadExpression = unwrapImplementationExpression(property.expression);
1873
+ if (ts.isObjectLiteralExpression(spreadExpression)) {
1874
+ const nested = findComputedImplementationProperty(spreadExpression);
1875
+ if (nested !== undefined) {
1876
+ return nested;
1877
+ }
1878
+ }
1879
+ }
1880
+ }
1881
+ return undefined;
1882
+ }
1883
+
1884
+ function findEffectiveOperationsProperty(
1885
+ objectLiteral: TS.ObjectLiteralExpression,
1886
+ ):
1887
+ | TS.PropertyAssignment
1888
+ | TS.ShorthandPropertyAssignment
1889
+ | TS.GetAccessorDeclaration
1890
+ | TS.SetAccessorDeclaration
1891
+ | TS.MethodDeclaration
1892
+ | undefined {
1893
+ let effective:
1894
+ | TS.PropertyAssignment
1895
+ | TS.ShorthandPropertyAssignment
1896
+ | TS.GetAccessorDeclaration
1897
+ | TS.SetAccessorDeclaration
1898
+ | TS.MethodDeclaration
1899
+ | undefined;
1900
+ for (const property of objectLiteral.properties) {
1901
+ if (isOperationsProperty(property) || isOperationsAccessorOrMethod(property)) {
1902
+ effective = property;
1903
+ continue;
1904
+ }
1905
+ if (ts.isSpreadAssignment(property)) {
1906
+ const spreadExpression = unwrapImplementationExpression(property.expression);
1907
+ if (ts.isObjectLiteralExpression(spreadExpression)) {
1908
+ const nested = findEffectiveOperationsProperty(spreadExpression);
1909
+ if (nested !== undefined) {
1910
+ effective = nested;
1911
+ }
1912
+ }
1913
+ }
1914
+ }
1915
+ return effective;
1916
+ }
1917
+
1918
+ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1919
+ const indexPath = resolve(providerRoot, "index.ts");
1920
+ const ruleId = "flat-operation-composition";
1921
+ if (!existsSync(indexPath)) {
1922
+ return pass(
1923
+ ruleId,
1924
+ SDK_NATIVE_CATEGORY,
1925
+ "Provider index.ts not found; flat-operation check skipped.",
1926
+ 0,
1927
+ );
1928
+ }
1929
+
1930
+ const source = readFileSync(indexPath, "utf8");
1931
+ const indexRelPath = toRelativeProviderPath(providerRoot, indexPath);
1932
+ // The report preflight already rejects unparseable index.ts sources before
1933
+ // this rule runs, so this local AST parse only replaces property location.
1934
+ const sourceFile = ts.createSourceFile(
1935
+ indexRelPath,
1936
+ source,
1937
+ ts.ScriptTarget.Latest,
1938
+ true,
1939
+ ts.ScriptKind.TS,
1940
+ );
1941
+ const implementationCall = findProviderImplementationCall(sourceFile);
1942
+ const rawImplementationArg = implementationCall?.arguments[0];
1943
+ const implementationArg =
1944
+ rawImplementationArg === undefined
1945
+ ? undefined
1946
+ : unwrapImplementationExpression(rawImplementationArg);
1947
+ if (implementationArg === undefined || !ts.isObjectLiteralExpression(implementationArg)) {
1948
+ return pass(
1949
+ ruleId,
1950
+ SDK_NATIVE_CATEGORY,
1951
+ "No defineProvider call to evaluate for operation composition.",
1952
+ 0,
1953
+ );
1954
+ }
1955
+
1956
+ // Resolve the top-level operations property from the AST, then feed its raw
1957
+ // initializer offset into the existing expression classifier and alias
1958
+ // resolver unchanged.
1959
+ const opsProp = findEffectiveOperationsProperty(implementationArg);
1960
+ let opsValue: ExpressionSlice | undefined;
1961
+ let opsLine = 1;
1962
+ if (opsProp !== undefined && ts.isPropertyAssignment(opsProp)) {
1963
+ const valueStart = opsProp.initializer.getStart(sourceFile);
1964
+ opsValue = unwrapParens(balancedExpressionSlice(source, valueStart, indexRelPath));
1965
+ opsLine = offsetToLine(source, valueStart);
1966
+ }
1967
+
1968
+ // Property shorthand: `buildProvider({ operations })` — resolve the
1969
+ // local `operations` const initializer.
1970
+ let aliasName: string | undefined;
1971
+ if (opsProp !== undefined && ts.isShorthandPropertyAssignment(opsProp)) {
1972
+ aliasName = "operations";
1973
+ } else if (opsValue !== undefined && /^[A-Za-z_$][\w$]*$/.test(opsValue.masked)) {
1974
+ // `operations: ops` — a bare identifier alias to resolve.
1975
+ aliasName = opsValue.raw;
1976
+ }
1977
+
1978
+ // A computed property might resolve to `operations`, but its composition is
1979
+ // not source-enumerable here. Fail closed, like an unresolved imported alias.
1980
+ const computed = findComputedImplementationProperty(implementationArg);
1981
+ if (computed !== undefined) {
1982
+ const computedLine = offsetToLine(source, computed.getStart(sourceFile));
1983
+ return blocker(
1984
+ ruleId,
1985
+ SDK_NATIVE_CATEGORY,
1986
+ "The defineProvider operations property uses a computed property name that cannot be statically resolved.",
1987
+ "Declare operations with the literal property name `operations` and a static object literal value so the provider-registry AST gate can enumerate it.",
1988
+ 0,
1989
+ [`${indexRelPath}:${computedLine} (computed property name)`],
1990
+ );
1991
+ }
1992
+
1993
+ if (opsProp !== undefined && isOperationsAccessorOrMethod(opsProp)) {
1994
+ const memberLine = offsetToLine(source, opsProp.getStart(sourceFile));
1995
+ return blocker(
1996
+ ruleId,
1997
+ SDK_NATIVE_CATEGORY,
1998
+ "The defineProvider operations member is an accessor/method whose value is not statically enumerable.",
1999
+ "Declare operations as a property assignment with a static object literal value so the provider-registry AST gate can enumerate it.",
2000
+ 0,
2001
+ [`${indexRelPath}:${memberLine} (operations accessor/method)`],
2002
+ );
2003
+ }
2004
+
2005
+ // A spread can override a preceding literal `operations` property. Only an
2006
+ // inline static object literal (including recursively inline static spreads)
2007
+ // exposes all of its property names to this source-only gate. Identifiers,
2008
+ // calls, and other expressions are opaque and therefore fail closed.
2009
+ const opaqueSpread = implementationArg.properties.find(
2010
+ (property): property is TS.SpreadAssignment =>
2011
+ ts.isSpreadAssignment(property) &&
2012
+ !isSourceEnumerableStaticObjectLiteral(property.expression),
2013
+ );
2014
+ if (opaqueSpread !== undefined) {
2015
+ const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
2016
+ return escapeHatchResult(
2017
+ providerRoot,
2018
+ ruleId,
2019
+ [{ file: indexRelPath, line: spreadLine }],
2020
+ {
2021
+ blockerMessage:
2022
+ "The defineProvider implementation uses a non-enumerable spread that could override operations.",
2023
+ remediation:
2024
+ "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.",
2025
+ passMessage: "defineProvider declares operations as a static object literal.",
2026
+ },
2027
+ );
2028
+ }
2029
+
2030
+ // Determine the effective initializer expression to classify. The alias may
2031
+ // be declared in index.ts OR re-exported from a sibling module (the common
2032
+ // generated scaffold: `import { operations } from "./operations"` where
2033
+ // ./operations.ts builds the map with makeOperations()/Object.fromEntries).
2034
+ // Resolve across every provider source file so cross-module factory
2035
+ // composition cannot evade the blocker.
2036
+ let effective = opsValue;
2037
+ let effectiveLine = opsLine;
2038
+ let effectiveFile = "index.ts";
1258
2039
  if (aliasName !== undefined) {
1259
2040
  const aliasDecl = new RegExp(
1260
2041
  `(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${aliasName}\\s*(?::[^=\\n]+)?\\s*=`,
@@ -1277,7 +2058,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1277
2058
  // do not resolve the exact import target path; "any same-named factory
1278
2059
  // blocks" is the conservative, false-negative-avoiding choice for a gate.)
1279
2060
  type Candidate = {
1280
- expr: string;
2061
+ expr: ExpressionSlice;
1281
2062
  line: number;
1282
2063
  file: string;
1283
2064
  isFactory: boolean;
@@ -1287,15 +2068,22 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1287
2068
  if (!existsSync(filePath)) {
1288
2069
  continue;
1289
2070
  }
1290
- const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
1291
2071
  const relPath = toRelativeProviderPath(providerRoot, filePath);
2072
+ const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
2073
+ const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
2074
+ blankPropertyKeys: true,
2075
+ });
1292
2076
 
1293
2077
  const declRe = new RegExp(aliasDecl.source, "g");
1294
- for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
2078
+ for (
2079
+ let m = declRe.exec(maskedFileSource);
2080
+ m !== null;
2081
+ m = declRe.exec(maskedFileSource)
2082
+ ) {
1295
2083
  const valueStart = m.index + m[0].length;
1296
- const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
2084
+ const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
1297
2085
  const isFactory =
1298
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
2086
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
1299
2087
  !isTransparentObjectReshape(expr);
1300
2088
  candidates.push({
1301
2089
  expr,
@@ -1305,9 +2093,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1305
2093
  });
1306
2094
  }
1307
2095
  const destructRe = new RegExp(destructured.source, "g");
1308
- for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
2096
+ for (
2097
+ let m = destructRe.exec(maskedFileSource);
2098
+ m !== null;
2099
+ m = destructRe.exec(maskedFileSource)
2100
+ ) {
2101
+ const raw = `${m[1]}(`;
1309
2102
  candidates.push({
1310
- expr: `${m[1]}(`,
2103
+ expr: { raw, masked: raw },
1311
2104
  line: offsetToLine(fileSource, m.index),
1312
2105
  file: relPath,
1313
2106
  isFactory: true,
@@ -1333,11 +2126,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1333
2126
  // the unresolved import as a factory-composed (non-static) shape rather
1334
2127
  // than silently passing.
1335
2128
  if (!resolved) {
1336
- const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
1337
- source,
1338
- );
2129
+ const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
2130
+ blankPropertyKeys: true,
2131
+ });
2132
+ const importMatch = new RegExp(
2133
+ `\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
2134
+ ).exec(maskedIndexSource);
1339
2135
  if (importMatch) {
1340
- effective = `${aliasName}(`;
2136
+ const raw = `${aliasName}(`;
2137
+ effective = { raw, masked: raw };
1341
2138
  effectiveLine = offsetToLine(source, importMatch.index);
1342
2139
  effectiveFile = "index.ts";
1343
2140
  }
@@ -1361,14 +2158,16 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1361
2158
  spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
1362
2159
  );
1363
2160
  const isStaticLiteral =
1364
- effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
2161
+ effective?.masked.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
1365
2162
  // A call expression `ident(...)` (factory) or a factory-spread literal is
1366
2163
  // the rejected, non-static shape — UNLESS it is the stdlib
1367
2164
  // `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
1368
2165
  // whose op set is still enumerable from source (verified golden pattern).
1369
2166
  const isFactoryCall =
1370
2167
  effective !== undefined &&
1371
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
2168
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective.masked) ||
2169
+ hasFactorySpread ||
2170
+ hasFactorySpreadIdentifier) &&
1372
2171
  !isTransparentObjectReshape(effective);
1373
2172
 
1374
2173
  if (isFactoryCall && !isStaticLiteral) {
@@ -1380,7 +2179,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1380
2179
  blockerMessage:
1381
2180
  "defineProvider operations are composed by a factory call instead of a static object literal.",
1382
2181
  remediation:
1383
- "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>`.",
2182
+ "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>`.",
1384
2183
  passMessage: "defineProvider declares operations as a static object literal.",
1385
2184
  });
1386
2185
  }
@@ -1394,7 +2193,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1394
2193
  }
1395
2194
 
1396
2195
  function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1397
- const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
2196
+ const credentialReferences = findCredentialReferences(providerRoot);
1398
2197
  const authMode = provider.auth?.mode ?? "none";
1399
2198
  const credentialKeys = provider.credential?.keys ?? [];
1400
2199
  const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
@@ -1424,26 +2223,291 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
1424
2223
  );
1425
2224
  }
1426
2225
 
2226
+ function isDirectCredentialReference(expression: TS.Expression): boolean {
2227
+ const reference = unwrapLocalExpression(expression);
2228
+ let receiver: TS.Expression | undefined;
2229
+ let memberName: string | undefined;
2230
+ if (ts.isPropertyAccessExpression(reference)) {
2231
+ receiver = reference.expression;
2232
+ memberName = reference.name.text;
2233
+ } else if (ts.isElementAccessExpression(reference)) {
2234
+ receiver = reference.expression;
2235
+ const argument = reference.argumentExpression;
2236
+ if (
2237
+ argument !== undefined &&
2238
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
2239
+ ) {
2240
+ memberName = argument.text;
2241
+ }
2242
+ }
2243
+ const unwrappedReceiver = receiver === undefined ? undefined : unwrapLocalExpression(receiver);
2244
+ return (
2245
+ memberName === "credential" &&
2246
+ unwrappedReceiver !== undefined &&
2247
+ ts.isIdentifier(unwrappedReceiver) &&
2248
+ unwrappedReceiver.text === "ctx"
2249
+ );
2250
+ }
2251
+
2252
+ function isCredentialReference(
2253
+ expression: TS.Expression,
2254
+ bindings: LocalBindings,
2255
+ resolving: ReadonlySet<LocalBinding> = new Set(),
2256
+ ): boolean {
2257
+ const reference = unwrapLocalExpression(expression);
2258
+ if (isDirectCredentialReference(reference)) {
2259
+ return true;
2260
+ }
2261
+ if (!ts.isIdentifier(reference)) {
2262
+ return false;
2263
+ }
2264
+ const localBindings = lookupLocalBindings(reference, bindings);
2265
+ if (localBindings === undefined || localBindings.length === 0) {
2266
+ return false;
2267
+ }
2268
+ return localBindings.every((binding) => {
2269
+ if (resolving.has(binding) || binding.mutable || binding.initializer === undefined) {
2270
+ return false;
2271
+ }
2272
+ const nextResolving = new Set(resolving);
2273
+ nextResolving.add(binding);
2274
+ if (binding.destructuredProperty !== undefined) {
2275
+ const source = unwrapLocalExpression(binding.initializer);
2276
+ return (
2277
+ binding.destructuredProperty === "credential" &&
2278
+ ts.isIdentifier(source) &&
2279
+ source.text === "ctx"
2280
+ );
2281
+ }
2282
+ return isCredentialReference(binding.initializer, bindings, nextResolving);
2283
+ });
2284
+ }
2285
+
2286
+ function findCredentialReferences(providerRoot: string): SourceFinding[] {
2287
+ const findings: SourceFinding[] = [];
2288
+ const seen = new Set<string>();
2289
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2290
+ const source = readFileSync(filePath, "utf8");
2291
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2292
+ const sourceFile = ts.createSourceFile(
2293
+ relPath,
2294
+ source,
2295
+ ts.ScriptTarget.Latest,
2296
+ true,
2297
+ ts.ScriptKind.TS,
2298
+ );
2299
+ const bindings = collectLocalBindings(sourceFile);
2300
+ const visit = (node: TS.Node): void => {
2301
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2302
+ return;
2303
+ }
2304
+ if (
2305
+ ts.isExpression(node) &&
2306
+ (isDirectCredentialReference(node) ||
2307
+ ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
2308
+ isCredentialReference(node.expression, bindings)))
2309
+ ) {
2310
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
2311
+ const key = `${relPath}:${line}`;
2312
+ if (!seen.has(key)) {
2313
+ seen.add(key);
2314
+ findings.push({ file: relPath, line });
2315
+ }
2316
+ }
2317
+ ts.forEachChild(node, visit);
2318
+ };
2319
+ visit(sourceFile);
2320
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2321
+ break;
2322
+ }
2323
+ }
2324
+ return findings;
2325
+ }
2326
+
2327
+ // ---------------------------------------------------------------------------
2328
+ // sdk-owned-secret-presence (warn): provider-local double validation of
2329
+ // declared env secrets.
2330
+ //
2331
+ // The SDK runtime is the single source of truth for secret presence: declared
2332
+ // `required: true` secrets are validated before every handler/auth-flow
2333
+ // invocation and fail with the canonical structured MISSING_SECRET error
2334
+ // (HTTP 400, category credential_unavailable). Provider-local presence guards
2335
+ // (requireServiceKey/requireApiKey style) are dead weight that historically
2336
+ // diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
2337
+ // with/without category), which broke uniform incident attribution when nine
2338
+ // providers shipped with unprovisioned secrets (2026-07-22).
2339
+ //
2340
+ // Heuristic, warn-only: a line reading a declared `required: true` secret via
2341
+ // `.env.get(...)` (string literal or a const alias of a declared name)
2342
+ // followed within a small window by a falsy presence check plus a `throw`.
2343
+ // The rule flags duplication of the SDK gate ONLY: env names that are not
2344
+ // declared in defineProvider secrets[], and optional declarations
2345
+ // (`required: false`/omitted) that the runtime deliberately does not enforce,
2346
+ // are out of scope. Escape hatch:
2347
+ // `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
2348
+ // ---------------------------------------------------------------------------
2349
+
2350
+ const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
2351
+ const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
2352
+
2353
+ const ENV_GET_CALL_PATTERN =
2354
+ /\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
2355
+
2356
+ const SECRET_ALIAS_CONST_PATTERN =
2357
+ /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
2358
+
2359
+ const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
2360
+
2361
+ // Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
2362
+ // "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
2363
+ // guards are detected, not just direct string literals.
2364
+ function buildDeclaredSecretAliasMap(
2365
+ providerRoot: string,
2366
+ declaredNames: ReadonlySet<string>,
2367
+ ): Map<string, string> {
2368
+ const aliases = new Map<string, string>();
2369
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2370
+ const content = readFileSync(filePath, "utf8");
2371
+ for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
2372
+ const alias = match[1];
2373
+ const name = match[2] ?? match[3] ?? match[4];
2374
+ if (alias && name && declaredNames.has(name)) {
2375
+ aliases.set(alias, name);
2376
+ }
2377
+ }
2378
+ }
2379
+ return aliases;
2380
+ }
2381
+
2382
+ function hasLocalSecretPresenceGuard(
2383
+ line: string,
2384
+ remainingLines: readonly string[],
2385
+ declaredNames: ReadonlySet<string>,
2386
+ aliases: ReadonlyMap<string, string>,
2387
+ ): boolean {
2388
+ const match = ENV_GET_CALL_PATTERN.exec(line);
2389
+ if (!match) {
2390
+ return false;
2391
+ }
2392
+ const literal = match[1] ?? match[2] ?? match[3];
2393
+ const identifier = match[4];
2394
+ const readsDeclaredSecret =
2395
+ literal !== undefined
2396
+ ? declaredNames.has(literal)
2397
+ : identifier !== undefined && aliases.has(identifier);
2398
+ if (!readsDeclaredSecret) {
2399
+ return false;
2400
+ }
2401
+
2402
+ const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
2403
+ if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
2404
+ return false;
2405
+ }
2406
+
2407
+ // Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
2408
+ // check on THAT variable counts as a presence guard. Anchoring on the
2409
+ // assigned identifier avoids false positives from unrelated guards/throws
2410
+ // that merely sit near the env read (mirrors the aliased runtime-guard rule).
2411
+ const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
2412
+ if (assigned) {
2413
+ const escaped = assigned.replace(/\$/g, "\\$");
2414
+ const guardPattern = new RegExp(
2415
+ `(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
2416
+ );
2417
+ return window.some((candidate) => guardPattern.test(candidate));
2418
+ }
2419
+
2420
+ // Un-assigned read: only an inline presence check on the same line counts,
2421
+ // e.g. `if (!ctx.env.get(KEY)) throw ...`.
2422
+ return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
2423
+ }
2424
+
2425
+ function scoreSdkOwnedSecretPresence(
2426
+ providerRoot: string,
2427
+ provider: ProviderDefinition,
2428
+ ): SubmitCheck {
2429
+ const passMessage = "Provider relies on SDK-owned secret presence validation.";
2430
+ // Only `required: true` declarations: those are exactly what the runtime
2431
+ // gate enforces. A presence guard over an optional secret is conditional
2432
+ // business logic the SDK will not replace, not double validation.
2433
+ const declaredNames: ReadonlySet<string> = new Set(
2434
+ (provider.secrets ?? [])
2435
+ .filter((secret) => secret.required === true)
2436
+ .map((secret) => secret.name),
2437
+ );
2438
+ if (declaredNames.size === 0) {
2439
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
2440
+ }
2441
+
2442
+ const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
2443
+ const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
2444
+ hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
2445
+ );
2446
+ if (findings.length === 0) {
2447
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
2448
+ }
2449
+
2450
+ const { violations, overridden } = partitionAllowOverrides(
2451
+ providerRoot,
2452
+ findings,
2453
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
2454
+ );
2455
+ if (violations.length === 0) {
2456
+ return pass(
2457
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
2458
+ SDK_NATIVE_CATEGORY,
2459
+ `${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
2460
+ 0,
2461
+ formatSourceFindings(overridden),
2462
+ );
2463
+ }
2464
+
2465
+ return {
2466
+ id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
2467
+ category: SDK_NATIVE_CATEGORY,
2468
+ level: "warn",
2469
+ status: "warn",
2470
+ points: 0,
2471
+ maxPoints: 0,
2472
+ message:
2473
+ "Provider source re-validates declared env secret presence locally; the SDK owns this check.",
2474
+ remediation:
2475
+ "The provider SDK validates declared required secrets before handlers and auth flows run and returns the canonical MISSING_SECRET error (HTTP 400, category credential_unavailable). Declare the secret with required: true in defineProvider({ secrets: [...] }), delete the provider-local presence guard (requireServiceKey/requireApiKey style), and read the value directly with ctx.env.get(); the guard is dead weight and its divergent CONFIGURATION_ERROR-style shape is deprecated. Acknowledge intentional exceptions with `// @apifuse-allow sdk-owned-secret-presence: <reason>`.",
2476
+ evidence: formatSourceFindings(violations),
2477
+ };
2478
+ }
2479
+
1427
2480
  function findSourceLineMatches(
1428
2481
  providerRoot: string,
1429
2482
  pattern: RegExp | ((line: string) => boolean),
2483
+ useMaskedSource = false,
1430
2484
  ): SourceFinding[] {
1431
- return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
2485
+ return findSourceFindings(
2486
+ providerRoot,
2487
+ (line) => matchesLinePattern(line, pattern),
2488
+ useMaskedSource,
2489
+ );
1432
2490
  }
1433
2491
 
1434
2492
  function findSourceFindings(
1435
2493
  providerRoot: string,
1436
2494
  matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
2495
+ useMaskedSource = false,
1437
2496
  ): SourceFinding[] {
1438
2497
  const findings: SourceFinding[] = [];
1439
2498
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1440
2499
  const content = readFileSync(filePath, "utf8");
1441
- const lines = content.split(/\r?\n/);
2500
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2501
+ const lines = (
2502
+ useMaskedSource
2503
+ ? maskCommentsAndStrings(content, relPath, { blankPropertyKeys: true })
2504
+ : content
2505
+ ).split(/\r?\n/);
1442
2506
  for (let index = 0; index < lines.length; index += 1) {
1443
2507
  const line = lines[index];
1444
2508
  if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
1445
2509
  findings.push({
1446
- file: toRelativeProviderPath(providerRoot, filePath),
2510
+ file: relPath,
1447
2511
  line: index + 1,
1448
2512
  });
1449
2513
  if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
@@ -1523,8 +2587,25 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1523
2587
  );
1524
2588
  }
1525
2589
 
2590
+ const EXCLUDED_SOURCE_DIRECTORY_SEGMENTS = new Set([
2591
+ ".git",
2592
+ ".worktree",
2593
+ "node_modules",
2594
+ "dist",
2595
+ "build",
2596
+ "coverage",
2597
+ ]);
2598
+
2599
+ // `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
2600
+ // markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
2601
+ // directories must not become a scan-exempt hiding place for secrets, raw
2602
+ // fetch, or vendor imports. Nested dependency/build output directories under
2603
+ // them use the same segment exclusions; `.worktree` is excluded because task
2604
+ // worktrees are duplicate repository checkouts, not provider source.
1526
2605
  function shouldScanSourceDirectory(relativePath: string): boolean {
1527
- return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
2606
+ return relativePath
2607
+ .split("/")
2608
+ .every((segment) => !EXCLUDED_SOURCE_DIRECTORY_SEGMENTS.has(segment));
1528
2609
  }
1529
2610
 
1530
2611
  function isExcludedTestSource(relativePath: string): boolean {
@@ -1542,7 +2623,31 @@ function toRelativeProviderPath(providerRoot: string, filePath: string): string
1542
2623
  }
1543
2624
 
1544
2625
  function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1545
- return findings.map((finding) => `${finding.file}:${finding.line}`);
2626
+ return findings.map(
2627
+ (finding) =>
2628
+ `${finding.file}:${finding.line}${finding.detail === undefined ? "" : `: ${finding.detail}`}`,
2629
+ );
2630
+ }
2631
+
2632
+ function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
2633
+ const verification = verifyPromptAssets(providerRoot);
2634
+ if (verification.ok) {
2635
+ return pass(
2636
+ "prompt-assets-fresh",
2637
+ "docs",
2638
+ "SDK-managed agent prompt assets match the installed SDK version.",
2639
+ 0,
2640
+ );
2641
+ }
2642
+
2643
+ return blocker(
2644
+ "prompt-assets-fresh",
2645
+ "docs",
2646
+ "SDK-managed agent prompt assets are missing, stale, or modified.",
2647
+ "Run `bun run sync-assets` (or `bunx apifuse sync-assets .`) to regenerate AGENTS.md, .agents/skills/**, the CLAUDE.md/.claude/.codex symlinks, and .apifuse/prompt-assets.json for the installed SDK version.",
2648
+ 0,
2649
+ formatPromptAssetIssues(verification),
2650
+ );
1546
2651
  }
1547
2652
 
1548
2653
  function scoreRepositoryDx(providerRoot: string): SubmitCheck {
@@ -1617,7 +2722,7 @@ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
1617
2722
  {
1618
2723
  message: "Base provider checks can run",
1619
2724
  passed: false,
1620
- details: [error instanceof Error ? error.message : String(error)],
2725
+ details: [formatThrownValue(error)],
1621
2726
  },
1622
2727
  ];
1623
2728
  }
@@ -1842,13 +2947,17 @@ function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string
1842
2947
 
1843
2948
  for (const operation of Object.values(provider.operations)) {
1844
2949
  addLocaleKeys(keys, [
2950
+ operation.titleKey,
1845
2951
  operation.descriptionKey,
1846
- operation.docs?.titleKey,
1847
- operation.docs?.descriptionKey,
1848
- operation.docs?.summaryKey,
1849
- operation.docs?.markdownKey,
2952
+ operation.summaryKey,
2953
+ operation.markdownKey,
1850
2954
  ...(operation.whenToUseKeys ?? []),
1851
2955
  ...(operation.whenNotToUseKeys ?? []),
2956
+ ...(operation.normalizationNotesKeys ?? []),
2957
+ ...(operation.examples ?? []).flatMap((example) => [
2958
+ example.scenarioKey,
2959
+ example.rationaleKey,
2960
+ ]),
1852
2961
  ...collectSchemaDescriptionKeys(operation.input),
1853
2962
  ...collectSchemaDescriptionKeys(operation.output),
1854
2963
  ]);
@@ -1915,8 +3024,8 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
1915
3024
  return true;
1916
3025
  })
1917
3026
  .map(([operationId]) => operationId);
1918
- const missingAnnotations = operations
1919
- .filter(([, operation]) => !operation.annotations)
3027
+ const missingRiskClass = operations
3028
+ .filter(([, operation]) => !operation.riskClass)
1920
3029
  .map(([operationId]) => operationId);
1921
3030
 
1922
3031
  if (weakDescriptions.length > 0) {
@@ -1928,30 +3037,30 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
1928
3037
  points: 0,
1929
3038
  maxPoints: CATEGORY_MAX_POINTS.operations,
1930
3039
  message: "One or more operations have weak descriptions.",
1931
- 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.`,
3040
+ remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json\`.`,
1932
3041
  evidence: weakDescriptions,
1933
3042
  };
1934
3043
  }
1935
3044
 
1936
- const points = missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
3045
+ const points = missingRiskClass.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
1937
3046
  return {
1938
3047
  id: "operation-metadata",
1939
3048
  category: "operations",
1940
- level: missingAnnotations.length > 0 ? "warn" : "info",
1941
- status: missingAnnotations.length > 0 ? "warn" : "pass",
3049
+ level: missingRiskClass.length > 0 ? "warn" : "info",
3050
+ status: missingRiskClass.length > 0 ? "warn" : "pass",
1942
3051
  points,
1943
3052
  maxPoints: CATEGORY_MAX_POINTS.operations,
1944
3053
  message:
1945
- missingAnnotations.length > 0
1946
- ? "Operations are described, but some are missing safety annotations."
3054
+ missingRiskClass.length > 0
3055
+ ? "Operations are described, but some are missing a safety classification."
1947
3056
  : "Operation descriptions and metadata are review-ready.",
1948
3057
  remediation:
1949
- missingAnnotations.length > 0
1950
- ? `For ${missingAnnotations.join(", ")}, add \`annotations\` with the applicable safety fields, such as \`readOnly\`, \`destructive\`, \`idempotent\`, \`openWorld\`, \`rateLimit\`, or \`timeoutMs\`.`
3058
+ missingRiskClass.length > 0
3059
+ ? `For ${missingRiskClass.join(", ")}, declare the operation's required \`riskClass\` as \`read\`, \`write\`, \`destructive\`, or \`external-send\`.`
1951
3060
  : undefined,
1952
3061
  evidence:
1953
- missingAnnotations.length > 0
1954
- ? missingAnnotations.map((operationId) => `${operationId}: missing annotations`)
3062
+ missingRiskClass.length > 0
3063
+ ? missingRiskClass.map((operationId) => `${operationId}: missing riskClass`)
1955
3064
  : operations.map(([operationId]) => operationId),
1956
3065
  };
1957
3066
  }
@@ -1983,11 +3092,16 @@ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
1983
3092
  function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1984
3093
  const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
1985
3094
  let hasRecordedEvidence = false;
3095
+ let fixtureValidationError: string | undefined;
1986
3096
  if (existsSync(rawPath)) {
1987
3097
  try {
1988
- hasRecordedEvidence = hasNonEmptyRecordedFixture(JSON.parse(readFileSync(rawPath, "utf8")));
1989
- } catch {
3098
+ hasRecordedEvidence = recordedFixtureStats(
3099
+ JSON.parse(readFileSync(rawPath, "utf8")),
3100
+ 0,
3101
+ ).hasNestedSubstance;
3102
+ } catch (error) {
1990
3103
  hasRecordedEvidence = false;
3104
+ fixtureValidationError = error instanceof Error ? error.message : String(error);
1991
3105
  }
1992
3106
  }
1993
3107
 
@@ -1999,6 +3113,16 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
1999
3113
  0,
2000
3114
  );
2001
3115
  }
3116
+ if (fixtureValidationError) {
3117
+ return blocker(
3118
+ "fixture-provenance",
3119
+ "fixtures",
3120
+ `Malformed recorded fixture evidence in __fixtures__/raw.json: ${fixtureValidationError}`,
3121
+ "Re-run `bun run record` to replace the malformed stream evidence, or repair the named field using the stream evidence contract.",
3122
+ 0,
3123
+ ["__fixtures__/raw.json"],
3124
+ );
3125
+ }
2002
3126
 
2003
3127
  if (allOperationsAreGeneratedLocalScaffold(provider)) {
2004
3128
  return {
@@ -2026,14 +3150,32 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
2026
3150
  );
2027
3151
  }
2028
3152
 
2029
- function hasNonEmptyRecordedFixture(value: unknown): boolean {
2030
- return recordedFixtureStats(value, 0).hasNestedSubstance;
3153
+ export function hasNonEmptyRecordedFixture(value: unknown): boolean {
3154
+ try {
3155
+ return recordedFixtureStats(value, 0).hasNestedSubstance;
3156
+ } catch {
3157
+ return false;
3158
+ }
2031
3159
  }
2032
3160
 
2033
3161
  function recordedFixtureStats(
2034
3162
  value: unknown,
2035
3163
  depth: number,
2036
3164
  ): { hasNestedSubstance: boolean; leafValues: number } {
3165
+ if (
3166
+ value !== null &&
3167
+ typeof value === "object" &&
3168
+ !Array.isArray(value) &&
3169
+ (value as Record<string, unknown>).__apifuse_capture__ === true
3170
+ ) {
3171
+ const group = findStreamCaptureGroup(value);
3172
+ if (!group) throw new Error("Stream capture envelope is invalid.");
3173
+ return { hasNestedSubstance: true, leafValues: group.items.length };
3174
+ }
3175
+ if (hasStreamEvidenceMarker(value)) {
3176
+ parseStreamEvidenceRecord(value);
3177
+ return { hasNestedSubstance: true, leafValues: 1 };
3178
+ }
2037
3179
  if (value === null || value === undefined) {
2038
3180
  return { hasNestedSubstance: false, leafValues: 0 };
2039
3181
  }
@@ -2068,10 +3210,14 @@ function recordedFixtureStats(
2068
3210
  if (value.length === 0) {
2069
3211
  return { hasNestedSubstance: false, leafValues: 0 };
2070
3212
  }
2071
- // A recorded operation value may be a raw XML success payload; treat a
2072
- // substantive, well-formed one as nested evidence while still counting the
2073
- // string as a leaf so existing JSON provenance heuristics are unchanged.
2074
- return { hasNestedSubstance: hasSubstantiveXmlStructure(value), leafValues: 1 };
3213
+ // A recorded operation value may be a raw XML or delimited-text success
3214
+ // payload. Treat a substantive one as nested evidence while still counting
3215
+ // the string as a leaf so existing JSON provenance heuristics are unchanged.
3216
+ return {
3217
+ hasNestedSubstance:
3218
+ hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
3219
+ leafValues: 1,
3220
+ };
2075
3221
  }
2076
3222
  return { hasNestedSubstance: false, leafValues: 1 };
2077
3223
  }
@@ -2136,68 +3282,326 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
2136
3282
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2137
3283
  const source = readFileSync(filePath, "utf8");
2138
3284
  const relPath = toRelativeProviderPath(providerRoot, filePath);
3285
+ maskCommentsAndStrings(source, relPath);
3286
+ const sourceFile = ts.createSourceFile(
3287
+ relPath,
3288
+ source,
3289
+ ts.ScriptTarget.Latest,
3290
+ true,
3291
+ ts.ScriptKind.TS,
3292
+ );
3293
+ const bindings = collectLocalBindings(sourceFile);
2139
3294
  const upstreamRanges = findUpstreamMarkedConstRanges(source);
2140
3295
  for (const zObject of findZObjectLiterals(source)) {
2141
3296
  if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
2142
3297
  continue;
2143
3298
  }
2144
- if (!zObjectAppearsPublicOutput(source, zObject)) {
2145
- continue;
3299
+ if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
3300
+ continue;
3301
+ }
3302
+ for (const keyFinding of vendorKeyFindingsForObject(
3303
+ source,
3304
+ sourceFile,
3305
+ zObject,
3306
+ bindings,
3307
+ )) {
3308
+ const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
3309
+ if (!seen.has(key)) {
3310
+ seen.add(key);
3311
+ findings.push({
3312
+ file: relPath,
3313
+ line: keyFinding.line,
3314
+ detail: keyFinding.key,
3315
+ });
3316
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3317
+ return findings;
3318
+ }
3319
+ }
3320
+ }
3321
+ }
3322
+ }
3323
+
3324
+ return findings;
3325
+ }
3326
+
3327
+ function findZObjectLiterals(source: string): ZObjectLiteral[] {
3328
+ const literals: ZObjectLiteral[] = [];
3329
+ const masked = maskCommentsAndStrings(source);
3330
+ const callPattern = /\bz\s*\.\s*object\s*\(/g;
3331
+ for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
3332
+ const parenIndex = masked.indexOf("(", match.index);
3333
+ const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
3334
+ if (objectStart === -1 || masked[objectStart] !== "{") {
3335
+ continue;
3336
+ }
3337
+ const objectEnd = findMatchingBracket(masked, objectStart);
3338
+ if (objectEnd === -1) {
3339
+ continue;
3340
+ }
3341
+ literals.push({
3342
+ objectStart,
3343
+ objectEnd,
3344
+ callStart: match.index,
3345
+ });
3346
+ callPattern.lastIndex = objectEnd;
3347
+ }
3348
+ return literals;
3349
+ }
3350
+
3351
+ function zObjectAppearsPublicOutput(
3352
+ sourceFile: TS.SourceFile,
3353
+ zObject: ZObjectLiteral,
3354
+ relPath: string,
3355
+ ): boolean {
3356
+ let zObjectCall: TS.CallExpression | undefined;
3357
+ const findCall = (node: TS.Node): void => {
3358
+ if (
3359
+ zObjectCall === undefined &&
3360
+ ts.isCallExpression(node) &&
3361
+ node.getStart(sourceFile) === zObject.callStart
3362
+ ) {
3363
+ zObjectCall = node;
3364
+ return;
3365
+ }
3366
+ ts.forEachChild(node, findCall);
3367
+ };
3368
+ findCall(sourceFile);
3369
+ if (zObjectCall === undefined) {
3370
+ return true;
3371
+ }
3372
+
3373
+ let expression: TS.Expression = zObjectCall;
3374
+ while (
3375
+ ts.isParenthesizedExpression(expression.parent) ||
3376
+ ts.isAsExpression(expression.parent) ||
3377
+ ts.isSatisfiesExpression(expression.parent) ||
3378
+ ts.isNonNullExpression(expression.parent)
3379
+ ) {
3380
+ expression = expression.parent;
3381
+ }
3382
+ if (
3383
+ ts.isPropertyAssignment(expression.parent) &&
3384
+ isPublicOutputPropertyName(expression.parent.name)
3385
+ ) {
3386
+ return true;
3387
+ }
3388
+
3389
+ const declaration = findEnclosingVariableDeclaration(expression);
3390
+ if (
3391
+ declaration === undefined ||
3392
+ !ts.isIdentifier(declaration.name) ||
3393
+ declaration.initializer === undefined ||
3394
+ !ts.isVariableDeclarationList(declaration.parent) ||
3395
+ (declaration.parent.flags & ts.NodeFlags.Const) === 0
3396
+ ) {
3397
+ return true;
3398
+ }
3399
+
3400
+ const reachability = localSchemaBindingReachability(
3401
+ sourceFile,
3402
+ declaration.name.text,
3403
+ declaration,
3404
+ new Set(),
3405
+ );
3406
+ if (reachability === "public") {
3407
+ return true;
3408
+ }
3409
+ if (reachability === "unknown") {
3410
+ return true;
3411
+ }
3412
+ // Sibling modules are scanned independently and therefore remain
3413
+ // fail-closed. An exported binding in index.ts is likewise consumable by
3414
+ // another module, so local inertness does not prove it is private.
3415
+ if (relPath !== "index.ts") {
3416
+ return true;
3417
+ }
3418
+ return isExportedVariableBinding(sourceFile, declaration);
3419
+ }
3420
+
3421
+ type SchemaReachability = "public" | "internal" | "unknown";
3422
+
3423
+ const PARSE_LIKE_SCHEMA_METHODS: ReadonlySet<string> = new Set([
3424
+ "parse",
3425
+ "safeParse",
3426
+ "parseAsync",
3427
+ "safeParseAsync",
3428
+ ]);
3429
+
3430
+ function isExportedVariableBinding(
3431
+ sourceFile: TS.SourceFile,
3432
+ declaration: TS.VariableDeclaration,
3433
+ ): boolean {
3434
+ const declarationStatement = declaration.parent.parent;
3435
+ if (
3436
+ ts.isVariableStatement(declarationStatement) &&
3437
+ declarationStatement.modifiers?.some(
3438
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
3439
+ ) === true
3440
+ ) {
3441
+ return true;
3442
+ }
3443
+ if (!ts.isIdentifier(declaration.name)) {
3444
+ return false;
3445
+ }
3446
+ const name = declaration.name.text;
3447
+ let exported = false;
3448
+ const visit = (node: TS.Node): void => {
3449
+ if (exported) {
3450
+ return;
3451
+ }
3452
+ if (ts.isExportSpecifier(node)) {
3453
+ const localName = node.propertyName ?? node.name;
3454
+ if (ts.isIdentifier(localName) && localName.text === name) {
3455
+ exported = true;
3456
+ return;
3457
+ }
3458
+ }
3459
+ if (ts.isExportAssignment(node)) {
3460
+ const expression = unwrapLocalExpression(node.expression);
3461
+ if (ts.isIdentifier(expression) && expression.text === name) {
3462
+ exported = true;
3463
+ return;
3464
+ }
3465
+ }
3466
+ ts.forEachChild(node, visit);
3467
+ };
3468
+ for (const statement of sourceFile.statements) {
3469
+ visit(statement);
3470
+ if (exported) {
3471
+ break;
3472
+ }
3473
+ }
3474
+ return exported;
3475
+ }
3476
+
3477
+ function isPublicOutputPropertyName(name: TS.PropertyName): boolean {
3478
+ const text = propertyNameText(name);
3479
+ return text === "output" || text === "response";
3480
+ }
3481
+
3482
+ function findEnclosingVariableDeclaration(node: TS.Node): TS.VariableDeclaration | undefined {
3483
+ let current: TS.Node | undefined = node;
3484
+ while (current !== undefined && !ts.isSourceFile(current)) {
3485
+ if (ts.isVariableDeclaration(current)) {
3486
+ return current;
3487
+ }
3488
+ current = current.parent;
3489
+ }
3490
+ return undefined;
3491
+ }
3492
+
3493
+ function localSchemaBindingReachability(
3494
+ sourceFile: TS.SourceFile,
3495
+ name: string,
3496
+ declaration: TS.VariableDeclaration,
3497
+ resolving: ReadonlySet<string>,
3498
+ ): SchemaReachability {
3499
+ if (resolving.has(name)) {
3500
+ return "unknown";
3501
+ }
3502
+ const nextResolving = new Set(resolving);
3503
+ nextResolving.add(name);
3504
+ let result: SchemaReachability = "internal";
3505
+ const visit = (node: TS.Node): void => {
3506
+ if (result === "public") {
3507
+ return;
3508
+ }
3509
+ if (ts.isIdentifier(node) && node.text === name && node !== declaration.name) {
3510
+ const parent = node.parent;
3511
+ if (isParseLikeSchemaReceiver(node)) {
3512
+ return;
3513
+ }
3514
+ if (ts.isExportSpecifier(parent)) {
3515
+ const localName = parent.propertyName ?? parent.name;
3516
+ if (localName === node) {
3517
+ result = "public";
3518
+ }
3519
+ return;
3520
+ }
3521
+ if (
3522
+ ts.isPropertyAssignment(parent) &&
3523
+ parent.initializer === node &&
3524
+ isPublicOutputPropertyName(parent.name)
3525
+ ) {
3526
+ result = "public";
3527
+ return;
3528
+ }
3529
+ if (ts.isShorthandPropertyAssignment(parent)) {
3530
+ result = isPublicOutputPropertyName(parent.name) ? "public" : "unknown";
3531
+ return;
2146
3532
  }
2147
- for (const keyFinding of vendorKeyFindingsForObject(source, zObject)) {
2148
- const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
2149
- if (!seen.has(key)) {
2150
- seen.add(key);
2151
- findings.push({ file: relPath, line: keyFinding.line });
2152
- if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2153
- return findings;
2154
- }
3533
+ if (
3534
+ ts.isVariableDeclaration(parent) &&
3535
+ parent.initializer === node &&
3536
+ ts.isIdentifier(parent.name) &&
3537
+ ts.isVariableDeclarationList(parent.parent) &&
3538
+ (parent.parent.flags & ts.NodeFlags.Const) !== 0
3539
+ ) {
3540
+ const aliasReachability = localSchemaBindingReachability(
3541
+ sourceFile,
3542
+ parent.name.text,
3543
+ parent,
3544
+ nextResolving,
3545
+ );
3546
+ if (aliasReachability === "public" || result === "internal") {
3547
+ result = aliasReachability;
2155
3548
  }
3549
+ return;
3550
+ }
3551
+ if (ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) {
3552
+ return;
2156
3553
  }
3554
+ if (
3555
+ (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
3556
+ (ts.isPropertyAssignment(parent) && parent.name === node) ||
3557
+ ts.isBindingElement(parent) ||
3558
+ ts.isImportSpecifier(parent)
3559
+ ) {
3560
+ return;
3561
+ }
3562
+ result = "unknown";
2157
3563
  }
2158
- }
2159
-
2160
- return findings;
3564
+ ts.forEachChild(node, visit);
3565
+ };
3566
+ visit(sourceFile);
3567
+ return result;
2161
3568
  }
2162
3569
 
2163
- function findZObjectLiterals(source: string): ZObjectLiteral[] {
2164
- const literals: ZObjectLiteral[] = [];
2165
- const masked = maskCommentsAndStrings(source);
2166
- const callPattern = /\bz\s*\.\s*object\s*\(/g;
2167
- for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
2168
- const parenIndex = masked.indexOf("(", match.index);
2169
- const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
2170
- if (objectStart === -1 || masked[objectStart] !== "{") {
2171
- continue;
2172
- }
2173
- const objectEnd = findMatchingBracket(masked, objectStart);
2174
- if (objectEnd === -1) {
2175
- continue;
3570
+ function isParseLikeSchemaReceiver(identifier: TS.Identifier): boolean {
3571
+ const access = identifier.parent;
3572
+ let methodName: string | undefined;
3573
+ if (ts.isPropertyAccessExpression(access) && access.expression === identifier) {
3574
+ methodName = access.name.text;
3575
+ } else if (ts.isElementAccessExpression(access) && access.expression === identifier) {
3576
+ const argument = access.argumentExpression;
3577
+ if (
3578
+ argument !== undefined &&
3579
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
3580
+ ) {
3581
+ methodName = argument.text;
2176
3582
  }
2177
- literals.push({
2178
- objectStart,
2179
- objectEnd,
2180
- callStart: match.index,
2181
- });
2182
- callPattern.lastIndex = objectEnd;
2183
- }
2184
- return literals;
2185
- }
2186
-
2187
- function zObjectAppearsPublicOutput(source: string, zObject: ZObjectLiteral): boolean {
2188
- const enclosingConst = findConstValueRangeContaining(source, zObject.callStart);
2189
- if (enclosingConst && /output|response|result/i.test(enclosingConst.name)) {
2190
- return true;
2191
3583
  }
2192
- const before = source.slice(Math.max(0, zObject.callStart - 160), zObject.callStart);
2193
- return /(?:^|[\s,{])(?:output|response)\s*:\s*$/.test(before);
3584
+ return (
3585
+ methodName !== undefined &&
3586
+ PARSE_LIKE_SCHEMA_METHODS.has(methodName) &&
3587
+ ts.isCallExpression(access.parent) &&
3588
+ access.parent.expression === access
3589
+ );
2194
3590
  }
2195
3591
 
2196
3592
  function vendorKeyFindingsForObject(
2197
3593
  source: string,
3594
+ sourceFile: TS.SourceFile,
2198
3595
  zObject: ZObjectLiteral,
3596
+ bindings: LocalBindings,
2199
3597
  ): Array<{ key: string; line: number }> {
2200
- const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
3598
+ const keys = collectTopLevelObjectKeys(
3599
+ source,
3600
+ sourceFile,
3601
+ zObject.objectStart,
3602
+ zObject.objectEnd,
3603
+ bindings,
3604
+ );
2201
3605
  const digitFamilies = new Map<string, Set<string>>();
2202
3606
  for (const key of keys) {
2203
3607
  const member = numberedFamilyMember(key.name);
@@ -2253,8 +3657,10 @@ function isAllowedPublicOutputKeyName(name: string): boolean {
2253
3657
 
2254
3658
  function collectTopLevelObjectKeys(
2255
3659
  source: string,
3660
+ sourceFile: TS.SourceFile,
2256
3661
  objectStart: number,
2257
3662
  objectEnd: number,
3663
+ bindings: LocalBindings,
2258
3664
  ): Array<{ name: string; offset: number }> {
2259
3665
  const keys: Array<{ name: string; offset: number }> = [];
2260
3666
  const masked = maskCommentsAndStrings(source);
@@ -2276,17 +3682,16 @@ function collectTopLevelObjectKeys(
2276
3682
  index = endQuote + 1;
2277
3683
  } else if (masked[index] === "[") {
2278
3684
  const computedEnd = findMatchingBracket(masked, index);
2279
- const literalStart = findNextNonWhitespace(masked, index + 1);
2280
- if (computedEnd === -1 || literalStart === -1) {
3685
+ if (computedEnd === -1) {
2281
3686
  break;
2282
3687
  }
2283
- const computedQuote = source[literalStart];
2284
- if (computedQuote === '"' || computedQuote === "'") {
2285
- const literalEnd = findStringEnd(source, literalStart);
2286
- const afterLiteral =
2287
- literalEnd === -1 ? -1 : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
2288
- if (literalEnd !== -1 && afterLiteral === computedEnd) {
2289
- key = source.slice(literalStart + 1, literalEnd);
3688
+ const expression = computedPropertyExpressionAtOffset(sourceFile, index);
3689
+ if (expression !== undefined) {
3690
+ const unwrapped = unwrapLocalExpression(expression);
3691
+ if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
3692
+ key = unwrapped.text;
3693
+ } else if (ts.isIdentifier(unwrapped)) {
3694
+ key = resolveConstStringIdentifier(unwrapped, bindings);
2290
3695
  }
2291
3696
  }
2292
3697
  index = computedEnd + 1;
@@ -2313,6 +3718,25 @@ function collectTopLevelObjectKeys(
2313
3718
  return keys;
2314
3719
  }
2315
3720
 
3721
+ function computedPropertyExpressionAtOffset(
3722
+ sourceFile: TS.SourceFile,
3723
+ offset: number,
3724
+ ): TS.Expression | undefined {
3725
+ let expression: TS.Expression | undefined;
3726
+ const visit = (node: TS.Node): void => {
3727
+ if (expression !== undefined || offset < node.getFullStart() || offset >= node.getEnd()) {
3728
+ return;
3729
+ }
3730
+ if (ts.isComputedPropertyName(node) && node.getStart(sourceFile) === offset) {
3731
+ expression = node.expression;
3732
+ return;
3733
+ }
3734
+ ts.forEachChild(node, visit);
3735
+ };
3736
+ visit(sourceFile);
3737
+ return expression;
3738
+ }
3739
+
2316
3740
  function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
2317
3741
  const findings: SourceFinding[] = [];
2318
3742
  const seen = new Set<string>();
@@ -2320,6 +3744,15 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2320
3744
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2321
3745
  const source = readFileSync(filePath, "utf8");
2322
3746
  const relPath = toRelativeProviderPath(providerRoot, filePath);
3747
+ maskCommentsAndStrings(source, relPath);
3748
+ const sourceFile = ts.createSourceFile(
3749
+ relPath,
3750
+ source,
3751
+ ts.ScriptTarget.Latest,
3752
+ true,
3753
+ ts.ScriptKind.TS,
3754
+ );
3755
+ const bindings = collectLocalBindings(sourceFile);
2323
3756
  const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
2324
3757
  start: zObject.callStart,
2325
3758
  end: zObject.objectEnd,
@@ -2332,7 +3765,7 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2332
3765
  ].filter((range) => rangeContainedInRanges(fixtureRanges, range));
2333
3766
 
2334
3767
  for (const range of fixtureResponseRanges) {
2335
- for (const literal of findStringLiteralsInRange(source, range)) {
3768
+ for (const literal of findStringLiteralsInRange(source, range, relPath)) {
2336
3769
  if (
2337
3770
  rangeContainsOffset(zObjectRanges, literal.offset) ||
2338
3771
  rangeContainsOffset(upstreamRanges, literal.offset) ||
@@ -2353,12 +3786,86 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2353
3786
  }
2354
3787
  }
2355
3788
  }
3789
+
3790
+ const visitAliases = (node: TS.Node): void => {
3791
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3792
+ return;
3793
+ }
3794
+ const offset = node.getStart(sourceFile);
3795
+ if (
3796
+ ts.isIdentifier(node) &&
3797
+ isIdentifierValueUse(node) &&
3798
+ offset >= range.start &&
3799
+ offset <= range.end &&
3800
+ !rangeContainsOffset(zObjectRanges, offset) &&
3801
+ !rangeContainsOffset(upstreamRanges, offset)
3802
+ ) {
3803
+ const value = resolveConstStringIdentifier(node, bindings);
3804
+ if (
3805
+ value !== undefined &&
3806
+ isVendorTimestampCandidate(value, propertyNameForValueExpression(node))
3807
+ ) {
3808
+ const line = offsetToLine(source, offset);
3809
+ const key = `${relPath}:${line}:${value}`;
3810
+ if (!seen.has(key)) {
3811
+ seen.add(key);
3812
+ findings.push({ file: relPath, line });
3813
+ }
3814
+ }
3815
+ }
3816
+ ts.forEachChild(node, visitAliases);
3817
+ };
3818
+ visitAliases(sourceFile);
3819
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3820
+ return findings;
3821
+ }
2356
3822
  }
2357
3823
  }
2358
3824
 
2359
3825
  return findings;
2360
3826
  }
2361
3827
 
3828
+ function isIdentifierValueUse(identifier: TS.Identifier): boolean {
3829
+ const parent = identifier.parent;
3830
+ return !(
3831
+ (ts.isPropertyAssignment(parent) && parent.name === identifier) ||
3832
+ (ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
3833
+ ts.isBindingElement(parent) ||
3834
+ ts.isVariableDeclaration(parent) ||
3835
+ ts.isParameter(parent) ||
3836
+ ts.isImportClause(parent) ||
3837
+ ts.isImportSpecifier(parent) ||
3838
+ ts.isNamespaceImport(parent) ||
3839
+ ts.isFunctionDeclaration(parent) ||
3840
+ ts.isFunctionExpression(parent) ||
3841
+ ts.isClassDeclaration(parent) ||
3842
+ ts.isClassExpression(parent) ||
3843
+ ts.isPropertyDeclaration(parent) ||
3844
+ ts.isPropertySignature(parent) ||
3845
+ ts.isMethodDeclaration(parent) ||
3846
+ ts.isMethodSignature(parent)
3847
+ );
3848
+ }
3849
+
3850
+ function propertyNameForValueExpression(expression: TS.Expression): string | undefined {
3851
+ let current: TS.Expression = expression;
3852
+ while (
3853
+ ts.isParenthesizedExpression(current.parent) ||
3854
+ ts.isAsExpression(current.parent) ||
3855
+ ts.isSatisfiesExpression(current.parent) ||
3856
+ ts.isNonNullExpression(current.parent)
3857
+ ) {
3858
+ current = current.parent;
3859
+ }
3860
+ if (ts.isPropertyAssignment(current.parent) && current.parent.initializer === current) {
3861
+ return propertyNameText(current.parent.name);
3862
+ }
3863
+ if (ts.isShorthandPropertyAssignment(current.parent)) {
3864
+ return current.parent.name.text;
3865
+ }
3866
+ return undefined;
3867
+ }
3868
+
2362
3869
  function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
2363
3870
  const ranges: ObjectRange[] = [];
2364
3871
  const masked = maskCommentsAndStrings(source);
@@ -2402,23 +3909,16 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
2402
3909
  return ranges;
2403
3910
  }
2404
3911
 
2405
- function findConstValueRangeContaining(
2406
- source: string,
2407
- offset: number,
2408
- ): NamedObjectRange | undefined {
2409
- return findNamedConstValueRanges(source).find(
2410
- (range) => offset >= range.start && offset <= range.end,
2411
- );
2412
- }
2413
-
2414
3912
  function findStringLiteralsInRange(
2415
3913
  source: string,
2416
3914
  range: ObjectRange,
3915
+ fileName: string,
2417
3916
  ): Array<{ value: string; offset: number }> {
2418
3917
  const literals: Array<{ value: string; offset: number }> = [];
3918
+ const masked = maskCommentsAndStrings(source, fileName);
2419
3919
  let index = range.start;
2420
3920
  while (index <= range.end) {
2421
- const quote = source[index];
3921
+ const quote = masked[index];
2422
3922
  if (quote !== '"' && quote !== "'" && quote !== "`") {
2423
3923
  index += 1;
2424
3924
  continue;
@@ -2528,57 +4028,199 @@ function findStringEnd(source: string, start: number): number {
2528
4028
  return -1;
2529
4029
  }
2530
4030
 
2531
- function maskCommentsAndStrings(source: string): string {
2532
- const chars = source.split("");
2533
- for (let index = 0; index < source.length; index += 1) {
2534
- if (source.startsWith("//", index)) {
2535
- const bodyStart = index + 2;
2536
- const newline = source.indexOf("\n", bodyStart);
2537
- const end = newline === -1 ? source.length : newline;
2538
- for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2539
- chars[bodyIndex] = " ";
2540
- }
2541
- index = end;
2542
- continue;
4031
+ // Masking now parses with TypeScript, which is orders of magnitude more
4032
+ // expensive than the character walk it replaced, and six scanners call this
4033
+ // helper once per candidate match the same file is masked thousands of
4034
+ // times in one run (measured: 1,872 calls for a 3.4k-line provider). Without
4035
+ // memoization that run regresses from ~8s to minutes and can exceed the CI
4036
+ // validation timeout. Successful results are cached by exact source text and
4037
+ // property-key mode; the cache stays small because a run only ever reads a
4038
+ // handful of files.
4039
+ // Parse failures are NOT cached: they throw and abort the check (fail-closed).
4040
+ const MASK_CACHE_LIMIT = 64;
4041
+ const maskCache = new Map<string, string>();
4042
+
4043
+ // Neutralize { } ( ) [ ], backtick, quotes, slash, backslash, comma, and semicolon
4044
+ // because scanners treat these as depth, string/regex delimiters, or top-level stops.
4045
+ const PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS = new Set([
4046
+ "{",
4047
+ "}",
4048
+ "(",
4049
+ ")",
4050
+ "[",
4051
+ "]",
4052
+ "`",
4053
+ '"',
4054
+ "'",
4055
+ "/",
4056
+ "\\",
4057
+ ",",
4058
+ ";",
4059
+ ]);
4060
+
4061
+ type MaskCommentsAndStringsOptions = {
4062
+ blankPropertyKeys?: boolean;
4063
+ };
4064
+
4065
+ export function maskCommentsAndStrings(
4066
+ source: string,
4067
+ fileName = "provider.ts",
4068
+ options: MaskCommentsAndStringsOptions = {},
4069
+ ): string {
4070
+ const blankPropertyKeys = options.blankPropertyKeys === true;
4071
+ const cacheKey = `${blankPropertyKeys ? "blank-keys" : "preserve-keys"}\0${source}`;
4072
+ const cached = maskCache.get(cacheKey);
4073
+ if (cached !== undefined) {
4074
+ return cached;
4075
+ }
4076
+ const masked = computeMaskedSource(source, fileName, blankPropertyKeys);
4077
+ if (maskCache.size >= MASK_CACHE_LIMIT) {
4078
+ const oldest = maskCache.keys().next();
4079
+ if (!oldest.done) {
4080
+ maskCache.delete(oldest.value);
2543
4081
  }
2544
- if (source.startsWith("/*", index)) {
2545
- const bodyStart = index + 2;
2546
- const close = source.indexOf("*/", bodyStart);
2547
- const end = close === -1 ? source.length : close;
2548
- for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2549
- if (chars[bodyIndex] !== "\n") {
2550
- chars[bodyIndex] = " ";
2551
- }
4082
+ }
4083
+ maskCache.set(cacheKey, masked);
4084
+ return masked;
4085
+ }
4086
+
4087
+ function computeMaskedSource(
4088
+ source: string,
4089
+ fileName: string,
4090
+ blankPropertyKeys: boolean,
4091
+ ): string {
4092
+ const transpiled = ts.transpileModule(source, {
4093
+ // Declaration files trigger an internal TypeScript Debug Failure when
4094
+ // passed to transpileModule. Parsing is all we need here, so always use a
4095
+ // synthetic implementation filename while retaining the real filename
4096
+ // for source mapping and sanitized diagnostics below.
4097
+ fileName: "provider.ts",
4098
+ reportDiagnostics: true,
4099
+ compilerOptions: { target: ts.ScriptTarget.Latest },
4100
+ });
4101
+ const sourceFile = ts.createSourceFile(
4102
+ fileName,
4103
+ source,
4104
+ ts.ScriptTarget.Latest,
4105
+ true,
4106
+ ts.ScriptKind.TS,
4107
+ );
4108
+ const parseDiagnostic = transpiled.diagnostics?.[0];
4109
+ if (parseDiagnostic) {
4110
+ const position = parseDiagnostic.start ?? 0;
4111
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
4112
+ throw new Error(
4113
+ `Cannot safely scan TypeScript source ${sanitizeDiagnosticFileName(fileName)} at ${line + 1}:${character + 1}: ${ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n")}`,
4114
+ );
4115
+ }
4116
+
4117
+ const chars = source.split("");
4118
+ const maskRange = (start: number, end: number): void => {
4119
+ for (let index = start; index < end; index += 1) {
4120
+ // Preserve the backslash of a string line continuation (\ followed
4121
+ // by LF, CRLF, or lone CR) and every line terminator, so masked
4122
+ // string bodies remain syntactically valid and the mask output can
4123
+ // be re-parsed (mask(mask(x)) === mask(x)).
4124
+ if (
4125
+ source[index] === "\\" &&
4126
+ (source[index + 1] === "\n" || source[index + 1] === "\r")
4127
+ ) {
4128
+ continue;
2552
4129
  }
2553
- index = close === -1 ? source.length : close + 1;
2554
- continue;
2555
- }
2556
- const quote = source[index];
2557
- if (quote !== '"' && quote !== "'" && quote !== "`") {
2558
- continue;
2559
- }
2560
- const end = findStringEnd(source, index);
2561
- if (end === -1) {
2562
- break;
4130
+ if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
2563
4131
  }
2564
- // Preserve quoted property keys ("response": ...) so range/key scanners
2565
- // can still match them; only string VALUES are blanked.
2566
- let probe = end + 1;
2567
- while (probe < source.length && /\s/.test(source[probe] ?? "")) {
2568
- probe += 1;
4132
+ };
4133
+ const commentRanges = new Map<string, TS.CommentRange>();
4134
+ const addCommentRanges = (ranges: readonly TS.CommentRange[] | undefined): void => {
4135
+ for (const range of ranges ?? []) {
4136
+ commentRanges.set(`${range.pos}:${range.end}`, range);
2569
4137
  }
2570
- if (source[probe] !== ":") {
2571
- for (let bodyIndex = index + 1; bodyIndex < end; bodyIndex += 1) {
2572
- if (chars[bodyIndex] !== "\n") {
2573
- chars[bodyIndex] = " ";
4138
+ };
4139
+
4140
+ const visit = (node: TS.Node): void => {
4141
+ addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
4142
+ addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
4143
+
4144
+ const start = node.getStart(sourceFile);
4145
+ if (ts.isStringLiteral(node)) {
4146
+ // Preserve quoted property keys ("response": ...) by default so range/key
4147
+ // scanners can still match them. Line scanners opt into blanking key bodies
4148
+ // as well, preventing key text from looking like executable source.
4149
+ const isQuotedPropertyKey =
4150
+ ts.isPropertyAssignment(node.parent) && node.parent.name === node;
4151
+ if (blankPropertyKeys || !isQuotedPropertyKey) {
4152
+ maskRange(start + 1, node.end - 1);
4153
+ } else {
4154
+ for (let index = start + 1; index < node.end - 1; index += 1) {
4155
+ if (
4156
+ source[index] === "\\" &&
4157
+ (source[index + 1] === "\n" || source[index + 1] === "\r")
4158
+ ) {
4159
+ continue;
4160
+ }
4161
+ if (PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS.has(source[index] ?? "")) {
4162
+ chars[index] = " ";
4163
+ }
2574
4164
  }
2575
4165
  }
4166
+ } else if (ts.isRegularExpressionLiteral(node)) {
4167
+ let closeDelimiter = node.end - 1;
4168
+ while (closeDelimiter > start && /[A-Za-z]/.test(source[closeDelimiter] ?? "")) {
4169
+ closeDelimiter -= 1;
4170
+ }
4171
+ maskRange(start + 1, closeDelimiter);
4172
+ } else if (ts.isNoSubstitutionTemplateLiteral(node)) {
4173
+ maskRange(start + 1, node.end - 1);
4174
+ } else if (node.kind === ts.SyntaxKind.TemplateHead) {
4175
+ maskRange(start + 1, node.end - 2);
4176
+ } else if (node.kind === ts.SyntaxKind.TemplateMiddle) {
4177
+ maskRange(start + 1, node.end - 2);
4178
+ } else if (node.kind === ts.SyntaxKind.TemplateTail) {
4179
+ maskRange(start + 1, node.end - 1);
2576
4180
  }
2577
- index = end;
4181
+
4182
+ for (const child of node.getChildren(sourceFile)) visit(child);
4183
+ };
4184
+
4185
+ visit(sourceFile);
4186
+ for (const comment of commentRanges.values()) {
4187
+ const markerLength = 2;
4188
+ const bodyEnd =
4189
+ comment.kind === ts.SyntaxKind.MultiLineCommentTrivia
4190
+ ? comment.end - markerLength
4191
+ : comment.end;
4192
+ maskRange(comment.pos + markerLength, bodyEnd);
2578
4193
  }
2579
4194
  return chars.join("");
2580
4195
  }
2581
4196
 
4197
+ // Escapes every non-printable or formatting character before a submitter-
4198
+ // controlled filename is interpolated into a diagnostic that the CLI prints.
4199
+ // Category-based on purpose: enumerated code-point lists kept missing members
4200
+ // of the same class (C1 controls, U+061C, U+2028/U+2029 were each found
4201
+ // individually in review), so this escapes the whole Unicode categories —
4202
+ // Cc (controls), Cf (formatting, includes all Bidi_Control), Zl/Zp
4203
+ // (line/paragraph separators). Ordinary letters, digits, spaces, and CJK
4204
+ // filenames pass through unchanged.
4205
+ const DIAGNOSTIC_UNSAFE_CHARACTER = /\p{Cc}|\p{Cf}|\p{Zl}|\p{Zp}/u;
4206
+
4207
+ function sanitizeDiagnosticFileName(fileName: string): string {
4208
+ let sanitized = "";
4209
+ for (const character of fileName) {
4210
+ const codePoint = character.codePointAt(0);
4211
+ if (codePoint === undefined) continue;
4212
+ if (DIAGNOSTIC_UNSAFE_CHARACTER.test(character)) {
4213
+ sanitized +=
4214
+ codePoint <= 0xff
4215
+ ? `\\x${codePoint.toString(16).padStart(2, "0")}`
4216
+ : `\\u{${codePoint.toString(16)}}`;
4217
+ } else {
4218
+ sanitized += character;
4219
+ }
4220
+ }
4221
+ return sanitized;
4222
+ }
4223
+
2582
4224
  function skipWhitespaceAndComments(source: string, start: number, end: number): number {
2583
4225
  let index = start;
2584
4226
  while (index < end) {
@@ -2674,6 +4316,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2674
4316
  const operations = Object.entries(provider.operations);
2675
4317
  const missing: string[] = [];
2676
4318
  const vacuous: string[] = [];
4319
+ const evidenceFreeScenario: string[] = [];
2677
4320
  const placeholder: string[] = [];
2678
4321
  const unsupported: string[] = [];
2679
4322
  const generatedStarter: string[] = [];
@@ -2685,8 +4328,13 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2685
4328
  missing.push(operationId);
2686
4329
  continue;
2687
4330
  }
2688
- if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
2689
- vacuous.push(operationId);
4331
+ if (hasCheck && !hasUnsupported) {
4332
+ const vacuousClass = vacuousHealthCaseClass(operation.healthCheck);
4333
+ if (vacuousClass === "empty-assertions") {
4334
+ vacuous.push(operationId);
4335
+ } else if (vacuousClass === "evidence-free-scenario") {
4336
+ evidenceFreeScenario.push(operationId);
4337
+ }
2690
4338
  }
2691
4339
  if (hasUnsupported) {
2692
4340
  const reason = operation.healthCheckUnsupported?.reason ?? "";
@@ -2713,14 +4361,36 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2713
4361
  );
2714
4362
  }
2715
4363
 
2716
- if (vacuous.length > 0) {
4364
+ if (vacuous.length > 0 || evidenceFreeScenario.length > 0) {
4365
+ const remediationParts: string[] = [];
4366
+ if (vacuous.length > 0) {
4367
+ remediationParts.push(
4368
+ `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.`,
4369
+ );
4370
+ }
4371
+ if (evidenceFreeScenario.length > 0) {
4372
+ remediationParts.push(
4373
+ `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.`,
4374
+ );
4375
+ }
4376
+ const message =
4377
+ evidenceFreeScenario.length === 0
4378
+ ? "One or more operations have healthCheck cases with empty assertions."
4379
+ : vacuous.length === 0
4380
+ ? "One or more operations have healthCheck scenarios without response evidence."
4381
+ : "One or more operations have healthCheck cases with empty assertions or evidence-free scenarios.";
2717
4382
  return blocker(
2718
4383
  "health-coverage",
2719
4384
  "health",
2720
- "One or more operations have healthCheck cases with empty assertions.",
2721
- `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.`,
4385
+ message,
4386
+ remediationParts.join(" "),
2722
4387
  CATEGORY_MAX_POINTS.health,
2723
- vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4388
+ [
4389
+ ...vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4390
+ ...evidenceFreeScenario.map(
4391
+ (operationId) => `${operationId}: healthCheck scenario asserts nothing beyond transport`,
4392
+ ),
4393
+ ],
2724
4394
  );
2725
4395
  }
2726
4396
 
@@ -2775,14 +4445,216 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2775
4445
  );
2776
4446
  }
2777
4447
 
2778
- function hasOnlyVacuousHealthCases(
4448
+ type VacuousHealthCaseClass = "empty-assertions" | "evidence-free-scenario";
4449
+
4450
+ /**
4451
+ * Classify an operation whose every health case is vacuous. Returns undefined
4452
+ * when at least one case carries real coverage: an imperative case with a
4453
+ * non-vacuous assertions function, or a declarative case whose scenario reads
4454
+ * the probe response. When all cases are vacuous, the class picks the blocker
4455
+ * wording — "empty-assertions" whenever any vacuous case is imperative (the
4456
+ * pre-scenario behavior, kept byte-identical), "evidence-free-scenario" when
4457
+ * the suite is scenario-only.
4458
+ */
4459
+ function vacuousHealthCaseClass(
2779
4460
  healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
2780
- ): boolean {
4461
+ ): VacuousHealthCaseClass | undefined {
2781
4462
  const cases = healthCheck?.cases;
2782
4463
  if (!Array.isArray(cases) || cases.length === 0) {
4464
+ return "empty-assertions";
4465
+ }
4466
+ let sawImperativeCase = false;
4467
+ for (const healthCase of cases) {
4468
+ if (healthCase?.scenario !== undefined) {
4469
+ if (scenarioCarriesResponseEvidence(healthCase.scenario)) {
4470
+ return undefined;
4471
+ }
4472
+ continue;
4473
+ }
4474
+ sawImperativeCase = true;
4475
+ if (!isVacuousAssertionFunction(healthCase?.assertions)) {
4476
+ return undefined;
4477
+ }
4478
+ }
4479
+ return sawImperativeCase ? "empty-assertions" : "evidence-free-scenario";
4480
+ }
4481
+
4482
+ /**
4483
+ * A declarative case (`scenario`) carries real coverage when at least one
4484
+ * `assert` step reads the probe response beyond the transport envelope. The
4485
+ * SDK's own definitions drive this judgement:
4486
+ *
4487
+ * - The assert step is the scenario's only verdict-bearing construct
4488
+ * (`AssertResult.passed`). A guard can only attribute
4489
+ * degraded/expected_absence and stop the run (an escape hatch, not a health
4490
+ * verdict), and an operation step does not intrinsically fail on a non-2xx
4491
+ * response — that is what the explicit `status_2xx` predicate exists for. So
4492
+ * a scenario without a semantic assert step proves nothing beyond the
4493
+ * operation merely executing, which is the same emptiness the imperative
4494
+ * vacuity check flags.
4495
+ * - Leaves that cannot observe upstream content carry no evidence: predicates
4496
+ * whose operands read nothing produced by a step (pure literals,
4497
+ * attempt/credential metadata, and the `status_code`/`request_id`/`kind`
4498
+ * envelope fields of a step result — this is what makes the legacy transport
4499
+ * pin `status_2xx` on `["status_code"]` non-evidence), predicates comparing
4500
+ * a reference against an identical copy of itself (`equals(x, x)` holds for
4501
+ * any response), and positive-polarity presence/type pins (`exists`,
4502
+ * `type_is`, `not(not_exists(...))`, zero-bound `array_length_gte`) on the
4503
+ * bare `data` root — the declarative spellings of the legacy transport-only
4504
+ * assertion (`status === 200` + `typeof data === "object"`), satisfied by
4505
+ * any well-formed response body. Polarity matters: `not(exists(data))`
4506
+ * ("this probe must return no body") is falsified by ordinary responses, so
4507
+ * the carve-out only applies where the pin appears un-negated.
4508
+ * - Everything else counts: paths under `data`, extract-step results,
4509
+ * `duration_ms` (parity with imperative `ctx.durationMs` reads),
4510
+ * `status_2xx` applied to a body path (envelope-style upstreams answer HTTP
4511
+ * 200 and carry the real code in the body), and quantifiers, which iterate
4512
+ * response rows by construction.
4513
+ *
4514
+ * Fail-open contract (same as isVacuousAssertionFunction): the provider module
4515
+ * already validated this scenario against HealthScenarioSchema in its own SDK
4516
+ * at import time, so any shape this walk does not recognize (unknown step
4517
+ * kind, expression kind, operator, or reference namespace) indicates a newer
4518
+ * grammar, not an empty probe — treat it as evidence rather than flag it.
4519
+ * The inverse direction is accepted and documented: a determined author can
4520
+ * still fabricate a read that never fails (a decorative deep path inside a
4521
+ * disjunction, a degenerate bound). That effort is comparable to writing the
4522
+ * real one-line assertion — the same accepted limitation as the imperative
4523
+ * check's decorative-ctx-read — and the live `--smoke` probe remains the
4524
+ * runtime defense.
4525
+ */
4526
+ export function scenarioCarriesResponseEvidence(scenario: unknown): boolean {
4527
+ if (!isRecord(scenario) || !Array.isArray(scenario.steps)) {
4528
+ return true;
4529
+ }
4530
+ let sawUnknownStepKind = false;
4531
+ for (const step of scenario.steps) {
4532
+ if (!isRecord(step)) {
4533
+ sawUnknownStepKind = true;
4534
+ continue;
4535
+ }
4536
+ if (step.kind === "assert") {
4537
+ if (expressionReadsResponse(step.expression)) {
4538
+ return true;
4539
+ }
4540
+ continue;
4541
+ }
4542
+ if (step.kind !== "operation" && step.kind !== "extract" && step.kind !== "guard") {
4543
+ sawUnknownStepKind = true;
4544
+ }
4545
+ }
4546
+ return sawUnknownStepKind;
4547
+ }
4548
+
4549
+ /**
4550
+ * Predicate operators of the current scenario grammar. An operator outside
4551
+ * this set indicates a newer grammar and fails open (counts as evidence),
4552
+ * because a future operator may source the response through fields this walk
4553
+ * does not know about.
4554
+ */
4555
+ const KNOWN_SCENARIO_PREDICATE_OPERATORS: ReadonlySet<string> = new Set([
4556
+ "exists",
4557
+ "not_exists",
4558
+ "non_empty",
4559
+ "is_true",
4560
+ "equals",
4561
+ "not_equals",
4562
+ "contains",
4563
+ "matches",
4564
+ "number_gt",
4565
+ "number_gte",
4566
+ "number_lt",
4567
+ "number_lte",
4568
+ "array_length_eq",
4569
+ "array_length_gte",
4570
+ "array_length_lte",
4571
+ "status_2xx",
4572
+ "type_is",
4573
+ ]);
4574
+
4575
+ function expressionReadsResponse(expression: unknown, negationDepth = 0): boolean {
4576
+ if (!isRecord(expression)) {
4577
+ return true;
4578
+ }
4579
+ if (expression.kind === "all" || expression.kind === "any") {
4580
+ return Array.isArray(expression.clauses)
4581
+ ? expression.clauses.some((clause) => expressionReadsResponse(clause, negationDepth))
4582
+ : true;
4583
+ }
4584
+ if (expression.kind === "not") {
4585
+ return expressionReadsResponse(expression.clause, negationDepth + 1);
4586
+ }
4587
+ if (expression.kind === "quantifier") {
4588
+ return true;
4589
+ }
4590
+ if (expression.kind !== "predicate" || typeof expression.operator !== "string") {
4591
+ return true;
4592
+ }
4593
+ const operator = expression.operator;
4594
+ if (!KNOWN_SCENARIO_PREDICATE_OPERATORS.has(operator)) {
4595
+ return true;
4596
+ }
4597
+ const actualReads = operandReadsStepOutput(expression.actual);
4598
+ const expectedReads = operandReadsStepOutput(expression.expected);
4599
+ if (!actualReads && !expectedReads) {
4600
+ return false;
4601
+ }
4602
+ // A predicate comparing a reference against an identical copy of itself
4603
+ // (equals(x, x) and friends) holds or fails identically for every response,
4604
+ // so the reads discriminate nothing.
4605
+ if (
4606
+ actualReads &&
4607
+ expectedReads &&
4608
+ JSON.stringify(expression.actual) === JSON.stringify(expression.expected)
4609
+ ) {
4610
+ return false;
4611
+ }
4612
+ // Presence/type pins on the whole `data` root are satisfied by any
4613
+ // well-formed JSON body — but only in positive polarity. Under an odd
4614
+ // number of `not` wrappers the same pin means "this probe must return no
4615
+ // body / a different shape", which ordinary responses falsify, so it is a
4616
+ // real (if unusual) constraint and stays evidence.
4617
+ const positivePolarity = negationDepth % 2 === 0;
4618
+ const bareEnvelopePin = positivePolarity
4619
+ ? operator === "exists" ||
4620
+ operator === "type_is" ||
4621
+ (operator === "array_length_gte" && expression.expected === 0)
4622
+ : operator === "not_exists";
4623
+ if (bareEnvelopePin && !expectedReads && isBareResponseEnvelope(expression.actual)) {
4624
+ return false;
4625
+ }
4626
+ return true;
4627
+ }
4628
+
4629
+ function operandReadsStepOutput(operand: unknown): boolean {
4630
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4631
+ return false;
4632
+ }
4633
+ const reference = operand.ref;
4634
+ if (reference.namespace === "attempt" || reference.namespace === "credentials") {
4635
+ return false;
4636
+ }
4637
+ if (reference.namespace !== "steps") {
4638
+ return true;
4639
+ }
4640
+ if (!Array.isArray(reference.path) || reference.path.length === 0) {
2783
4641
  return true;
2784
4642
  }
2785
- return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
4643
+ const head = reference.path[0];
4644
+ return head !== "status_code" && head !== "request_id" && head !== "kind";
4645
+ }
4646
+
4647
+ function isBareResponseEnvelope(operand: unknown): boolean {
4648
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4649
+ return false;
4650
+ }
4651
+ const reference = operand.ref;
4652
+ return (
4653
+ reference.namespace === "steps" &&
4654
+ Array.isArray(reference.path) &&
4655
+ reference.path.length === 1 &&
4656
+ reference.path[0] === "data"
4657
+ );
2786
4658
  }
2787
4659
 
2788
4660
  function isVacuousAssertionFunction(assertions: unknown): boolean {
@@ -3464,25 +5336,6 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
3464
5336
  };
3465
5337
  }
3466
5338
 
3467
- if (authMode === "none") {
3468
- const securedOperations = Object.entries(provider.operations).filter(
3469
- ([, operation]) => operation.annotations?.openWorld === false,
3470
- );
3471
- if (securedOperations.length > 0) {
3472
- return {
3473
- id: "auth-safety",
3474
- category: "auth",
3475
- level: "warn",
3476
- status: "warn",
3477
- points: 7,
3478
- maxPoints: CATEGORY_MAX_POINTS.auth,
3479
- message: "Provider is no-auth but at least one operation is not marked openWorld.",
3480
- 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(", ")}.`,
3481
- evidence: securedOperations.map(([operationId]) => operationId),
3482
- };
3483
- }
3484
- }
3485
-
3486
5339
  return pass(
3487
5340
  "auth-safety",
3488
5341
  "auth",
@@ -3832,9 +5685,7 @@ type LineStringLiteral = {
3832
5685
  // Stable identity for the container a literal sits in ("top" when the
3833
5686
  // literal is not inside any bracket on the line).
3834
5687
  function literalContainerKey(literal: LineStringLiteral): string {
3835
- return literal.container
3836
- ? `${literal.container.bracket}${literal.container.index}`
3837
- : "top";
5688
+ return literal.container ? `${literal.container.bracket}${literal.container.index}` : "top";
3838
5689
  }
3839
5690
 
3840
5691
  // Single-pass line tokenizer: extracts every string literal with its span and
@@ -3975,8 +5826,9 @@ function shannonEntropy(value: string): number {
3975
5826
 
3976
5827
  function guessSecretName(line: string): string {
3977
5828
  const match =
3978
- /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
3979
- /["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
5829
+ /\b[A-Za-z_$][\w$]*(?:\s*\.\s*([A-Za-z_$][\w$]*))+\s*=/.exec(line) ??
5830
+ /["']?([A-Za-z_$][\w$-]*)["']?\s*:\s*["'`]/.exec(line) ??
5831
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line);
3980
5832
  const raw = match?.[1] ?? "SECRET";
3981
5833
  return raw
3982
5834
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
@@ -3991,6 +5843,35 @@ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/
3991
5843
  // the three stay coherent.
3992
5844
  const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
3993
5845
 
5846
+ // A secret-ish name is only supporting evidence. Shorter values must also look
5847
+ // credential-like, and known config tokens/numeric values are never candidates.
5848
+ const SECRET_NAMED_ENV_VALUE_MIN_LENGTH = 4;
5849
+ const SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH = 8;
5850
+ const NON_SECRET_ENV_VALUE_TOKENS = new Set([
5851
+ "true",
5852
+ "false",
5853
+ "yes",
5854
+ "no",
5855
+ "on",
5856
+ "off",
5857
+ "none",
5858
+ "null",
5859
+ "nil",
5860
+ "auto",
5861
+ "default",
5862
+ "local",
5863
+ "debug",
5864
+ "info",
5865
+ "warn",
5866
+ "error",
5867
+ "always",
5868
+ "never",
5869
+ "enabled",
5870
+ "disabled",
5871
+ "0",
5872
+ "1",
5873
+ ]);
5874
+
3994
5875
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
3995
5876
  ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
3996
5877
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
@@ -4002,12 +5883,151 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
4002
5883
  ],
4003
5884
  ];
4004
5885
 
4005
- async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
5886
+ type SafeLoadProviderResult =
5887
+ | { ok: true; provider: ProviderDefinition | undefined }
5888
+ | { ok: false; error: unknown };
5889
+
5890
+ async function safeLoadProvider(providerRoot: string): Promise<SafeLoadProviderResult> {
5891
+ try {
5892
+ return { ok: true, provider: await loadProvider(providerRoot) };
5893
+ } catch (error) {
5894
+ return { ok: false, error };
5895
+ }
5896
+ }
5897
+
5898
+ const UNRENDERABLE_LOAD_ERROR = "<thrown value could not be rendered>";
5899
+
5900
+ function formatLoadErrorEvidence(error: unknown, providerRoot: string): string[] {
5901
+ const evidence: string[] = [];
5902
+ const seen = new Set<object>();
5903
+ let current: unknown = error;
5904
+ let frame = 0;
5905
+ while (frame < MAX_LOAD_ERROR_EVIDENCE) {
5906
+ if (typeof current === "object" || typeof current === "function") {
5907
+ if (current !== null && seen.has(current)) {
5908
+ break;
5909
+ }
5910
+ if (current !== null) seen.add(current);
5911
+ }
5912
+ evidence.push(
5913
+ `${frame === 0 ? "Load error" : "Cause"}: ${sanitizeLoadErrorText(
5914
+ formatThrownValue(current),
5915
+ providerRoot,
5916
+ )}`,
5917
+ );
5918
+ frame += 1;
5919
+ const cause = readThrownValueCause(current);
5920
+ if (!cause.found || cause.value === undefined || cause.value === null) {
5921
+ break;
5922
+ }
5923
+ current = cause.value;
5924
+ }
5925
+ return evidence;
5926
+ }
5927
+
5928
+ function formatThrownValue(value: unknown): string {
4006
5929
  try {
4007
- return await loadProvider(providerRoot);
5930
+ if (value instanceof Error) {
5931
+ return String(value.message);
5932
+ }
5933
+ if (
5934
+ value !== null &&
5935
+ (typeof value === "object" || typeof value === "function") &&
5936
+ "message" in value
5937
+ ) {
5938
+ return String((value as { message?: unknown }).message);
5939
+ }
5940
+ return `<non-Error value thrown: ${String(value)}>`;
4008
5941
  } catch {
4009
- return undefined;
5942
+ return UNRENDERABLE_LOAD_ERROR;
5943
+ }
5944
+ }
5945
+
5946
+ function readThrownValueCause(value: unknown): { found: boolean; value?: unknown } {
5947
+ try {
5948
+ if (
5949
+ value === null ||
5950
+ (typeof value !== "object" && typeof value !== "function") ||
5951
+ !("cause" in value)
5952
+ ) {
5953
+ return { found: false };
5954
+ }
5955
+ return { found: true, value: (value as { cause?: unknown }).cause };
5956
+ } catch {
5957
+ return { found: false };
5958
+ }
5959
+ }
5960
+
5961
+ function isPlausibleSecretNamedEnvValue(value: string): boolean {
5962
+ const normalized = value.trim();
5963
+ if (normalized.length < SECRET_NAMED_ENV_VALUE_MIN_LENGTH) return false;
5964
+ if (NON_SECRET_ENV_VALUE_TOKENS.has(normalized.toLowerCase())) return false;
5965
+ if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return false;
5966
+ if (normalized.length >= SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH) return true;
5967
+
5968
+ const hasLetter = /[A-Za-z]/.test(normalized);
5969
+ const hasDigitOrSymbol = /[^A-Za-z\s]/.test(normalized);
5970
+ return hasLetter && hasDigitOrSymbol;
5971
+ }
5972
+
5973
+ function replaceEnvValue(
5974
+ input: string,
5975
+ value: string,
5976
+ requireIdentifierBoundaries: boolean,
5977
+ ): string {
5978
+ if (!requireIdentifierBoundaries) return input.replaceAll(value, "[REDACTED]");
5979
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5980
+ return input.replace(
5981
+ new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, "g"),
5982
+ "[REDACTED]",
5983
+ );
5984
+ }
5985
+
5986
+ function sanitizeLoadErrorText(value: string, providerRoot: string): string {
5987
+ let output = value.replace(
5988
+ /(^|[^A-Za-z0-9_.])((?:\/[^\s"'`]+)+)/g,
5989
+ (_match, prefix: string, rawPath: string) => {
5990
+ const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
5991
+ const relativePath = relative(providerRoot, resolve(pathCandidate));
5992
+ const insideProvider =
5993
+ relativePath === "" ||
5994
+ (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
5995
+ return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
5996
+ },
5997
+ );
5998
+ output = output.replace(/[A-Za-z]:[\\/][^\s"'`]+/g, "[REDACTED_PATH]");
5999
+ output = output.replace(
6000
+ /((?:password|token|secret|api[_-]?key|credential|authorization)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|["'`]?[^\s,;"'`]+)/gi,
6001
+ "$1[REDACTED]",
6002
+ );
6003
+ const envValues: Array<{ value: string; requireIdentifierBoundaries: boolean }> = [];
6004
+ for (const [name, item] of Object.entries(process.env)) {
6005
+ if (typeof item !== "string") continue;
6006
+ const secretNamed = SECRETISH_IDENTIFIER_PATTERN.test(name);
6007
+ let shouldRedact = secretNamed;
6008
+ if (secretNamed) {
6009
+ shouldRedact = isPlausibleSecretNamedEnvValue(item);
6010
+ } else if (item.length >= ENTROPY_CANDIDATE_MIN_LENGTH && shouldConsiderEntropyValue(item)) {
6011
+ const charset = classifyEntropyCharset(item);
6012
+ const threshold = charset === "hex" ? 3.0 : 4.5;
6013
+ shouldRedact = charset !== undefined && shannonEntropy(item) >= threshold;
6014
+ }
6015
+ if (shouldRedact) {
6016
+ envValues.push({
6017
+ value: item,
6018
+ requireIdentifierBoundaries:
6019
+ secretNamed && item.trim().length < ENTROPY_CANDIDATE_MIN_LENGTH,
6020
+ });
6021
+ }
6022
+ }
6023
+ envValues.sort((a, b) => b.value.length - a.value.length);
6024
+ for (const envValue of envValues) {
6025
+ output = replaceEnvValue(output, envValue.value, envValue.requireIdentifierBoundaries);
4010
6026
  }
6027
+ output = redact(output).replace(/\s+/g, " ");
6028
+ return output.length > MAX_LOAD_ERROR_MESSAGE_LENGTH
6029
+ ? `${output.slice(0, MAX_LOAD_ERROR_MESSAGE_LENGTH - 1)}…`
6030
+ : output;
4011
6031
  }
4012
6032
 
4013
6033
  async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {