pi-lens 4.2.1 → 4.4.0

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 (671) hide show
  1. package/CHANGELOG.md +1562 -0
  2. package/README.md +157 -24
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -2
  4. package/dist/chunk-22AVIGXS.js +560 -0
  5. package/dist/chunk-27BBHSZC.js +106 -0
  6. package/dist/chunk-2BEMIWEW.js +51 -0
  7. package/dist/chunk-2EECBNC5.js +89 -0
  8. package/dist/chunk-34U7PLGE.js +23 -0
  9. package/dist/chunk-3I5QFT3N.js +181 -0
  10. package/dist/chunk-3MED75HT.js +37 -0
  11. package/dist/chunk-47D2BO4S.js +87 -0
  12. package/dist/chunk-4RERDEXR.js +348 -0
  13. package/dist/chunk-4W2PLC4J.js +27 -0
  14. package/dist/chunk-52H5B5TC.js +202 -0
  15. package/dist/chunk-54B42YRX.js +27 -0
  16. package/dist/chunk-5RXBNSW2.js +266 -0
  17. package/dist/chunk-5THXD6X5.js +201 -0
  18. package/dist/chunk-5W3R6LFT.js +24 -0
  19. package/dist/chunk-5ZEJHV35.js +341 -0
  20. package/dist/chunk-6P62QVEA.js +118 -0
  21. package/dist/chunk-6QIUX4EP.js +459 -0
  22. package/dist/chunk-6VCD4VKZ.js +78 -0
  23. package/dist/chunk-7F72PXY2.js +326 -0
  24. package/dist/chunk-7FBF7NPR.js +998 -0
  25. package/dist/chunk-7U2IDNXN.js +37 -0
  26. package/dist/chunk-ABBOA7UD.js +157 -0
  27. package/dist/chunk-AHDW3GPE.js +50 -0
  28. package/dist/chunk-AJ3QXTRQ.js +756 -0
  29. package/dist/chunk-AJVLWNFN.js +1610 -0
  30. package/dist/chunk-AJXODZY6.js +112 -0
  31. package/dist/chunk-ARFCXG7S.js +84 -0
  32. package/dist/chunk-AS44CFQ4.js +708 -0
  33. package/dist/chunk-AWE5TMPO.js +38 -0
  34. package/dist/chunk-B3I2UUT4.js +4692 -0
  35. package/dist/chunk-BRJKOBZP.js +331 -0
  36. package/dist/chunk-BW5KFIQC.js +59 -0
  37. package/dist/chunk-C2AUYXQH.js +149 -0
  38. package/dist/chunk-CGGAIP7J.js +29 -0
  39. package/dist/chunk-D6QS3U6M.js +30 -0
  40. package/dist/chunk-DFVMIKMQ.js +349 -0
  41. package/dist/chunk-DIULIWKU.js +169 -0
  42. package/dist/chunk-DQ2NZ7C4.js +2317 -0
  43. package/dist/chunk-E2WFBRHM.js +213 -0
  44. package/dist/chunk-E4MAEDPM.js +50 -0
  45. package/dist/chunk-EOKRQ3SA.js +30 -0
  46. package/dist/chunk-F3AU2TMM.js +2626 -0
  47. package/dist/chunk-F3P7VQ7H.js +170 -0
  48. package/dist/chunk-FC4RP4I2.js +12 -0
  49. package/dist/chunk-FJNKANOS.js +32 -0
  50. package/dist/chunk-GUOGVTCQ.js +146 -0
  51. package/dist/chunk-GVXA6CCE.js +18 -0
  52. package/dist/chunk-HHYAELLJ.js +2334 -0
  53. package/dist/chunk-HMFXILPV.js +83 -0
  54. package/dist/chunk-HVUZXSWK.js +421 -0
  55. package/dist/chunk-HWBZIGMJ.js +62 -0
  56. package/dist/chunk-I3R7GKDR.js +17 -0
  57. package/dist/chunk-I7SPGOWI.js +55 -0
  58. package/dist/chunk-IIXOAGHR.js +38 -0
  59. package/dist/chunk-IVP5KAB7.js +2250 -0
  60. package/dist/chunk-IXEBJSUE.js +22 -0
  61. package/dist/chunk-JOLITGCC.js +27 -0
  62. package/dist/chunk-K6VYLB2U.js +145 -0
  63. package/dist/chunk-LJDODBBZ.js +78 -0
  64. package/dist/chunk-LNAKEO63.js +78 -0
  65. package/dist/chunk-LSMJ6KBE.js +4139 -0
  66. package/dist/chunk-LW6XKDZQ.js +5565 -0
  67. package/dist/chunk-N3YQJI6O.js +899 -0
  68. package/dist/chunk-NABLC7W3.js +148 -0
  69. package/dist/chunk-NKOUFPQF.js +659 -0
  70. package/dist/chunk-NL5GLMFI.js +211 -0
  71. package/dist/chunk-NMABWBZN.js +327 -0
  72. package/dist/chunk-NTHH2J74.js +27 -0
  73. package/dist/chunk-NTUJEGDZ.js +59 -0
  74. package/dist/chunk-NXL4FFQQ.js +47 -0
  75. package/dist/chunk-NXPS2L6U.js +466 -0
  76. package/dist/chunk-O5WILGMI.js +42 -0
  77. package/dist/chunk-O6TQT6RI.js +48 -0
  78. package/dist/chunk-OAXNDZJ7.js +323 -0
  79. package/dist/chunk-ODEEOXPH.js +44 -0
  80. package/dist/chunk-OQGFCZJ4.js +4533 -0
  81. package/dist/chunk-ORTMY4Q4.js +282 -0
  82. package/dist/chunk-OS32RNR2.js +442 -0
  83. package/dist/chunk-PAK6IDME.js +189 -0
  84. package/dist/chunk-PWFHBTSF.js +406 -0
  85. package/dist/chunk-Q5U6FMDI.js +2352 -0
  86. package/dist/chunk-QOZXJ4NA.js +3364 -0
  87. package/dist/chunk-QYF5S6P6.js +45 -0
  88. package/dist/chunk-RIISMHKN.js +36 -0
  89. package/dist/chunk-RPD4ZSB7.js +25 -0
  90. package/dist/chunk-RPD5K5NC.js +79 -0
  91. package/dist/chunk-RUOG5D2C.js +262 -0
  92. package/dist/chunk-T4ERT73Y.js +599 -0
  93. package/dist/chunk-T6D4Q4AQ.js +54 -0
  94. package/dist/chunk-TGPXLY2J.js +391 -0
  95. package/dist/chunk-TLGFBM3F.js +12116 -0
  96. package/dist/chunk-TO6WL3GM.js +88 -0
  97. package/dist/chunk-TT22N74J.js +517 -0
  98. package/dist/chunk-TVN5N6GY.js +21216 -0
  99. package/dist/chunk-U3VGR4VU.js +308 -0
  100. package/dist/chunk-UK3CMEAL.js +1250 -0
  101. package/dist/chunk-UOPVJQ4N.js +103 -0
  102. package/dist/chunk-UXVNL3D7.js +23 -0
  103. package/dist/chunk-UY752QSN.js +45 -0
  104. package/dist/chunk-V5PXFSZK.js +188 -0
  105. package/dist/chunk-V7R425XG.js +398 -0
  106. package/dist/chunk-VCSRSYLF.js +81 -0
  107. package/dist/chunk-VD4JQCQC.js +162 -0
  108. package/dist/chunk-VDBPVIKD.js +62 -0
  109. package/dist/chunk-VHQLPIRJ.js +114 -0
  110. package/dist/chunk-VN2AXLEX.js +5414 -0
  111. package/dist/chunk-VNMT7C64.js +478 -0
  112. package/dist/chunk-WONERJTP.js +309 -0
  113. package/dist/chunk-XECLWQZN.js +85 -0
  114. package/dist/chunk-XIMYZ3JA.js +27 -0
  115. package/dist/chunk-XK4CIILI.js +144 -0
  116. package/dist/chunk-Y4T27COV.js +562 -0
  117. package/dist/chunk-YGVYKNSN.js +1860 -0
  118. package/dist/chunk-YLF3PPT5.js +308 -0
  119. package/dist/chunk-YSNFMJKN.js +14 -0
  120. package/dist/chunk-Z3CCVDUB.js +76 -0
  121. package/dist/chunk-Z54ZSOBZ.js +61 -0
  122. package/dist/chunk-ZAWW3AF6.js +97 -0
  123. package/dist/chunk-ZMOL6RQC.js +167 -0
  124. package/dist/index.js +19039 -8956
  125. package/dist/mcp/analyze-cli.js +186 -228
  126. package/dist/mcp/cli.js +137 -98
  127. package/dist/mcp/server.js +22592 -1564
  128. package/dist/mcp/worker.js +97 -38
  129. package/dist/probes/installer.js +135 -0
  130. package/dist/workers/project-snapshot-persist-worker.js +22 -0
  131. package/dist/workers/review-graph-persist-worker.js +15 -0
  132. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  133. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  134. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  135. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  136. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  137. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  138. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  139. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  140. package/docs/adr/0009-reported-path-attribution.md +114 -0
  141. package/docs/adr/README.md +26 -0
  142. package/docs/agent-guide.md +13 -4
  143. package/docs/agent-tools.md +60 -1
  144. package/docs/ast-grep_rules_catalog.md +6 -4
  145. package/docs/configuration.md +75 -2
  146. package/docs/engineering-principles.md +202 -0
  147. package/docs/environment-variables.md +113 -6
  148. package/docs/features.md +10 -6
  149. package/docs/globalconfig.md +7 -1
  150. package/docs/io-bridge-v2-migration.md +94 -0
  151. package/docs/language-coverage.md +17 -0
  152. package/docs/lsp-capability-matrix.md +131 -32
  153. package/docs/lsp-idle-eviction.md +124 -0
  154. package/docs/mcp.md +21 -5
  155. package/docs/merge-queue-rollout.md +77 -0
  156. package/docs/pi-lens-fixer.md +161 -39
  157. package/docs/pi-lens-investigator.md +77 -20
  158. package/docs/pi-lens-merge-policy.md +694 -0
  159. package/docs/pi-lens-monitor.md +73 -7
  160. package/docs/pi-lens-retro.md +6 -4
  161. package/docs/pi-lens-reviewer.md +190 -34
  162. package/docs/pi-lens-subagent.md +247 -37
  163. package/docs/pi-lens-warden.md +18 -7
  164. package/docs/public-api-stability.md +56 -0
  165. package/docs/release-qa-baseline.md +86 -17
  166. package/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
  167. package/docs/servercapabilities.md +66 -52
  168. package/docs/settings.md +13 -3
  169. package/docs/subagent-compat.md +1 -1
  170. package/docs/usage.md +5 -1
  171. package/grammars/tree-sitter-bash.wasm +0 -0
  172. package/grammars/tree-sitter-bash.wasm.json +3 -3
  173. package/package.json +23 -9
  174. package/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
  175. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  176. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  177. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  178. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  179. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  180. package/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
  181. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  182. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  183. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  184. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  185. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  186. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  187. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  188. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  189. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  190. package/scripts/analyze-pi-lens-logs.mjs +1275 -62
  191. package/scripts/download-grammars.js +5 -0
  192. package/scripts/grammars.lock.json +6 -1
  193. package/scripts/install-selftest.mjs +38 -35
  194. package/scripts/lib/host-provided-deps.mjs +13 -4
  195. package/scripts/lib/web-tree-sitter-dir.mjs +141 -0
  196. package/dist/clients/actionable-warnings-logger.js +0 -35
  197. package/dist/clients/actionable-warnings.js +0 -1594
  198. package/dist/clients/advisory-provenance.js +0 -457
  199. package/dist/clients/agent-behavior-client.js +0 -147
  200. package/dist/clients/agent-nudge.js +0 -325
  201. package/dist/clients/analysed-root.js +0 -1
  202. package/dist/clients/ast-grep-client.js +0 -731
  203. package/dist/clients/ast-grep-rule-manager.js +0 -146
  204. package/dist/clients/ast-grep-tool-logger.js +0 -118
  205. package/dist/clients/ast-grep-types.js +0 -10
  206. package/dist/clients/ast-grep-yaml-synth.js +0 -154
  207. package/dist/clients/atomic-write-staging.js +0 -75
  208. package/dist/clients/atomic-write.js +0 -189
  209. package/dist/clients/availability-probe-flight.js +0 -12
  210. package/dist/clients/bash-file-access.js +0 -1056
  211. package/dist/clients/biome-client.js +0 -357
  212. package/dist/clients/blocker-freshness.js +0 -837
  213. package/dist/clients/blocker-past-eof.js +0 -129
  214. package/dist/clients/bootstrap.js +0 -569
  215. package/dist/clients/bounded-cache.js +0 -190
  216. package/dist/clients/bounded-pid-file-lock.js +0 -202
  217. package/dist/clients/bounded-telemetry.js +0 -295
  218. package/dist/clients/build-identity.js +0 -95
  219. package/dist/clients/bundled-resource-health.js +0 -113
  220. package/dist/clients/bus-events-logger.js +0 -105
  221. package/dist/clients/bus-publish.js +0 -171
  222. package/dist/clients/cache/rule-cache.js +0 -190
  223. package/dist/clients/cache-manager.js +0 -465
  224. package/dist/clients/cache-observability.js +0 -1313
  225. package/dist/clients/call-graph.js +0 -852
  226. package/dist/clients/cargo-manifest.js +0 -422
  227. package/dist/clients/cascade-budget.js +0 -196
  228. package/dist/clients/cascade-format.js +0 -315
  229. package/dist/clients/cascade-logger.js +0 -31
  230. package/dist/clients/cascade-types.js +0 -9
  231. package/dist/clients/child-unref.js +0 -115
  232. package/dist/clients/code-quality-warnings.js +0 -286
  233. package/dist/clients/codebase-model.js +0 -173
  234. package/dist/clients/collateral-test-role.js +0 -40
  235. package/dist/clients/complexity-client.js +0 -462
  236. package/dist/clients/config-core/deny.js +0 -221
  237. package/dist/clients/config-core/index.js +0 -50
  238. package/dist/clients/config-core/merge.js +0 -357
  239. package/dist/clients/config-core/normalize.js +0 -340
  240. package/dist/clients/config-core/process-spec.js +0 -248
  241. package/dist/clients/config-core/provenance.js +0 -164
  242. package/dist/clients/config-core/records.js +0 -218
  243. package/dist/clients/config-core/resolve.js +0 -125
  244. package/dist/clients/config-core/safe-object.js +0 -78
  245. package/dist/clients/config-core/schema.js +0 -165
  246. package/dist/clients/config-diagnostic-codes.js +0 -283
  247. package/dist/clients/config-locations.js +0 -160
  248. package/dist/clients/config-resolve.js +0 -792
  249. package/dist/clients/config-schema.js +0 -251
  250. package/dist/clients/config-warn.js +0 -407
  251. package/dist/clients/console-guard-install.js +0 -26
  252. package/dist/clients/cooperative-budget.js +0 -42
  253. package/dist/clients/dead-code-client.js +0 -510
  254. package/dist/clients/dead-code-logger.js +0 -30
  255. package/dist/clients/deadline-utils.js +0 -280
  256. package/dist/clients/debug-handles.js +0 -211
  257. package/dist/clients/debug-heap.js +0 -174
  258. package/dist/clients/deferred-lsp-work.js +0 -106
  259. package/dist/clients/degradation-ledger.js +0 -396
  260. package/dist/clients/demoted-finding-render.js +0 -169
  261. package/dist/clients/dependency-checker.js +0 -862
  262. package/dist/clients/deps/ast-grep-napi.js +0 -22
  263. package/dist/clients/deps/js-yaml.js +0 -9
  264. package/dist/clients/deps/minimatch.js +0 -4
  265. package/dist/clients/deps/pi-tui.js +0 -14
  266. package/dist/clients/deps/typebox.js +0 -14
  267. package/dist/clients/deps/vscode-jsonrpc.js +0 -1
  268. package/dist/clients/deps/web-tree-sitter.js +0 -24
  269. package/dist/clients/diagnostic-dispositions.js +0 -594
  270. package/dist/clients/diagnostic-line-freshness.js +0 -305
  271. package/dist/clients/diagnostic-logger.js +0 -73
  272. package/dist/clients/diagnostic-tracker.js +0 -105
  273. package/dist/clients/diagnostics-publish.js +0 -230
  274. package/dist/clients/dispatch/ast-grep-catalog.js +0 -65
  275. package/dist/clients/dispatch/auxiliary-lsp.js +0 -343
  276. package/dist/clients/dispatch/collect-later-tier.js +0 -35
  277. package/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
  278. package/dist/clients/dispatch/dispatcher.js +0 -1269
  279. package/dist/clients/dispatch/fact-provider-types.js +0 -1
  280. package/dist/clients/dispatch/fact-rule-runner.js +0 -17
  281. package/dist/clients/dispatch/fact-runner.js +0 -30
  282. package/dist/clients/dispatch/fact-scheduler.js +0 -68
  283. package/dist/clients/dispatch/fact-store.js +0 -377
  284. package/dist/clients/dispatch/facts/comment-facts.js +0 -22
  285. package/dist/clients/dispatch/facts/file-content.js +0 -19
  286. package/dist/clients/dispatch/facts/function-facts.js +0 -348
  287. package/dist/clients/dispatch/facts/import-facts.js +0 -204
  288. package/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
  289. package/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
  290. package/dist/clients/dispatch/finding-policy.js +0 -222
  291. package/dist/clients/dispatch/indent-detect.js +0 -411
  292. package/dist/clients/dispatch/inline-suppressions.js +0 -70
  293. package/dist/clients/dispatch/integration.js +0 -2377
  294. package/dist/clients/dispatch/lazy.js +0 -11
  295. package/dist/clients/dispatch/pending-runner-findings.js +0 -87
  296. package/dist/clients/dispatch/plan.js +0 -278
  297. package/dist/clients/dispatch/priorities.js +0 -20
  298. package/dist/clients/dispatch/rule-id-normalize.js +0 -50
  299. package/dist/clients/dispatch/rule-ignores.js +0 -62
  300. package/dist/clients/dispatch/rule-policy.js +0 -135
  301. package/dist/clients/dispatch/rules/async-noise.js +0 -40
  302. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
  303. package/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
  304. package/dist/clients/dispatch/rules/error-obscuring.js +0 -32
  305. package/dist/clients/dispatch/rules/error-swallowing.js +0 -31
  306. package/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
  307. package/dist/clients/dispatch/rules/high-complexity.js +0 -72
  308. package/dist/clients/dispatch/rules/high-fan-out.js +0 -74
  309. package/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
  310. package/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
  311. package/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
  312. package/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
  313. package/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
  314. package/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
  315. package/dist/clients/dispatch/runner-context.js +0 -52
  316. package/dist/clients/dispatch/runners/actionlint.js +0 -113
  317. package/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
  318. package/dist/clients/dispatch/runners/biome-check.js +0 -283
  319. package/dist/clients/dispatch/runners/cpp-check.js +0 -236
  320. package/dist/clients/dispatch/runners/credo.js +0 -80
  321. package/dist/clients/dispatch/runners/cue-vet.js +0 -398
  322. package/dist/clients/dispatch/runners/dart-analyze.js +0 -202
  323. package/dist/clients/dispatch/runners/detekt.js +0 -169
  324. package/dist/clients/dispatch/runners/dotnet-build.js +0 -159
  325. package/dist/clients/dispatch/runners/elixir-check.js +0 -210
  326. package/dist/clients/dispatch/runners/eslint.js +0 -154
  327. package/dist/clients/dispatch/runners/fact-rules.js +0 -29
  328. package/dist/clients/dispatch/runners/fish-indent.js +0 -69
  329. package/dist/clients/dispatch/runners/gleam-check.js +0 -87
  330. package/dist/clients/dispatch/runners/go-vet.js +0 -90
  331. package/dist/clients/dispatch/runners/golangci-lint.js +0 -120
  332. package/dist/clients/dispatch/runners/hadolint.js +0 -79
  333. package/dist/clients/dispatch/runners/helm-lint.js +0 -171
  334. package/dist/clients/dispatch/runners/helm-render.js +0 -940
  335. package/dist/clients/dispatch/runners/htmlhint.js +0 -108
  336. package/dist/clients/dispatch/runners/index.js +0 -108
  337. package/dist/clients/dispatch/runners/javac.js +0 -89
  338. package/dist/clients/dispatch/runners/ktlint.js +0 -136
  339. package/dist/clients/dispatch/runners/lsp.js +0 -378
  340. package/dist/clients/dispatch/runners/markdownlint.js +0 -160
  341. package/dist/clients/dispatch/runners/mypy.js +0 -101
  342. package/dist/clients/dispatch/runners/oxlint.js +0 -398
  343. package/dist/clients/dispatch/runners/php-lint.js +0 -62
  344. package/dist/clients/dispatch/runners/phpstan.js +0 -120
  345. package/dist/clients/dispatch/runners/prisma-validate.js +0 -62
  346. package/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
  347. package/dist/clients/dispatch/runners/pyright.js +0 -169
  348. package/dist/clients/dispatch/runners/rubocop.js +0 -90
  349. package/dist/clients/dispatch/runners/ruff.js +0 -89
  350. package/dist/clients/dispatch/runners/rust-clippy.js +0 -251
  351. package/dist/clients/dispatch/runners/shellcheck.js +0 -169
  352. package/dist/clients/dispatch/runners/shfmt.js +0 -102
  353. package/dist/clients/dispatch/runners/spellcheck.js +0 -121
  354. package/dist/clients/dispatch/runners/spotbugs.js +0 -259
  355. package/dist/clients/dispatch/runners/sqlfluff.js +0 -174
  356. package/dist/clients/dispatch/runners/stylelint.js +0 -158
  357. package/dist/clients/dispatch/runners/swiftlint.js +0 -154
  358. package/dist/clients/dispatch/runners/taplo.js +0 -168
  359. package/dist/clients/dispatch/runners/terragrunt.js +0 -168
  360. package/dist/clients/dispatch/runners/tflint.js +0 -110
  361. package/dist/clients/dispatch/runners/tree-sitter.js +0 -620
  362. package/dist/clients/dispatch/runners/trivy-config.js +0 -247
  363. package/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
  364. package/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
  365. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -85
  366. package/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
  367. package/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
  368. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
  369. package/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
  370. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
  371. package/dist/clients/dispatch/runners/utils.js +0 -15
  372. package/dist/clients/dispatch/runners/vale.js +0 -116
  373. package/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
  374. package/dist/clients/dispatch/runners/yamllint.js +0 -85
  375. package/dist/clients/dispatch/runners/zig-check.js +0 -91
  376. package/dist/clients/dispatch/suppress-writer.js +0 -69
  377. package/dist/clients/dispatch/tool-profile.js +0 -30
  378. package/dist/clients/dispatch/types.js +0 -21
  379. package/dist/clients/dispatch/utils/format-utils.js +0 -45
  380. package/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
  381. package/dist/clients/disposition-logger.js +0 -32
  382. package/dist/clients/disposition-publish.js +0 -124
  383. package/dist/clients/durable-store.js +0 -97
  384. package/dist/clients/effective-config.js +0 -413
  385. package/dist/clients/env-utils.js +0 -83
  386. package/dist/clients/error-class.js +0 -23
  387. package/dist/clients/eval-timestamp.js +0 -17
  388. package/dist/clients/event-loop-hold.js +0 -274
  389. package/dist/clients/event-loop-monitor.js +0 -250
  390. package/dist/clients/extension-log.js +0 -504
  391. package/dist/clients/extension-mode.js +0 -73
  392. package/dist/clients/feature-hints.js +0 -103
  393. package/dist/clients/file-kinds.js +0 -412
  394. package/dist/clients/file-role.js +0 -134
  395. package/dist/clients/file-time.js +0 -160
  396. package/dist/clients/file-utils.js +0 -1032
  397. package/dist/clients/finding-delivery-gate.js +0 -463
  398. package/dist/clients/finding-identity.js +0 -88
  399. package/dist/clients/fix-worklog.js +0 -103
  400. package/dist/clients/format-events-publish.js +0 -343
  401. package/dist/clients/format-service.js +0 -222
  402. package/dist/clients/formatters-lazy.js +0 -9
  403. package/dist/clients/formatters.js +0 -1994
  404. package/dist/clients/freshness-cadence.js +0 -17
  405. package/dist/clients/freshness.js +0 -38
  406. package/dist/clients/generated-artifacts.js +0 -394
  407. package/dist/clients/generation-guard.js +0 -215
  408. package/dist/clients/git-guard.js +0 -923
  409. package/dist/clients/git-tracked-ignore.js +0 -243
  410. package/dist/clients/gitleaks-client.js +0 -509
  411. package/dist/clients/go-client.js +0 -104
  412. package/dist/clients/govulncheck-client.js +0 -605
  413. package/dist/clients/gradle-ktfmt-style.js +0 -252
  414. package/dist/clients/grammar-source.js +0 -465
  415. package/dist/clients/gzip-stage-write.js +0 -135
  416. package/dist/clients/hashline-anchor.js +0 -424
  417. package/dist/clients/hook-budgets.js +0 -76
  418. package/dist/clients/host-edit-normalize.js +0 -140
  419. package/dist/clients/host-ports.js +0 -39
  420. package/dist/clients/indent-retarget.js +0 -154
  421. package/dist/clients/install-diagnostics.js +0 -143
  422. package/dist/clients/installer/index.js +0 -5167
  423. package/dist/clients/installer/managed-tool-refresh-session.js +0 -58
  424. package/dist/clients/installer/managed-tool-refresh.js +0 -783
  425. package/dist/clients/instance-reaper-state.js +0 -7
  426. package/dist/clients/instance-reaper.js +0 -1137
  427. package/dist/clients/instance-registry-lock.js +0 -145
  428. package/dist/clients/instance-registry.js +0 -773
  429. package/dist/clients/jscpd-client.js +0 -370
  430. package/dist/clients/json-cache-read.js +0 -65
  431. package/dist/clients/knip-client.js +0 -788
  432. package/dist/clients/language-policy.js +0 -252
  433. package/dist/clients/language-profile.js +0 -361
  434. package/dist/clients/language-registry.js +0 -603
  435. package/dist/clients/latency-logger.js +0 -719
  436. package/dist/clients/lazy-import.js +0 -21
  437. package/dist/clients/ledger-bounds.js +0 -54
  438. package/dist/clients/lens-config.js +0 -346
  439. package/dist/clients/lens-engine.js +0 -358
  440. package/dist/clients/lens-events.js +0 -125
  441. package/dist/clients/lens-flag-registry.js +0 -467
  442. package/dist/clients/lens-map.js +0 -1156
  443. package/dist/clients/live-bus-emitter.js +0 -74
  444. package/dist/clients/log-cleanup.js +0 -222
  445. package/dist/clients/lsp/aggregation.js +0 -178
  446. package/dist/clients/lsp/cascade-tier.js +0 -421
  447. package/dist/clients/lsp/client.js +0 -4465
  448. package/dist/clients/lsp/config.js +0 -689
  449. package/dist/clients/lsp/diagnostic-binding.js +0 -225
  450. package/dist/clients/lsp/document-drift.js +0 -467
  451. package/dist/clients/lsp/edits.js +0 -1219
  452. package/dist/clients/lsp/index.js +0 -8305
  453. package/dist/clients/lsp/inferred-project.js +0 -216
  454. package/dist/clients/lsp/jvm-runtime.js +0 -298
  455. package/dist/clients/lsp/language.js +0 -63
  456. package/dist/clients/lsp/launch.js +0 -725
  457. package/dist/clients/lsp/lombok.js +0 -158
  458. package/dist/clients/lsp/path-utils.js +0 -5
  459. package/dist/clients/lsp/pending-aux-coverage.js +0 -251
  460. package/dist/clients/lsp/position-encoding.js +0 -61
  461. package/dist/clients/lsp/ruby-drive-dirs.js +0 -83
  462. package/dist/clients/lsp/server.js +0 -3209
  463. package/dist/clients/lsp/session-roots.js +0 -192
  464. package/dist/clients/lsp/spawn-history.js +0 -17
  465. package/dist/clients/lsp/sync-kind.js +0 -42
  466. package/dist/clients/lsp/tsserver-sync.js +0 -332
  467. package/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
  468. package/dist/clients/lsp/wait-policy/classification.js +0 -85
  469. package/dist/clients/lsp/wait-policy/index.js +0 -10
  470. package/dist/clients/lsp/wait-policy/strategies.js +0 -303
  471. package/dist/clients/lsp/watch-queue.js +0 -61
  472. package/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
  473. package/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
  474. package/dist/clients/lsp/workspace-sweep-hold.js +0 -184
  475. package/dist/clients/lsp-budget.js +0 -186
  476. package/dist/clients/lsp-document-symbols.js +0 -110
  477. package/dist/clients/lsp-lazy.js +0 -9
  478. package/dist/clients/lsp-mutation.js +0 -396
  479. package/dist/clients/lsp-status.js +0 -59
  480. package/dist/clients/map-with-concurrency.js +0 -40
  481. package/dist/clients/mcp/analyze.js +0 -420
  482. package/dist/clients/mcp/host-shim.js +0 -31
  483. package/dist/clients/mcp/ipc.js +0 -335
  484. package/dist/clients/mcp/review.js +0 -151
  485. package/dist/clients/mcp/session.js +0 -412
  486. package/dist/clients/memory-sampler.js +0 -208
  487. package/dist/clients/message-end-attribution.js +0 -30
  488. package/dist/clients/metrics-client.js +0 -107
  489. package/dist/clients/metrics-history.js +0 -327
  490. package/dist/clients/middle-man-analysis.js +0 -263
  491. package/dist/clients/model-provider.js +0 -53
  492. package/dist/clients/module-report-lsp.js +0 -189
  493. package/dist/clients/module-report.js +0 -2150
  494. package/dist/clients/mutating-tool.js +0 -651
  495. package/dist/clients/mutation-attribution.js +0 -368
  496. package/dist/clients/mutation-bridge.js +0 -240
  497. package/dist/clients/ndjson-logger.js +0 -734
  498. package/dist/clients/observed-mutation-sources.js +0 -101
  499. package/dist/clients/observed-mutation.js +0 -1253
  500. package/dist/clients/oldtext-autopatch.js +0 -128
  501. package/dist/clients/opaque-mutation-scan.js +0 -496
  502. package/dist/clients/opengrep-client.js +0 -328
  503. package/dist/clients/opengrep-config.js +0 -76
  504. package/dist/clients/package-manager.js +0 -607
  505. package/dist/clients/package-root.js +0 -38
  506. package/dist/clients/partial-edit-apply.js +0 -439
  507. package/dist/clients/path-attribution-telemetry.js +0 -43
  508. package/dist/clients/path-keyed-map.js +0 -108
  509. package/dist/clients/path-utils.js +0 -1244
  510. package/dist/clients/performance-report.js +0 -331
  511. package/dist/clients/persist-debounce.js +0 -70
  512. package/dist/clients/persistent-reverify.js +0 -271
  513. package/dist/clients/php-cs-fixer-config.js +0 -119
  514. package/dist/clients/pipeline.js +0 -1425
  515. package/dist/clients/post-autofix-notice.js +0 -37
  516. package/dist/clients/print-mode.js +0 -21
  517. package/dist/clients/probe-home-state.js +0 -228
  518. package/dist/clients/process-bridge.js +0 -66
  519. package/dist/clients/process-singletons.js +0 -210
  520. package/dist/clients/process-snapshot.js +0 -68
  521. package/dist/clients/project-changes.js +0 -181
  522. package/dist/clients/project-conventions.js +0 -171
  523. package/dist/clients/project-diagnostics/cache.js +0 -137
  524. package/dist/clients/project-diagnostics/extractors.js +0 -109
  525. package/dist/clients/project-diagnostics/fresh-fetch.js +0 -650
  526. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
  527. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
  528. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
  529. package/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
  530. package/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
  531. package/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
  532. package/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
  533. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
  534. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
  535. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
  536. package/dist/clients/project-diagnostics/scanner.js +0 -520
  537. package/dist/clients/project-diagnostics/types.js +0 -1
  538. package/dist/clients/project-lens-config.js +0 -799
  539. package/dist/clients/project-report.js +0 -701
  540. package/dist/clients/project-scale.js +0 -237
  541. package/dist/clients/project-scan-policy.js +0 -11
  542. package/dist/clients/project-snapshot-fingerprint.js +0 -48
  543. package/dist/clients/project-snapshot-persist-worker.js +0 -20
  544. package/dist/clients/project-snapshot.js +0 -1653
  545. package/dist/clients/project-trust.js +0 -181
  546. package/dist/clients/python-environment.js +0 -217
  547. package/dist/clients/python-provenance.js +0 -639
  548. package/dist/clients/quiet-window-config.js +0 -47
  549. package/dist/clients/quiet-window.js +0 -236
  550. package/dist/clients/read-bridge.js +0 -139
  551. package/dist/clients/read-expansion.js +0 -266
  552. package/dist/clients/read-guard-logger.js +0 -251
  553. package/dist/clients/read-guard-tool-lines.js +0 -1306
  554. package/dist/clients/read-guard.js +0 -1561
  555. package/dist/clients/recent-touches.js +0 -240
  556. package/dist/clients/redact/secrets.js +0 -449
  557. package/dist/clients/resource-sampler.js +0 -628
  558. package/dist/clients/reverse-deps.js +0 -270
  559. package/dist/clients/review-graph/builder.js +0 -5118
  560. package/dist/clients/review-graph/format.js +0 -33
  561. package/dist/clients/review-graph/git-identity.js +0 -157
  562. package/dist/clients/review-graph/import-resolvers.js +0 -481
  563. package/dist/clients/review-graph/persist-worker.js +0 -10
  564. package/dist/clients/review-graph/query.js +0 -244
  565. package/dist/clients/review-graph/revision-drift.js +0 -21
  566. package/dist/clients/review-graph/service.js +0 -96
  567. package/dist/clients/review-graph/shared-extraction-ir.js +0 -82
  568. package/dist/clients/review-graph/symbol-id.js +0 -103
  569. package/dist/clients/review-graph/tsconfig-paths.js +0 -299
  570. package/dist/clients/review-graph/types.js +0 -1
  571. package/dist/clients/review-graph/workspace-modules.js +0 -449
  572. package/dist/clients/review-graph-logger.js +0 -81
  573. package/dist/clients/ruff-client.js +0 -152
  574. package/dist/clients/rules-scanner.js +0 -131
  575. package/dist/clients/run-duration.js +0 -55
  576. package/dist/clients/runtime-agent-end.js +0 -746
  577. package/dist/clients/runtime-config.js +0 -77
  578. package/dist/clients/runtime-context.js +0 -211
  579. package/dist/clients/runtime-coordinator.js +0 -1290
  580. package/dist/clients/runtime-session.js +0 -2485
  581. package/dist/clients/runtime-tool-call.js +0 -1284
  582. package/dist/clients/runtime-tool-result.js +0 -2034
  583. package/dist/clients/runtime-turn.js +0 -3600
  584. package/dist/clients/rust-client.js +0 -97
  585. package/dist/clients/safe-spawn.js +0 -2166
  586. package/dist/clients/sanitize.js +0 -286
  587. package/dist/clients/scan-utils.js +0 -25
  588. package/dist/clients/scratch-tree-policy.js +0 -165
  589. package/dist/clients/search-read-registration.js +0 -94
  590. package/dist/clients/secret-findings.js +0 -114
  591. package/dist/clients/security-scan-client.js +0 -352
  592. package/dist/clients/session-event-guard.js +0 -297
  593. package/dist/clients/session-lifecycle.js +0 -484
  594. package/dist/clients/session-start-observability.js +0 -94
  595. package/dist/clients/session-state-store.js +0 -101
  596. package/dist/clients/session-summary.js +0 -15
  597. package/dist/clients/sessionstart-logger.js +0 -27
  598. package/dist/clients/sg-runner.js +0 -876
  599. package/dist/clients/sgconfig.js +0 -347
  600. package/dist/clients/shared-checkout-guard.js +0 -338
  601. package/dist/clients/single-flight.js +0 -158
  602. package/dist/clients/situational-tool-telemetry.js +0 -108
  603. package/dist/clients/skills-resolver.js +0 -141
  604. package/dist/clients/slow-fs.js +0 -138
  605. package/dist/clients/smells-rollup.js +0 -231
  606. package/dist/clients/source-filter.js +0 -596
  607. package/dist/clients/source-walker.js +0 -211
  608. package/dist/clients/spawn-output-cap.js +0 -37
  609. package/dist/clients/spawn-timeout-cooldown.js +0 -110
  610. package/dist/clients/stale-marker.js +0 -17
  611. package/dist/clients/startup-scan.js +0 -400
  612. package/dist/clients/startup-timing.js +0 -130
  613. package/dist/clients/string-utils.js +0 -33
  614. package/dist/clients/subagent-mode.js +0 -111
  615. package/dist/clients/symbol-containment.js +0 -24
  616. package/dist/clients/symbol-types.js +0 -5
  617. package/dist/clients/test-runner-client.js +0 -2507
  618. package/dist/clients/test-runner-delivery.js +0 -287
  619. package/dist/clients/todo-scanner.js +0 -229
  620. package/dist/clients/tool-agreement.js +0 -423
  621. package/dist/clients/tool-config.js +0 -249
  622. package/dist/clients/tool-cwd.js +0 -270
  623. package/dist/clients/tool-definition.js +0 -47
  624. package/dist/clients/tool-event.js +0 -105
  625. package/dist/clients/tool-policy.js +0 -2755
  626. package/dist/clients/tool-probe.js +0 -53
  627. package/dist/clients/tool-render.js +0 -156
  628. package/dist/clients/tool-set-policy.js +0 -118
  629. package/dist/clients/tree-sitter-cache.js +0 -364
  630. package/dist/clients/tree-sitter-client.js +0 -4134
  631. package/dist/clients/tree-sitter-logger.js +0 -100
  632. package/dist/clients/tree-sitter-navigator.js +0 -269
  633. package/dist/clients/tree-sitter-query-loader.js +0 -569
  634. package/dist/clients/tree-sitter-shared.js +0 -158
  635. package/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
  636. package/dist/clients/trivy-client.js +0 -467
  637. package/dist/clients/tui-fit.js +0 -50
  638. package/dist/clients/turn-context.js +0 -52
  639. package/dist/clients/turn-summary-render.js +0 -72
  640. package/dist/clients/turn-summary.js +0 -132
  641. package/dist/clients/typos-config.js +0 -29
  642. package/dist/clients/user-notify.js +0 -51
  643. package/dist/clients/vanished-instance-marker.js +0 -57
  644. package/dist/clients/warm-attach.js +0 -246
  645. package/dist/clients/widget-state.js +0 -1677
  646. package/dist/clients/word-index-logger.js +0 -49
  647. package/dist/clients/word-index-store.js +0 -459
  648. package/dist/clients/word-index.js +0 -2080
  649. package/dist/clients/workspace-topology.js +0 -389
  650. package/dist/clients/write-ordering-guard.js +0 -65
  651. package/dist/clients/zizmor-config.js +0 -327
  652. package/dist/i18n.js +0 -69
  653. package/dist/mcp/build-staleness.js +0 -124
  654. package/dist/scripts/lib/process-scan.mjs +0 -583
  655. package/dist/scripts/lib/skills-predicate.mjs +0 -129
  656. package/dist/tools/activate-tools.js +0 -87
  657. package/dist/tools/ast-grep-outline.js +0 -137
  658. package/dist/tools/ast-grep-replace.js +0 -244
  659. package/dist/tools/ast-grep-search.js +0 -762
  660. package/dist/tools/effective-config.js +0 -90
  661. package/dist/tools/lens-diagnostic-mark.js +0 -375
  662. package/dist/tools/lens-diagnostics.js +0 -2544
  663. package/dist/tools/lsp-diagnostics.js +0 -1634
  664. package/dist/tools/lsp-navigation.js +0 -1367
  665. package/dist/tools/lsp-structured-output.js +0 -294
  666. package/dist/tools/module-report.js +0 -382
  667. package/dist/tools/project-report.js +0 -86
  668. package/dist/tools/render-compact.js +0 -374
  669. package/dist/tools/scan-progress.js +0 -53
  670. package/dist/tools/shared.js +0 -29
  671. package/dist/tools/symbol-search.js +0 -155
@@ -1,4134 +0,0 @@
1
- /**
2
- * Tree-sitter Structural Search Client for pi-lens
3
- *
4
- * Inspired by pi-lsp-extension's search-engine.ts and pattern-compiler.ts
5
- * Provides AST-aware structural search with metavariable capture.
6
- *
7
- * Uses web-tree-sitter (WASM) for parsing - no native compilation needed.
8
- *
9
- * Pattern syntax:
10
- * $NAME - Matches any single AST node, captures as NAME
11
- * $$$NAME - Matches zero or more sibling nodes (variadic)
12
- *
13
- * Example:
14
- * "console.log($MSG)" matches any console.log call, captures argument as MSG
15
- * "function $NAME($$$PARAMS) { $BODY }" matches function declarations
16
- */
17
- import { AsyncLocalStorage } from "node:async_hooks";
18
- import * as crypto from "node:crypto";
19
- import * as fs from "node:fs";
20
- import { createRequire } from "node:module";
21
- import * as path from "node:path";
22
- import { getDegradationLedgerGeneration, incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "./degradation-ledger.js";
23
- import { BoundedFifoMap } from "./bounded-cache.js";
24
- import { loadWebTreeSitter } from "./deps/web-tree-sitter.js";
25
- import { transientRetryDelayMs } from "./dispatch/runners/utils/availability-policy.js";
26
- import { getProjectIgnoreMatcher, isExcludedDirName } from "./file-utils.js";
27
- import { downloadGrammarDetailed, fileHasWasmMagic, grammarBlockReason, grammarFileSha256, isVendoredGrammar, LANGUAGE_TO_GRAMMAR, pinnedGrammarHash, vendoredGrammarRefusal, vendoredGrammarsDir, } from "./grammar-source.js";
28
- import { resolvePackagePath } from "./package-root.js";
29
- import { assertInstallAllowed, getProjectTrustGeneration, } from "./project-trust.js";
30
- import { escapeRegExp } from "./string-utils.js";
31
- import { logTreeSitterDiagnostic } from "./tree-sitter-logger.js";
32
- import { notifyUserDegradation } from "./user-notify.js";
33
- const _require = createRequire(import.meta.url);
34
- import { createTreeCacheCounters, deriveScanTreeCacheCapacity, TreeCache, } from "./tree-sitter-cache.js";
35
- import { TreeSitterNavigator } from "./tree-sitter-navigator.js";
36
- import { isProvenSqlAlchemySessionReceiver, isSafePsycopgIdentifierComposition, isSqlAlchemyEntityQueryArgument, isSqlAlchemyStatementArgument, PYTHON_SQLALCHEMY_RECEIVER_NAMES, PYTHON_SQLALCHEMY_STATEMENT_BUILDERS, } from "./python-provenance.js";
37
- import { TreeSitterQueryLoader, } from "./tree-sitter-query-loader.js";
38
- // Hard cap on a single structural-search file walk. Bounds a misrooted scan so
39
- // it can't enumerate an unbounded tree synchronously before result collection
40
- // short-circuits (#262).
41
- const TREE_SITTER_MAX_SCAN_FILES = 20_000;
42
- // Consecutive grammar-load failures after which a batch cache key gives up and
43
- // caches null like any other deterministic failure. Bounds the retry loop when
44
- // a grammar simply never loads, while still letting a transient loadLanguage()
45
- // failure (offline lazy fetch, mid-scan load error) recover on a later scan
46
- // instead of paying the 3.3x per-rule fallback for the process lifetime (#889).
47
- const QUERY_BATCH_MAX_LOAD_FAILURES = 3;
48
- // Keep post-filter tree walks bounded. A malformed or unexpectedly huge tree
49
- // must not stall the batched query walk; the filter fails open when this cap
50
- // is reached so unrelated diagnostics and the current match are preserved.
51
- const NO_NESTED_ANCHOR_VISIT_CAP = 10_000;
52
- // The execute-style Python DB-API/ORM methods this rule treats as SQL sinks.
53
- // Hoisted so the post-filter does not rebuild the set per match.
54
- const PYTHON_SQL_SINK_METHODS = new Set([
55
- "execute",
56
- "executemany",
57
- "query",
58
- "raw",
59
- ]);
60
- const TYPESCRIPT_SQL_KNOWN_PACKAGES = new Set([
61
- "pg",
62
- "mysql2",
63
- "better-sqlite3",
64
- "knex",
65
- "@prisma/client",
66
- ]);
67
- const TYPESCRIPT_SQL_IMPORTS = new WeakMap();
68
- const NOT_PARSED = { parsed: false };
69
- function createParserCounters() {
70
- return {
71
- parserInvocations: 0,
72
- parserDurationMs: 0,
73
- parserFailures: 0,
74
- };
75
- }
76
- /**
77
- * `size:mtimeMs` identity for a grammar candidate, or undefined when the path
78
- * is absent or not a regular file. Doubles as the existence check on the
79
- * resolve path and as the invalidation key for the verified-preamble memo
80
- * (#1548) — `size` + `mtimeMs` is the codebase's standard cheap stamp.
81
- */
82
- function grammarFileStamp(filePath) {
83
- try {
84
- const stat = fs.statSync(filePath);
85
- return stat.isFile() ? `${stat.size}:${stat.mtimeMs}` : undefined;
86
- }
87
- catch {
88
- return undefined;
89
- }
90
- }
91
- function isTreeSitterWasmAbortError(error) {
92
- const message = error instanceof Error ? error.message : String(error);
93
- return message.includes("Aborted") || message.includes("abort()");
94
- }
95
- /**
96
- * Positively-identified RESOLUTION-failure codes for a dynamic `import()`:
97
- * the specifier couldn't be found/resolved, or a transient fs error hit
98
- * before `import()` got as far as evaluating the target module. All of
99
- * these recover once whatever was missing/busy appears — they say nothing
100
- * durable about the module itself (mirrors the errno allowlist in
101
- * clients/dispatch/runners/ast-grep-napi.ts's `classifyAstGrepLoadFailure`,
102
- * kept local here rather than shared because that file is owned by an
103
- * in-flight PR, #1701, at the time of writing).
104
- */
105
- const RESOLUTION_ERROR_CODES = new Set([
106
- "ERR_MODULE_NOT_FOUND",
107
- "MODULE_NOT_FOUND",
108
- "ERR_INVALID_MODULE_SPECIFIER",
109
- "ERR_UNSUPPORTED_DIR_IMPORT",
110
- "ENOENT",
111
- "EMFILE",
112
- "EBUSY",
113
- "EAGAIN",
114
- "EPERM",
115
- "ETXTBSY",
116
- ]);
117
- /** Walk an error's `.cause` chain, collecting every `code` seen along the
118
- * way — a resolution failure's `code` can be wrapped in a cause rather than
119
- * sitting on the top-level thrown Error. */
120
- function collectErrorCodes(err) {
121
- const codes = [];
122
- let current = err;
123
- const seen = new Set();
124
- while (current instanceof Error && !seen.has(current)) {
125
- seen.add(current);
126
- const code = current.code;
127
- if (typeof code === "string")
128
- codes.push(code);
129
- current = current.cause;
130
- }
131
- return codes;
132
- }
133
- /**
134
- * Classify a `loadWebTreeSitter()` rejection (#1592). RESOLUTION failures
135
- * (a positively-identified code above) are recoverable: the module gets a
136
- * fresh `import()` attempt on the very next `init()` call, same as before
137
- * this fix, because whatever blocked resolution can plausibly clear on its
138
- * own. Everything else is treated as EVALUATION-shaped and latches — an
139
- * unrecognized error is far more likely to be the target module's own
140
- * top-level code throwing (which Node's ESM loader then memoizes as a
141
- * permanently-rejected module record for that URL) than an unclassified
142
- * resolution hiccup, so "unknown" defaults to the case a retry cannot fix.
143
- */
144
- function classifyWebTreeSitterLoadFailure(err) {
145
- const codes = collectErrorCodes(err);
146
- return codes.some((code) => RESOLUTION_ERROR_CODES.has(code))
147
- ? "resolution"
148
- : "evaluation";
149
- }
150
- // --- Parser Manager ---
151
- export class TreeSitterClient {
152
- initialized = false;
153
- initPromise = null;
154
- /**
155
- * Set when `loadWebTreeSitter()` itself rejects with an EVALUATION-shaped
156
- * error (#1592, review round 2 F1/F2) — `classifyWebTreeSitterLoadFailure`
157
- * above draws that line. That call is a dynamic `import()` of a fixed
158
- * resolved URL, and Node's ESM loader permanently memoizes a module
159
- * record that threw during evaluation — a later `import()` of the SAME
160
- * URL from a later `init()` call would just replay the cached rejection,
161
- * not re-attempt the load. Without this latch, every `withTreeSitterRoot()`
162
- * call (one per file parse) would re-invoke `init()`, see `initPromise`
163
- * cleared by the previous attempt's `finally`, and dynamically re-import —
164
- * a dead retry on the hot path. A RESOLUTION-shaped rejection does NOT
165
- * set this: it leaves `initPromise` cleared as before, so the next
166
- * `init()` call retries for real, because that class of failure can
167
- * plausibly clear before the next call arrives.
168
- *
169
- * SESSION-scoped, not process-lifetime (round 2 F2, the #1567/#1575
170
- * `sgSessionHold` precedent): re-armed by `resetLoadStateForSession()`,
171
- * wired into `resetDispatchBaselines()` via `tree-sitter-shared.ts`'s
172
- * `resetTreeSitterClientLoadState()`. Re-arming does not guarantee the
173
- * next attempt succeeds — if the process itself didn't restart between
174
- * sessions, Node's module cache is unchanged and the replay will just
175
- * fail fast again — but it gives a fresh degradation record for the new
176
- * session (the ledger's own `onceKeys` are cleared at session_start too)
177
- * instead of silently reusing a stale verdict forever, and it does let a
178
- * genuinely fixed install (process WAS restarted) recover.
179
- *
180
- * Distinct from `wasmAborted`, which stays process-lifetime and is
181
- * deliberately NOT included in the session reset: that flag means the
182
- * Emscripten WASM heap itself aborted mid-use — a runtime that DID load
183
- * and then corrupted its own memory. Reusing that heap after a "session"
184
- * boundary that isn't an actual process restart is not a retry, it's
185
- * operating on data already documented (tree-sitter-shared.ts) as
186
- * requiring a real restart to recover. `webTreeSitterLoadFailed` only
187
- * ever covers a runtime that never loaded in the first place, so nothing
188
- * corrupted survives a re-arm.
189
- */
190
- webTreeSitterLoadFailed = false;
191
- languages = new Map();
192
- parsers = new Map();
193
- treeCache;
194
- navigator = new TreeSitterNavigator();
195
- grammarsDir;
196
- /** In-flight lazy grammar fetches, keyed by wasm filename. Evicted on
197
- * settle (#1536) — a rejected/false attempt must not be remembered as the
198
- * permanent answer for the session; only concurrent demands during the
199
- * SAME probe share it. */
200
- grammarEnsurePromises = new Map();
201
- /** Epoch ms before which a failed grammar download is not retried
202
- * (#1536). A transient download failure — offline, DNS hiccup, CDN blip —
203
- * says nothing durable about the grammar, so it gets a bounded cooldown
204
- * (the `transientRetryDelayMs` shape from availability-policy.ts) instead
205
- * of latching for the process lifetime. */
206
- grammarRetryAtMs = new Map();
207
- /**
208
- * Grammar path → the `size:mtimeMs` stamp at which its wasm preamble was
209
- * verified (#1548). A POSITIVE-only memo, so the steady-state resolve costs
210
- * one `stat` (which the old `existsSync` already paid) and re-reads the
211
- * header only when the stamp moves. Stamped rather than a bare `Set`
212
- * because a memo that never expires is defect-shape 6: the file it vouches
213
- * for can be replaced under it. Failures are deliberately NOT memoized — a
214
- * poisoned path must be re-examined on the next resolve so a successful
215
- * re-download over it is picked up immediately.
216
- */
217
- verifiedGrammarPaths = new Map();
218
- /**
219
- * Grammar path → the `size:mtimeMs` stamp at which `Language.load` failed
220
- * on it despite passing the wasm-preamble check (#1564) — e.g. a truncated
221
- * download whose first four bytes are a genuine `\0asm` preamble but whose
222
- * body decodes short ("Code section extends past end of the module"). The
223
- * preamble check alone can't see this; only a real decode attempt can.
224
- * `resolveGrammarFile` treats a path recorded here (at the SAME stamp) as
225
- * absent, so the next demand re-fetches instead of reusing the same broken
226
- * file forever. Stamped so a fresh download (new mtime) is re-examined
227
- * rather than permanently distrusted — same shape as `verifiedGrammarPaths`.
228
- */
229
- decodeFailedGrammarPaths = new Map();
230
- /**
231
- * Grammar path → the `size:mtimeMs` stamp at which its bytes were verified
232
- * to match the CURRENTLY PINNED sha256 manifest (#1760). Positive-only,
233
- * same discipline as `verifiedGrammarPaths`: the hash is computed once per
234
- * stamp (not per parse), and a stamp change — a fresh download — forces a
235
- * re-check rather than trusting a memo made for different bytes.
236
- */
237
- verifiedGrammarVersionAt = new Map();
238
- /**
239
- * Grammar path → the `size:mtimeMs` stamp at which it was found to no
240
- * longer match the pinned manifest hash — a version bump
241
- * (`TREE_SITTER_WASMS_VERSION`, or a `SOURCE_OVERRIDES` entry), or on-disk
242
- * corruption (#1760). `resolveGrammarFile` treats a path recorded here (at
243
- * the SAME stamp) as absent, exactly like `decodeFailedGrammarPaths`, so
244
- * the next demand re-fetches instead of serving the stale/corrupt file for
245
- * the rest of the process's life. A fresh download (new stamp) is
246
- * re-examined rather than permanently distrusted.
247
- */
248
- staleGrammarVersionAt = new Map();
249
- /**
250
- * Paths already reported as version-stale THIS SESSION, to log/record them
251
- * once each (#1801 review F1). Deliberately separate from
252
- * `staleGrammarVersionAt`: that map is a pure hash-verdict memo whose whole
253
- * point is to persist for the process's life (re-hashing an unchanged file
254
- * is exactly what it exists to avoid), so it must never double as a report
255
- * gate — a gate riding on process-lifetime state can't re-arm at a session
256
- * boundary. Cleared in `refreshGrammarSessionLatches`, mirroring
257
- * `poisonedGrammarPaths`.
258
- */
259
- staleReportedGrammarPaths = new Set();
260
- /** Paths already reported as non-wasm, to log/record them once each. */
261
- poisonedGrammarPaths = new Set();
262
- /** Consecutive download failures per grammar, for the exponential
263
- * cooldown; reset on success. */
264
- grammarFailureAttempts = new Map();
265
- /**
266
- * Retry delay (ms) last SHOWN to the user for this grammar, or `-1` for a
267
- * durable (non-retryable) failure already announced. The cooldown lets
268
- * the ensure loop retry silently in the background — only the delay's
269
- * FIRST appearance, and any later escalation the user was never told
270
- * about, needs to interrupt them (#1536 review F6): a fresh 30s cooldown
271
- * re-notifies the same as an escalated 300s one once it's a genuinely new
272
- * number, but two consecutive 30s cooldowns (identical, nothing new to
273
- * say) do not. Absence means "not yet notified this streak."
274
- *
275
- * Session-scoped (#1536 review F5): cleared whenever the degradation
276
- * ledger's own generation moves past `grammarNotificationsLedgerGen`,
277
- * mirroring `trustBlockedGrammarNotifications` below — a lazy
278
- * compare-at-use-time against a monotonic counter, not a listener. Tied to
279
- * the LEDGER's generation (bumped by `resetDegradationLedger`, which
280
- * `handleSessionStart` calls first thing) rather than trust, since this
281
- * is a session boundary, not a trust transition.
282
- */
283
- grammarLastNotifiedDelayMs = new Map();
284
- grammarNotificationsLedgerGen = getDegradationLedgerGeneration();
285
- trustBlockedGrammarNotifications = new Set();
286
- trustNotificationsGeneration = getProjectTrustGeneration();
287
- // biome-ignore lint/suspicious/noExplicitAny: Optional dependency loaded dynamically
288
- ParserClass = null;
289
- // biome-ignore lint/suspicious/noExplicitAny: Language loader from module
290
- LanguageLoader = null;
291
- // Declared BEFORE the two caches below: a static read by an instance field
292
- // initializer must already be initialized (TS2729).
293
- static QUERY_CACHE_MAX_ENTRIES = 256;
294
- static QUERY_BATCH_CACHE_MAX_ENTRIES = 256;
295
- // biome-ignore lint/suspicious/noExplicitAny: Compiled query cache by language+pattern hash
296
- // BoundedFifoMap, not BoundedLruCache: recency here is refreshed by this
297
- // class's own explicit delete+set on both the read and the write path (see
298
- // cacheQuery and the two lookup sites) — exactly the raw-`Map` discipline
299
- // the FIFO map documents. A get() that promoted on its own would change
300
- // which entry eviction targets (#2442 review F5/F7).
301
- queryCache = new BoundedFifoMap(TreeSitterClient.QUERY_CACHE_MAX_ENTRIES);
302
- /** Combined multi-rule queries by language + rule-set identity (null = don't retry). */
303
- queryBatchCache = new BoundedFifoMap(TreeSitterClient.QUERY_BATCH_CACHE_MAX_ENTRIES);
304
- queryCacheCap() {
305
- const value = Number.parseInt(process.env.PI_LENS_TREE_SITTER_QUERY_CACHE_CAP ?? "", 10);
306
- return Number.isSafeInteger(value) && value > 0
307
- ? value
308
- : TreeSitterClient.QUERY_CACHE_MAX_ENTRIES;
309
- }
310
- queryBatchCacheCap() {
311
- const value = Number.parseInt(process.env.PI_LENS_TREE_SITTER_QUERY_BATCH_CACHE_CAP ?? "", 10);
312
- return Number.isSafeInteger(value) && value > 0
313
- ? value
314
- : TreeSitterClient.QUERY_BATCH_CACHE_MAX_ENTRIES;
315
- }
316
- // biome-ignore lint/suspicious/noExplicitAny: compiled query objects
317
- cacheQuery(key, value) {
318
- this.queryCache.delete(key); // write-refresh: this write is the newest
319
- // The cap is env-readable, so re-apply it before every write — a raised
320
- // or lowered ceiling has to take effect on the next insert, not on the
321
- // next restart. Both calls hand back the dropped [key, value] pairs so
322
- // the evicted compiled query's native handle is still freed (#2442
323
- // review F5/F7 — this is why set() returns the VALUE, not just the key).
324
- const evicted = this.queryCache.setMaxEntries(this.queryCacheCap());
325
- evicted.push(...this.queryCache.set(key, value));
326
- for (const [, dropped] of evicted)
327
- dropped?.query?.delete?.();
328
- }
329
- cacheQueryBatch(key, value) {
330
- this.queryBatchCache.delete(key);
331
- const evicted = this.queryBatchCache.setMaxEntries(this.queryBatchCacheCap());
332
- evicted.push(...this.queryBatchCache.set(key, value));
333
- for (const [, dropped] of evicted)
334
- dropped?.query?.delete?.();
335
- }
336
- /** Consecutive grammar-load failures per batch key — bounds load retries (#889). */
337
- queryBatchLoadFailures = new Map();
338
- queryLoader = new TreeSitterQueryLoader();
339
- verbose;
340
- parserCounters = createParserCounters();
341
- parseCacheMeasurement = new AsyncLocalStorage();
342
- activeMeasurements = 0;
343
- onWasmAbort;
344
- wasmAborted = false;
345
- grammarDirResolutionDeps;
346
- constructor(verbose = false, onWasmAbort, grammarDirResolutionDeps) {
347
- this.grammarDirResolutionDeps = grammarDirResolutionDeps ?? {
348
- resolveAsset: (asset) => this.resolveWebTreeSitterAsset(asset),
349
- resolvePackage: (specifier) => _require.resolve(specifier),
350
- cwd: () => process.cwd(),
351
- };
352
- this.grammarsDir = this.findGrammarsDir();
353
- this.verbose = verbose;
354
- this.onWasmAbort = onWasmAbort;
355
- this.treeCache = new TreeCache(50, verbose, 4096, (key, amount) => {
356
- const measurement = this.parseCacheMeasurement.getStore();
357
- if (measurement)
358
- measurement[key] += amount;
359
- }, (error) => this.reportWasmAbort(error));
360
- }
361
- recordParserCounter(key, amount = 1) {
362
- this.parserCounters[key] += amount;
363
- const measurement = this.parseCacheMeasurement.getStore();
364
- if (measurement)
365
- measurement[key] += amount;
366
- }
367
- reportWasmAbort(error) {
368
- if (!isTreeSitterWasmAbortError(error))
369
- return false;
370
- if (!this.wasmAborted) {
371
- this.wasmAborted = true;
372
- recordDegradation({
373
- kind: "wasm-abort",
374
- subject: "web-tree-sitter",
375
- reason: error instanceof Error ? error.message : String(error),
376
- });
377
- this.onWasmAbort?.();
378
- }
379
- return true;
380
- }
381
- /**
382
- * O(1) runtime footprint counters (#1123 item 2 memory attribution): every
383
- * field is a `Map.size` read, never an iteration over the maps' contents.
384
- * `wasmMemoryBytes` is deliberately NOT included here — web-tree-sitter
385
- * 0.25.10's Emscripten `Module` (which owns the WASM linear memory /
386
- * `HEAPU8.buffer`) is a private closure variable in the package's
387
- * `bindings.ts` and is not exposed through any public export (`Parser`,
388
- * `Language`, `Query`, ...); reaching it would require either reflecting
389
- * into the package's internal module state (brittle across web-tree-sitter
390
- * versions/bundling) or overriding Emscripten's `wasmMemory` module option
391
- * at `Parser.init()` time with a hand-constructed `WebAssembly.Memory`
392
- * matching the library's own default page-count math (risks a memory-import
393
- * mismatch that would break ALL structural analysis, for an
394
- * observability-only feature). `process.memoryUsage().arrayBuffers` is the
395
- * process-wide proxy used instead (WASM linear memory backs an ArrayBuffer,
396
- * so it is included there) — see clients/memory-sampler.ts.
397
- */
398
- getRuntimeStats() {
399
- return {
400
- languagesLoaded: this.languages.size,
401
- parsersLoaded: this.parsers.size,
402
- queryCacheSize: this.queryCache.size,
403
- queryBatchCacheSize: this.queryBatchCache.size,
404
- };
405
- }
406
- getParseCacheStats() {
407
- return {
408
- ...this.treeCache.getStats(),
409
- ...this.parserCounters,
410
- };
411
- }
412
- /**
413
- * Grow the tree cache to span a full-project scan's working set (#1715).
414
- * The interactive default (50 entries) can't hold a mid/large project's
415
- * file count, so a second scan re-parses everything the first scan's LRU
416
- * evicted — live dogfood evidence showed every miss on a second 110-file
417
- * scan was a `capacityMisses` one. Bounded by
418
- * `TREE_CACHE_SCAN_CAPACITY_CEILING` (see `deriveScanTreeCacheCapacity`'s
419
- * heap-cost note) and monotonic — a smaller scan never shrinks a capacity
420
- * an earlier, larger one already grew.
421
- */
422
- ensureTreeCacheCapacity(fileCount) {
423
- this.treeCache.setMaxSize(deriveScanTreeCacheCapacity(fileCount, this.treeCache.getMaxSize()));
424
- }
425
- async withParseCacheMeasurement(work, onComplete) {
426
- const measurement = {
427
- ...createTreeCacheCounters(),
428
- ...createParserCounters(),
429
- };
430
- this.activeMeasurements++;
431
- try {
432
- return await this.parseCacheMeasurement.run(measurement, async () => {
433
- try {
434
- return await work();
435
- }
436
- finally {
437
- const cacheStats = this.treeCache.getStats();
438
- try {
439
- onComplete({
440
- ...cacheStats,
441
- ...measurement,
442
- misses: measurement.lookups - measurement.hits,
443
- });
444
- }
445
- catch {
446
- // Telemetry must not fail the measured work.
447
- }
448
- }
449
- });
450
- }
451
- finally {
452
- // On Node 22 an active AsyncLocalStorage keeps an async_hooks init hook
453
- // enabled for every promise in the process, so release it once the last
454
- // overlapping measurement is done rather than taxing the whole session.
455
- if (--this.activeMeasurements === 0)
456
- this.parseCacheMeasurement.disable();
457
- }
458
- }
459
- /** Debug logging helper */
460
- dbg(msg) {
461
- if (this.verbose) {
462
- // #1333: verbose gating is preserved, but the SINK is tree-sitter.log —
463
- // a raw write would corrupt pi's frame the moment verbose is enabled.
464
- logTreeSitterDiagnostic({
465
- subsystem: "tree-sitter-client",
466
- level: "debug",
467
- message: msg,
468
- });
469
- }
470
- }
471
- /**
472
- * Resolve a web-tree-sitter asset path using multiple strategies:
473
- * 1. Node module resolution via createRequire (handles hoisted installs — issue #20)
474
- * 2. Package-root walk from import.meta.url (handles on-the-fly TS compilation by pi)
475
- * 3. process.cwd() fallback
476
- */
477
- resolveWebTreeSitterAsset(asset) {
478
- // Strategy 1: Node module resolution (hoisted installs, pnpm workspaces)
479
- try {
480
- const resolved = _require.resolve(`web-tree-sitter/${asset}`);
481
- if (fs.existsSync(resolved))
482
- return resolved;
483
- }
484
- catch {
485
- /* fall through */
486
- }
487
- // Strategy 2: Walk up from this module to find package.json, then into node_modules.
488
- // This is required when pi compiles TS on-the-fly to a temp directory —
489
- // createRequire(import.meta.url) resolves from the temp dir and can't find
490
- // web-tree-sitter, but the package root (where package.json lives) still has
491
- // the correct node_modules layout.
492
- try {
493
- const candidate = resolvePackagePath(import.meta.url, "node_modules", "web-tree-sitter", asset);
494
- if (fs.existsSync(candidate))
495
- return candidate;
496
- }
497
- catch {
498
- /* fall through */
499
- }
500
- // Strategy 3: cwd fallback
501
- const cwdCandidate = path.join(process.cwd(), "node_modules", "web-tree-sitter", asset);
502
- if (fs.existsSync(cwdCandidate))
503
- return cwdCandidate;
504
- return undefined;
505
- }
506
- /**
507
- * The `grammars/` dir bundled inside the pi-lens package (the core grammars
508
- * shipped in the tarball, so common languages parse offline on every package
509
- * manager). Resolved from the package root; cached. Absent in a source
510
- * checkout where `prepare` hasn't populated it.
511
- */
512
- _bundledGrammarsDir;
513
- bundledGrammarsDir() {
514
- // Cache only a positive hit; keep re-checking until it exists (prepare may
515
- // not have populated it yet at first probe).
516
- if (this._bundledGrammarsDir)
517
- return this._bundledGrammarsDir;
518
- try {
519
- const dir = resolvePackagePath(import.meta.url, "grammars");
520
- if (fs.existsSync(dir))
521
- this._bundledGrammarsDir = dir;
522
- return this._bundledGrammarsDir;
523
- }
524
- catch {
525
- return undefined;
526
- }
527
- }
528
- _vendoredGrammarsDir;
529
- /**
530
- * The committed `vendor/grammars` dir, if it exists. Cached only on a hit,
531
- * mirroring `bundledGrammarsDir`.
532
- */
533
- vendoredGrammarsDir() {
534
- if (this._vendoredGrammarsDir)
535
- return this._vendoredGrammarsDir;
536
- try {
537
- const dir = vendoredGrammarsDir();
538
- if (fs.existsSync(dir))
539
- this._vendoredGrammarsDir = dir;
540
- return this._vendoredGrammarsDir;
541
- }
542
- catch {
543
- return undefined;
544
- }
545
- }
546
- /**
547
- * All directories that may hold grammar wasms, in precedence order: the
548
- * committed vendor dir, the bundled core dir, the resolved
549
- * `this.grammarsDir`, and the web-tree-sitter grammars dir (the lazy-fetch
550
- * write target). Deduped.
551
- *
552
- * `vendor/grammars` comes first because it is the ONLY source for a grammar
553
- * we build ourselves (`VENDORED_GRAMMARS`) — nothing downloads into the
554
- * later dirs for it, so a miss here is a miss everywhere.
555
- */
556
- grammarSourceDirs() {
557
- const dirs = [];
558
- const push = (d) => {
559
- if (d && !dirs.includes(d))
560
- dirs.push(d);
561
- };
562
- push(this.vendoredGrammarsDir());
563
- push(this.bundledGrammarsDir());
564
- push(this.grammarsDir || undefined);
565
- push(this.resolveWebTreeSitterAsset("grammars"));
566
- return dirs;
567
- }
568
- /**
569
- * Absolute path to `grammarFile` across all source dirs, else undefined.
570
- *
571
- * Existence is not enough (#1548): a file whose first four bytes aren't the
572
- * wasm preamble is not a grammar, and returning it would report the language
573
- * as available while every `Language.load` fails — the permanent-poisoning
574
- * shape this issue is about. #1548 stops NEW poisoned files from being
575
- * written, but a file poisoned before that shipped is already on disk, so
576
- * the resolve path has to reject it too: a rejected candidate is treated as
577
- * absent, which lets `ensureGrammar` re-download over it.
578
- */
579
- resolveGrammarFile(grammarFile) {
580
- for (const dir of this.grammarSourceDirs()) {
581
- const candidate = path.join(dir, grammarFile);
582
- const stamp = grammarFileStamp(candidate);
583
- if (!stamp)
584
- continue;
585
- // #1564: a file that passed the preamble check but failed
586
- // `Language.load` at this exact stamp is known-bad — treat it as
587
- // absent so `ensureGrammar` re-fetches over it, same as the
588
- // non-wasm-magic case below. A stamp mismatch (the file changed —
589
- // re-downloaded) falls through to re-examine it fresh.
590
- if (this.decodeFailedGrammarPaths.get(candidate) === stamp)
591
- continue;
592
- if (this.verifiedGrammarPaths.get(candidate) === stamp) {
593
- if (this.isGrammarVersionCurrent(candidate, grammarFile, stamp)) {
594
- return candidate;
595
- }
596
- continue;
597
- }
598
- if (fileHasWasmMagic(candidate)) {
599
- this.verifiedGrammarPaths.set(candidate, stamp);
600
- if (this.isGrammarVersionCurrent(candidate, grammarFile, stamp)) {
601
- return candidate;
602
- }
603
- continue;
604
- }
605
- this.verifiedGrammarPaths.delete(candidate);
606
- this.reportPoisonedGrammarFile(candidate, grammarFile);
607
- }
608
- return undefined;
609
- }
610
- /**
611
- * Clear the once-per-session grammar report gates when the degradation
612
- * ledger's own generation has moved (#1536 review F5). Lazy
613
- * compare-at-use-time against a monotonic counter, bumped by
614
- * `resetDegradationLedger`, which `handleSessionStart` calls first thing —
615
- * no listener, no retention. Every gate keyed to a SESSION rather than to
616
- * the process lives here, so a new one cannot forget to re-arm: a gate that
617
- * outlives the ledger it guards silently swallows the record it was only
618
- * ever meant to de-duplicate (#1560 review F1).
619
- */
620
- refreshGrammarSessionLatches() {
621
- const ledgerGen = getDegradationLedgerGeneration();
622
- if (ledgerGen === this.grammarNotificationsLedgerGen)
623
- return;
624
- this.grammarNotificationsLedgerGen = ledgerGen;
625
- this.grammarLastNotifiedDelayMs.clear();
626
- this.poisonedGrammarPaths.clear();
627
- this.staleReportedGrammarPaths.clear();
628
- }
629
- /**
630
- * Log + record a grammar file on disk that isn't a wasm module, once per
631
- * path per session. No user notification here: the caller goes on to attempt
632
- * a re-download, and `recordGrammarFailure` is what interrupts the user if
633
- * that also fails. A silent recovery should stay silent.
634
- */
635
- reportPoisonedGrammarFile(candidate, grammarFile) {
636
- // Per SESSION, not per process (#1560 review F1). The file is still
637
- // poisoned after a session boundary and still degrades the language, so
638
- // the new session's ledger has to carry the entry — a client-lifetime
639
- // gate would leave `resetDegradationLedger` with a permanently empty
640
- // grammar-blocked group and no record of why the language is degraded.
641
- this.refreshGrammarSessionLatches();
642
- if (this.poisonedGrammarPaths.has(candidate))
643
- return;
644
- this.poisonedGrammarPaths.add(candidate);
645
- logTreeSitterDiagnostic({
646
- subsystem: "tree-sitter-client",
647
- level: "warn",
648
- message: `ignoring ${candidate}: the file is not a wasm module (missing the \\0asm preamble) — ` +
649
- `most likely a captive-portal or proxy page written by an earlier download (#1548). ` +
650
- `pi-lens will treat the grammar as missing and try to fetch it again.`,
651
- metadata: { grammarFile, path: candidate, outcome: "not-wasm" },
652
- });
653
- incrementDegradationCount({
654
- kind: "grammar-blocked",
655
- subject: grammarFile,
656
- reason: "on-disk grammar file is not a wasm module — ignored, re-fetching",
657
- });
658
- }
659
- /**
660
- * Is the cached grammar at `candidate` (whose wasm preamble already passed)
661
- * still current against the pinned sha256 manifest (#1760)?
662
- *
663
- * A grammar downloaded once is never revisited when this repo bumps
664
- * `TREE_SITTER_WASMS_VERSION` or changes a `SOURCE_OVERRIDES` entry — the
665
- * cached file's name carries no version, so a stale build serves forever.
666
- * The check is cheap and NEVER touches the network: it compares the
667
- * on-disk sha256 (hashed once per `size:mtimeMs` stamp, memoized exactly
668
- * like `verifiedGrammarPaths` above so a hot parse loop never re-hashes an
669
- * unchanged file) against `pinnedGrammarHash`, which itself is a pure
670
- * in-memory manifest lookup. A mismatch also catches on-disk corruption,
671
- * which nothing detected before this.
672
- *
673
- * Vendored grammars (`VENDORED_GRAMMARS`) are skipped: they have no CDN
674
- * pin to drift against, and their bytes are already guarded by the
675
- * separate build-provenance check (`scripts/check-grammar-provenance.mjs`).
676
- * No pinned hash for this filename (manifest missing, or a grammar added
677
- * before `--write-manifest` was re-run) is treated as "can't verify" and
678
- * trusted, the same fallback `downloadGrammarDetailed`'s own hash check
679
- * already uses — never as a forced, unbounded refetch loop.
680
- */
681
- isGrammarVersionCurrent(candidate, grammarFile, stamp) {
682
- if (isVendoredGrammar(grammarFile))
683
- return true;
684
- if (this.staleGrammarVersionAt.get(candidate) === stamp) {
685
- // #1801 review F1: the hash-verdict memo above is process-lifetime by
686
- // design (re-hashing an unchanged file on every resolve is the exact
687
- // cost this whole check exists to avoid), so an early return here must
688
- // NOT skip the report — a still-stale grammar has to keep showing up
689
- // in every NEW session's ledger, not just the session that first
690
- // discovered the mismatch.
691
- this.reportStaleGrammarVersion(candidate, grammarFile);
692
- return false;
693
- }
694
- if (this.verifiedGrammarVersionAt.get(candidate) === stamp)
695
- return true;
696
- const pinnedHash = pinnedGrammarHash(grammarFile);
697
- if (!pinnedHash) {
698
- this.verifiedGrammarVersionAt.set(candidate, stamp);
699
- return true;
700
- }
701
- const actualHash = grammarFileSha256(candidate);
702
- if (actualHash === pinnedHash) {
703
- this.verifiedGrammarVersionAt.set(candidate, stamp);
704
- return true;
705
- }
706
- this.verifiedGrammarVersionAt.delete(candidate);
707
- this.staleGrammarVersionAt.set(candidate, stamp);
708
- this.reportStaleGrammarVersion(candidate, grammarFile);
709
- return false;
710
- }
711
- /**
712
- * Log + record a version-stale (or corrupt) cached grammar, once per path
713
- * per SESSION (#1801 review F1). Called from BOTH the memoized-stale path
714
- * and the fresh-mismatch path in `isGrammarVersionCurrent`, so the report
715
- * gate is independent of `staleGrammarVersionAt`'s own process-lifetime
716
- * memo — a grammar that is STILL stale in a later session must still emit
717
- * a fresh record into that session's ledger, exactly like
718
- * `reportPoisonedGrammarFile` already does for the non-wasm case.
719
- */
720
- reportStaleGrammarVersion(candidate, grammarFile) {
721
- this.refreshGrammarSessionLatches();
722
- if (this.staleReportedGrammarPaths.has(candidate))
723
- return;
724
- this.staleReportedGrammarPaths.add(candidate);
725
- logTreeSitterDiagnostic({
726
- subsystem: "tree-sitter-client",
727
- level: "warn",
728
- message: `ignoring ${candidate}: its sha256 no longer matches the pinned grammar ` +
729
- `manifest — the pinned tree-sitter-wasms version (or a SOURCE_OVERRIDES entry) ` +
730
- `moved since this file was downloaded. pi-lens will treat the grammar as ` +
731
- `missing and re-fetch the current build (#1760).`,
732
- metadata: { grammarFile, path: candidate, outcome: "stale-version" },
733
- });
734
- incrementDegradationCount({
735
- kind: "grammar-blocked",
736
- subject: grammarFile,
737
- reason: "cached grammar no longer matches the pinned manifest hash — ignored, re-fetching",
738
- });
739
- }
740
- /** Find tree-sitter grammar directory */
741
- findGrammarsDir(deps = this.grammarDirResolutionDeps) {
742
- const grammarsDir = deps.resolveAsset("grammars");
743
- if (grammarsDir &&
744
- fs.existsSync(path.join(grammarsDir, "tree-sitter-typescript.wasm"))) {
745
- return grammarsDir;
746
- }
747
- // Fallback: a real `tree-sitter-wasms` package, if the user installed one
748
- // (it is not a pi-lens dependency — grammars ship bundled / lazy-fetched).
749
- try {
750
- const wasmsOut = path.join(path.dirname(deps.resolvePackage("tree-sitter-wasms/package.json")), "out");
751
- if (fs.existsSync(wasmsOut))
752
- return wasmsOut;
753
- }
754
- catch {
755
- /* fall through */
756
- }
757
- const cwdWasms = path.join(deps.cwd(), "node_modules", "tree-sitter-wasms", "out");
758
- if (fs.existsSync(cwdWasms))
759
- return cwdWasms;
760
- return "";
761
- }
762
- /**
763
- * The directory where grammars SHOULD live (web-tree-sitter/grammars),
764
- * whether or not it exists yet — so we can create + populate it when the
765
- * postinstall download was skipped (pnpm/bun). Returns undefined if
766
- * web-tree-sitter itself can't be located.
767
- */
768
- grammarsWriteDir() {
769
- try {
770
- let dir = path.dirname(_require.resolve("web-tree-sitter"));
771
- while (path.basename(dir) !== "web-tree-sitter" &&
772
- dir !== path.dirname(dir)) {
773
- dir = path.dirname(dir);
774
- }
775
- if (path.basename(dir) === "web-tree-sitter") {
776
- return path.join(dir, "grammars");
777
- }
778
- }
779
- catch {
780
- /* fall through */
781
- }
782
- return undefined;
783
- }
784
- /**
785
- * Ensure a single grammar wasm is on disk, fetching it at runtime if the
786
- * postinstall didn't (pnpm/bun skip lifecycle scripts — the documented
787
- * build-scripts gap). Idempotent and de-duplicated per file. Best-effort:
788
- * a failed fetch (e.g. offline) degrades to "grammar unavailable", never
789
- * throws.
790
- */
791
- async ensureGrammar(grammarFile) {
792
- if (this.resolveGrammarFile(grammarFile)) {
793
- return true;
794
- }
795
- if (!assertInstallAllowed(`tree-sitter grammar fetch: ${grammarFile}`)) {
796
- const unavailable = `tree-sitter grammar '${grammarFile}' is unavailable because the project is not trusted; ` +
797
- `runtime grammar downloads are disabled until trust is granted.`;
798
- logTreeSitterDiagnostic({
799
- subsystem: "tree-sitter-client",
800
- message: unavailable,
801
- metadata: { grammarFile, outcome: "trust-gated" },
802
- });
803
- recordDegradation({
804
- kind: "grammar-blocked",
805
- subject: grammarFile,
806
- reason: "runtime grammar download blocked because project is untrusted",
807
- });
808
- // Lazy clear-on-transition (#1363 review): compare the trust
809
- // generation at use time -- no listener registration, no retention.
810
- const generation = getProjectTrustGeneration();
811
- if (generation !== this.trustNotificationsGeneration) {
812
- this.trustNotificationsGeneration = generation;
813
- this.trustBlockedGrammarNotifications.clear();
814
- }
815
- if (!this.trustBlockedGrammarNotifications.has(grammarFile)) {
816
- this.trustBlockedGrammarNotifications.add(grammarFile);
817
- notifyUserDegradation(`pi-lens: ${unavailable}`);
818
- }
819
- return false;
820
- }
821
- const inflight = this.grammarEnsurePromises.get(grammarFile);
822
- if (inflight)
823
- return inflight;
824
- // A failed download only stays refused for its cooldown window (#1536):
825
- // past that, fall through and retry rather than remembering an offline
826
- // moment for the life of the process. No download is attempted while the
827
- // cooldown is live, so a hard-down CDN is not re-hit on every parse. A
828
- // DURABLE failure (grammarBlockDurable below) sets this to +Infinity,
829
- // which reads the same as "still cooling down" here — it just never
830
- // expires, matching the issue's "a genuinely unavailable grammar may
831
- // still latch" allowance.
832
- const retryAt = this.grammarRetryAtMs.get(grammarFile);
833
- if (retryAt !== undefined && Date.now() < retryAt) {
834
- return false;
835
- }
836
- const task = (async () => {
837
- try {
838
- return await this.fetchGrammar(grammarFile);
839
- }
840
- catch (err) {
841
- // Defensive (#1548): nothing in `fetchGrammar` throws today —
842
- // `downloadGrammarDetailed` catches internally. But a thrown error
843
- // here used to skip the whole `else` arm, so NO cooldown was armed
844
- // and the rejection escaped through `task.finally` — the exact
845
- // pre-#1536 shape (no cooldown, possible unhandled rejection). Funnel
846
- // it into the same retryable failure path instead, so a future
847
- // refactor that lets an exception out cannot regress to it.
848
- const message = err instanceof Error ? err.message : String(err);
849
- this.recordGrammarFailure(grammarFile, `The runtime grammar fetch threw an unexpected error (${message}).`,
850
- /* retryable */ true);
851
- return false;
852
- }
853
- })();
854
- this.grammarEnsurePromises.set(grammarFile, task);
855
- // Evict on settle regardless of outcome (#1536): a resolved TRUE is
856
- // superseded by resolveGrammarFile() finding the file on disk on the
857
- // next call, and a resolved FALSE must not be remembered as the
858
- // permanent verdict — only concurrent callers during the SAME in-flight
859
- // attempt should ever observe this promise.
860
- //
861
- // The trailing `.catch` is UNFALSIFIABLE scaffolding, not verified
862
- // defence (#1560 review F3): `finally` returns a DERIVED promise that
863
- // rejects whenever `task` does, but the task's own try/catch above means
864
- // it cannot reject, so no test can make this handler fire and removing it
865
- // changes nothing today. It is here for the day the try/catch above stops
866
- // being exhaustive — or the eviction callback itself throws — because at
867
- // that point the fork becomes an unhandled rejection nobody is awaiting.
868
- void task
869
- .finally(() => {
870
- if (this.grammarEnsurePromises.get(grammarFile) === task) {
871
- this.grammarEnsurePromises.delete(grammarFile);
872
- }
873
- })
874
- .catch(() => {
875
- /* see above: the task's own catch is the real handler */
876
- });
877
- return task;
878
- }
879
- /**
880
- * One grammar-fetch attempt: locate a writable dir, download, and record the
881
- * outcome. Split out of `ensureGrammar` so the whole body sits under one
882
- * try/catch that funnels any throw into `recordGrammarFailure` (#1548).
883
- */
884
- async fetchGrammar(grammarFile) {
885
- // BEFORE resolving a write directory: a vendored grammar is never
886
- // downloaded, so the write dir has no bearing on its verdict. Resolving
887
- // first let the "no writable grammars directory" branch below answer
888
- // RETRYABLE for a missing vendored wasm on any host where
889
- // web-tree-sitter isn't locatable — reporting a packaging fault as a
890
- // transient download failure, and arming a cooldown for a fetch that can
891
- // never happen.
892
- if (isVendoredGrammar(grammarFile)) {
893
- const { reason } = vendoredGrammarRefusal(grammarFile);
894
- this.recordGrammarFailure(grammarFile, reason ?? `${grammarFile} is missing from vendor/grammars/.`,
895
- /* retryable */ false);
896
- return false;
897
- }
898
- const dir = this.grammarsDir && fs.existsSync(this.grammarsDir)
899
- ? this.grammarsDir
900
- : this.grammarsWriteDir();
901
- if (!dir) {
902
- // No writable grammars directory could be located (e.g. pi compiled
903
- // this module to a temp dir and web-tree-sitter isn't resolvable
904
- // from there yet). This is an environment condition, not a CDN
905
- // verdict — #1536 review F2: it must get the same cooldown +
906
- // notification treatment as a download failure, not a silent
907
- // unmemoized `false` that re-does the same failing resolution
908
- // sweep on every single demand.
909
- this.recordGrammarFailure(grammarFile, "No writable grammars directory could be located for the runtime fetch.",
910
- /* retryable */ true);
911
- return false;
912
- }
913
- // Reuse the shared single-file downloader (same CDN/source as the
914
- // postinstall) — see clients/grammar-source.ts.
915
- const { ok, retryable, reason } = await downloadGrammarDetailed(dir, grammarFile);
916
- if (ok) {
917
- if (!this.grammarsDir)
918
- this.grammarsDir = dir;
919
- this.grammarRetryAtMs.delete(grammarFile);
920
- this.grammarFailureAttempts.delete(grammarFile);
921
- this.grammarLastNotifiedDelayMs.delete(grammarFile);
922
- logTreeSitterDiagnostic({
923
- subsystem: "tree-sitter-client",
924
- level: "warn",
925
- message: `fetched missing tree-sitter grammar ${grammarFile} at runtime (install scripts were skipped by the package manager)`,
926
- metadata: { grammarFile, outcome: "fetched" },
927
- });
928
- }
929
- else {
930
- // `retryable` distinguishes a durable CDN verdict (404/410 — a
931
- // retry hits the same answer) from everything else (offline, DNS,
932
- // a down CDN, a 5xx, a timeout), which says nothing durable about
933
- // the grammar (#1536 review F4). Only the retryable case gets the
934
- // bounded cooldown; a durable failure keeps the pre-#1536 latched
935
- // behavior, matching the issue's own allowance.
936
- //
937
- // `reason`, when the downloader supplies one, names the actual failure
938
- // shape (#1548: "returned an HTML page, not a wasm module") instead of
939
- // the generic guess — the difference between a diagnosable log line
940
- // and one that sends the user to inspect their package manager while a
941
- // captive portal is the real cause.
942
- this.recordGrammarFailure(grammarFile, reason ??
943
- "The package manager skipped install scripts and the runtime download failed.", retryable);
944
- }
945
- return ok;
946
- }
947
- /**
948
- * Record one failed grammar-ensure attempt: arm (or extend) the retry
949
- * cooldown, record the degradation, and notify the user at most once per
950
- * session per DISTINCT retry delay (#1536 review F1/F2/F4/F5/F6). Shared
951
- * by the "no writable directory" and "download failed" arms of
952
- * `ensureGrammar` — both are failures of the SAME ensure attempt, just
953
- * with a different point of failure.
954
- */
955
- recordGrammarFailure(grammarFile, detail, retryable) {
956
- const attempts = (this.grammarFailureAttempts.get(grammarFile) ?? 0) + 1;
957
- this.grammarFailureAttempts.set(grammarFile, attempts);
958
- // A durable verdict (404/410) never expires -- matches the issue's "a
959
- // genuinely unavailable grammar may still latch" allowance. Everything
960
- // else gets the bounded exponential cooldown.
961
- const retryDelayMs = retryable
962
- ? transientRetryDelayMs(attempts, "probe-timeout")
963
- : undefined;
964
- this.grammarRetryAtMs.set(grammarFile, retryDelayMs === undefined
965
- ? Number.POSITIVE_INFINITY
966
- : Date.now() + retryDelayMs);
967
- const unavailable = retryable
968
- ? `tree-sitter grammar '${grammarFile}' is unavailable — symbol search, ` +
969
- `module reports and structural rules for this language will be degraded. ` +
970
- `${detail} pi-lens will retry automatically in ${Math.round(retryDelayMs / 1000)}s; ` +
971
- `if the problem persists, allow the package manager's build scripts ` +
972
- `(pnpm approve-builds / bun trustedDependencies) or restore network access.`
973
- : `tree-sitter grammar '${grammarFile}' is unavailable — symbol search, ` +
974
- `module reports and structural rules for this language will be degraded. ` +
975
- `${detail} The grammar source reports it does not exist (not a network problem), so this will not ` +
976
- `resolve on retry. Fix: reinstall with a manager that runs postinstall, allow its build scripts ` +
977
- `(pnpm approve-builds / bun trustedDependencies), or restore network access.`;
978
- logTreeSitterDiagnostic({
979
- subsystem: "tree-sitter-client",
980
- message: unavailable,
981
- metadata: {
982
- grammarFile,
983
- outcome: "unavailable",
984
- retryable,
985
- retryDelayMs,
986
- attempts,
987
- },
988
- });
989
- // #1536 review F1: incrementDegradationCount keeps ONE ring-buffer slot
990
- // per subject (bumping its count in place) instead of recordDegradation's
991
- // one-new-entry-per-call — a grammar retrying every cooldown window would
992
- // otherwise flood the shared "grammar-blocked" kind's 20-slot ring and
993
- // evict unrelated entries (e.g. a different grammar's V8-crash block
994
- // reason from loadLanguage/grammarBlockReason) after ~20 cycles.
995
- incrementDegradationCount({
996
- kind: "grammar-blocked",
997
- subject: grammarFile,
998
- // #1548: carry `detail` into the RETRYABLE reason too. All retryable
999
- // failures used to share one string, so the ledger could not tell an
1000
- // offline laptop from a captive portal serving HTML — the two need
1001
- // different user actions, and the ledger is what a bug report shows.
1002
- reason: retryable
1003
- ? `runtime grammar download failed — retryable (${detail.trim()})`
1004
- : `runtime grammar download failed — durable (${detail.trim()})`,
1005
- });
1006
- this.refreshGrammarSessionLatches();
1007
- // HUMAN-audience: an offline grammar fetch silently degrades this
1008
- // language's features, so it reaches the user through the HOST's render
1009
- // path (#1333) rather than a raw terminal write. Notify once per DISTINCT
1010
- // retry delay this session (#1536 review F6) -- a fresh failure streak or
1011
- // an escalated backoff is new information worth surfacing; an unchanged
1012
- // repeat during the same cooldown tier is not.
1013
- const notifyKey = retryDelayMs ?? -1;
1014
- if (this.grammarLastNotifiedDelayMs.get(grammarFile) !== notifyKey) {
1015
- this.grammarLastNotifiedDelayMs.set(grammarFile, notifyKey);
1016
- notifyUserDegradation(`pi-lens: ${unavailable}`);
1017
- }
1018
- }
1019
- /**
1020
- * Record a `Language.load` failure on a file `resolveGrammarFile` had just
1021
- * vouched for (#1564) — the diagnosis gap #1548 left open: the preamble
1022
- * check proves the first four bytes are `\0asm`, not that the whole body
1023
- * decodes. A decode error here (a truncated download, on-disk corruption)
1024
- * is evidence the FILE is bad, so it gets the exact same treatment as an
1025
- * ensure-time download failure: invalidate the resolve memo so the next
1026
- * demand re-fetches instead of reusing the same broken file forever, and
1027
- * reuse `recordGrammarFailure`'s cooldown/degradation/notification
1028
- * machinery rather than hand-rolling a parallel one — a persistently
1029
- * truncated CDN response would otherwise re-download and re-fail on every
1030
- * single parse.
1031
- *
1032
- * A DURABLE decode failure (the bytes are complete and correct, but ABI-
1033
- * incompatible with the installed `web-tree-sitter` — a version drift, not
1034
- * a truncation) is classified `retryable` here too, same as everything
1035
- * else this method sees: there is no way to tell "truncated" from "wrong
1036
- * ABI" from the error message alone, so it re-downloads and re-fails once
1037
- * per cooldown tier instead of latching forever. That is bounded by the
1038
- * cooldown ladder (this never re-fetches faster than #1536's backoff) and
1039
- * by `BLOCKED_GRAMMARS` for the narrower case that's fatal to the process
1040
- * rather than just wrong (`grammar-source.ts`). The redownload only helps
1041
- * if a fresh fetch could actually be a DIFFERENT (compatible) build,
1042
- * which is exactly what the `grammar-source.test.ts` version/lock sync
1043
- * guard (`lock.version === TREE_SITTER_WASMS_VERSION`) exists to keep
1044
- * true — without it, a version bump with a stale lock would make every
1045
- * redownload land the identical bytes and spin the ladder against a file
1046
- * that can never pass.
1047
- */
1048
- recordGrammarLoadFailure(grammarPath, grammarFile, err) {
1049
- const stamp = grammarFileStamp(grammarPath);
1050
- this.verifiedGrammarPaths.delete(grammarPath);
1051
- if (stamp)
1052
- this.decodeFailedGrammarPaths.set(grammarPath, stamp);
1053
- const message = err instanceof Error ? err.message : String(err);
1054
- logTreeSitterDiagnostic({
1055
- subsystem: "tree-sitter-client",
1056
- level: "warn",
1057
- message: `Language.load failed for ${grammarPath} even though the wasm preamble check ` +
1058
- `passed (${message}) — most likely a truncated download (#1564). Treating the ` +
1059
- `grammar as missing so the next demand re-fetches it.`,
1060
- metadata: { grammarFile, path: grammarPath, outcome: "load-failed" },
1061
- });
1062
- this.recordGrammarFailure(grammarFile, `Language.load failed on a resolved grammar file (${message}).`,
1063
- /* retryable */ true);
1064
- }
1065
- /** Initialize tree-sitter WASM runtime */
1066
- async init() {
1067
- if (this.wasmAborted)
1068
- return false;
1069
- if (this.initialized)
1070
- return true;
1071
- if (this.webTreeSitterLoadFailed)
1072
- return false;
1073
- if (this.initPromise)
1074
- return this.initPromise;
1075
- this.initPromise = (async () => {
1076
- try {
1077
- let mod;
1078
- try {
1079
- mod = await loadWebTreeSitter();
1080
- }
1081
- catch (err) {
1082
- // See webTreeSitterLoadFailed's doc: an EVALUATION-shaped
1083
- // rejection is a dead retry, so latch instead of leaving
1084
- // initPromise clearable (the finally below would otherwise let
1085
- // the very next parse call re-import the same doomed URL). A
1086
- // RESOLUTION-shaped rejection is left alone — initPromise still
1087
- // clears below, so the next init() call retries for real.
1088
- const classification = classifyWebTreeSitterLoadFailure(err);
1089
- if (classification === "evaluation") {
1090
- this.webTreeSitterLoadFailed = true;
1091
- }
1092
- recordDegradationOnce({
1093
- kind: "web-tree-sitter-load-failed",
1094
- subject: "web-tree-sitter",
1095
- reason: (err instanceof Error ? err.message : String(err)) +
1096
- (classification === "resolution"
1097
- ? " (resolution failure — retryable)"
1098
- : " (evaluation failure — latched for the session)"),
1099
- });
1100
- throw err;
1101
- }
1102
- // biome-ignore lint/suspicious/noExplicitAny: web-tree-sitter module shape varies (Parser direct / default-wrapped)
1103
- const anyMod = mod;
1104
- const ParserClass = anyMod.Parser || anyMod.default || anyMod;
1105
- if (!ParserClass || typeof ParserClass.init !== "function") {
1106
- this.dbg("Parser class not found or missing init method");
1107
- return false;
1108
- }
1109
- // biome-ignore lint/suspicious/noExplicitAny: Parser class type
1110
- this.ParserClass = ParserClass;
1111
- // Store Language loader from module (not from Parser)
1112
- this.LanguageLoader = mod.Language;
1113
- // Resolve WASM path using same multi-strategy helper (hoisted installs +
1114
- // on-the-fly compilation by pi).
1115
- const wasmPath = this.resolveWebTreeSitterAsset("tree-sitter.wasm");
1116
- if (!wasmPath) {
1117
- this.dbg("Could not resolve tree-sitter.wasm");
1118
- return false;
1119
- }
1120
- const wasmDir = path.dirname(wasmPath);
1121
- this.dbg(`Looking for WASM at: ${wasmPath}, exists: ${fs.existsSync(wasmPath)}`);
1122
- await ParserClass.init({
1123
- locateFile: (scriptName) => {
1124
- const fullPath = path.join(wasmDir, scriptName);
1125
- this.dbg(`locateFile: ${scriptName} -> ${fullPath}`);
1126
- return fullPath;
1127
- },
1128
- });
1129
- this.initialized = true;
1130
- return true;
1131
- }
1132
- catch (err) {
1133
- this.reportWasmAbort(err);
1134
- this.dbg(`Init error: ${err}`);
1135
- return false;
1136
- }
1137
- finally {
1138
- this.initPromise = null;
1139
- }
1140
- })();
1141
- return this.initPromise;
1142
- }
1143
- /**
1144
- * Re-arm the web-tree-sitter load latch for a new session (#1592 review
1145
- * round 2 F2). Deliberately does NOT touch `wasmAborted` or `initialized`
1146
- * — see `webTreeSitterLoadFailed`'s doc for why the abort case stays
1147
- * process-lifetime, and a successful init has nothing to re-arm. Called
1148
- * from `resetTreeSitterClientLoadState()` (tree-sitter-shared.ts), wired
1149
- * into `resetDispatchBaselines()` beside `resetAstGrepNapiLoadState()`.
1150
- */
1151
- resetLoadStateForSession() {
1152
- this.webTreeSitterLoadFailed = false;
1153
- }
1154
- /** Load language grammar */
1155
- async loadLanguage(languageId) {
1156
- if (this.wasmAborted)
1157
- return null;
1158
- this.dbg(`Loading language: ${languageId}`);
1159
- if (this.languages.has(languageId)) {
1160
- this.dbg(`Language ${languageId} already loaded`);
1161
- return this.languages.get(languageId);
1162
- }
1163
- if (!this.ParserClass) {
1164
- this.dbg(`ParserClass not initialized`);
1165
- return null;
1166
- }
1167
- const grammarFile = LANGUAGE_TO_GRAMMAR[languageId];
1168
- if (!grammarFile) {
1169
- this.dbg(`No grammar file for ${languageId}`);
1170
- return null;
1171
- }
1172
- // A grammar that fatally crashes this runtime (uncatchable V8 abort) must
1173
- // never be loaded — skip it and degrade to "unavailable" (#423/#432). The
1174
- // grammar-health nightly is what decides membership of BLOCKED_GRAMMARS.
1175
- const blockReason = grammarBlockReason(grammarFile);
1176
- if (blockReason) {
1177
- this.dbg(`Grammar ${grammarFile} blocked on this runtime — ${blockReason}`);
1178
- recordDegradation({
1179
- kind: "grammar-blocked",
1180
- subject: grammarFile,
1181
- reason: blockReason,
1182
- });
1183
- return null;
1184
- }
1185
- // Look across the bundled core `grammars/` dir and the postinstall/lazy
1186
- // dir. Lazily fetch only if the grammar is in neither (pnpm/bun skip
1187
- // postinstall; the long-tail grammars aren't bundled). Only the language
1188
- // actually being parsed is fetched.
1189
- let grammarPath = this.resolveGrammarFile(grammarFile);
1190
- if (!grammarPath) {
1191
- if (await this.ensureGrammar(grammarFile)) {
1192
- grammarPath = this.resolveGrammarFile(grammarFile);
1193
- }
1194
- }
1195
- this.dbg(`Grammar path: ${grammarPath}, exists: ${grammarPath && fs.existsSync(grammarPath)}`);
1196
- if (!grammarPath || !fs.existsSync(grammarPath)) {
1197
- this.dbg(`Grammar file not found: ${grammarPath}`);
1198
- return null;
1199
- }
1200
- try {
1201
- if (!this.LanguageLoader?.load) {
1202
- this.dbg(`LanguageLoader.load not available`);
1203
- return null;
1204
- }
1205
- this.dbg(`Calling Language.load...`);
1206
- const language = await this.LanguageLoader.load(grammarPath);
1207
- this.dbg(`Language loaded: ${language?.name || "unknown"}`);
1208
- if (language) {
1209
- this.languages.set(languageId, language);
1210
- }
1211
- return language;
1212
- }
1213
- catch (err) {
1214
- // An uncatchable-shaped WASM abort poisons the whole process (#402) —
1215
- // `reportWasmAbort` already records + handles it. Everything else
1216
- // (#1564) is a DECODE failure on a file `resolveGrammarFile` just
1217
- // vouched for — e.g. "Code section extends past end of the module" on
1218
- // a truncated-but-magic-valid download. That is evidence the file
1219
- // itself is bad, not the runtime, so record it and stop vouching for
1220
- // this exact file so the next demand can re-fetch it.
1221
- if (!this.reportWasmAbort(err)) {
1222
- this.recordGrammarLoadFailure(grammarPath, grammarFile, err);
1223
- }
1224
- this.dbg(`Language load error: ${err}`);
1225
- return null;
1226
- }
1227
- }
1228
- /** Get or create parser for a language */
1229
- async getParser(languageId) {
1230
- if (this.wasmAborted)
1231
- return null;
1232
- if (this.parsers.has(languageId)) {
1233
- return this.parsers.get(languageId);
1234
- }
1235
- const language = await this.loadLanguage(languageId);
1236
- if (!language || !this.ParserClass)
1237
- return null;
1238
- const parser = new this.ParserClass();
1239
- parser.setLanguage(language);
1240
- this.parsers.set(languageId, parser);
1241
- return parser;
1242
- }
1243
- /**
1244
- * Parse a file and return the AST tree. The tree stays valid only until the
1245
- * caller's next `await` — prefer `withParsedTree`, which extracts inside the
1246
- * cache-safe window (#417/#675).
1247
- */
1248
- async parseFile(filePath, languageId, contentOverride) {
1249
- const outcome = await this.parseFileAndUse(filePath, languageId, contentOverride, (tree) => tree);
1250
- return outcome.parsed ? outcome.value : null;
1251
- }
1252
- async withParsedTree(filePath, languageId, contentOverride, consume) {
1253
- return this.parseFileAndUse(filePath, languageId, contentOverride, consume);
1254
- }
1255
- async parseFileAndUse(filePath, languageId, contentOverride, consume) {
1256
- this.dbg(`Parsing ${filePath} with language ${languageId}`);
1257
- const parser = await this.getParser(languageId);
1258
- if (!parser) {
1259
- this.dbg(`Failed to get parser for ${languageId}`);
1260
- return NOT_PARSED;
1261
- }
1262
- let tree;
1263
- try {
1264
- const content = contentOverride ?? fs.readFileSync(filePath, "utf-8");
1265
- this.dbg(`File content length: ${content.length}`);
1266
- const cachedTree = this.treeCache.get(filePath, content, languageId);
1267
- if (cachedTree) {
1268
- this.dbg(`Using cached tree for ${filePath}`);
1269
- tree = cachedTree;
1270
- }
1271
- else {
1272
- const parseStartedAt = performance.now();
1273
- this.recordParserCounter("parserInvocations");
1274
- try {
1275
- tree = parser.parse(content);
1276
- }
1277
- catch (err) {
1278
- this.recordParserCounter("parserFailures");
1279
- throw err;
1280
- }
1281
- finally {
1282
- this.recordParserCounter("parserDurationMs", performance.now() - parseStartedAt);
1283
- }
1284
- this.dbg(`Parsed, root node type: ${tree.rootNode.type}`);
1285
- this.treeCache.set(filePath, content, languageId, tree);
1286
- }
1287
- }
1288
- catch (err) {
1289
- this.reportWasmAbort(err);
1290
- this.dbg(`Parse error: ${err}`);
1291
- return NOT_PARSED;
1292
- }
1293
- try {
1294
- return { parsed: true, value: consume(tree) };
1295
- }
1296
- catch (error) {
1297
- this.reportWasmAbort(error);
1298
- throw error;
1299
- }
1300
- }
1301
- /**
1302
- * Detect and extract injected content from template literals
1303
- * Used for security analysis (SQL injection, unsafe regex, etc.)
1304
- */
1305
- extractInjections(filePath, content) {
1306
- const injections = [];
1307
- // Pattern: sql`SELECT * FROM users` or query`...`
1308
- const sqlPattern = /\b(sql|query|execute)\s*`([^`]+)`/gi;
1309
- let match;
1310
- while ((match = sqlPattern.exec(content)) !== null) {
1311
- const lines = content.slice(0, match.index).split("\n");
1312
- injections.push({
1313
- type: "sql",
1314
- content: match[2],
1315
- line: lines.length,
1316
- column: lines[lines.length - 1].length,
1317
- });
1318
- }
1319
- // Pattern: styled.div`color: red;` or css`...`
1320
- const cssPattern = /\b(styled(?:\.\w+)?|css)\s*`([^`]+)`/gi;
1321
- while ((match = cssPattern.exec(content)) !== null) {
1322
- const lines = content.slice(0, match.index).split("\n");
1323
- injections.push({
1324
- type: "css",
1325
- content: match[2],
1326
- line: lines.length,
1327
- column: lines[lines.length - 1].length,
1328
- });
1329
- }
1330
- // Pattern: new RegExp(`pattern`)
1331
- const regexPattern = /new\s+RegExp\s*\(\s*`([^`]+)`/gi;
1332
- while ((match = regexPattern.exec(content)) !== null) {
1333
- const lines = content.slice(0, match.index).split("\n");
1334
- injections.push({
1335
- type: "regex",
1336
- content: match[1],
1337
- line: lines.length,
1338
- column: lines[lines.length - 1].length,
1339
- });
1340
- }
1341
- this.dbg(`Found ${injections.length} injections in ${filePath}`);
1342
- return injections;
1343
- }
1344
- /** Check if tree-sitter is available (a core grammar resolves somewhere). */
1345
- isAvailable() {
1346
- if (this.wasmAborted)
1347
- return false;
1348
- // Available if the core TS grammar resolves in ANY source dir — the bundled
1349
- // `grammars/` counts even when web-tree-sitter/grammars is empty (no
1350
- // postinstall on pnpm/bun, or a fresh CI checkout).
1351
- if (this.resolveGrammarFile("tree-sitter-typescript.wasm"))
1352
- return true;
1353
- // Re-evaluate the legacy dir in case grammars were installed after start.
1354
- const dir = this.findGrammarsDir();
1355
- this.grammarsDir = dir;
1356
- return !!dir && fs.existsSync(dir);
1357
- }
1358
- /** Check if specific language is supported */
1359
- async isLanguageSupported(languageId) {
1360
- if (this.wasmAborted)
1361
- return false;
1362
- if (!this.initialized)
1363
- await this.init();
1364
- const language = await this.loadLanguage(languageId);
1365
- return language !== null;
1366
- }
1367
- /** Get loaded language for symbol extraction */
1368
- getLanguage(languageId) {
1369
- if (this.wasmAborted)
1370
- return null;
1371
- return this.languages.get(languageId) || null;
1372
- }
1373
- // --- Structural Search ---
1374
- /**
1375
- * Search for a structural pattern in files
1376
- *
1377
- * @param pattern - Pattern with metavariables (e.g., "console.log($MSG)")
1378
- * @param languageId - Language ID (typescript, python, etc.)
1379
- * @param rootDir - Directory to search
1380
- * @param options - Search options
1381
- * @returns Array of matches with captures
1382
- */
1383
- async structuralSearch(pattern, languageId, rootDir, options = {}) {
1384
- if (!this.initialized) {
1385
- const ok = await this.init();
1386
- if (!ok)
1387
- return [];
1388
- }
1389
- try {
1390
- await this.queryLoader.loadQueries(rootDir);
1391
- }
1392
- catch (err) {
1393
- this.dbg(`Failed to load queries for ${rootDir}: ${err}`);
1394
- }
1395
- // Compile pattern into tree-sitter query
1396
- this.dbg(`Compiling pattern: ${pattern.slice(0, 50)}...`);
1397
- const compiled = await this.compileQuery(pattern, languageId);
1398
- if (!compiled) {
1399
- this.dbg(`Pattern compilation failed`);
1400
- return [];
1401
- }
1402
- this.dbg(`Pattern compiled, metavars: ${compiled.metavars.join(", ")}`);
1403
- // Collect source files
1404
- const files = this.collectFiles(rootDir, languageId, options.fileFilter);
1405
- this.dbg(`Scanning ${files.length} files...`);
1406
- const matches = [];
1407
- const maxResults = options.maxResults ?? 50;
1408
- for (const file of files) {
1409
- if (matches.length >= maxResults)
1410
- break;
1411
- const fileMatches = await this.searchFileWithQuery(file, compiled.query, compiled.metavars, languageId, pattern, compiled.postFilter, compiled.postFilterParams);
1412
- matches.push(...fileMatches);
1413
- }
1414
- return matches.slice(0, maxResults);
1415
- }
1416
- /**
1417
- * Run a preloaded query definition against a single file.
1418
- *
1419
- * Optimized for dispatch runner usage to avoid per-query directory scans.
1420
- */
1421
- async runQueryOnFile(queryDef, filePath, languageId, options = {}, contentOverride) {
1422
- if (!this.initialized) {
1423
- const ok = await this.init();
1424
- if (!ok)
1425
- return [];
1426
- }
1427
- // Compile against the language the FILE is parsed as, never the query's
1428
- // own `language:` key. A Query is bound to the grammar it compiled
1429
- // against; run it on a tree from another grammar and tree-sitter returns
1430
- // zero matches forever — which is what the javascript→typescript rule
1431
- // merge had been doing (31 queries per JS file, structurally unable to
1432
- // fire). `getQueryCacheKey` already namespaces by language.
1433
- const compiled = await this.compileRawQuery(queryDef.id, queryDef.query, queryDef.metavars, languageId, queryDef.post_filter, queryDef.post_filter_params);
1434
- if (!compiled)
1435
- return [];
1436
- const matches = await this.searchFileWithQuery(filePath, compiled.query, compiled.metavars, languageId, queryDef.id, compiled.postFilter, compiled.postFilterParams, contentOverride);
1437
- const maxResults = options.maxResults ?? 50;
1438
- return matches.slice(0, maxResults);
1439
- }
1440
- /**
1441
- * Run a whole rule set against one file in a SINGLE tree walk (#675).
1442
- *
1443
- * Calling `runQueryOnFile` per rule re-walks the tree once per rule — ~34
1444
- * walks per file on a project scan, measured at 3.3× the cost of one
1445
- * combined query for byte-identical matches. Patterns are concatenated in
1446
- * rule order and `match.patternIndex` maps back to the owning rule, so
1447
- * per-rule metavars, predicates, post-filters and caps still apply and
1448
- * results stay grouped in rule order. Rules that don't compile against this
1449
- * language are dropped individually (never poisoning the batch), and a
1450
- * combined query that fails to compile falls back to per-rule execution.
1451
- */
1452
- async runQueriesOnFile(queryDefs, filePath, languageId, options = {}, contentOverride) {
1453
- if (queryDefs.length === 0)
1454
- return [];
1455
- if (!this.initialized) {
1456
- const ok = await this.init();
1457
- if (!ok)
1458
- return [];
1459
- }
1460
- const maxResults = options.maxResults ?? 50;
1461
- const batch = await this.compileQueryBatch(queryDefs, languageId);
1462
- if (!batch) {
1463
- // Fallback: one walk per rule, preserving rule order.
1464
- const results = [];
1465
- for (const queryDef of queryDefs) {
1466
- const matches = await this.runQueryOnFile(queryDef, filePath, languageId, options, contentOverride);
1467
- for (const match of matches)
1468
- results.push({ queryDef, match });
1469
- }
1470
- return results;
1471
- }
1472
- const perQuery = new Map();
1473
- await this.parseFileAndUse(filePath, languageId, contentOverride, (tree) => {
1474
- try {
1475
- const rootNode = tree.rootNode;
1476
- for (const match of batch.query.matches(rootNode)) {
1477
- const owner = batch.ownerOfPattern[match.patternIndex];
1478
- if (owner === undefined)
1479
- continue;
1480
- const bucket = perQuery.get(owner) ?? [];
1481
- if (bucket.length >= maxResults)
1482
- continue;
1483
- const entry = batch.entries[owner];
1484
- const captures = {};
1485
- for (const capture of match.captures) {
1486
- if (entry.metavars.includes(capture.name)) {
1487
- captures[capture.name] = capture.node;
1488
- }
1489
- }
1490
- if (!this.evaluatePredicates(batch.query, match))
1491
- continue;
1492
- if (entry.postFilter &&
1493
- !this.applyPostFilter(entry.postFilter, entry.postFilterParams, captures, rootNode)) {
1494
- continue;
1495
- }
1496
- if (match.captures.length === 0)
1497
- continue;
1498
- const firstNode = match.captures[0].node;
1499
- const textCaptures = {};
1500
- for (const [name, node] of Object.entries(captures)) {
1501
- textCaptures[name] = node.text;
1502
- }
1503
- bucket.push({
1504
- file: filePath,
1505
- line: firstNode.startPosition.row + 1,
1506
- column: firstNode.startPosition.column + 1,
1507
- matchedText: firstNode.text,
1508
- nodeType: firstNode.type,
1509
- captures: textCaptures,
1510
- });
1511
- perQuery.set(owner, bucket);
1512
- }
1513
- }
1514
- catch (err) {
1515
- this.reportWasmAbort(err);
1516
- this.dbg(`Batched query matching error: ${err}`);
1517
- }
1518
- });
1519
- const results = [];
1520
- for (let i = 0; i < batch.entries.length; i++) {
1521
- for (const match of perQuery.get(i) ?? []) {
1522
- results.push({ queryDef: batch.entries[i].queryDef, match });
1523
- }
1524
- }
1525
- return results;
1526
- }
1527
- /**
1528
- * Compile `queryDefs` into one multi-pattern Query for `languageId`, with a
1529
- * pattern-index → rule map. Cached per language + rule-set identity.
1530
- */
1531
- async compileQueryBatch(queryDefs, languageId) {
1532
- // Key on rule CONTENT, not just ids: the batch stores each queryDef (its
1533
- // message reaches diagnostics) and the compiled patterns. Rule ids are
1534
- // stable across edits, so an id-only key kept serving the pre-edit batch
1535
- // for the process lifetime even after the runner reloaded the rules from
1536
- // disk on a RuleCache miss (#878). sha256 keeps the key bounded without
1537
- // #889's 32-bit collision risk.
1538
- const identity = crypto
1539
- .createHash("sha256")
1540
- .update(JSON.stringify(queryDefs))
1541
- .digest("hex");
1542
- const cacheKey = this.getQueryCacheKey(`batch:${identity}`, languageId);
1543
- const cached = this.queryBatchCache.get(cacheKey);
1544
- if (cached !== undefined) {
1545
- this.queryBatchCache.delete(cacheKey);
1546
- this.queryBatchCache.set(cacheKey, cached);
1547
- return cached;
1548
- }
1549
- // A loadLanguage() failure is transient (offline lazy grammar fetch,
1550
- // transient mid-scan load error) — do NOT cache null for it, or every
1551
- // later scan pays the ~3.3x per-rule fallback for the process lifetime
1552
- // (#889). Retry on the next call, bounded so a grammar that never loads
1553
- // doesn't re-attempt every scan.
1554
- const language = await this.loadLanguage(languageId);
1555
- if (!language) {
1556
- const failures = (this.queryBatchLoadFailures.get(cacheKey) ?? 0) + 1;
1557
- if (failures >= QUERY_BATCH_MAX_LOAD_FAILURES) {
1558
- this.dbg(`Batch: grammar for ${languageId} failed to load ${failures} times — caching miss`);
1559
- this.queryBatchLoadFailures.delete(cacheKey);
1560
- this.cacheQueryBatch(cacheKey, null);
1561
- }
1562
- else {
1563
- this.queryBatchLoadFailures.set(cacheKey, failures);
1564
- }
1565
- return null;
1566
- }
1567
- this.queryBatchLoadFailures.delete(cacheKey);
1568
- const build = async () => {
1569
- const Query = (await loadWebTreeSitter()).Query;
1570
- const entries = [];
1571
- const sources = [];
1572
- const ownerOfPattern = [];
1573
- for (const queryDef of queryDefs) {
1574
- let patternCount;
1575
- try {
1576
- // biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
1577
- const probe = new Query(language, queryDef.query);
1578
- patternCount = probe.patternCount();
1579
- probe.delete?.();
1580
- }
1581
- catch (err) {
1582
- if (this.reportWasmAbort(err))
1583
- return null;
1584
- // A rule that can't compile against THIS grammar is simply not
1585
- // applicable here (e.g. a `type_annotation` pattern on javascript).
1586
- this.dbg(`Batch: skipping ${queryDef.id} for ${languageId}: ${err}`);
1587
- this.reportQueryCompileFailure(queryDef.id, languageId, err);
1588
- continue;
1589
- }
1590
- const owner = entries.length;
1591
- entries.push({
1592
- queryDef,
1593
- metavars: queryDef.metavars ?? [],
1594
- postFilter: queryDef.post_filter,
1595
- postFilterParams: queryDef.post_filter_params,
1596
- });
1597
- sources.push(queryDef.query);
1598
- for (let i = 0; i < patternCount; i++)
1599
- ownerOfPattern.push(owner);
1600
- }
1601
- if (entries.length === 0)
1602
- return null;
1603
- try {
1604
- // biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
1605
- const query = new Query(language, sources.join("\n"));
1606
- if (query.patternCount() !== ownerOfPattern.length) {
1607
- this.dbg(`Batch pattern count mismatch for ${languageId} (${query.patternCount()} vs ${ownerOfPattern.length}) — falling back`);
1608
- return null;
1609
- }
1610
- return { query, entries, ownerOfPattern };
1611
- }
1612
- catch (err) {
1613
- if (this.reportWasmAbort(err))
1614
- return null;
1615
- this.dbg(`Batch compile failed for ${languageId}: ${err}`);
1616
- return null;
1617
- }
1618
- };
1619
- const batch = await build();
1620
- this.cacheQueryBatch(cacheKey, batch);
1621
- return batch;
1622
- }
1623
- /**
1624
- * Convert pattern to tree-sitter query
1625
- * First tries to load from query files, then falls back to inline patterns
1626
- */
1627
- patternToQuery(pattern, languageId) {
1628
- // Try to find matching query from loaded files
1629
- const loadedQuery = this.queryLoader.findMatchingQuery(pattern, languageId);
1630
- if (loadedQuery) {
1631
- this.dbg(`Using loaded query: ${loadedQuery.id}`);
1632
- return {
1633
- query: loadedQuery.query,
1634
- metavars: loadedQuery.metavars,
1635
- postFilter: loadedQuery.post_filter,
1636
- postFilterParams: loadedQuery.post_filter_params,
1637
- queryDef: loadedQuery,
1638
- };
1639
- }
1640
- // Fallback to inline patterns
1641
- return this.getInlinePattern(pattern);
1642
- }
1643
- /**
1644
- * Inline patterns as fallback when no query file matches
1645
- */
1646
- getInlinePattern(pattern) {
1647
- // Pattern: async function $NAME($$$PARAMS) { $BODY }
1648
- if (pattern.includes("async function") && pattern.includes("$NAME")) {
1649
- return {
1650
- query: `(function_declaration
1651
- "async"
1652
- name: (identifier) @NAME
1653
- parameters: (formal_parameters) @PARAMS
1654
- body: (statement_block) @BODY)`,
1655
- metavars: ["NAME", "PARAMS", "BODY"],
1656
- };
1657
- }
1658
- // Pattern: console.$METHOD($MSG)
1659
- if (pattern.includes("console")) {
1660
- return {
1661
- query: `(call_expression
1662
- function: (member_expression
1663
- object: (identifier) @OBJ (#eq? @OBJ "console")
1664
- property: (property_identifier) @METHOD)
1665
- arguments: (arguments) @ARGS)`,
1666
- metavars: ["OBJ", "METHOD", "ARGS"],
1667
- };
1668
- }
1669
- // Pattern: function $NAME($$$PARAMS) { $BODY } - match long parameter lists
1670
- if (pattern.includes("function $NAME") && pattern.includes("PARAMS")) {
1671
- return {
1672
- query: `(function_declaration
1673
- name: (identifier) @NAME
1674
- parameters: (formal_parameters) @PARAMS
1675
- body: (statement_block) @BODY)`,
1676
- metavars: ["NAME", "PARAMS", "BODY"],
1677
- postFilter: "count_params",
1678
- postFilterParams: { min_params: 6 },
1679
- };
1680
- }
1681
- // Pattern: promise chains with .then().catch().then() - 3+ levels
1682
- if (pattern.includes(".then") && pattern.includes(".catch")) {
1683
- return {
1684
- query: `(call_expression
1685
- function: (member_expression
1686
- object: (call_expression
1687
- function: (member_expression
1688
- object: (call_expression
1689
- function: (member_expression
1690
- property: (property_identifier) @M1)
1691
- arguments: (arguments))
1692
- property: (property_identifier) @M2)
1693
- arguments: (arguments))
1694
- property: (property_identifier) @M3)
1695
- arguments: (arguments))
1696
- (#match? @M1 "^(then|catch)$")
1697
- (#match? @M2 "^(then|catch)$")
1698
- (#match? @M3 "^(then|catch)$")`,
1699
- metavars: ["M1", "M2", "M3"],
1700
- };
1701
- }
1702
- // Fallback: try to create a simple identifier capture
1703
- const simpleMatch = pattern.match(/\$([A-Z_][A-Z0-9_]*)/);
1704
- if (simpleMatch) {
1705
- const name = simpleMatch[1];
1706
- return {
1707
- query: `(identifier) @${name}`,
1708
- metavars: [name],
1709
- };
1710
- }
1711
- // If we can't convert, return empty to trigger fallback
1712
- return { query: "", metavars: [] };
1713
- }
1714
- /**
1715
- * Inject native tree-sitter predicates into S-expression query
1716
- * This moves text filtering to WASM for better performance
1717
- */
1718
- /** Generate cache key for compiled query */
1719
- getQueryCacheKey(pattern, languageId) {
1720
- // Full pattern text as the key. The previous 32-bit hash could collide
1721
- // across rules and permanently poison a cache slot with the wrong
1722
- // compiled query (or a null meant for a different rule set) (#889).
1723
- return `${languageId}:${pattern}`;
1724
- }
1725
- /** Compile a pattern into a tree-sitter Query with caching */
1726
- async compileQuery(pattern, languageId) {
1727
- const cacheKey = this.getQueryCacheKey(pattern, languageId);
1728
- // Check cache first
1729
- if (this.queryCache.has(cacheKey)) {
1730
- this.dbg(`Query cache hit: ${cacheKey}`);
1731
- const cached = this.queryCache.get(cacheKey);
1732
- this.queryCache.delete(cacheKey);
1733
- this.queryCache.set(cacheKey, cached);
1734
- return cached;
1735
- }
1736
- const language = await this.loadLanguage(languageId);
1737
- if (!language) {
1738
- this.dbg(`Could not load language ${languageId}`);
1739
- return null;
1740
- }
1741
- const { query: queryStr, metavars, postFilter, postFilterParams, } = this.patternToQuery(pattern, languageId);
1742
- this.dbg(`Query string: ${queryStr.slice(0, 100)}...`);
1743
- try {
1744
- // biome-ignore lint/suspicious/noExplicitAny: Query constructor
1745
- const Query = (await loadWebTreeSitter()).Query;
1746
- // biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
1747
- const query = new Query(language, queryStr);
1748
- this.dbg(`Query compiled with ${query.patternCount()} patterns`);
1749
- const result = { query, metavars, postFilter, postFilterParams };
1750
- // Cache the compiled query
1751
- this.cacheQuery(cacheKey, result);
1752
- return result;
1753
- }
1754
- catch (err) {
1755
- this.reportWasmAbort(err);
1756
- this.dbg(`Query compilation failed: ${err}`);
1757
- return null;
1758
- }
1759
- }
1760
- /** Compile a raw tree-sitter query string with caching */
1761
- async compileRawQuery(queryId, queryStr, metavars, languageId, postFilter, postFilterParams) {
1762
- const cacheKey = this.getQueryCacheKey(`raw:${queryId}:${queryStr}`, languageId);
1763
- if (this.queryCache.has(cacheKey)) {
1764
- const cached = this.queryCache.get(cacheKey);
1765
- this.queryCache.delete(cacheKey);
1766
- this.queryCache.set(cacheKey, cached);
1767
- return cached;
1768
- }
1769
- const language = await this.loadLanguage(languageId);
1770
- if (!language)
1771
- return null;
1772
- try {
1773
- // biome-ignore lint/suspicious/noExplicitAny: Query constructor from web-tree-sitter
1774
- const Query = (await loadWebTreeSitter()).Query;
1775
- // biome-ignore lint/suspicious/noExplicitAny: Language type compatibility
1776
- const query = new Query(language, queryStr);
1777
- const result = { query, metavars, postFilter, postFilterParams };
1778
- this.cacheQuery(cacheKey, result);
1779
- return result;
1780
- }
1781
- catch (err) {
1782
- this.reportWasmAbort(err);
1783
- this.dbg(`Raw query compilation failed (${queryId}): ${err}`);
1784
- this.reportQueryCompileFailure(queryId, languageId, err);
1785
- return null;
1786
- }
1787
- }
1788
- reportedCompileFailures = new Set();
1789
- /** Warn once per rule+grammar pair whose query fails to compile — a silently-dead rule needs a trail. */
1790
- reportQueryCompileFailure(ruleId, languageId, err) {
1791
- // Keyed by rule AND language: a rule can be dispatched against more than one
1792
- // grammar (`queriesForLanguage` hands the typescript set to tsx too), and
1793
- // failing on one must not mute the report for the others.
1794
- const key = `${ruleId}:${languageId}`;
1795
- if (this.reportedCompileFailures.has(key))
1796
- return;
1797
- this.reportedCompileFailures.add(key);
1798
- logTreeSitterDiagnostic({
1799
- subsystem: "tree-sitter-client",
1800
- languageId,
1801
- message: `tree-sitter rule '${ruleId}' failed to compile against '${languageId}' — ` +
1802
- `matches for this rule are silently dropped rather than reported. ` +
1803
- `Fix the query in the rule definition to re-enable it. (${err})`,
1804
- metadata: { ruleId },
1805
- });
1806
- }
1807
- hasChildToken(node, token) {
1808
- return node.children?.some((child) => child.type === token || child.text === token);
1809
- }
1810
- /**
1811
- * Collect names introduced by a parameter binding pattern.
1812
- *
1813
- * Binding-aware: only identifiers in a *binding* position are counted.
1814
- * Two node shapes hold reference expressions rather than bindings and
1815
- * must not be descended into:
1816
- * - `assignment_pattern` / `object_assignment_pattern` (`pattern = default`,
1817
- * e.g. `[a = b]` or `{a = b}`) — the `value`/`right` side is a default
1818
- * *expression*, not a new name (`b` in `{a = b}` is a reference).
1819
- * - `computed_property_name` (`[expr]:` in a destructuring pattern,
1820
- * e.g. `{[key]: a}`) — `expr` is a reference, not a binding.
1821
- * Nested binding positions (destructured sub-patterns) are still walked
1822
- * so renamed/duplicate collisions inside them are found.
1823
- */
1824
- bindingNames(node) {
1825
- const names = new Set();
1826
- if (!node)
1827
- return names;
1828
- const stack = [node];
1829
- while (stack.length > 0) {
1830
- const current = stack.pop();
1831
- if (current.type === "identifier")
1832
- names.add(current.text);
1833
- else if (current.type === "shorthand_property_identifier_pattern") {
1834
- names.add(current.text);
1835
- }
1836
- if (current.type === "property_identifier" ||
1837
- current.type === "type_identifier" ||
1838
- current.type === "computed_property_name")
1839
- continue;
1840
- if (current.type === "assignment_pattern" ||
1841
- current.type === "object_assignment_pattern") {
1842
- const left = current.childForFieldName?.("left");
1843
- if (left)
1844
- stack.push(left);
1845
- continue;
1846
- }
1847
- stack.push(...(current.children ?? []));
1848
- }
1849
- return names;
1850
- }
1851
- containsYieldInFunctionBody(node, root = node) {
1852
- for (const child of node.children ?? []) {
1853
- if (child.type === "yield")
1854
- return true;
1855
- if (child !== root &&
1856
- ["function_definition", "class_definition", "lambda"].includes(child.type)) {
1857
- continue;
1858
- }
1859
- if (this.containsYieldInFunctionBody(child, root))
1860
- return true;
1861
- }
1862
- return false;
1863
- }
1864
- /**
1865
- * SQLAlchemy sessions are conventionally bound to one of a handful of
1866
- * receiver names. Structural provenance (`python-provenance.ts`) proves the
1867
- * annotated case; this name check keeps the far more common unannotated
1868
- * `session.execute(stmt)` quiet, as it has been since the exemption was
1869
- * added. Removing it regresses every codebase that never annotates.
1870
- */
1871
- isLikelySqlAlchemyReceiver(text) {
1872
- const tail = text.split(".").pop() ?? text;
1873
- return PYTHON_SQLALCHEMY_RECEIVER_NAMES.has(tail.toLowerCase());
1874
- }
1875
- /**
1876
- * The body statements of a `switch_case`, in order. A switch_case's named
1877
- * children are `[value, ...statements]` (its statements are direct children,
1878
- * not a wrapping statement_block), so drop the leading `case <value>` and any
1879
- * comments.
1880
- */
1881
- switchCaseBodyStatements(caseNode) {
1882
- // biome-ignore lint/suspicious/noExplicitAny: AST child iteration
1883
- const named = (caseNode.children ?? []).filter((c) => c.isNamed && !c.type.includes("comment"));
1884
- // First named child is the case's value expression (`case <value>`).
1885
- return named.slice(1);
1886
- }
1887
- /**
1888
- * Whether a case body carries an intentional fallthrough marker comment
1889
- * (`// fallthrough`, `// falls through`, …). Only a *trailing* marker is
1890
- * honored: a comment attached to the case node after its body statements
1891
- * (TypeScript), the case's next sibling in the switch body (JavaScript), or
1892
- * one inside the trailing body statement's block. A comment in a nested
1893
- * function or an earlier statement is not a marker for this case and must
1894
- * not suppress a genuine fall-through. Only comment nodes are checked, never
1895
- * the case value or statement text.
1896
- */
1897
- hasFallthroughMarker(caseNode) {
1898
- const comments = [];
1899
- const body = this.switchCaseBodyStatements(caseNode);
1900
- const last = body[body.length - 1];
1901
- const lastEnd = last?.endIndex ?? caseNode.startIndex;
1902
- // TypeScript attaches a trailing `// fallthrough` as a direct child of the
1903
- // case node, after the body statements.
1904
- for (const c of caseNode.children ?? []) {
1905
- if (c.type.includes("comment") && c.startIndex >= lastEnd) {
1906
- comments.push(c);
1907
- }
1908
- }
1909
- // JavaScript attaches it to the switch body as the case's next sibling.
1910
- const siblings = (caseNode.parent?.children ?? []).filter((c) => c.isNamed);
1911
- const index = siblings.findIndex((c) => c.startIndex === caseNode.startIndex);
1912
- const next = index >= 0 ? siblings[index + 1] : undefined;
1913
- if (next?.type.includes("comment"))
1914
- comments.push(next);
1915
- // A marker inside the trailing body statement's block (e.g. `{ work();
1916
- // /* fallthrough */ }`). Only the trailing statement is walked, and nested
1917
- // functions are not descended into, so a comment in a nested function or
1918
- // an earlier statement can't suppress a genuine fall-through.
1919
- if (last) {
1920
- const NESTED_FN = new Set([
1921
- "function_declaration",
1922
- "function_expression",
1923
- "arrow_function",
1924
- "method_definition",
1925
- "generator_function",
1926
- "generator_function_declaration",
1927
- "class_declaration",
1928
- ]);
1929
- const stack = [last];
1930
- for (let visited = 0; stack.length > 0 && visited < 500; visited++) {
1931
- const node = stack.pop();
1932
- if (!node)
1933
- break;
1934
- if (node.type.includes("comment"))
1935
- comments.push(node);
1936
- if (!NESTED_FN.has(node.type)) {
1937
- stack.push(...(node.children ?? []));
1938
- }
1939
- }
1940
- }
1941
- return comments.some((c) => /falls?\s?through/i.test(c.text));
1942
- }
1943
- /**
1944
- * Whether a statement terminates control flow (does not fall through to the
1945
- * next statement). Handles terminators, trailing blocks, try/catch/finally,
1946
- * and exhaustive if/else. Fail-safe: any unrecognized shape returns false
1947
- * (falls through), so a blocking rule never under-reports. Depth-bounded so a
1948
- * pathological nesting cannot blow the stack.
1949
- */
1950
- statementTerminates(node, depth = 0) {
1951
- if (depth > 20)
1952
- return false;
1953
- const t = node.type;
1954
- const TERMINATORS = new Set([
1955
- "break_statement",
1956
- "return_statement",
1957
- "throw_statement",
1958
- "continue_statement",
1959
- ]);
1960
- if (TERMINATORS.has(t))
1961
- return true;
1962
- // Wrapper nodes whose trailing statement decides termination: a block, an
1963
- // `else` clause, a `catch` clause, or a `finally` clause.
1964
- if (t === "statement_block" ||
1965
- t === "else_clause" ||
1966
- t === "catch_clause" ||
1967
- t === "finally_clause") {
1968
- const inner = (node.children ?? []).filter((c) => c.isNamed && !c.type.includes("comment"));
1969
- const last = inner[inner.length - 1];
1970
- if (!last)
1971
- return false; // empty wrapper falls through
1972
- return this.statementTerminates(last, depth + 1);
1973
- }
1974
- if (t === "try_statement") {
1975
- // The try body is the first statement_block child. A returning/throwing
1976
- // try completes after finally, so only a catch that handles a thrown try
1977
- // and falls through can still reach the end. A try body that completes
1978
- // normally reaches the end unless a finally terminates it.
1979
- const tryBody = (node.children ?? []).find((c) => c.type === "statement_block");
1980
- const tryTerminates = tryBody
1981
- ? this.statementTerminates(tryBody, depth + 1)
1982
- : false;
1983
- const catches = (node.children ?? []).filter((c) => c.type === "catch_clause");
1984
- const fin = (node.children ?? []).find((c) => c.type === "finally_clause");
1985
- if (tryTerminates) {
1986
- return catches.every((c) => this.statementTerminates(c, depth + 1));
1987
- }
1988
- return !!fin && this.statementTerminates(fin, depth + 1);
1989
- }
1990
- if (t === "if_statement") {
1991
- // An if only terminates when it has an else and both branches do.
1992
- const named = (node.children ?? []).filter((c) => c.isNamed && !c.type.includes("comment"));
1993
- // named = [condition, consequence, (else_clause)]
1994
- const consequence = named[1];
1995
- const alternative = named[2];
1996
- if (!consequence || !alternative)
1997
- return false;
1998
- return (this.statementTerminates(consequence, depth + 1) &&
1999
- this.statementTerminates(alternative, depth + 1));
2000
- }
2001
- return false;
2002
- }
2003
- /**
2004
- * Whether a `switch_case`'s next sibling is another label — `case "a": case
2005
- * "b": handle()` groups two labels onto one body, which is idiomatic, not a
2006
- * dead case.
2007
- */
2008
- nextSiblingIsSwitchLabel(caseNode) {
2009
- const siblings = (caseNode.parent?.children ?? []).filter((c) => c.isNamed && !c.type.includes("comment"));
2010
- // Match by source offset: web-tree-sitter hands out fresh node wrappers per
2011
- // access, so identity comparison against `caseNode` is not reliable.
2012
- const index = siblings.findIndex((c) => c.startIndex === caseNode.startIndex);
2013
- const next = index >= 0 ? siblings[index + 1] : undefined;
2014
- return next?.type === "switch_case" || next?.type === "switch_default";
2015
- }
2016
- /**
2017
- * Whether a loop body contains a statement that can terminate the loop:
2018
- * `return`/`throw` anywhere (they unwind past the loop), or a `break` that is
2019
- * not swallowed by a nested loop/switch. Does not descend into nested
2020
- * functions, whose `return` exits the function rather than the loop.
2021
- */
2022
- bodyHasLoopExit(node, insideNestedLoop) {
2023
- const NESTS_BREAK = new Set([
2024
- "for_statement",
2025
- "for_in_statement",
2026
- "while_statement",
2027
- "do_statement",
2028
- "switch_statement",
2029
- ]);
2030
- const NESTED_FN = new Set([
2031
- "function_declaration",
2032
- "function_expression",
2033
- "arrow_function",
2034
- "method_definition",
2035
- "generator_function",
2036
- "generator_function_declaration",
2037
- "class_declaration",
2038
- ]);
2039
- for (const child of node.children ?? []) {
2040
- const t = child.type;
2041
- if (NESTED_FN.has(t))
2042
- continue;
2043
- if (t === "return_statement" || t === "throw_statement")
2044
- return true;
2045
- // A labeled `break outer;` is not swallowed by the nested loop it sits
2046
- // in. Which label it targets isn't resolved here — counting any labeled
2047
- // break as an exit errs toward not flagging, the safe direction for a
2048
- // blocking rule.
2049
- if (t === "break_statement") {
2050
- const labeled = (child.children ?? []).some((c) => c.isNamed);
2051
- if (!insideNestedLoop || labeled)
2052
- return true;
2053
- }
2054
- if (this.bodyHasLoopExit(child, insideNestedLoop || NESTS_BREAK.has(t))) {
2055
- return true;
2056
- }
2057
- }
2058
- return false;
2059
- }
2060
- /**
2061
- * Name of the binding a node's value flows into: the declared name of an
2062
- * enclosing `variable_declarator`, or the left-hand side of an enclosing
2063
- * `assignment_expression`. Returns "" if the node is not part of a binding
2064
- * (the walk stops at function/block/program boundaries so it never reaches
2065
- * out to an unrelated outer binding).
2066
- */
2067
- enclosingBindingName(node) {
2068
- let cur = node?.parent;
2069
- for (let depth = 0; cur && depth < 12; depth++) {
2070
- const t = cur.type;
2071
- if (t === "variable_declarator") {
2072
- const name = cur.children?.find((c) => c.isNamed);
2073
- return name?.text ?? "";
2074
- }
2075
- if (t === "assignment_expression") {
2076
- return cur.children?.[0]?.text ?? "";
2077
- }
2078
- if (t === "statement_block" ||
2079
- t === "program" ||
2080
- t === "function_declaration" ||
2081
- t === "function_expression" ||
2082
- t === "arrow_function" ||
2083
- t === "method_definition") {
2084
- return "";
2085
- }
2086
- cur = cur.parent;
2087
- }
2088
- return "";
2089
- }
2090
- /**
2091
- * Resolves `name` (as used in the *same file*) to a provably fixed URL:
2092
- * a `const` declarator whose initializer is a string literal, or a
2093
- * template literal with no `${...}` substitutions.
2094
- *
2095
- * This is deliberately conservative — naming convention (e.g.
2096
- * SCREAMING_SNAKE_CASE) proves nothing about provenance, so it is never
2097
- * consulted here. Any of the following makes resolution fail (and the
2098
- * caller must then treat the identifier as potentially tainted):
2099
- * - no declarator found for `name` in this file;
2100
- * - more than one declarator for `name` (ambiguous/shadowed — refuse
2101
- * rather than guess which one applies at the use site);
2102
- * - declared with `let`/`var` (not `const`);
2103
- * - the identifier is reassigned anywhere in the file
2104
- * (`name = ...`), even if the declaration itself is `const`-like in
2105
- * spirit — this also catches destructuring/compound-assignment
2106
- * edge cases conservatively since we only special-case a clean
2107
- * assignment_expression;
2108
- * - the initializer is not a plain string/no-substitution template
2109
- * literal (e.g. `process.env.X`, a function call, a member
2110
- * expression, another identifier).
2111
- */
2112
- resolvesToFileLiteralConst(name, rootNode) {
2113
- const valueNode = this.resolveFileConstValueNode(name, rootNode);
2114
- if (!valueNode)
2115
- return false;
2116
- return this.isFixedUrlLiteralExpr(valueNode);
2117
- }
2118
- /**
2119
- * Resolves `name` to the initializer value node of its *single, clean*
2120
- * file-local `const` declarator, or `null` when resolution must be refused.
2121
- *
2122
- * Refusal (returns `null`) on any of: no declarator; more than one
2123
- * declarator (shadowed/ambiguous — don't guess); a `let`/`var` binding for
2124
- * the same name anywhere; or a reassignment (`name = ...`) anywhere in the
2125
- * file. This is the shared, provenance-safe gate used by every "provably
2126
- * fixed value" check; callers inspect the returned value node themselves.
2127
- */
2128
- resolveFileConstValueNode(name, rootNode) {
2129
- const constDeclarators = [];
2130
- let hasNonConstBinding = false;
2131
- let hasReassignment = false;
2132
- const stack = [rootNode];
2133
- while (stack.length > 0) {
2134
- const node = stack.pop();
2135
- if (!node)
2136
- continue;
2137
- if (node.type === "variable_declarator") {
2138
- const nameNode = node.childForFieldName?.("name");
2139
- if (nameNode?.type === "identifier" && nameNode.text === name) {
2140
- const decl = node.parent;
2141
- const isConst = decl?.type === "lexical_declaration" &&
2142
- (decl.children ?? []).some((c) => c.type === "const");
2143
- if (isConst) {
2144
- constDeclarators.push(node);
2145
- }
2146
- else {
2147
- hasNonConstBinding = true;
2148
- }
2149
- }
2150
- }
2151
- else if (node.type === "assignment_expression" ||
2152
- node.type === "augmented_assignment_expression") {
2153
- const left = node.childForFieldName?.("left");
2154
- if (left?.type === "identifier" && left.text === name) {
2155
- hasReassignment = true;
2156
- }
2157
- else if ((left?.type === "member_expression" ||
2158
- left?.type === "subscript_expression") &&
2159
- left.childForFieldName?.("object")?.type === "identifier" &&
2160
- left.childForFieldName?.("object")?.text === name) {
2161
- // Property/subscript write to the bound receiver
2162
- // (`name.<prop> = …` / `name[…] = …`). Any origin/host/path/
2163
- // protocol/port/href mutation re-taints the destination after
2164
- // construction, so fail closed and treat it as a reassignment
2165
- // (#1008). The ONE safe exception is a query-string-only write
2166
- // (`name.search = …`), which never alters the origin;
2167
- // `name.searchParams.<method>(…)` is a call_expression (not an
2168
- // assignment) and so never reaches this branch, staying exempt.
2169
- const searchOnly = left.type === "member_expression" &&
2170
- left.childForFieldName?.("property")?.text === "search";
2171
- if (!searchOnly) {
2172
- hasReassignment = true;
2173
- }
2174
- }
2175
- }
2176
- for (const child of node.children ?? [])
2177
- stack.push(child);
2178
- }
2179
- // Ambiguous (shadowed/duplicated), reassigned anywhere, or backed by a
2180
- // non-const binding somewhere in the file: refuse to resolve.
2181
- if (hasNonConstBinding ||
2182
- hasReassignment ||
2183
- constDeclarators.length !== 1) {
2184
- return null;
2185
- }
2186
- return constDeclarators[0].childForFieldName?.("value") ?? null;
2187
- }
2188
- /**
2189
- * True when `node` is a self-contained fixed URL string: a plain string
2190
- * literal, or a template literal with no `${...}` substitutions.
2191
- */
2192
- isFixedUrlLiteralExpr(node) {
2193
- if (node.type === "string")
2194
- return true;
2195
- if (node.type === "template_string") {
2196
- return !(node.children ?? []).some((c) => c.type === "template_substitution");
2197
- }
2198
- return false;
2199
- }
2200
- /**
2201
- * True when `name` is a binding introduced by an `import` in this file
2202
- * (named/aliased/default/namespace). An import binding is immutable and its
2203
- * value is fixed at module-load time from source — it is never request- or
2204
- * attacker-scoped, so an imported base URL is treated as fixed. (Limitation:
2205
- * we cannot see the exporting module, so an imported value that is itself
2206
- * `process.env.X` in another file is not distinguished — an accepted, bounded
2207
- * gap; direct env/param/request taint at the sink still fires.)
2208
- */
2209
- isImportedBinding(name, rootNode) {
2210
- return this.importedAs(name, rootNode) !== null;
2211
- }
2212
- /**
2213
- * If `name` is an import binding, returns the *imported* name (e.g. `URL`
2214
- * for `import { URL as NodeURL }` → `importedAs("NodeURL") === "URL"`, and
2215
- * `importedAs("URL") === "URL"`). Returns `null` when `name` is not imported.
2216
- */
2217
- importedAs(name, rootNode) {
2218
- const stack = [rootNode];
2219
- while (stack.length > 0) {
2220
- const node = stack.pop();
2221
- if (!node)
2222
- continue;
2223
- if (node.type === "import_specifier") {
2224
- const nameNode = node.childForFieldName?.("name");
2225
- const aliasNode = node.childForFieldName?.("alias");
2226
- const local = (aliasNode ?? nameNode)?.text;
2227
- if (local === name)
2228
- return nameNode?.text ?? null;
2229
- }
2230
- else if (node.type === "namespace_import" ||
2231
- node.type === "import_clause") {
2232
- // `import X from …` / `import * as X from …` — default/namespace
2233
- // local binding is a direct identifier child.
2234
- for (const c of node.children ?? []) {
2235
- if (c.type === "identifier" && c.text === name)
2236
- return name;
2237
- }
2238
- }
2239
- for (const child of node.children ?? [])
2240
- stack.push(child);
2241
- }
2242
- return null;
2243
- }
2244
- /**
2245
- * True when `base` (the second argument of `new URL(path, base)`) is a
2246
- * provably fixed origin: a literal/substitution-free template, an identifier
2247
- * resolving to a file-local literal `const` or an import binding, or a
2248
- * template whose every `${…}` substitution is such an identifier. Anything
2249
- * else (function params, `process.env.X`, member expressions, calls) fails.
2250
- */
2251
- isFixedUrlBaseExpr(base, rootNode) {
2252
- if (base.type === "string")
2253
- return true;
2254
- if (base.type === "identifier") {
2255
- return this.isFixedBaseIdentifier(base, rootNode);
2256
- }
2257
- if (base.type === "template_string") {
2258
- for (const child of base.children ?? []) {
2259
- if (child.type !== "template_substitution")
2260
- continue;
2261
- const inner = (child.children ?? []).find((c) => c.isNamed);
2262
- if (!inner ||
2263
- inner.type !== "identifier" ||
2264
- !this.isFixedBaseIdentifier(inner, rootNode)) {
2265
- return false;
2266
- }
2267
- }
2268
- return true;
2269
- }
2270
- return false;
2271
- }
2272
- /**
2273
- * True when the identifier `ident` (used as, or inside, a `new URL` base) is
2274
- * a provably fixed origin AT ITS USE SITE. A file-local literal `const` is
2275
- * only trusted when no nearer binding shadows it: `resolveFileConstValueNode`
2276
- * already fails closed on any `let`/`var` of the same name and on multiple
2277
- * `const` declarators, but function/method PARAMETERS are not variable
2278
- * declarators and so slip past that gate — a request-tainted parameter base
2279
- * would otherwise be exempted merely because an unrelated same-named
2280
- * module-level `const` literal exists (#1008). So we additionally refuse the
2281
- * file-const path when an enclosing function on the path from the use site to
2282
- * the module root binds a parameter of the same name. Imported bindings stay
2283
- * trusted unconditionally (imported-base-as-fixed is sound; see #1000).
2284
- */
2285
- isFixedBaseIdentifier(ident, rootNode) {
2286
- const name = ident.text;
2287
- if (!this.isShadowedByEnclosingParam(ident, name) &&
2288
- this.resolvesToFileLiteralConst(name, rootNode)) {
2289
- return true;
2290
- }
2291
- return this.isImportedBinding(name, rootNode);
2292
- }
2293
- /**
2294
- * True when some function/method/arrow on the ancestor chain of `node` (up to
2295
- * the module root) binds a PARAMETER named `name` — i.e. `name` at `node`'s
2296
- * location resolves to a parameter, not to an outer `const`. Only binding
2297
- * positions are inspected (a parameter's `pattern`, including destructured
2298
- * bindings); default-value expressions (`= expr`) are uses, not bindings, and
2299
- * are skipped so an outer const referenced in a default is not mistaken for a
2300
- * shadow. Fail-closed bias: unknown parameter shapes that surface a matching
2301
- * identifier in a binding position are treated as a shadow.
2302
- */
2303
- isShadowedByEnclosingParam(node, name) {
2304
- const FUNCTION_TYPES = new Set([
2305
- "function_declaration",
2306
- "function_expression",
2307
- "generator_function",
2308
- "generator_function_declaration",
2309
- "arrow_function",
2310
- "method_definition",
2311
- ]);
2312
- let current = node.parent;
2313
- while (current) {
2314
- if (FUNCTION_TYPES.has(current.type)) {
2315
- // Bare-identifier arrow parameter: `name => …`.
2316
- const bare = current.childForFieldName?.("parameter");
2317
- if (bare?.type === "identifier" && bare.text === name)
2318
- return true;
2319
- const params = current.childForFieldName?.("parameters");
2320
- if (params && this.paramsBindName(params, name))
2321
- return true;
2322
- }
2323
- current = current.parent;
2324
- }
2325
- return false;
2326
- }
2327
- /** True when a `formal_parameters` node binds `name` in any binding position. */
2328
- paramsBindName(params, name) {
2329
- for (const param of params.children ?? []) {
2330
- if (!param.isNamed)
2331
- continue;
2332
- // required_parameter / optional_parameter carry the binding in `pattern`
2333
- // and the default (a use, not a binding) in `value`.
2334
- const pattern = param.type === "required_parameter" ||
2335
- param.type === "optional_parameter"
2336
- ? param.childForFieldName?.("pattern")
2337
- : param;
2338
- if (pattern && this.patternBindsName(pattern, name))
2339
- return true;
2340
- }
2341
- return false;
2342
- }
2343
- /**
2344
- * True when a binding pattern (`identifier`, or a destructuring
2345
- * object/array/rest pattern) introduces `name`. Walks the pattern but skips
2346
- * `assignment_pattern` default values (`= expr`), which are uses.
2347
- */
2348
- patternBindsName(pattern, name) {
2349
- if (pattern.type === "identifier")
2350
- return pattern.text === name;
2351
- const stack = [pattern];
2352
- while (stack.length > 0) {
2353
- const n = stack.pop();
2354
- if (!n)
2355
- continue;
2356
- if ((n.type === "identifier" ||
2357
- n.type === "shorthand_property_identifier_pattern") &&
2358
- n.text === name) {
2359
- return true;
2360
- }
2361
- if (n.type === "assignment_pattern") {
2362
- // Only the left (binding) side introduces names; skip the default.
2363
- const left = n.childForFieldName?.("left") ?? n.children?.[0];
2364
- if (left)
2365
- stack.push(left);
2366
- continue;
2367
- }
2368
- for (const c of n.children ?? [])
2369
- stack.push(c);
2370
- }
2371
- return false;
2372
- }
2373
- /**
2374
- * True when `name` resolves (same file) to a `const` initialized with
2375
- * `new URL(<literalPath>, <fixedBase>)` — a fully fixed destination origin
2376
- * and path. Query parameters added later via `url.searchParams.set(...)` do
2377
- * not alter origin/path, so they never taint the destination. The `URL`
2378
- * constructor may be imported under an alias (e.g. `NodeURL`).
2379
- */
2380
- resolvesToFixedNewUrlConst(name, rootNode) {
2381
- const value = this.resolveFileConstValueNode(name, rootNode);
2382
- if (!value || value.type !== "new_expression")
2383
- return false;
2384
- const ctor = value.childForFieldName?.("constructor");
2385
- const ctorName = ctor?.text ?? "";
2386
- const isUrlCtor = ctorName === "URL" ||
2387
- (ctor?.type === "identifier" &&
2388
- this.importedAs(ctorName, rootNode) === "URL");
2389
- if (!isUrlCtor)
2390
- return false;
2391
- const args = (value.childForFieldName?.("arguments")?.children ?? []).filter((c) => c.isNamed && c.type !== "comment");
2392
- if (args.length === 0)
2393
- return false;
2394
- // First arg (relative path / full URL) must be a literal.
2395
- if (!this.isFixedUrlLiteralExpr(args[0]))
2396
- return false;
2397
- // Single-arg form: `new URL("https://host/path")` — already fully fixed.
2398
- if (args.length === 1)
2399
- return true;
2400
- // Two-arg form: base must resolve to a fixed origin.
2401
- return this.isFixedUrlBaseExpr(args[1], rootNode);
2402
- }
2403
- /**
2404
- * For a fetch URL argument of the form `u.toString()` (call_expression) or
2405
- * `u.href` (member_expression), returns the receiver identifier name `u`
2406
- * (only for the `toString`/`href` accessors a `URL` yields). Returns `null`
2407
- * for any other shape so the caller falls through to taint heuristics.
2408
- */
2409
- newUrlBaseVarName(urlNode) {
2410
- let member;
2411
- if (urlNode.type === "call_expression") {
2412
- member = urlNode.childForFieldName?.("function");
2413
- }
2414
- else if (urlNode.type === "member_expression") {
2415
- member = urlNode;
2416
- }
2417
- if (!member || member.type !== "member_expression")
2418
- return null;
2419
- const prop = member.childForFieldName?.("property")?.text;
2420
- if (prop !== "toString" && prop !== "href")
2421
- return null;
2422
- const object = member.childForFieldName?.("object");
2423
- if (object?.type !== "identifier")
2424
- return null;
2425
- return object.text;
2426
- }
2427
- /**
2428
- * `session.execute(select(...))` and friends pass a statement OBJECT, not a
2429
- * SQL string: parameterized by construction, and far too noisy as blockers.
2430
- */
2431
- isSafeSqlAlchemyExpressionCall(node) {
2432
- if (node.type !== "call")
2433
- return false;
2434
- const callee = node.children?.[0]?.text ?? "";
2435
- const expression = node.text;
2436
- for (const name of PYTHON_SQLALCHEMY_STATEMENT_BUILDERS) {
2437
- if (callee === name || expression.startsWith(`${name}(`))
2438
- return true;
2439
- }
2440
- return false;
2441
- }
2442
- /**
2443
- * Post-filter predicate: returns true if the match should be kept, false to skip.
2444
- * Each branch is an independent filter identified by name — flat dispatch, no nesting.
2445
- */
2446
- // biome-ignore lint/suspicious/noExplicitAny: postFilterParams is untyped per-filter config
2447
- applyPostFilter(postFilter, postFilterParams, captures, rootNode) {
2448
- /**
2449
- * Extract the list of declared slot names from a class_definition's
2450
- * `__slots__` assignment. Returns:
2451
- * - `null` if the class has no `__slots__` declaration
2452
- * - an array of slot names (strings) otherwise
2453
- *
2454
- * Handles both common shapes:
2455
- * - string tuple/list: `__slots__ = ("a", "b")` or `['a', 'b']`
2456
- * - single string: `__slots__ = "a"` (Python's quirky single-slot form)
2457
- * - parent inheritance: returns null (we don't follow MRO)
2458
- */
2459
- function extractSlots(classNode) {
2460
- const classText = classNode.text ?? "";
2461
- if (!classText.includes("__slots__"))
2462
- return null;
2463
- // biome-ignore lint/suspicious/noExplicitAny: AST iteration
2464
- const body = classNode.children?.find((c) => c.type === "block");
2465
- if (!body)
2466
- return null;
2467
- const slots = [];
2468
- // biome-ignore lint/suspicious/noExplicitAny: AST iteration
2469
- for (const stmt of body.children ?? []) {
2470
- if (stmt.type !== "expression_statement")
2471
- continue;
2472
- // biome-ignore lint/suspicious/noExplicitAny: AST traversal
2473
- const assignment = stmt.children?.find((c) => c.type === "assignment");
2474
- if (!assignment)
2475
- continue;
2476
- // biome-ignore lint/suspicious/noExplicitAny: LHS check
2477
- // LHS text may include a leading whitespace token from the AST
2478
- // (tree-sitter separates the space before the LHS identifier).
2479
- const lhsText = (assignment.children?.[0]?.text ?? "").trim();
2480
- if (lhsText !== "__slots__")
2481
- continue;
2482
- // biome-ignore lint/suspicious/noExplicitAny: RHS extraction
2483
- // children layout: [LHS identifier, `=` operator, RHS expression]
2484
- const rhs = assignment.children?.[2];
2485
- if (!rhs)
2486
- continue;
2487
- if (rhs.type === "string") {
2488
- // __slots__ = "a" — single string form (Python quirk)
2489
- const s = (rhs.text ?? "").replace(/^["']|["']$/g, "");
2490
- if (s)
2491
- slots.push(s);
2492
- }
2493
- else if (rhs.type === "tuple" || rhs.type === "list") {
2494
- // biome-ignore lint/suspicious/noExplicitAny: list element extraction
2495
- for (const el of rhs.children ?? []) {
2496
- if (!el.isNamed)
2497
- continue; // skip "," punctuation
2498
- if (el.type === "string") {
2499
- slots.push((el.text ?? "").replace(/^["']|["']$/g, ""));
2500
- }
2501
- }
2502
- }
2503
- break; // first __slots__ wins
2504
- }
2505
- return slots;
2506
- }
2507
- switch (postFilter) {
2508
- case "no_nested_anchor_chain": {
2509
- // Tree-sitter queries can express the opening-tag shape, but they
2510
- // cannot express both an arbitrary-depth descendant relationship and
2511
- // an ancestor exclusion. Walk the captured JSX element here so the
2512
- // TSX path agrees with the ast-grep rule: keep exactly the outermost
2513
- // anchor that contains another anchor, including through wrappers.
2514
- // This post-filter runs inside a batched query walk. It must never
2515
- // abort that walk or turn a malformed tree into a false negative.
2516
- try {
2517
- const outer = captures.OUTER_ELEMENT;
2518
- if (!outer) {
2519
- this.reportPostFilterFailure(postFilter, "missing OUTER_ELEMENT capture");
2520
- return true;
2521
- }
2522
- const isAnchorElement = (node) => {
2523
- if (node.type !== "jsx_element")
2524
- return false;
2525
- const opening = node.childForFieldName?.("open_tag") ??
2526
- node.children?.find((child) => child.type === "jsx_opening_element");
2527
- const name = opening?.childForFieldName?.("name") ??
2528
- opening?.children?.find((child) => child.type === "identifier");
2529
- return name?.text === "a";
2530
- };
2531
- let visited = 0;
2532
- let ancestor = outer.parent;
2533
- while (ancestor) {
2534
- if (++visited > NO_NESTED_ANCHOR_VISIT_CAP) {
2535
- this.reportPostFilterFailure(postFilter, `ancestor walk exceeded ${NO_NESTED_ANCHOR_VISIT_CAP} nodes`);
2536
- return true;
2537
- }
2538
- if (isAnchorElement(ancestor))
2539
- return false;
2540
- ancestor = ancestor.parent;
2541
- }
2542
- const pending = [...(outer.children ?? [])];
2543
- while (pending.length > 0) {
2544
- const node = pending.pop();
2545
- if (!node)
2546
- continue;
2547
- if (++visited > NO_NESTED_ANCHOR_VISIT_CAP) {
2548
- this.reportPostFilterFailure(postFilter, `descendant walk exceeded ${NO_NESTED_ANCHOR_VISIT_CAP} nodes`);
2549
- return true;
2550
- }
2551
- if (isAnchorElement(node))
2552
- return true;
2553
- pending.push(...(node.children ?? []));
2554
- }
2555
- return false;
2556
- }
2557
- catch (error) {
2558
- const reason = error instanceof Error ? error.message : String(error);
2559
- this.reportPostFilterFailure(postFilter, `tree walk failed${reason ? `: ${reason}` : ""}`);
2560
- return true;
2561
- }
2562
- }
2563
- case "differs_only_by_case": {
2564
- try {
2565
- const field = captures.FIELD?.text ?? "";
2566
- const method = captures.METHOD?.text ?? "";
2567
- return (field !== method &&
2568
- field.toLocaleLowerCase() === method.toLocaleLowerCase());
2569
- }
2570
- catch {
2571
- return true;
2572
- }
2573
- }
2574
- case "in_default_package": {
2575
- try {
2576
- if (!rootNode)
2577
- return true;
2578
- return !(rootNode.children ?? []).some((node) => node.type === "package_declaration");
2579
- }
2580
- catch {
2581
- return true;
2582
- }
2583
- }
2584
- case "missing_mimetype_and_download_name": {
2585
- try {
2586
- const firstArg = captures.FIRST_ARG;
2587
- if (!firstArg)
2588
- return true;
2589
- // Flask can infer a MIME type from a filesystem path. The warning
2590
- // is for file-like objects, where it needs an explicit MIME type or
2591
- // download name.
2592
- if (firstArg.type === "string")
2593
- return false;
2594
- const args = firstArg.parent;
2595
- if (!args)
2596
- return true;
2597
- return !(args.children ?? []).some((node) => {
2598
- if (node.type !== "keyword_argument")
2599
- return false;
2600
- const name = node.childForFieldName?.("name")?.text;
2601
- return name === "mimetype" || name === "download_name";
2602
- });
2603
- }
2604
- catch {
2605
- return true;
2606
- }
2607
- }
2608
- case "missing_super_call": {
2609
- try {
2610
- const body = captures.BODY;
2611
- const method = captures.METHOD?.text ?? "";
2612
- if (!body || !method)
2613
- return true;
2614
- const stack = [body];
2615
- for (let visited = 0; stack.length > 0 && visited < 10_000; visited++) {
2616
- const node = stack.pop();
2617
- if (!node)
2618
- break;
2619
- if (node.type === "method_invocation" &&
2620
- node.childForFieldName?.("object")?.text === "super" &&
2621
- node.childForFieldName?.("name")?.text === method) {
2622
- return false;
2623
- }
2624
- stack.push(...(node.children ?? []));
2625
- }
2626
- return true;
2627
- }
2628
- catch {
2629
- return true;
2630
- }
2631
- }
2632
- case "no_assertion_call": {
2633
- try {
2634
- const body = captures.BODY;
2635
- if (!body)
2636
- return true;
2637
- const assertionNames = /^(?:assert\w*|fail|verify|expect|check|assume\w*)$/i;
2638
- const stack = [body];
2639
- for (let visited = 0; stack.length > 0 && visited < 10_000; visited++) {
2640
- const node = stack.pop();
2641
- if (!node)
2642
- break;
2643
- if (node.type === "method_invocation") {
2644
- const name = node.childForFieldName?.("name")?.text ?? "";
2645
- if (assertionNames.test(name))
2646
- return false;
2647
- }
2648
- stack.push(...(node.children ?? []));
2649
- }
2650
- return true;
2651
- }
2652
- catch {
2653
- return true;
2654
- }
2655
- }
2656
- case "not_closed_or_try_with_resources": {
2657
- try {
2658
- const declaration = captures.DECL;
2659
- const resource = captures.RESOURCE?.text ?? "";
2660
- if (!declaration || !resource)
2661
- return true;
2662
- // (#956 review: the old resource_specification ANCESTOR walk was
2663
- // dead code — the query only matches local_variable_declaration,
2664
- // which the Java grammar never places inside a try header. The
2665
- // real try-with-resources shapes are handled in the scope scan
2666
- // below: a `resource` node naming this variable covers both
2667
- // `try (Type x = …)` and the Java 9 `try (x)` form.)
2668
- const scope = this.navigator.findParent(declaration, [
2669
- "method_declaration",
2670
- "constructor_declaration",
2671
- "block",
2672
- ]) ?? rootNode;
2673
- if (!scope)
2674
- return true;
2675
- // `\b` in a template literal is the BACKSPACE control char
2676
- // (U+0008), not a regex word-boundary escape — that requires
2677
- // the literal two-character sequence `\\b`. The identifier
2678
- // itself must also be regex-escaped (#1089 P2).
2679
- const resourceWord = new RegExp(`\\b${escapeRegExp(resource)}\\b`);
2680
- const stack = [scope];
2681
- for (let visited = 0; stack.length > 0 && visited < 10_000; visited++) {
2682
- const node = stack.pop();
2683
- if (!node)
2684
- break;
2685
- if (node.type === "method_invocation" &&
2686
- node.childForFieldName?.("object")?.text === resource &&
2687
- node.childForFieldName?.("name")?.text === "close") {
2688
- return false;
2689
- }
2690
- if (node.type === "resource" &&
2691
- resourceWord.test(node.text ?? "")) {
2692
- return false;
2693
- }
2694
- stack.push(...(node.children ?? []));
2695
- }
2696
- return true;
2697
- }
2698
- catch {
2699
- return true;
2700
- }
2701
- }
2702
- case "same_method_no_base_case": {
2703
- try {
2704
- const method = captures.NAME?.text ?? "";
2705
- if (!method || captures.RECURSE?.text !== method)
2706
- return false;
2707
- const call = captures.CALL;
2708
- const declaration = call
2709
- ? this.navigator.findParent(call, ["method_declaration"])
2710
- : undefined;
2711
- if (!declaration)
2712
- return true;
2713
- const stack = [declaration];
2714
- for (let visited = 0; stack.length > 0 && visited < 10_000; visited++) {
2715
- const node = stack.pop();
2716
- if (!node)
2717
- break;
2718
- // Any conditional or loop construct is a plausible base-case
2719
- // guard (#956 review: while/for-guarded recursion and ternary
2720
- // base cases are common). This errs toward suppressing a
2721
- // blocking diagnostic, never inventing one.
2722
- if (node.type === "if_statement" ||
2723
- node.type === "switch_expression" ||
2724
- node.type === "switch_statement" ||
2725
- node.type === "while_statement" ||
2726
- node.type === "do_statement" ||
2727
- node.type === "for_statement" ||
2728
- node.type === "enhanced_for_statement" ||
2729
- node.type === "ternary_expression") {
2730
- return false;
2731
- }
2732
- stack.push(...(node.children ?? []));
2733
- }
2734
- // Cap exhausted without a verdict: this rule is BLOCKING, so a
2735
- // >10k-node method whose guard sits beyond the budget must not
2736
- // become a silent blocking FP — suppress instead (#956 review;
2737
- // the advisory filters keep their keep-the-diagnostic default).
2738
- return stack.length > 0 ? false : true;
2739
- }
2740
- catch {
2741
- return true;
2742
- }
2743
- }
2744
- case "memset_for_sensitive_data": {
2745
- try {
2746
- const destination = captures.DEST?.text ?? "";
2747
- const value = captures.VALUE?.text?.trim() ?? "";
2748
- if (!/^(?:0|0x0+|NULL|nullptr)$/.test(value))
2749
- return false;
2750
- return /(?:pass(?:word)?|passwd|secret|token|api_?key|private_?key|credential|auth|pin)/i.test(destination);
2751
- }
2752
- catch {
2753
- return true;
2754
- }
2755
- }
2756
- case "unsafe_regex_dynamic_identifier": {
2757
- // unsafe-regex is intentionally a coarse advisory heuristic. When the
2758
- // interpolation is a plain identifier, recover the common safe-before-
2759
- // assignment pattern without pretending to perform whole-program
2760
- // dataflow. #949 review hardening: the LAST write (declarator or
2761
- // assignment) textually BEFORE the use decides — a later reassignment
2762
- // to user input un-suppresses, and an unrelated declaration after the
2763
- // use never suppresses. Initializers come from the "value" field (a
2764
- // type annotation is not an initializer), and bare .replace("a","b")
2765
- // is not escaping — only escape-named callees or .replace(/regex/
2766
- // count. Any internal error keeps the diagnostic: never silently
2767
- // suppress via a filter crash, and a throw here would abort the whole
2768
- // file's batch.
2769
- try {
2770
- const interpolationNode = captures.INTERPOLATION;
2771
- const interpolation = interpolationNode?.text ?? "";
2772
- const identifier = interpolation.match(/^\$\{\s*([A-Za-z_$][\w$]*)\s*\}$/)?.[1];
2773
- if (!identifier || !rootNode || !interpolationNode)
2774
- return true;
2775
- const useRow = interpolationNode.startPosition.row;
2776
- const hasEscapeSignal = (text) => /\b(?:escape|Escape)\w*\s*\(/.test(text) ||
2777
- /\.\s*replace\s*\(\s*\//.test(text);
2778
- // Track the last write to the identifier before the use site.
2779
- let lastWriteRow = -1;
2780
- let lastWriteSafe = false;
2781
- const consider = (row, valueText) => {
2782
- if (row >= useRow || row < lastWriteRow)
2783
- return;
2784
- lastWriteRow = row;
2785
- lastWriteSafe =
2786
- valueText !== undefined && hasEscapeSignal(valueText);
2787
- };
2788
- const stack = [rootNode];
2789
- while (stack.length > 0) {
2790
- const node = stack.pop();
2791
- if (!node)
2792
- continue;
2793
- if (node.type === "variable_declarator") {
2794
- const name = node.childForFieldName?.("name");
2795
- if (name?.text === identifier) {
2796
- const initializer = node.childForFieldName?.("value");
2797
- consider(node.startPosition.row, initializer?.text);
2798
- }
2799
- }
2800
- else if (node.type === "assignment_expression") {
2801
- const left = node.childForFieldName?.("left");
2802
- if (left?.type === "identifier" && left.text === identifier) {
2803
- const right = node.childForFieldName?.("right");
2804
- consider(node.startPosition.row, right?.text);
2805
- }
2806
- }
2807
- stack.push(...node.children);
2808
- }
2809
- return lastWriteRow >= 0 && lastWriteSafe ? false : true;
2810
- }
2811
- catch {
2812
- return true;
2813
- }
2814
- }
2815
- case "is_generator_with_valued_return": {
2816
- const returnNode = captures.RETURN;
2817
- const functionNode = captures.FUNCTION ??
2818
- (returnNode
2819
- ? this.navigator.findParent(returnNode, ["function_definition"])
2820
- : undefined);
2821
- if (!functionNode)
2822
- return false;
2823
- // In the Python grammar, `async def` is also a function_definition with
2824
- // an anonymous `async` child. Coroutines may return values normally;
2825
- // only synchronous generator functions should be flagged.
2826
- if (this.hasChildToken(functionNode, "async"))
2827
- return false;
2828
- return this.containsYieldInFunctionBody(functionNode);
2829
- }
2830
- case "count_params": {
2831
- const paramsNode = captures.PARAMS;
2832
- if (!paramsNode)
2833
- return true;
2834
- // Count only truly required params — exclude:
2835
- // • optional_parameter nodes (foo?: T) if the grammar uses that type
2836
- // • required_parameter nodes that have a "?" child (same semantic,
2837
- // different grammar version — web-tree-sitter-typescript collapses
2838
- // both into required_parameter with a "?" token child)
2839
- // • params with a default value (text contains "=")
2840
- // biome-ignore lint/suspicious/noExplicitAny: Count parameter nodes
2841
- const paramCount = paramsNode.children.filter((c) => {
2842
- if (c.type !== "required_parameter")
2843
- return false;
2844
- if (c.text.includes("="))
2845
- return false;
2846
- // biome-ignore lint/suspicious/noExplicitAny: child node check
2847
- if (c.children?.some((ch) => ch.text === "?"))
2848
- return false;
2849
- return true;
2850
- }).length;
2851
- return paramCount >= (postFilterParams?.min_params ?? 6);
2852
- }
2853
- case "empty_body": {
2854
- const bodyNode = captures.BODY;
2855
- if (!bodyNode)
2856
- return true;
2857
- // biome-ignore lint/suspicious/noExplicitAny: Check for meaningful statements
2858
- const meaningful = bodyNode.children.filter((c) => c.isNamed &&
2859
- c.type !== "comment" &&
2860
- c.type !== "line_comment" &&
2861
- c.type !== "block_comment");
2862
- return meaningful.length === 0;
2863
- }
2864
- case "is_empty_block": {
2865
- // empty-switch-case: keep a `switch_case` that has no body statements.
2866
- // A switch_case's named children are `[value, ...body statements]`
2867
- // (the case's statements are direct children, not a statement_block),
2868
- // so an empty case is one whose only named child is its `case <value>`.
2869
- // An empty case followed by another label is a fall-through group, not
2870
- // a dead case — only the last label of a group carries the body.
2871
- const caseNode = captures.CASE;
2872
- if (!caseNode)
2873
- return false;
2874
- if (this.switchCaseBodyStatements(caseNode).length > 0)
2875
- return false;
2876
- return !this.nextSiblingIsSwitchLabel(caseNode);
2877
- }
2878
- case "no_break_or_return_in_body": {
2879
- // infinite-loop: keep a `while(true)`/`for(;;)` whose body contains no
2880
- // statement that can terminate the loop (break/return/throw). `continue`
2881
- // does not count — it keeps the loop running.
2882
- const bodyNode = captures.BODY;
2883
- if (!bodyNode)
2884
- return false;
2885
- return !this.bodyHasLoopExit(bodyNode, false);
2886
- }
2887
- case "same_param_name": {
2888
- // duplicate-function-arg: keep the pair when the captured binding
2889
- // patterns share any name.
2890
- const first = this.bindingNames(captures.PARAM1);
2891
- const second = this.bindingNames(captures.NAME);
2892
- return [...first].some((name) => second.has(name));
2893
- }
2894
- case "no_terminating_statement": {
2895
- // switch-case-termination: keep a non-empty case (already known to be
2896
- // followed by another case via the query) whose last body statement
2897
- // does not terminate, i.e. it falls through. Empty cases are handled
2898
- // by empty-switch-case, so require at least one body statement here.
2899
- // An intentional `// fallthrough` marker suppresses the finding.
2900
- const caseNode = captures.CASE;
2901
- if (!caseNode)
2902
- return false;
2903
- if (this.hasFallthroughMarker(caseNode))
2904
- return false;
2905
- const body = this.switchCaseBodyStatements(caseNode);
2906
- if (body.length === 0)
2907
- return false;
2908
- return !this.statementTerminates(body[body.length - 1]);
2909
- }
2910
- case "no_break_or_return": {
2911
- // infinite-loop-java: keep a `while(true)`/`for(;;)` whose body has no
2912
- // break/return/throw reachable in this loop's scope.
2913
- const bodyNode = captures.BODY;
2914
- if (!bodyNode)
2915
- return false;
2916
- return !this.bodyHasLoopExit(bodyNode, false);
2917
- }
2918
- case "is_double_checked_locking": {
2919
- // no-double-checked-locking: the query pins the if→synchronized→if
2920
- // shape; keep it only when both null-checks test the same field.
2921
- const outer = captures.FIELD?.text;
2922
- const inner = captures.FIELD2?.text;
2923
- return !!outer && outer === inner;
2924
- }
2925
- case "shadows_parent_field": {
2926
- // no-field-shadowing: keep only when the named parent class is
2927
- // declared in the same file and it declares a field with the same
2928
- // name. Cross-file inheritance can't be resolved here — fail closed.
2929
- const parentName = captures.PARENT?.text;
2930
- const fieldName = captures.NAME?.text;
2931
- if (!parentName || !fieldName)
2932
- return false;
2933
- // biome-ignore lint/suspicious/noExplicitAny: AST traversal
2934
- let root = captures.NAME;
2935
- while (root.parent)
2936
- root = root.parent;
2937
- // biome-ignore lint/suspicious/noExplicitAny: AST traversal
2938
- const stack = [root];
2939
- while (stack.length) {
2940
- const node = stack.pop();
2941
- if (node.type === "class_declaration" &&
2942
- node.childForFieldName?.("name")?.text === parentName) {
2943
- const body = node.childForFieldName?.("body");
2944
- for (const member of body?.children ?? []) {
2945
- if (member.type !== "field_declaration")
2946
- continue;
2947
- for (const declarator of member.children ?? []) {
2948
- if (declarator.type === "variable_declarator" &&
2949
- declarator.childForFieldName?.("name")?.text === fieldName) {
2950
- return true;
2951
- }
2952
- }
2953
- }
2954
- }
2955
- for (const child of node.children ?? [])
2956
- stack.push(child);
2957
- }
2958
- return false;
2959
- }
2960
- case "missing_break_between_cases": {
2961
- // switch-fall-through: keep when the labeled statement group contains
2962
- // no terminating statement at all — its control flow reaches the next
2963
- // case. The query already requires a following group.
2964
- const label = captures.LABEL;
2965
- const group = label?.parent;
2966
- if (!group)
2967
- return false;
2968
- const TERMINATORS = new Set([
2969
- "break_statement",
2970
- "return_statement",
2971
- "throw_statement",
2972
- "continue_statement",
2973
- "yield_statement",
2974
- ]);
2975
- for (const child of group.children ?? []) {
2976
- if (TERMINATORS.has(child.type))
2977
- return false;
2978
- }
2979
- return true;
2980
- }
2981
- case "scoped_lock_empty_args": {
2982
- // no-scoped-lock-without-args: the query only matches a bare
2983
- // `declarator: (identifier)` — an arg-taking declaration parses as an
2984
- // init/function declarator instead — so a match IS the defect. Keep it
2985
- // as long as the captured declarator has no sibling argument list.
2986
- const decl = captures.DECL;
2987
- if (!decl)
2988
- return false;
2989
- for (const sibling of decl.parent?.children ?? []) {
2990
- if (sibling.type === "argument_list")
2991
- return false;
2992
- }
2993
- return true;
2994
- }
2995
- case "calc_missing_spaces": {
2996
- // calc-spacing: keep a calc() whose +/- has an operand directly on
2997
- // both sides (e.g. `100%-20px`). A leading sign after `(` or a comma
2998
- // is legitimate and stays allowed.
2999
- const text = captures.EXPR?.text ?? "";
3000
- return /[\w%)][+-][\w.(]/.test(text);
3001
- }
3002
- case "bare_except_only": {
3003
- const clauseNode = captures.CLAUSE;
3004
- if (!clauseNode)
3005
- return true;
3006
- // A typed `except` clause has a named child for the exception
3007
- // spec — one of: identifier (e.g. `except ValueError`),
3008
- // attribute (e.g. `except asyncio.TimeoutError` — dotted name),
3009
- // tuple (e.g. `except (E, F)`), as_pattern (e.g. `except E as e`),
3010
- // parenthesized_expression (e.g. `except (E)`), or subscript
3011
- // (e.g. `except dict[str, int]` — #1244, mirrors the ast-grep
3012
- // rule's shape space).
3013
- // Bare `except:` has NO named children (just the `except` keyword,
3014
- // the `:` colon, and the body block).
3015
- // biome-ignore lint/suspicious/noExplicitAny: AST iteration
3016
- const hasExceptionSpec = clauseNode.children.some((c) => {
3017
- if (!c.isNamed)
3018
- return false;
3019
- return c.type !== "block";
3020
- });
3021
- // Fire ONLY when bare (no exception spec)
3022
- return !hasExceptionSpec;
3023
- }
3024
- case "eq_mod_fn": {
3025
- // Belt-and-braces re-check of the #eq? predicates that
3026
- // evaluatePredicates already enforces (clients/tree-sitter-client.ts).
3027
- // This filter re-applies the #eq? checks at post_filter time.
3028
- const mod = captures.MOD?.text ?? "";
3029
- const fn = captures.FN?.text ?? "";
3030
- return mod === "threading" && fn === "Thread";
3031
- }
3032
- case "regex_first_arg_identifier": {
3033
- // Belt-and-braces re-check of the #eq?/#match? predicates that
3034
- // evaluatePredicates already enforces, plus the first-argument
3035
- // check that predicates can't express. This post_filter re-applies
3036
- // both predicate checks AND the first-argument check:
3037
- // 1. MOD must be "re" (would-be #eq? @MOD "re")
3038
- // 2. FUNC must match the regex method pattern (#match? @FUNC ...)
3039
- // 3. First arg must be an identifier (dynamic pattern)
3040
- // String literals (r"...", "...") are safe static patterns.
3041
- const mod = captures.MOD?.text ?? "";
3042
- if (mod !== "re")
3043
- return false;
3044
- const func = captures.FUNC?.text ?? "";
3045
- if (!/^(compile|match|search|fullmatch|findall|finditer|sub|subn|split)$/.test(func)) {
3046
- return false;
3047
- }
3048
- const argsNode = captures.ARGS;
3049
- if (!argsNode)
3050
- return false;
3051
- // biome-ignore lint/suspicious/noExplicitAny: AST iteration
3052
- const firstNamed = (argsNode.children ?? []).find((c) => c.isNamed);
3053
- if (!firstNamed)
3054
- return false;
3055
- return firstNamed.type === "identifier";
3056
- }
3057
- case "open_mode_invalid": {
3058
- const modeNode = captures.MODE;
3059
- if (!modeNode)
3060
- return false;
3061
- // Python's open() mode accepts: r, w, a, x (basic), b/t/+ (suffix).
3062
- // Strip surrounding quotes from the string literal text.
3063
- const text = modeNode.text ?? "";
3064
- const stripped = text.replace(/^["']|["']$/g, "");
3065
- // Skip empty mode (defaults to 'r')
3066
- if (stripped.length === 0)
3067
- return false;
3068
- // Skip single-char modes (r/w/a/x — always valid)
3069
- if (stripped.length === 1)
3070
- return false;
3071
- // Must contain only valid characters
3072
- if (!/^[rwxabt+]+$/.test(stripped))
3073
- return true;
3074
- // Multi-char must be exactly: basic + optional (b|t) + optional +
3075
- // Examples valid: "rb", "rb+", "r+", "ab", "rt"
3076
- // Examples invalid: "rwb", "rrr", "rw", "rbb" (no + between r and w is invalid)
3077
- // The "rw" case (basic mode followed by another basic mode without +) is invalid
3078
- // Allow: [basic][bt]?[+]
3079
- const validShape = /^[rwax][bt]?\+?$/;
3080
- if (!validShape.test(stripped))
3081
- return true;
3082
- return false;
3083
- }
3084
- case "status_204_with_value_return": {
3085
- const funcNode = captures.FUNC;
3086
- const valNode = captures.VAL;
3087
- if (!funcNode || !valNode)
3088
- return false;
3089
- // Only fire if status_code=204
3090
- if (Number(valNode.text ?? 0) !== 204)
3091
- return false;
3092
- // Walk the function subtree looking for return_statement nodes.
3093
- // Manual BFS because web-tree-sitter doesn't expose
3094
- // descendantsOfType directly.
3095
- // biome-ignore lint/suspicious/noExplicitAny: tree-sitter node iteration
3096
- const queue = [funcNode];
3097
- while (queue.length > 0) {
3098
- const node = queue.shift();
3099
- if (node.type === "return_statement") {
3100
- // Has a value child (not just the `return` keyword)
3101
- // biome-ignore lint/suspicious/noExplicitAny: child check
3102
- const hasValue = node.children.some((c) => c.isNamed && c.type !== "comment");
3103
- if (hasValue)
3104
- return true;
3105
- }
3106
- // biome-ignore lint/suspicious/noExplicitAny: child queue
3107
- if (node.children)
3108
- queue.push(...node.children);
3109
- }
3110
- return false;
3111
- }
3112
- case "has_mixed_async": {
3113
- const bodyNode = captures.BODY;
3114
- if (!bodyNode)
3115
- return true;
3116
- const bodyText = bodyNode.text;
3117
- return (bodyText.includes("await") && /\.\s*(then|catch)\s*\(/.test(bodyText));
3118
- }
3119
- case "format_arity_mismatch": {
3120
- const formatNode = captures.FORMAT;
3121
- const argsNode = captures.ARGS;
3122
- if (!formatNode || !argsNode)
3123
- return false;
3124
- // Strip quotes from format string
3125
- const fmtText = (formatNode.text ?? "").replace(/^["']|["']$/g, "");
3126
- // Don't strip a leading "%" — the format string's contents are
3127
- // intact after stripping only the surrounding quotes. The original
3128
- // code stripped the first "%" thinking it was the operator, but
3129
- // the operator is a separate binary_operator node, not part of
3130
- // the string literal's text.
3131
- const fmt = fmtText;
3132
- // Count placeholders: %s, %d, %f, %(name)s, %i, etc.
3133
- // The simple %s/%d style: each %X counts as 1
3134
- // The %(name)s style: counts as 1 with name
3135
- // The %% escape: doesn't count
3136
- let placeholderCount = 0;
3137
- const namedKeys = [];
3138
- // biome-ignore lint/suspicious/noExplicitAny: regex match
3139
- const positionalRegex = /%(?:\([^)]+\))?[#0\- +]*\d*(?:\.\d+)?[hlL]?[diouxXeEfFgGcrs%]/g;
3140
- // biome-ignore lint/suspicious/noExplicitAny: regex match
3141
- const positionalMatches = fmt.match(positionalRegex) ?? [];
3142
- for (const m of positionalMatches) {
3143
- if (m === "%%")
3144
- continue;
3145
- placeholderCount++;
3146
- // biome-ignore lint/suspicious/noExplicitAny: capture group
3147
- const namedMatch = m.match(/^%\(([^)]+)\)/);
3148
- if (namedMatch)
3149
- namedKeys.push(namedMatch[1]);
3150
- }
3151
- // If format uses named placeholders, RHS should be a dict
3152
- if (namedKeys.length > 0) {
3153
- // Check if dict contains all named keys
3154
- if (argsNode.type === "dictionary") {
3155
- // biome-ignore lint/suspicious/noExplicitAny: AST iteration
3156
- const dictKeys = [];
3157
- for (const child of argsNode.children ?? []) {
3158
- // biome-ignore lint/suspicious/noExplicitAny: child check
3159
- if (child.type === "pair" && child.children?.[0]) {
3160
- // biome-ignore lint/suspicious/noExplicitAny: child text
3161
- // Strip quotes — child is a string literal node,
3162
- // text includes the surrounding "...".
3163
- dictKeys.push((child.children[0].text ?? "").replace(/^["']|["']$/g, ""));
3164
- }
3165
- }
3166
- const missing = namedKeys.filter((k) => !dictKeys.includes(k));
3167
- return missing.length > 0;
3168
- }
3169
- // Format uses named but RHS isn't a dict — definitely wrong
3170
- return true;
3171
- }
3172
- // Positional: count tuple args
3173
- if (argsNode.type === "tuple") {
3174
- const argCount = (argsNode.children ?? []).filter((c) => c.isNamed).length;
3175
- if (argCount !== placeholderCount)
3176
- return true;
3177
- }
3178
- return false;
3179
- }
3180
- case "aws_policy_public": {
3181
- const policyNode = captures.POLICY;
3182
- if (!policyNode)
3183
- return false;
3184
- const text = policyNode.text ?? "";
3185
- // Match patterns indicating public access
3186
- const patterns = [
3187
- /"Principal"\s*:\s*"\*"/, // direct wildcard
3188
- /"Principal"\s*:\s*\{\s*"AWS"\s*:\s*"\*"\s*\}/, // AWS wildcard
3189
- /"Effect"\s*:\s*"Allow"[\s\S]*?"Action"\s*:\s*"\*"[\s\S]*?"Resource"\s*:\s*"\*"/, // full admin
3190
- /"Principal"\s*:\s*"\*"/,
3191
- ];
3192
- return patterns.some((p) => p.test(text));
3193
- }
3194
- case "slots_attribute_mismatch": {
3195
- const selfNode = captures.SELF;
3196
- const attrNode = captures.ATTR;
3197
- const methodNode = captures.METHOD;
3198
- if (!selfNode || !attrNode || !methodNode)
3199
- return false;
3200
- // Only consider self.X = (not other.X)
3201
- if (selfNode.text !== "self")
3202
- return false;
3203
- const attrName = attrNode.text ?? "";
3204
- // Find parent class_definition
3205
- // biome-ignore lint/suspicious/noExplicitAny: AST navigation
3206
- let parent = methodNode.parent;
3207
- while (parent && parent.type !== "class_definition") {
3208
- parent = parent.parent;
3209
- }
3210
- if (!parent)
3211
- return false;
3212
- // Parse the class's __slots__ list and check if attrName is in it.
3213
- // Fires ONLY when self.X = ... assigns to an attribute NOT in __slots__
3214
- // (a real S8494 violation — the assignment will raise AttributeError).
3215
- const slots = extractSlots(parent);
3216
- // null = no __slots__ declared in this class. [] = __slots__ declared
3217
- // but we couldn't parse it (treat as null to avoid FPs on inner-class
3218
- // parent walks where the parent text mentions __slots__ but the
3219
- // direct children don't contain the assignment).
3220
- if (slots === null || slots.length === 0)
3221
- return false;
3222
- return !slots.includes(attrName);
3223
- }
3224
- case "special_method_arity": {
3225
- const nameNode = captures.NAME;
3226
- const paramsNode = captures.PARAMS;
3227
- if (!nameNode || !paramsNode)
3228
- return false;
3229
- const name = nameNode.text ?? "";
3230
- // Expected arities: {method_name: expected_arg_count}
3231
- // (excluding `self`/`cls` which is always 1)
3232
- const expected = {
3233
- __del__: 0,
3234
- __repr__: 0,
3235
- __str__: 0,
3236
- __hash__: 0,
3237
- __bool__: 0,
3238
- __len__: 0,
3239
- __eq__: 1,
3240
- __lt__: 1,
3241
- __le__: 1,
3242
- __gt__: 1,
3243
- __ge__: 1,
3244
- __ne__: 1,
3245
- };
3246
- const expectedCount = expected[name];
3247
- if (expectedCount === undefined)
3248
- return false; // not in our list
3249
- // Count required params (excluding defaults)
3250
- // biome-ignore lint/suspicious/noExplicitAny: AST iteration
3251
- const paramCount = (paramsNode.children ?? []).filter((c) => {
3252
- if (c.type !== "identifier" && c.type !== "typed_parameter")
3253
- return false;
3254
- if (c.text.includes("="))
3255
- return false;
3256
- return true;
3257
- }).length;
3258
- // Expected total = expectedCount + 1 (for self/cls)
3259
- return paramCount !== expectedCount + 1;
3260
- }
3261
- case "no_super_call": {
3262
- const bodyNode = captures.BODY;
3263
- if (!bodyNode)
3264
- return true;
3265
- return !/(?<!\/\/.*)super\s*\(/.test(bodyNode.text);
3266
- }
3267
- case "in_test_block": {
3268
- const first = Object.values(captures)[0];
3269
- return !!first && this.navigator.isInTestBlock(first);
3270
- }
3271
- case "not_in_test_block": {
3272
- const first = Object.values(captures)[0];
3273
- return !first || !this.navigator.isInTestBlock(first);
3274
- }
3275
- case "not_in_try_catch": {
3276
- const first = Object.values(captures)[0];
3277
- return !first || !this.navigator.isInTryCatch(first);
3278
- }
3279
- case "in_try_catch": {
3280
- const first = Object.values(captures)[0];
3281
- return !!first && this.navigator.isInTryCatch(first);
3282
- }
3283
- case "name_matches_param": {
3284
- const nameNode = captures.NAME;
3285
- const paramNode = captures.PARAM;
3286
- return !!nameNode && !!paramNode && nameNode.text === paramNode.text;
3287
- }
3288
- case "not_in_function": {
3289
- const first = Object.values(captures)[0];
3290
- return (!first ||
3291
- !this.navigator.isInside(first, [
3292
- "function_definition",
3293
- "function_declaration",
3294
- "method_definition",
3295
- "arrow_function",
3296
- ]));
3297
- }
3298
- case "check_secret_pattern": {
3299
- const varName = captures.VARNAME?.text ?? "";
3300
- const varNameLower = varName.toLowerCase();
3301
- // Skip UPPER_CASE constants — they're module-level constants
3302
- // (e.g. `GITHUB_TYPE_FOR_PERSONAL_API_KEY = "..."`), not secrets.
3303
- // A constant has no lowercase letters in its name.
3304
- if (varName === varName.toUpperCase() && /[A-Z]/.test(varName)) {
3305
- return false;
3306
- }
3307
- return [
3308
- /api[_-]?key/,
3309
- /api[_-]?secret/,
3310
- /password/,
3311
- /passwd/,
3312
- /secret/,
3313
- /token/,
3314
- /auth/,
3315
- /private[_-]?key/,
3316
- /access[_-]?token/,
3317
- /credentials/,
3318
- /aws[_-]?secret/,
3319
- /github[_-]?token/,
3320
- /client[_-]?secret/,
3321
- ].some((p) => p.test(varNameLower));
3322
- }
3323
- case "returns_error": {
3324
- const first = Object.values(captures)[0];
3325
- if (!first)
3326
- return false;
3327
- const funcNode = this.navigator.findParent(first, [
3328
- "function_declaration",
3329
- "method_declaration",
3330
- ]);
3331
- if (!funcNode)
3332
- return false;
3333
- const signature = String(funcNode.text ?? "")
3334
- .split("{", 1)[0]
3335
- ?.trim() ?? "";
3336
- const returnPart = signature
3337
- .match(/func\s*(?:\([^)]*\)\s*)?[A-Za-z_]\w*\s*\([^)]*\)\s*(.*)$/s)?.[1]
3338
- ?.trim() ?? "";
3339
- return returnPart.length > 0 && /\berror\b/.test(returnPart);
3340
- }
3341
- case "python_empty_except": {
3342
- const bodyNode = captures.BODY;
3343
- if (!bodyNode)
3344
- return true;
3345
- // biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
3346
- return !bodyNode.children.some((c) => c.isNamed && c.type !== "pass_statement" && c.type !== "comment");
3347
- }
3348
- case "check_in_operator_types": {
3349
- // `in`/`not in` require __contains__, __iter__ or __getitem__. We can't
3350
- // do real type inference from a bare identifier, so only flag when the
3351
- // right-hand side is a literal of a type known NOT to support
3352
- // containment (None, bool, int, float) — identifiers, strings, lists,
3353
- // dicts, sets, tuples etc. are left alone to avoid false positives.
3354
- const target = captures.TARGET;
3355
- if (!target)
3356
- return false;
3357
- return ["none", "true", "false", "integer", "float"].includes(target.type);
3358
- }
3359
- case "torchscript_super_call": {
3360
- // The query only anchors on the `super()` call itself (so it can find
3361
- // it at any nesting depth inside the method body); this filter walks
3362
- // back up to confirm the enclosing method OR its class actually carries
3363
- // a @torch.jit.script / @jit.script decorator.
3364
- const call = captures.CALL;
3365
- if (!call)
3366
- return false;
3367
- const isTorchScriptDecorated = (node) => {
3368
- const decorated = node?.parent;
3369
- if (!decorated || decorated.type !== "decorated_definition")
3370
- return false;
3371
- // biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
3372
- return (decorated.children ?? []).some((c) => c.type === "decorator" &&
3373
- /^@(torch\.jit\.script|jit\.script)$/.test(c.text ?? ""));
3374
- };
3375
- const methodNode = this.navigator.findParent(call, [
3376
- "function_definition",
3377
- ]);
3378
- if (!methodNode)
3379
- return false;
3380
- const classNode = this.navigator.findParent(methodNode, [
3381
- "class_definition",
3382
- ]);
3383
- return (isTorchScriptDecorated(methodNode) ||
3384
- isTorchScriptDecorated(classNode));
3385
- }
3386
- case "exit_params_insufficient": {
3387
- // __exit__ must accept (self, exc_type, exc_value, traceback) — 4 named
3388
- // parameters total. The query captures the whole `parameters` node
3389
- // rather than binding each slot individually: tree-sitter's optional
3390
- // (`?`) quantifiers on consecutive anchored siblings match every valid
3391
- // sub-alignment (e.g. self+exc_type alone, or self+exc_type+exc_value),
3392
- // not just the maximal one, so per-slot captures produce spurious
3393
- // duplicate/partial matches for a single, fully-correct signature.
3394
- // Counting named children of the whole node sidesteps that entirely.
3395
- const params = captures.PARAMS;
3396
- if (!params)
3397
- return true;
3398
- // biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
3399
- const named = (params.children ?? []).filter((c) => c.isNamed);
3400
- // `def __exit__(self, *args)` / `(self, *exc_info)` are valid: the splat
3401
- // absorbs the whole exception triple, so it satisfies every remaining
3402
- // slot no matter how few named children the node has.
3403
- if (named.some((c) => c.type === "list_splat_pattern" ||
3404
- c.type === "dictionary_splat_pattern")) {
3405
- return false;
3406
- }
3407
- return named.length < 4;
3408
- }
3409
- case "ruby_empty_rescue": {
3410
- const bodyNode = captures.BODY;
3411
- if (!bodyNode)
3412
- return true;
3413
- // biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
3414
- return !bodyNode.children.some((c) => c.isNamed && !["comment", "nil", "nil_literal"].includes(c.type));
3415
- }
3416
- case "ts_command_injection_sink":
3417
- return (captures.MOD?.text === "child_process" &&
3418
- /^(exec|execSync)$/.test(captures.FN?.text ?? ""));
3419
- case "ts_sql_injection_sink": {
3420
- const template = captures.TEMPLATE?.text ?? "";
3421
- // Inspect only the template's raw prefix. Leading SQL comments are
3422
- // ignored; comments and unrelated strings elsewhere cannot satisfy it.
3423
- const rawPrefix = template
3424
- .replace(/^`/, "")
3425
- .replace(/`$/, "")
3426
- .replace(/^(?:\s|\/\*[\s\S]*?\*\/|--[^\n]*(?:\n|$))*/, "");
3427
- if (/^(?:SELECT[\s\S]*(?:\bFROM\b|\$\{)|INSERT\s+INTO\b|UPDATE\s+[\s\S]*\bSET\b|DELETE\s+FROM\b|CREATE\s+(?:OR\s+REPLACE\s+|TEMP(?:ORARY)?\s+|UNIQUE\s+|MATERIALIZED\s+)*(?:TABLE|INDEX|VIEW|SCHEMA|DATABASE|TRIGGER|FUNCTION|PROCEDURE|SEQUENCE|TYPE|EXTENSION)\b|DROP\s+(?:OR\s+REPLACE\s+|TEMP(?:ORARY)?\s+|UNIQUE\s+|MATERIALIZED\s+)*(?:TABLE|INDEX|VIEW|SCHEMA|DATABASE|TRIGGER|FUNCTION|PROCEDURE|SEQUENCE|TYPE|EXTENSION)\b|ALTER\s+(?:OR\s+REPLACE\s+|TEMP(?:ORARY)?\s+|UNIQUE\s+|MATERIALIZED\s+)*(?:TABLE|INDEX|VIEW|SCHEMA|DATABASE|TRIGGER|FUNCTION|PROCEDURE|SEQUENCE|TYPE|EXTENSION)\b|GRANT\b|REVOKE\b|WITH[\s\S]*\bSELECT\b|MERGE\s+INTO\b|TRUNCATE\s+TABLE\b|REPLACE\s+INTO\b)/i.test(rawPrefix))
3428
- return true;
3429
- if (!rootNode || !captures.OBJ)
3430
- return false;
3431
- let imported = TYPESCRIPT_SQL_IMPORTS.get(rootNode);
3432
- if (!imported) {
3433
- const names = new Set();
3434
- const stack = [rootNode];
3435
- while (stack.length > 0) {
3436
- const node = stack.pop();
3437
- if (!node)
3438
- continue;
3439
- if (node.type === "import_statement") {
3440
- const source = node.childForFieldName?.("source")?.text ?? "";
3441
- const packageName = source.replace(/^['"]|['"]$/g, "");
3442
- const isKnown = [...TYPESCRIPT_SQL_KNOWN_PACKAGES].some((pkg) => packageName === pkg || packageName.startsWith(`${pkg}/`));
3443
- if (isKnown) {
3444
- const importStack = [node];
3445
- while (importStack.length > 0) {
3446
- const importNode = importStack.pop();
3447
- if (!importNode)
3448
- continue;
3449
- if (importNode.type === "identifier" ||
3450
- importNode.type === "import_specifier") {
3451
- names.add(importNode.text);
3452
- }
3453
- importStack.push(...importNode.children);
3454
- }
3455
- }
3456
- }
3457
- stack.push(...node.children);
3458
- }
3459
- const declarations = new Map();
3460
- const declarationStack = [rootNode];
3461
- while (declarationStack.length > 0) {
3462
- const node = declarationStack.pop();
3463
- if (!node)
3464
- continue;
3465
- if (node.type === "variable_declarator") {
3466
- const name = node.childForFieldName?.("name")?.text;
3467
- const value = node.childForFieldName?.("value");
3468
- if (name && value)
3469
- declarations.set(name, value);
3470
- }
3471
- declarationStack.push(...node.children);
3472
- }
3473
- const resolvesKnownValue = (node, seen = new Set()) => {
3474
- if (node.type === "identifier") {
3475
- if (names.has(node.text))
3476
- return true;
3477
- if (seen.has(node.text))
3478
- return false;
3479
- const value = declarations.get(node.text);
3480
- if (!value)
3481
- return false;
3482
- seen.add(node.text);
3483
- return resolvesKnownValue(value, seen);
3484
- }
3485
- if (node.type === "await_expression") {
3486
- const value = node.children.find((child) => child.isNamed);
3487
- return value ? resolvesKnownValue(value, seen) : false;
3488
- }
3489
- if (node.type === "new_expression") {
3490
- const ctor = node.childForFieldName?.("constructor");
3491
- return ctor ? resolvesKnownValue(ctor, seen) : false;
3492
- }
3493
- if (node.type === "call_expression") {
3494
- const fn = node.childForFieldName?.("function");
3495
- return fn ? resolvesKnownValue(fn, seen) : false;
3496
- }
3497
- return false;
3498
- };
3499
- let changed = true;
3500
- while (changed) {
3501
- changed = false;
3502
- for (const [name, value] of declarations) {
3503
- if (!names.has(name) && resolvesKnownValue(value)) {
3504
- names.add(name);
3505
- changed = true;
3506
- }
3507
- }
3508
- }
3509
- imported = names;
3510
- TYPESCRIPT_SQL_IMPORTS.set(rootNode, imported);
3511
- }
3512
- const resolvesToKnownClient = (node) => {
3513
- if (node.type === "identifier")
3514
- return imported.has(node.text);
3515
- if (node.type === "await_expression") {
3516
- const value = node.children.find((child) => child.isNamed);
3517
- return value ? resolvesToKnownClient(value) : false;
3518
- }
3519
- if (node.type === "new_expression") {
3520
- const ctor = node.childForFieldName?.("constructor");
3521
- return ctor ? resolvesToKnownClient(ctor) : false;
3522
- }
3523
- if (node.type === "call_expression") {
3524
- const fn = node.childForFieldName?.("function");
3525
- return fn ? resolvesToKnownClient(fn) : false;
3526
- }
3527
- return false;
3528
- };
3529
- return resolvesToKnownClient(captures.OBJ);
3530
- }
3531
- case "ts_ssrf_sink": {
3532
- const fn = captures.FN?.text ?? "";
3533
- const obj = captures.OBJ?.text ?? "";
3534
- const urlText = captures.URL?.text ?? "";
3535
- const allowedFns = new Set([
3536
- "fetch",
3537
- "request",
3538
- "get",
3539
- "post",
3540
- "put",
3541
- "patch",
3542
- "delete",
3543
- ]);
3544
- if (!allowedFns.has(fn))
3545
- return false;
3546
- // A bare identifier that provably resolves (in this file) to a
3547
- // `const` initialized with a fixed string/template literal is a
3548
- // genuinely fixed URL — exempt it regardless of naming
3549
- // convention (SCREAMING_SNAKE_CASE proves nothing about
3550
- // provenance; see #963). Anything we cannot definitively prove
3551
- // falls through to the existing broad heuristic below, so
3552
- // member expressions, non-literal consts, reassigned
3553
- // bindings, and unresolved identifiers all keep being
3554
- // evaluated for taint as before — a false positive here is
3555
- // acceptable, a missed SSRF is not.
3556
- if (rootNode &&
3557
- captures.URL?.type === "identifier" &&
3558
- this.resolvesToFileLiteralConst(urlText, rootNode)) {
3559
- return false;
3560
- }
3561
- // `fetch(u.toString())` / `fetch(u.href)` where `u` is a file-local
3562
- // `const u = new URL(<literalPath>, <fixedBase>)`. The origin+path are
3563
- // fully fixed; dynamic query params set later via
3564
- // `u.searchParams.set(...)` don't control the destination. Anything we
3565
- // cannot prove fixed (param/env/member/tainted base) falls through to
3566
- // the broad heuristic and still fires. See #1000.
3567
- if (rootNode && captures.URL) {
3568
- const urlVar = this.newUrlBaseVarName(captures.URL);
3569
- if (urlVar && this.resolvesToFixedNewUrlConst(urlVar, rootNode)) {
3570
- return false;
3571
- }
3572
- }
3573
- // Only flag when the URL argument looks like it could carry external
3574
- // input: member expressions (req.url, ctx.query.x) or identifiers
3575
- // whose names suggest user/external provenance. Plain generic names
3576
- // like `url` or `path` in internal download utilities produce too
3577
- // many false positives — those need data-flow analysis to resolve.
3578
- const looksLikeExternalInput = urlText.includes(".") ||
3579
- /user|external|remote|input|target|webhook|callback|redirect|untrusted|arbitrary/i.test(urlText);
3580
- if (!looksLikeExternalInput)
3581
- return false;
3582
- if (!obj)
3583
- return fn === "fetch";
3584
- return new Set([
3585
- "axios",
3586
- "http",
3587
- "https",
3588
- "got",
3589
- "request",
3590
- "superagent",
3591
- "undici",
3592
- ]).has(obj);
3593
- }
3594
- case "ts_weak_hash_algorithm":
3595
- return (captures.FN?.text === "createHash" &&
3596
- /^(md5|sha1)$/i.test(captures.ALG?.text ?? ""));
3597
- case "ts_insecure_random_source": {
3598
- if (captures.OBJ?.text !== "Math" || captures.FN?.text !== "random")
3599
- return false;
3600
- // Only flag when the result flows into a security-sensitive binding.
3601
- // Walk up from the `Math.random()` call so chained forms such as
3602
- // `Math.random().toString(36)` are still attributed to their binding.
3603
- const varName = this.enclosingBindingName(captures.CALL ?? captures.VAR);
3604
- return /token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin/i.test(varName);
3605
- }
3606
- case "ts_detached_async_call":
3607
- return /(Async$|fetch$|request$)/.test(captures.FN?.text ?? "");
3608
- case "incomplete_assertion": {
3609
- const expectNode = captures.EXPECT;
3610
- if (!expectNode)
3611
- return false;
3612
- const CHAI_PROPERTY_ASSERTIONS = new Set([
3613
- "true",
3614
- "false",
3615
- "null",
3616
- "undefined",
3617
- "empty",
3618
- "NaN",
3619
- "finite",
3620
- "exist",
3621
- "arguments",
3622
- "extensible",
3623
- "sealed",
3624
- "frozen",
3625
- "locked",
3626
- ]);
3627
- // The expect identifier is inside a call_expression. Walk up past that
3628
- // call_expression to the container that determines if it's a complete
3629
- // assertion or an incomplete one.
3630
- let current = expectNode.parent;
3631
- if (!current)
3632
- return false;
3633
- current = current.parent; // skip the expect(...) call_expression
3634
- if (!current)
3635
- return false;
3636
- // Bare expect(foo); or return expect(foo);
3637
- if (current.type === "expression_statement" ||
3638
- current.type === "return_statement")
3639
- return true;
3640
- let lastPropertyName = null;
3641
- while (current && current.type === "member_expression") {
3642
- const propNode = current.children?.find((c) => c.type === "property_identifier");
3643
- if (propNode)
3644
- lastPropertyName = propNode.text;
3645
- const parent = current.parent;
3646
- if (!parent)
3647
- return false;
3648
- if (parent.type === "expression_statement" ||
3649
- parent.type === "return_statement") {
3650
- if (lastPropertyName &&
3651
- CHAI_PROPERTY_ASSERTIONS.has(lastPropertyName))
3652
- return false;
3653
- return true;
3654
- }
3655
- if (parent.type === "call_expression")
3656
- return false;
3657
- current = parent;
3658
- }
3659
- return false;
3660
- }
3661
- case "py_command_injection_sink": {
3662
- const mod = captures.MOD?.text ?? "";
3663
- const fn = captures.FN?.text ?? "";
3664
- const kw = captures.KW?.text ?? "";
3665
- return ((mod === "os" && /^(system|popen)$/.test(fn)) ||
3666
- (mod === "subprocess" &&
3667
- /^(run|Popen|call|check_output|check_call)$/.test(fn) &&
3668
- kw === "shell"));
3669
- }
3670
- case "go_command_injection_sink":
3671
- return (captures.PKG?.text === "exec" &&
3672
- /^(Command|CommandContext)$/.test(captures.FN?.text ?? "") &&
3673
- /^"(sh|bash|zsh|cmd|powershell|pwsh)"$/.test(captures.SHELL?.text ?? "") &&
3674
- /^"(-c|\/c)"$/.test(captures.FLAG?.text ?? ""));
3675
- case "ruby_command_injection_sink":
3676
- return /^(system|exec|spawn|popen|capture3|capture2|capture2e)$/.test(captures.FN?.text ?? "");
3677
- case "py_ssrf_sink":
3678
- return (captures.MOD?.text === "requests" &&
3679
- /^(get|post|put|patch|delete|request|head|options)$/.test(captures.FN?.text ?? ""));
3680
- case "py_path_traversal_sink":
3681
- return /^(open|read_text|read_bytes|write_text|write_bytes|remove|unlink|rmdir)$/.test(captures.FN?.text ?? "");
3682
- case "go_path_traversal_sink":
3683
- return (/^(os|ioutil)$/.test(captures.PKG?.text ?? "") &&
3684
- /^(Open|OpenFile|ReadFile|WriteFile|Create|Remove|RemoveAll)$/.test(captures.FN?.text ?? ""));
3685
- case "py_sql_injection_sink": {
3686
- const fn = captures.FN?.text ?? "";
3687
- if (!PYTHON_SQL_SINK_METHODS.has(fn))
3688
- return false;
3689
- const sqlNode = captures.SQL;
3690
- const receiver = captures.OBJ;
3691
- // Pre-existing exemptions — kept verbatim so no current user
3692
- // regresses (#2577 review): an unannotated session receiver, and a
3693
- // statement-builder call in argument position.
3694
- if (fn === "execute" &&
3695
- this.isLikelySqlAlchemyReceiver(receiver?.text ?? "")) {
3696
- return false;
3697
- }
3698
- if (sqlNode && this.isSafeSqlAlchemyExpressionCall(sqlNode)) {
3699
- return false;
3700
- }
3701
- // #2576: a receiver PROVEN to be a sqlalchemy Session/AsyncSession.
3702
- // `Session.query` takes entity classes and `Session.execute` a
3703
- // statement object (a builder call, or a name bound to one —
3704
- // `stmt = select(User)`, which the check above cannot see). Neither
3705
- // suppression may swallow composed SQL text (#2577 review F1/F2).
3706
- if (isProvenSqlAlchemySessionReceiver(receiver, rootNode)) {
3707
- if (fn === "query" &&
3708
- isSqlAlchemyEntityQueryArgument(sqlNode, rootNode)) {
3709
- return false;
3710
- }
3711
- if (fn === "execute" &&
3712
- isSqlAlchemyStatementArgument(sqlNode, rootNode)) {
3713
- return false;
3714
- }
3715
- }
3716
- // #2576: psycopg `sql.SQL("...").format(sql.Identifier(...))`.
3717
- if (isSafePsycopgIdentifierComposition(sqlNode, rootNode)) {
3718
- return false;
3719
- }
3720
- return true;
3721
- }
3722
- case "go_sql_injection_sink":
3723
- return (/^(Query|QueryContext|QueryRow|QueryRowContext|Exec|ExecContext)$/.test(captures.DBFN?.text ?? "") &&
3724
- captures.FMTPKG?.text === "fmt" &&
3725
- captures.FMTFN?.text === "Sprintf");
3726
- case "py_insecure_deserialization_sink":
3727
- return (/^(pickle|yaml)$/.test(captures.MOD?.text ?? "") &&
3728
- /^(load|loads|unsafe_load)$/.test(captures.FN?.text ?? ""));
3729
- case "ruby_insecure_deserialization_sink":
3730
- return (/^(Marshal|YAML|Psych)$/.test(captures.MOD?.text ?? "") &&
3731
- /^(load|unsafe_load)$/.test(captures.FN?.text ?? ""));
3732
- case "match_captures": {
3733
- // Generic filter: each key in postFilterParams is a capture name,
3734
- // value is a regex string. All must match.
3735
- for (const [captureName, pattern] of Object.entries(postFilterParams ?? {})) {
3736
- const node = captures[captureName];
3737
- if (!node)
3738
- return false;
3739
- if (!new RegExp(pattern).test(node.text))
3740
- return false;
3741
- }
3742
- return true;
3743
- }
3744
- case "case_range_single_value": {
3745
- const start = captures.START?.text ?? "";
3746
- const end = captures.END?.text ?? "";
3747
- return start === end;
3748
- }
3749
- case "goto_jumps_backward": {
3750
- const label = captures.LABEL;
3751
- const gotoNode = captures.GOTO;
3752
- if (!label || !gotoNode)
3753
- return false;
3754
- return label.startIndex < gotoNode.startIndex;
3755
- }
3756
- case "goto_targets_inner_block": {
3757
- const target = captures.TARGET;
3758
- if (!target)
3759
- return false;
3760
- // A goto targets an inner block if its label is inside a
3761
- // compound_statement that is nested inside another compound_statement.
3762
- let depth = 0;
3763
- let node = target.parent;
3764
- while (node) {
3765
- if (node.type === "compound_statement")
3766
- depth++;
3767
- node = node.parent;
3768
- }
3769
- return depth >= 2;
3770
- }
3771
- case "c_memset_sensitive_arg": {
3772
- const callNode = captures.CALL;
3773
- if (!callNode || callNode.type !== "call_expression")
3774
- return false;
3775
- // Find the first argument in the argument_list
3776
- const argList = callNode.children?.find((c) => c.type === "argument_list");
3777
- if (!argList)
3778
- return false;
3779
- // First named child after the opening paren is the first arg
3780
- const firstArg = argList.children?.find((c) => c.isNamed);
3781
- if (!firstArg)
3782
- return false;
3783
- const argName = firstArg.text ?? "";
3784
- return /password|secret|key|token|credential|auth|private|passwd|pin|salt|nonce|iv|seed/i.test(argName);
3785
- }
3786
- case "c_stdlib_name": {
3787
- const name = captures.NAME?.text ?? "";
3788
- const STDLIB_NAMES = new Set([
3789
- "malloc",
3790
- "calloc",
3791
- "realloc",
3792
- "free",
3793
- "alloca",
3794
- "printf",
3795
- "fprintf",
3796
- "sprintf",
3797
- "snprintf",
3798
- "vprintf",
3799
- "vfprintf",
3800
- "vsprintf",
3801
- "vsnprintf",
3802
- "scanf",
3803
- "fscanf",
3804
- "sscanf",
3805
- "vscanf",
3806
- "vfscanf",
3807
- "vsscanf",
3808
- "strcpy",
3809
- "strncpy",
3810
- "strcat",
3811
- "strncat",
3812
- "strcmp",
3813
- "strncmp",
3814
- "strlen",
3815
- "strchr",
3816
- "strrchr",
3817
- "strstr",
3818
- "strerror",
3819
- "memcpy",
3820
- "memmove",
3821
- "memset",
3822
- "memcmp",
3823
- "memchr",
3824
- "fopen",
3825
- "fclose",
3826
- "fread",
3827
- "fwrite",
3828
- "fgets",
3829
- "fputs",
3830
- "getc",
3831
- "putc",
3832
- "getchar",
3833
- "putchar",
3834
- "exit",
3835
- "abort",
3836
- "assert",
3837
- "errno",
3838
- "abs",
3839
- "labs",
3840
- "llabs",
3841
- "div",
3842
- "ldiv",
3843
- "lldiv",
3844
- "atoi",
3845
- "atol",
3846
- "atoll",
3847
- "strtol",
3848
- "strtoll",
3849
- "strtoul",
3850
- "strtoull",
3851
- "strtod",
3852
- "strtof",
3853
- "strtold",
3854
- "qsort",
3855
- "bsearch",
3856
- "time",
3857
- "clock",
3858
- "difftime",
3859
- "mktime",
3860
- "strftime",
3861
- "getenv",
3862
- "setenv",
3863
- "putenv",
3864
- "system",
3865
- "isalpha",
3866
- "isdigit",
3867
- "isalnum",
3868
- "isspace",
3869
- "isupper",
3870
- "islower",
3871
- "toupper",
3872
- "tolower",
3873
- "sizeof",
3874
- "offsetof",
3875
- "NULL",
3876
- "EXIT_SUCCESS",
3877
- "EXIT_FAILURE",
3878
- ]);
3879
- return STDLIB_NAMES.has(name);
3880
- }
3881
- case "c_octal_literal": {
3882
- const num = captures.NUM?.text ?? "";
3883
- return /^0[0-7]+$/.test(num);
3884
- }
3885
- case "c_noreturn_attr": {
3886
- const attr = captures.ATTR?.text ?? "";
3887
- return attr === "noreturn";
3888
- }
3889
- case "c_label_in_switch": {
3890
- const stmt = captures.STMT;
3891
- if (!stmt)
3892
- return false;
3893
- let node = stmt.parent;
3894
- while (node) {
3895
- if (node.type === "switch_statement")
3896
- return true;
3897
- node = node.parent;
3898
- }
3899
- return false;
3900
- }
3901
- default:
3902
- // A rule whose post_filter has no implementation cannot honour its
3903
- // own definition, so DROP the match instead of reporting every raw
3904
- // structural hit. Failing open here made `duplicate-function-arg`
3905
- // (`same_param_name`, unimplemented) fire on 59 of 60 files that
3906
- // contain no duplicate parameter at all.
3907
- this.reportMissingPostFilter(postFilter);
3908
- return false;
3909
- }
3910
- }
3911
- reportedMissingPostFilters = new Set();
3912
- reportedPostFilterFailures = new Set();
3913
- /** Keep a failed post-filter match and leave a bounded diagnostic trail. */
3914
- reportPostFilterFailure(postFilter, reason) {
3915
- if (this.reportedPostFilterFailures.has(postFilter))
3916
- return;
3917
- this.reportedPostFilterFailures.add(postFilter);
3918
- logTreeSitterDiagnostic({
3919
- subsystem: "tree-sitter-client",
3920
- message: `tree-sitter rule post_filter '${postFilter}' failed — ` +
3921
- `keeping the diagnostic (fail-open): ${reason}.`,
3922
- metadata: { postFilter },
3923
- });
3924
- }
3925
- /** Warn once per unimplemented post_filter — a silent rule needs a trail. */
3926
- reportMissingPostFilter(postFilter) {
3927
- if (this.reportedMissingPostFilters.has(postFilter))
3928
- return;
3929
- this.reportedMissingPostFilters.add(postFilter);
3930
- logTreeSitterDiagnostic({
3931
- subsystem: "tree-sitter-client",
3932
- message: `tree-sitter rule post_filter '${postFilter}' is not implemented — ` +
3933
- `matches for the rules using it are suppressed rather than reported unfiltered. ` +
3934
- `Implement it in applyPostFilter (clients/tree-sitter-client.ts) to re-enable them.`,
3935
- metadata: { postFilter },
3936
- });
3937
- }
3938
- /**
3939
- * Latch so the "textPredicates is malformed" diagnostic log line fires once
3940
- * per `TreeSitterClient` instance for the life of the process (this client is
3941
- * a process-wide singleton in production — see clients/tree-sitter-shared.ts),
3942
- * not once per call. This gates ONLY the log line: process-wide log volume is
3943
- * the concern here, not the user-facing degradation record below, which must
3944
- * re-arm every session (see recordDegradationOnce) — including for a Query
3945
- * object the LRU query cache (queryCache/queryBatchCache above) kept alive
3946
- * across a session boundary, since the cache is evicted, never cleared.
3947
- */
3948
- textPredicatesInvalidLogged = false;
3949
- /**
3950
- * Typed accessor for `query.textPredicates`. If the property is missing or the
3951
- * wrong shape — e.g. a future web-tree-sitter upgrade renames or removes it —
3952
- * `query.textPredicates?.[i] ?? []` would silently mean "no predicates for this
3953
- * pattern," passing every match through unfiltered. That's a silent fail-open on
3954
- * #match?/#eq? predicates that rules rely on for correctness, and because this
3955
- * runs for every match of every query, it would zero out ALL structural matches
3956
- * across every language while pi-lens reports "no issues found." Fail loud (log
3957
- * once per client instance, record a degradation so it reaches the user) and
3958
- * closed (report the query has no usable predicates) instead of guessing.
3959
- *
3960
- * No WeakSet short-circuit for already-known-bad queries here (#1523 review
3961
- * R1): `Array.isArray` is O(1), so memoizing it buys nothing, and a
3962
- * process-lifetime WeakSet would make the SAME cached Query object (the query
3963
- * cache is LRU-evicted, never cleared) skip straight past
3964
- * `recordDegradationOnce` on every call after the first — which is exactly
3965
- * what made the degradation record fail to re-arm across a session boundary
3966
- * for a query the cache kept warm. `recordDegradationOnce` itself is called
3967
- * unconditionally on every invalid check; the ledger's own once-per-kind/
3968
- * subject dedupe (cleared by resetDegradationLedger, which handleSessionStart
3969
- * calls first thing — clients/runtime-session.ts) is the single source of
3970
- * truth for "how often does this actually get recorded."
3971
- */
3972
- // biome-ignore lint/suspicious/noExplicitAny: web-tree-sitter Query instances
3973
- hasValidTextPredicates(query) {
3974
- const valid = Array.isArray(query?.textPredicates);
3975
- if (!valid) {
3976
- if (!this.textPredicatesInvalidLogged) {
3977
- this.textPredicatesInvalidLogged = true;
3978
- logTreeSitterDiagnostic({
3979
- subsystem: "tree-sitter-client",
3980
- message: "web-tree-sitter Query.textPredicates is missing or not an array — " +
3981
- "#match?/#eq? predicates cannot be evaluated. Failing CLOSED: matches " +
3982
- "for this query are dropped rather than reported unfiltered.",
3983
- metadata: { textPredicatesType: typeof query?.textPredicates },
3984
- });
3985
- }
3986
- // User-facing signal (#1523 review F1). Called unconditionally on every
3987
- // invalid check (not gated by a client-lifetime latch) so it re-arms
3988
- // every session, per the R1 fix above.
3989
- recordDegradationOnce({
3990
- kind: "query-predicates-invalid",
3991
- subject: "web-tree-sitter",
3992
- reason: "Query.textPredicates is missing or not an array — #match?/#eq? " +
3993
- "predicates cannot be evaluated; structural matches relying on them " +
3994
- "are dropped fail-closed",
3995
- });
3996
- }
3997
- return valid;
3998
- }
3999
- /**
4000
- * Evaluate text predicates (#match?, #eq?) for a query match. web-tree-sitter
4001
- * 0.25's `Query.matches()` DOES apply these predicates itself (probed on the
4002
- * shipped grammars — see clients/tree-sitter-symbol-extractor.ts), so this is a
4003
- * belt-and-braces re-filter, not a workaround for missing enforcement. It also
4004
- * doubles as the fail-closed guard (#1523): if `query.textPredicates` is ever
4005
- * missing or malformed, this drops the match instead of assuming it already
4006
- * passed upstream.
4007
- */
4008
- // biome-ignore lint/suspicious/noExplicitAny: web-tree-sitter types
4009
- evaluatePredicates(query, match) {
4010
- if (!this.hasValidTextPredicates(query))
4011
- return false;
4012
- // `?.` guards a post-validation removal of the property (e.g. a later
4013
- // mutation strips it after the first successful check) so the read can't
4014
- // throw here — an uncaught throw would be swallowed by the outer try/catch
4015
- // in searchFileWithQuery and silently zero out matches for the whole file.
4016
- const predicates = query.textPredicates?.[match.patternIndex] ?? [];
4017
- return predicates.every((fn) => fn(match.captures));
4018
- }
4019
- /** Search a single file using tree-sitter Query */
4020
- async searchFileWithQuery(filePath,
4021
- // biome-ignore lint/suspicious/noExplicitAny: Query type from web-tree-sitter
4022
- query, metavars, languageId, _originalPattern, postFilter,
4023
- // biome-ignore lint/suspicious/noExplicitAny: Post filter params
4024
- postFilterParams, contentOverride) {
4025
- const matches = [];
4026
- await this.parseFileAndUse(filePath, languageId, contentOverride, (tree) => {
4027
- try {
4028
- const rootNode = tree.rootNode;
4029
- const queryMatches = query.matches(rootNode);
4030
- for (const match of queryMatches) {
4031
- const captures = {};
4032
- for (const capture of match.captures) {
4033
- if (metavars.includes(capture.name)) {
4034
- captures[capture.name] = capture.node;
4035
- }
4036
- }
4037
- // Belt-and-braces re-check of #match?/#eq? predicates (web-tree-sitter
4038
- // 0.25's Query.matches() already applies them) plus the fail-closed
4039
- // guard for a malformed query.textPredicates (see evaluatePredicates).
4040
- if (!this.evaluatePredicates(query, match)) {
4041
- continue;
4042
- }
4043
- if (postFilter &&
4044
- !this.applyPostFilter(postFilter, postFilterParams, captures, rootNode)) {
4045
- continue;
4046
- }
4047
- if (match.captures.length > 0) {
4048
- const firstNode = match.captures[0].node;
4049
- const textCaptures = {};
4050
- for (const [name, node] of Object.entries(captures)) {
4051
- textCaptures[name] = node.text;
4052
- }
4053
- matches.push({
4054
- file: filePath,
4055
- line: firstNode.startPosition.row + 1,
4056
- column: firstNode.startPosition.column + 1,
4057
- matchedText: firstNode.text,
4058
- nodeType: firstNode.type,
4059
- captures: textCaptures,
4060
- });
4061
- }
4062
- }
4063
- if (matches.length > 0) {
4064
- this.dbg(`Found ${matches.length} matches in ${path.basename(filePath)}`);
4065
- }
4066
- }
4067
- catch (err) {
4068
- this.reportWasmAbort(err);
4069
- this.dbg(`Query matching error: ${err}`);
4070
- }
4071
- });
4072
- return matches;
4073
- }
4074
- /** Collect source files for a language */
4075
- collectFiles(dir, languageId, fileFilter) {
4076
- const files = [];
4077
- const extensions = this.getExtensionsForLanguage(languageId);
4078
- const rootDir = path.resolve(dir);
4079
- const ignoreMatcher = getProjectIgnoreMatcher(rootDir);
4080
- // Hard cap on the walk itself (not just result collection). The per-file
4081
- // `maxResults` break upstream only stops gathering matches *after* the walk
4082
- // has already enumerated the whole tree — so a misrooted structuralSearch
4083
- // would still synchronously read every directory. Bound the walk (#262).
4084
- const scan = (d) => {
4085
- if (files.length >= TREE_SITTER_MAX_SCAN_FILES)
4086
- return;
4087
- try {
4088
- const entries = fs.readdirSync(d, { withFileTypes: true });
4089
- for (const entry of entries) {
4090
- if (files.length >= TREE_SITTER_MAX_SCAN_FILES)
4091
- return;
4092
- const full = path.join(d, entry.name);
4093
- if (entry.isDirectory()) {
4094
- if (isExcludedDirName(entry.name))
4095
- continue;
4096
- if (ignoreMatcher.isIgnored(full, true))
4097
- continue;
4098
- scan(full);
4099
- }
4100
- else if (extensions.some((ext) => entry.name.endsWith(ext))) {
4101
- if (ignoreMatcher.isIgnored(full, false))
4102
- continue;
4103
- if (!fileFilter || fileFilter(full)) {
4104
- files.push(full);
4105
- }
4106
- }
4107
- }
4108
- }
4109
- catch { }
4110
- };
4111
- scan(rootDir);
4112
- return files;
4113
- }
4114
- /** Get file extensions for a language */
4115
- getExtensionsForLanguage(languageId) {
4116
- const mapping = {
4117
- typescript: [".ts", ".mts", ".cts"],
4118
- tsx: [".tsx"],
4119
- javascript: [".js", ".mjs", ".cjs"],
4120
- python: [".py"],
4121
- rust: [".rs"],
4122
- go: [".go"],
4123
- java: [".java"],
4124
- kotlin: [".kt", ".kts"],
4125
- dart: [".dart"],
4126
- c: [".c", ".h"],
4127
- cpp: [".cpp", ".hpp", ".cc", ".hh"],
4128
- elixir: [".ex", ".exs"],
4129
- ruby: [".rb"],
4130
- };
4131
- return mapping[languageId] || [];
4132
- }
4133
- }
4134
- // --- Simplified Pattern Search (regex fallback) ---