@7n/rules 1.26.0 → 1.28.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 (292) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +2 -5
  3. package/rules/doc-files/docgen-crc/docs/main.md +1 -1
  4. package/rules/doc-files/docgen-crc/main.mjs +5 -12
  5. package/rules/doc-files/docgen-gen/docs/main.md +1 -1
  6. package/rules/doc-files/docgen-gen/main.mjs +12 -4
  7. package/rules/doc-files/docgen-scan/docs/main.md +2 -2
  8. package/rules/doc-files/docgen-scan/main.mjs +6 -13
  9. package/schemas/concern.json +4 -0
  10. package/scripts/auto-rules.mjs +2 -3
  11. package/scripts/docs/auto-rules.md +1 -1
  12. package/scripts/lib/concern-meta.mjs +14 -1
  13. package/scripts/lib/docs/concern-meta.md +3 -6
  14. package/scripts/lib/docs/glob-to-regex.md +31 -0
  15. package/scripts/lib/docs/index.md +2 -0
  16. package/scripts/lib/docs/js-source-signals.md +32 -0
  17. package/scripts/lib/glob-to-regex.mjs +79 -0
  18. package/scripts/lib/js-source-signals.mjs +50 -0
  19. package/scripts/lib/lint-surface/docs/ladder.md +3 -5
  20. package/scripts/lib/lint-surface/docs/run-fix.md +3 -2
  21. package/scripts/lib/lint-surface/ladder.mjs +18 -9
  22. package/scripts/lib/lint-surface/run-fix.mjs +15 -2
  23. package/scripts/utils/ast-scan-utils.mjs +2 -2
  24. package/scripts/utils/docs/ast-scan-utils.md +1 -1
  25. package/scripts/utils/docs/test-helpers.md +2 -1
  26. package/skills/doc-files/SKILL.md +1 -1
  27. package/skills/taze/js/docs/orchestrate.md +1 -1
  28. package/rules/bun/bunfig/bunfig.mdc +0 -17
  29. package/rules/bun/bunfig/bunfig.rego +0 -29
  30. package/rules/bun/bunfig/concern.json +0 -9
  31. package/rules/bun/bunfig/template/bunfig.toml.snippet.toml +0 -2
  32. package/rules/bun/docs/index.md +0 -11
  33. package/rules/bun/layout/concern.json +0 -16
  34. package/rules/bun/layout/docs/fix-layout.md +0 -29
  35. package/rules/bun/layout/docs/main.md +0 -34
  36. package/rules/bun/layout/fix-layout.mjs +0 -63
  37. package/rules/bun/layout/layout.mdc +0 -60
  38. package/rules/bun/layout/main.mjs +0 -53
  39. package/rules/bun/licensee/concern.json +0 -7
  40. package/rules/bun/licensee/docs/fix-licensee.md +0 -27
  41. package/rules/bun/licensee/docs/index.md +0 -12
  42. package/rules/bun/licensee/docs/main.md +0 -30
  43. package/rules/bun/licensee/fix-licensee.mjs +0 -31
  44. package/rules/bun/licensee/main.mjs +0 -68
  45. package/rules/bun/lint-surface/concern.json +0 -3
  46. package/rules/bun/lint-surface/lint-surface.mdc +0 -13
  47. package/rules/bun/main.json +0 -1
  48. package/rules/bun/main.mdc +0 -11
  49. package/rules/bun/package_json/concern.json +0 -9
  50. package/rules/bun/package_json/docs/fix-package_json.md +0 -28
  51. package/rules/bun/package_json/docs/index.md +0 -9
  52. package/rules/bun/package_json/fix-package_json.mjs +0 -311
  53. package/rules/bun/package_json/package_json.mdc +0 -14
  54. package/rules/bun/package_json/package_json.rego +0 -64
  55. package/rules/bun/package_json/template/package.json.deny.json +0 -4
  56. package/rules/doc-files/docgen-extract/concern.json +0 -3
  57. package/rules/doc-files/docgen-extract/docs/main.md +0 -38
  58. package/rules/doc-files/docgen-extract/main.mjs +0 -275
  59. package/rules/doc-files/units/concern.json +0 -3
  60. package/rules/doc-files/units/docs/main.md +0 -31
  61. package/rules/doc-files/units/main.mjs +0 -19
  62. package/rules/doc-files/units-js/concern.json +0 -3
  63. package/rules/doc-files/units-js/docs/main.md +0 -32
  64. package/rules/doc-files/units-js/main.mjs +0 -141
  65. package/rules/js/check/check.mdc +0 -26
  66. package/rules/js/check/concern.json +0 -19
  67. package/rules/js/check/docs/eslint-config.md +0 -56
  68. package/rules/js/check/docs/fix-check.md +0 -48
  69. package/rules/js/check/docs/index.md +0 -13
  70. package/rules/js/check/docs/main.md +0 -48
  71. package/rules/js/check/eslint-config.mjs +0 -262
  72. package/rules/js/check/fix-check.mjs +0 -82
  73. package/rules/js/check/main.mjs +0 -310
  74. package/rules/js/dep-policy/concern.json +0 -4
  75. package/rules/js/dep-policy/dep-policy.mdc +0 -36
  76. package/rules/js/dep-policy/docs/main.md +0 -35
  77. package/rules/js/dep-policy/main.mjs +0 -99
  78. package/rules/js/docs/index.md +0 -11
  79. package/rules/js/eslint/concern.json +0 -7
  80. package/rules/js/eslint/docs/fix-eslint.md +0 -50
  81. package/rules/js/eslint/docs/fix-worker.md +0 -31
  82. package/rules/js/eslint/docs/index.md +0 -11
  83. package/rules/js/eslint/docs/main.md +0 -35
  84. package/rules/js/eslint/fix-eslint.mjs +0 -160
  85. package/rules/js/eslint/fix-worker.mjs +0 -139
  86. package/rules/js/eslint/main.mjs +0 -115
  87. package/rules/js/file-extensions/concern.json +0 -3
  88. package/rules/js/file-extensions/file-extensions.mdc +0 -12
  89. package/rules/js/jscpd_config/concern.json +0 -11
  90. package/rules/js/jscpd_config/docs/fix-jscpd_config.md +0 -26
  91. package/rules/js/jscpd_config/docs/index.md +0 -9
  92. package/rules/js/jscpd_config/fix-jscpd_config.mjs +0 -3
  93. package/rules/js/jscpd_config/jscpd_config.mdc +0 -42
  94. package/rules/js/jscpd_config/jscpd_config.rego +0 -44
  95. package/rules/js/jscpd_config/template/.jscpd.json.snippet.json +0 -7
  96. package/rules/js/jscpd_duplicates/concern.json +0 -7
  97. package/rules/js/jscpd_duplicates/docs/main.md +0 -29
  98. package/rules/js/jscpd_duplicates/main.mjs +0 -69
  99. package/rules/js/knip/concern.json +0 -7
  100. package/rules/js/knip/docs/main.md +0 -32
  101. package/rules/js/knip/knip.mdc +0 -15
  102. package/rules/js/knip/main.mjs +0 -68
  103. package/rules/js/lint-findings/concern.json +0 -3
  104. package/rules/js/lint-findings/docs/main.md +0 -41
  105. package/rules/js/lint-findings/main.mjs +0 -125
  106. package/rules/js/main.json +0 -1
  107. package/rules/js/main.mdc +0 -18
  108. package/rules/js/package_json/concern.json +0 -9
  109. package/rules/js/package_json/docs/fix-package_json.md +0 -26
  110. package/rules/js/package_json/docs/index.md +0 -9
  111. package/rules/js/package_json/fix-package_json.mjs +0 -3
  112. package/rules/js/package_json/package_json.mdc +0 -15
  113. package/rules/js/package_json/package_json.rego +0 -142
  114. package/rules/js/package_json/template/package.json.snippet.json +0 -6
  115. package/rules/js/tooling/concern.json +0 -3
  116. package/rules/js/tooling/data/tooling/knip-canonical.json +0 -30
  117. package/rules/js/tooling/data/tooling/oxlint-canonical.json +0 -400
  118. package/rules/js/tooling/docs/main.md +0 -53
  119. package/rules/js/tooling/main.mjs +0 -183
  120. package/rules/js/utils_imports/concern.json +0 -4
  121. package/rules/js/utils_imports/docs/main.md +0 -50
  122. package/rules/js/utils_imports/main.mjs +0 -185
  123. package/rules/js/utils_imports/utils_imports.mdc +0 -15
  124. package/rules/js/vscode_extensions/concern.json +0 -11
  125. package/rules/js/vscode_extensions/docs/fix-vscode_extensions.md +0 -25
  126. package/rules/js/vscode_extensions/docs/index.md +0 -11
  127. package/rules/js/vscode_extensions/fix-vscode_extensions.mjs +0 -1
  128. package/rules/js/vscode_extensions/template/extensions.json.snippet.json +0 -6
  129. package/rules/js/vscode_extensions/vscode_extensions.mdc +0 -11
  130. package/rules/js/vscode_extensions/vscode_extensions.rego +0 -12
  131. package/rules/js-bun-db/connection/concern.json +0 -3
  132. package/rules/js-bun-db/connection/connection.mdc +0 -42
  133. package/rules/js-bun-db/docs/index.md +0 -11
  134. package/rules/js-bun-db/lib/bun-sql-scan.mjs +0 -1055
  135. package/rules/js-bun-db/lib/docs/bun-sql-scan.md +0 -63
  136. package/rules/js-bun-db/lib/docs/index.md +0 -11
  137. package/rules/js-bun-db/main.json +0 -1
  138. package/rules/js-bun-db/main.mdc +0 -8
  139. package/rules/js-bun-db/package_json/concern.json +0 -9
  140. package/rules/js-bun-db/package_json/package_json.mdc +0 -31
  141. package/rules/js-bun-db/package_json/package_json.rego +0 -15
  142. package/rules/js-bun-db/package_json/template/package.json.deny.json +0 -6
  143. package/rules/js-bun-db/pg_format_identifiers/concern.json +0 -3
  144. package/rules/js-bun-db/pg_format_identifiers/pg_format_identifiers.mdc +0 -104
  145. package/rules/js-bun-db/safety/concern.json +0 -4
  146. package/rules/js-bun-db/safety/docs/main.md +0 -34
  147. package/rules/js-bun-db/safety/main.mjs +0 -430
  148. package/rules/js-bun-db/safety/safety.mdc +0 -458
  149. package/rules/js-bun-redis/docs/index.md +0 -11
  150. package/rules/js-bun-redis/imports/concern.json +0 -4
  151. package/rules/js-bun-redis/imports/docs/main.md +0 -36
  152. package/rules/js-bun-redis/imports/imports.mdc +0 -47
  153. package/rules/js-bun-redis/imports/main.mjs +0 -88
  154. package/rules/js-bun-redis/lib/docs/index.md +0 -11
  155. package/rules/js-bun-redis/lib/docs/redis-imports.md +0 -227
  156. package/rules/js-bun-redis/lib/redis-imports.mjs +0 -130
  157. package/rules/js-bun-redis/main.json +0 -1
  158. package/rules/js-bun-redis/main.mdc +0 -8
  159. package/rules/js-bun-redis/package_json/concern.json +0 -9
  160. package/rules/js-bun-redis/package_json/package_json.mdc +0 -11
  161. package/rules/js-bun-redis/package_json/package_json.rego +0 -15
  162. package/rules/js-bun-redis/package_json/template/package.json.deny.json +0 -12
  163. package/rules/js-mssql/deps/concern.json +0 -4
  164. package/rules/js-mssql/deps/docs/main.md +0 -33
  165. package/rules/js-mssql/deps/main.mjs +0 -297
  166. package/rules/js-mssql/docs/index.md +0 -11
  167. package/rules/js-mssql/lib/docs/index.md +0 -11
  168. package/rules/js-mssql/lib/docs/mssql-pool-scan.md +0 -380
  169. package/rules/js-mssql/lib/mssql-pool-scan.mjs +0 -610
  170. package/rules/js-mssql/main.json +0 -1
  171. package/rules/js-mssql/main.mdc +0 -144
  172. package/rules/js-mssql/mssql-tvp/concern.json +0 -3
  173. package/rules/js-mssql/mssql-tvp/mssql-tvp.mdc +0 -77
  174. package/rules/js-mssql/package_json/concern.json +0 -9
  175. package/rules/js-mssql/package_json/package_json.mdc +0 -9
  176. package/rules/js-mssql/package_json/package_json.rego +0 -57
  177. package/rules/js-run/configmap/concern.json +0 -9
  178. package/rules/js-run/configmap/configmap.mdc +0 -37
  179. package/rules/js-run/configmap/configmap.rego +0 -21
  180. package/rules/js-run/configmap/template/configmap.yaml.contains.yml +0 -4
  181. package/rules/js-run/docs/index.md +0 -11
  182. package/rules/js-run/jsconfig/concern.json +0 -9
  183. package/rules/js-run/jsconfig/docs/fix-jsconfig.md +0 -29
  184. package/rules/js-run/jsconfig/docs/index.md +0 -9
  185. package/rules/js-run/jsconfig/fix-jsconfig.mjs +0 -119
  186. package/rules/js-run/jsconfig/jsconfig.mdc +0 -48
  187. package/rules/js-run/jsconfig/jsconfig.rego +0 -59
  188. package/rules/js-run/jsconfig/template/jsconfig.json.snippet.json +0 -10
  189. package/rules/js-run/lib/bunyan-imports.mjs +0 -98
  190. package/rules/js-run/lib/check-env-scan.mjs +0 -338
  191. package/rules/js-run/lib/conn-file-rules.mjs +0 -214
  192. package/rules/js-run/lib/conn-imports-scan.mjs +0 -154
  193. package/rules/js-run/lib/docs/bunyan-imports.md +0 -121
  194. package/rules/js-run/lib/docs/check-env-scan.md +0 -438
  195. package/rules/js-run/lib/docs/conn-file-rules.md +0 -304
  196. package/rules/js-run/lib/docs/conn-imports-scan.md +0 -208
  197. package/rules/js-run/lib/docs/index.md +0 -16
  198. package/rules/js-run/lib/docs/promise-settimeout-scan.md +0 -334
  199. package/rules/js-run/lib/docs/temporal-scan.md +0 -29
  200. package/rules/js-run/lib/promise-settimeout-scan.mjs +0 -128
  201. package/rules/js-run/lib/temporal-scan.mjs +0 -52
  202. package/rules/js-run/main.json +0 -1
  203. package/rules/js-run/main.mdc +0 -16
  204. package/rules/js-run/package_json/concern.json +0 -9
  205. package/rules/js-run/package_json/package_json.mdc +0 -44
  206. package/rules/js-run/package_json/package_json.rego +0 -37
  207. package/rules/js-run/package_json/template/package.json.deny.json +0 -22
  208. package/rules/js-run/project-structure/concern.json +0 -3
  209. package/rules/js-run/project-structure/project-structure.mdc +0 -11
  210. package/rules/js-run/runtime/concern.json +0 -12
  211. package/rules/js-run/runtime/docs/fix-runtime.md +0 -27
  212. package/rules/js-run/runtime/docs/main.md +0 -35
  213. package/rules/js-run/runtime/fix-runtime.mjs +0 -46
  214. package/rules/js-run/runtime/main.mjs +0 -494
  215. package/rules/js-run/runtime/runtime.mdc +0 -184
  216. package/rules/js-run/scope/concern.json +0 -3
  217. package/rules/js-run/scope/scope.mdc +0 -11
  218. package/rules/npm-module/docs/index.md +0 -11
  219. package/rules/npm-module/emit_types_config/concern.json +0 -9
  220. package/rules/npm-module/emit_types_config/docs/fix-emit_types_config.md +0 -25
  221. package/rules/npm-module/emit_types_config/docs/index.md +0 -9
  222. package/rules/npm-module/emit_types_config/emit_types_config.mdc +0 -43
  223. package/rules/npm-module/emit_types_config/emit_types_config.rego +0 -28
  224. package/rules/npm-module/emit_types_config/fix-emit_types_config.mjs +0 -5
  225. package/rules/npm-module/emit_types_config/template/tsconfig.emit-types.json.snippet.json +0 -9
  226. package/rules/npm-module/header_doc_pointer/concern.json +0 -5
  227. package/rules/npm-module/header_doc_pointer/docs/main.md +0 -40
  228. package/rules/npm-module/header_doc_pointer/header_doc_pointer.mdc +0 -18
  229. package/rules/npm-module/header_doc_pointer/main.mjs +0 -131
  230. package/rules/npm-module/main.json +0 -1
  231. package/rules/npm-module/main.mdc +0 -36
  232. package/rules/npm-module/npm_package_json/concern.json +0 -9
  233. package/rules/npm-module/npm_package_json/docs/fix-npm_package_json.md +0 -27
  234. package/rules/npm-module/npm_package_json/docs/index.md +0 -9
  235. package/rules/npm-module/npm_package_json/fix-npm_package_json.mjs +0 -5
  236. package/rules/npm-module/npm_package_json/npm_package_json.mdc +0 -57
  237. package/rules/npm-module/npm_package_json/npm_package_json.rego +0 -73
  238. package/rules/npm-module/npm_package_json/template/package.json.snippet.json +0 -1
  239. package/rules/npm-module/package_structure/concern.json +0 -5
  240. package/rules/npm-module/package_structure/docs/main.md +0 -37
  241. package/rules/npm-module/package_structure/main.mjs +0 -515
  242. package/rules/npm-module/package_structure/package_structure.mdc +0 -63
  243. package/rules/npm-module/root_package_json/concern.json +0 -9
  244. package/rules/npm-module/root_package_json/docs/fix-root_package_json.md +0 -26
  245. package/rules/npm-module/root_package_json/docs/index.md +0 -9
  246. package/rules/npm-module/root_package_json/fix-root_package_json.mjs +0 -5
  247. package/rules/npm-module/root_package_json/root_package_json.mdc +0 -41
  248. package/rules/npm-module/root_package_json/root_package_json.rego +0 -28
  249. package/rules/npm-module/root_package_json/template/package.json.snippet.json +0 -1
  250. package/rules/npm-module/rule_meta/concern.json +0 -8
  251. package/rules/npm-module/rule_meta/docs/main.md +0 -35
  252. package/rules/npm-module/rule_meta/main.mjs +0 -119
  253. package/rules/npm-module/rule_meta/rule_meta.mdc +0 -11
  254. package/rules/npm-module/skill_meta/concern.json +0 -5
  255. package/rules/npm-module/skill_meta/docs/main.md +0 -149
  256. package/rules/npm-module/skill_meta/main.mjs +0 -91
  257. package/rules/npm-module/skill_meta/skill_meta.mdc +0 -11
  258. package/rules/tool-surface/docs/index.md +0 -11
  259. package/rules/tool-surface/main.json +0 -6
  260. package/rules/tool-surface/main.mdc +0 -72
  261. package/rules/vue/composition-api/composition-api.mdc +0 -82
  262. package/rules/vue/composition-api/concern.json +0 -3
  263. package/rules/vue/docs/index.md +0 -11
  264. package/rules/vue/lib/docs/index.md +0 -11
  265. package/rules/vue/lib/docs/vue-forbidden-imports.md +0 -265
  266. package/rules/vue/lib/vue-forbidden-imports.mjs +0 -264
  267. package/rules/vue/main.json +0 -1
  268. package/rules/vue/main.mdc +0 -18
  269. package/rules/vue/nheader-layout/concern.json +0 -3
  270. package/rules/vue/nheader-layout/nheader-layout.mdc +0 -171
  271. package/rules/vue/package_json/concern.json +0 -9
  272. package/rules/vue/package_json/package_json.mdc +0 -30
  273. package/rules/vue/package_json/package_json.rego +0 -140
  274. package/rules/vue/packages/concern.json +0 -6
  275. package/rules/vue/packages/docs/index.md +0 -11
  276. package/rules/vue/packages/docs/main.md +0 -35
  277. package/rules/vue/packages/main.mjs +0 -575
  278. package/rules/vue/packages/packages.mdc +0 -56
  279. package/rules/vue/quasar-ui/concern.json +0 -3
  280. package/rules/vue/quasar-ui/quasar-ui.mdc +0 -32
  281. package/rules/vue/structure/concern.json +0 -3
  282. package/rules/vue/structure/structure.mdc +0 -101
  283. package/rules/vue/testing/concern.json +0 -3
  284. package/rules/vue/testing/testing.mdc +0 -40
  285. package/rules/vue/tfm-translations/concern.json +0 -7
  286. package/rules/vue/tfm-translations/docs/main.md +0 -29
  287. package/rules/vue/tfm-translations/main.mjs +0 -55
  288. package/rules/vue/tfm-translations/tfm-translations.mdc +0 -32
  289. package/rules/vue/vite-config/concern.json +0 -3
  290. package/rules/vue/vite-config/vite-config.mdc +0 -153
  291. package/rules/vue/vite-env/concern.json +0 -3
  292. package/rules/vue/vite-env/vite-env.mdc +0 -61
@@ -1,1055 +0,0 @@
1
- /**
2
- * AST-сканер небезпечних патернів Bun SQL (`import { sql, SQL } from 'bun'`).
3
- *
4
- * Знаходить:
5
- * - `new SQL(...)` всередині функції — пул має бути singleton на рівні модуля,
6
- * а не на кожен виклик handler-а.
7
- * - Будь-який виклик `<obj>.unsafe(...)` без маркера-коментаря `// allow-unsafe: <reason>`
8
- * на тому ж рядку або рядком вище. `sql.unsafe` за замовчуванням заборонено: дозволено
9
- * тільки якщо значення контролюється кодом (не user input) і потрібно підставити
10
- * назву таблиці/колонки або dynamic SQL/DDL. Інакше — переробити на tagged template
11
- * `sql\`...\${value}...\``. Маркер фіксує цю причину для ревʼюера.
12
- * - Динамічні SQL-списки у tagged template `sql\`... IN (${arr.join(',')}) ...\``:
13
- * навіть «через tagged template» у запит потрапляє готовий шматок SQL замість
14
- * параметризованих значень — треба `sql([...])`.
15
- *
16
- * Семантика — через **oxc-parser**, без regex по тексту коду.
17
- * Якщо файл не парситься / містить синтаксичні помилки — повертаємо порожній
18
- * результат: спочатку треба полагодити синтаксис, потім перезапустити перевірку.
19
- */
20
- import {
21
- isFunctionNode,
22
- isJoinCall,
23
- isSqlListContextTemplate,
24
- normalizeSnippet,
25
- offsetToLine,
26
- parseProgramAndCommentsOrNull,
27
- parseProgramOrNull,
28
- templateQuasisText,
29
- walkAstWithAncestors
30
- } from '../../../scripts/utils/ast-scan-utils.mjs'
31
-
32
- const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
33
- const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
34
- // Імпорт із npm-пакета `pg` — будь-яка з форм: default, named, namespace, side-effect,
35
- // а також `require('pg')`. `pg-format`/`pg-pool` свідомо НЕ матчаться: на них діє
36
- // окрема заборона (denylist) і свої повідомлення. Виключення для LISTEN/NOTIFY
37
- // стосується лише самого клієнта `pg`.
38
- const PG_LIB_IMPORT_RE = /(?:\bimport\b[\s\S]*?\bfrom\s*["']pg["']|\brequire\s*\(\s*["']pg["']\s*\))/u
39
- // Першоквазі-рядок або string literal, що починається з LISTEN / UNLISTEN / NOTIFY
40
- // (case-insensitive), з опційним leading whitespace. Сигнал, що в коді запит
41
- // `LISTEN ch` / `NOTIFY ch, 'msg'` / `UNLISTEN *` — це функціонал, якого Bun SQL
42
- // поки не має, тож у проекті лишається легітимна потреба у клієнті `pg`.
43
- const PG_LISTEN_NOTIFY_SQL_RE = /^\s*(LISTEN|UNLISTEN|NOTIFY)\b/iu
44
- const IN_PLACEHOLDER_END_RE = /\bin\s*(\(\s*)?$/iu
45
- // `// allow-unsafe: <reason>` — `allow-unsafe`, двокрапка, **непорожня** причина.
46
- // Без причини маркер не приймається: ціль — лишити слід для ревʼюера, а не «німий» прапорець.
47
- const ALLOW_UNSAFE_MARKER_RE = /\ballow-unsafe\s*:\s*\S+/u
48
- // `// allow-pg-leftover: <reason>` — opt-in для виправданих `.connect()` / `.end()`
49
- // у файлах з Bun SQL (наприклад, `sql.end()` у graceful shutdown або `.connect()`
50
- // на сторонньому об'єкті, що випадково ділить імʼя методу з `pg`).
51
- const ALLOW_PG_LEFTOVER_MARKER_RE = /\ballow-pg-leftover\s*:\s*\S+/u
52
- // pg-API, які не потрібні з Bun SQL: pool/client життєвий цикл вручну.
53
- // `release` не входить — Bun SQL такого методу не має, а `.connect()` / `.end()`
54
- // формально існують і там, тому опт-аут маркером лишається доречним.
55
- const PG_LEFTOVER_METHOD_NAMES = new Set(['connect', 'end'])
56
-
57
- // pg-format placeholders — `%L` (literal), `%I` (identifier), `%s` (raw string).
58
- // Якщо у тілі функції з підозрілим іменем зустрічається такий літерал/regex —
59
- // це pg-format-сумісний шим (drop-in замінник pg-format поверх Bun SQL).
60
- const PG_FORMAT_PLACEHOLDER_RE = /%[LIs]/u
61
- // Імена функцій-кандидатів на pg-format-шим. Спрацьовує лише у поєднанні
62
- // з наявністю `%L` / `%I` / `%s` у тілі — щоб не плутати з невинним `format(date)`.
63
- const PG_FORMAT_SHIM_FUNC_NAMES = new Set(['format', 'pgFormat', 'sqlFormat', 'pgFmt'])
64
- // Імена quote/escape-хелперів — самі по собі сильний сигнал pg-format-шиму,
65
- // без додаткової перевірки тіла. Це pg-format-специфічні API, нерідко публікуються
66
- // як named export з модуля-обгортки.
67
- const QUOTE_HELPER_NAMES = new Set(['quoteLiteral', 'quoteIdent', 'escapeLiteral', 'escapeIdent'])
68
-
69
- // Імена першого параметра pg-style query-обгортки (`function query(text, params)` тощо).
70
- const PG_QUERY_FIRST_PARAM_RE = /^(text|sql|query)$/u
71
-
72
- /**
73
- * @param {unknown} node AST node
74
- * @param {string} name імʼя змінної
75
- * @returns {boolean} true, якщо це MemberExpression `${name}.length`
76
- */
77
- function isLengthMember(node, name) {
78
- return (
79
- !!node &&
80
- typeof node === 'object' &&
81
- node.type === 'MemberExpression' &&
82
- !node.computed &&
83
- node.object &&
84
- node.object.type === 'Identifier' &&
85
- node.object.name === name &&
86
- node.property &&
87
- node.property.type === 'Identifier' &&
88
- node.property.name === 'length'
89
- )
90
- }
91
-
92
- /**
93
- * @param {unknown} node AST node
94
- * @returns {boolean} true, якщо це числовий 0-літерал
95
- */
96
- function isZeroNumberLiteral(node) {
97
- return (
98
- !!node &&
99
- typeof node === 'object' &&
100
- ((node.type === 'NumericLiteral' && node.value === 0) || (node.type === 'Literal' && node.value === 0))
101
- )
102
- }
103
-
104
- /**
105
- * @param {unknown} node AST node
106
- * @returns {boolean} true, якщо це Identifier з імʼям `sql`
107
- */
108
- function isSqlHelperIdentifier(node) {
109
- return !!node && typeof node === 'object' && node.type === 'Identifier' && node.name === 'sql'
110
- }
111
-
112
- /**
113
- * Витягає імʼя змінної списку для `IN ...`:
114
- * - `${ids}` → `ids`
115
- * - `${sql(ids)}` → `ids`
116
- * @param {unknown} expr template expression
117
- * @returns {{ name: string } | { error: 'not_var' } | { error: 'sql_helper_not_var' }} імʼя змінної або причина відмови
118
- */
119
- function extractInListVarNameFromExpr(expr) {
120
- if (!expr || typeof expr !== 'object') return { error: 'not_var' }
121
- if (expr.type === 'Identifier' && typeof expr.name === 'string') return { name: expr.name }
122
-
123
- if (expr.type === 'CallExpression' && isSqlHelperIdentifier(expr.callee)) {
124
- const args = expr.arguments
125
- if (!Array.isArray(args) || args.length === 0) return { error: 'sql_helper_not_var' }
126
- const first = args[0]
127
- if (first && typeof first === 'object' && first.type === 'Identifier' && typeof first.name === 'string') {
128
- return { name: first.name }
129
- }
130
- return { error: 'sql_helper_not_var' }
131
- }
132
-
133
- return { error: 'not_var' }
134
- }
135
-
136
- /**
137
- * Чи містить тест if-умови перевірку “список порожній”.
138
- * Підтримує базові форми:
139
- * - `if (!ids.length) ...`
140
- * - `if (ids.length === 0) ...` / `<= 0` / `< 1`
141
- * @param {unknown} test IfStatement.test
142
- * @param {string} name імʼя змінної списку
143
- * @returns {boolean} true, якщо це перевірка на пустоту списку
144
- */
145
- function isEmptyListTest(test, name) {
146
- if (!test || typeof test !== 'object') return false
147
-
148
- if (test.type === 'UnaryExpression' && test.operator === '!') {
149
- const arg = test.argument
150
- if (!arg || typeof arg !== 'object') return false
151
- return isLengthMember(arg, name)
152
- }
153
-
154
- if (test.type === 'BinaryExpression') {
155
- const { left, right, operator } = test
156
- if (!['===', '==', '<=', '<'].includes(operator)) return false
157
- if (isLengthMember(left, name) && isZeroNumberLiteral(right)) return true
158
- // допускаємо `0 === ids.length` теж
159
- if (isZeroNumberLiteral(left) && isLengthMember(right, name) && (operator === '===' || operator === '=='))
160
- return true
161
- }
162
-
163
- return false
164
- }
165
-
166
- /**
167
- * Чи є в consequent (або в його BlockStatement) ThrowStatement.
168
- * @param {unknown} consequent IfStatement.consequent
169
- * @returns {boolean} true, якщо consequent містить throw
170
- */
171
- function consequentHasThrow(consequent) {
172
- if (!consequent || typeof consequent !== 'object') return false
173
- if (consequent.type === 'ThrowStatement') return true
174
- if (consequent.type === 'BlockStatement' && Array.isArray(consequent.body)) {
175
- return consequent.body.some(s => s && typeof s === 'object' && s.type === 'ThrowStatement')
176
- }
177
- return false
178
- }
179
-
180
- /**
181
- * Шукає “guard” `if (empty) throw` перед statementIndex у межах того ж BlockStatement.
182
- * @param {unknown} block BlockStatement
183
- * @param {number} statementIndex індекс statement, перед яким шукаємо guard
184
- * @param {string} name імʼя змінної списку
185
- * @returns {boolean} true, якщо guard знайдено
186
- */
187
- function hasEmptyGuardBefore(block, statementIndex, name) {
188
- if (!block || typeof block !== 'object' || block.type !== 'BlockStatement') return false
189
- const body = block.body
190
- if (!Array.isArray(body)) return false
191
- for (let i = 0; i < statementIndex; i++) {
192
- const st = body[i]
193
- if (!st || typeof st !== 'object') continue
194
- if (st.type !== 'IfStatement') continue
195
- if (!isEmptyListTest(st.test, name)) continue
196
- if (!consequentHasThrow(st.consequent)) continue
197
- return true
198
- }
199
- return false
200
- }
201
-
202
- /**
203
- * Знаходить найближчий enclosing BlockStatement і statement всередині нього.
204
- * @param {unknown[]} ancestors ancestors масив з walkAstWithAncestors
205
- * @returns {{ block: unknown, index: number } | null} block+індекс statement або null
206
- */
207
- function findEnclosingBlockAndStatementIndex(ancestors) {
208
- if (!Array.isArray(ancestors) || ancestors.length === 0) return null
209
-
210
- // statement — перший зверху вузол, який лежить у block.body
211
- // шукаємо пару (block, statement), де statement ∈ block.body
212
- for (let i = ancestors.length - 1; i >= 1; i--) {
213
- const maybeStatement = ancestors[i]
214
- const maybeBlock = ancestors[i - 1]
215
- if (!maybeBlock || typeof maybeBlock !== 'object' || maybeBlock.type !== 'BlockStatement') continue
216
- if (!Array.isArray(maybeBlock.body)) continue
217
- const idx = maybeBlock.body.indexOf(maybeStatement)
218
- if (idx !== -1) return { block: maybeBlock, index: idx }
219
- }
220
- return null
221
- }
222
-
223
- /**
224
- * Чи це `new SQL(...)` (Identifier callee з імʼям `SQL`).
225
- * @param {unknown} node AST node
226
- * @returns {boolean} true, якщо це `new SQL(...)`
227
- */
228
- function isNewSqlConstructor(node) {
229
- if (!node || node.type !== 'NewExpression') return false
230
- const callee = node.callee
231
- return !!callee && callee.type === 'Identifier' && callee.name === 'SQL'
232
- }
233
-
234
- /**
235
- * Чи це виклик `<obj>.unsafe(...)` (будь-який обʼєкт, не тільки `sql`).
236
- * Файл сканується лише якщо є `import { sql|SQL } from 'bun'`, тож у практиці це
237
- * або `sql.unsafe`, або `tx.unsafe` всередині `sql.begin(async tx => ...)` —
238
- * обидва однаково небезпечні, тому розрізняти імʼя обʼєкта не треба.
239
- * @param {unknown} node AST node
240
- * @returns {boolean} true для будь-якого `<obj>.unsafe(...)`
241
- */
242
- function isUnsafeCall(node) {
243
- if (!node || node.type !== 'CallExpression') return false
244
- const callee = node.callee
245
- if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
246
- const prop = callee.property
247
- return !!prop && prop.type === 'Identifier' && prop.name === 'unsafe'
248
- }
249
-
250
- /**
251
- * Чи є біля виклику маркер-коментар, що матчиться на `markerRe`, на тому ж рядку,
252
- * що й початок виклику, або на рядку, що передує початку виклику. Це навмисно строга
253
- * суміжність: відірваний коментар через порожній рядок не зараховується — щоб маркер
254
- * стояв саме біля виклику, а не «загубився десь вище».
255
- *
256
- * Використовується для opt-in маркерів типу `// allow-unsafe: ...` і `// allow-pg-leftover: ...`.
257
- * @param {{ start: number }} callNode виклик
258
- * @param {{ type: 'Line' | 'Block', value: string, start: number, end: number }[]} comments коментарі з парсера
259
- * @param {string} content вихідний код
260
- * @param {RegExp} markerRe регекс, що валідує текст маркера в `comment.value`
261
- * @returns {boolean} true, якщо маркер знайдено
262
- */
263
- function hasMarkerCommentNear(callNode, comments, content, markerRe) {
264
- const callStartLine = offsetToLine(content, callNode.start)
265
- for (const c of comments) {
266
- if (!c || (c.type !== 'Line' && c.type !== 'Block')) continue
267
- if (typeof c.value !== 'string' || !markerRe.test(c.value)) continue
268
- const startLine = offsetToLine(content, c.start)
269
- // trailing-коментар на тому ж рядку (`<call> // marker: ...`)
270
- if (startLine === callStartLine) return true
271
- // коментар на рядку, що безпосередньо передує виклику — для блокових
272
- // коментарів важливим є саме `endLine`, бо block може займати кілька рядків.
273
- const endLine = offsetToLine(content, c.end)
274
- if (endLine === callStartLine - 1) return true
275
- }
276
- return false
277
- }
278
-
279
- /**
280
- * Чи це pg-leftover виклик: `<obj>.connect(...)` або `<obj>.end(...)`. Bun SQL пулом
281
- * керує сам — і `.connect()`, і `.end()` у файлах з Bun SQL зазвичай зайві, тож такі
282
- * виклики прапоруються (з opt-in маркером для рідкісних легітимних випадків).
283
- * @param {unknown} node AST node
284
- * @returns {{ name: 'connect' | 'end' } | null} ім'я pg-leftover методу або null
285
- */
286
- function asPgLeftoverCall(node) {
287
- if (!node || node.type !== 'CallExpression') return null
288
- const callee = node.callee
289
- if (!callee || callee.type !== 'MemberExpression' || callee.computed) return null
290
- const prop = callee.property
291
- if (!prop || prop.type !== 'Identifier' || typeof prop.name !== 'string') return null
292
- if (!PG_LEFTOVER_METHOD_NAMES.has(prop.name)) return null
293
- return { name: /** @type {'connect' | 'end'} */ (prop.name) }
294
- }
295
-
296
- // Локальний alias на `isUnsafeCall` — щоб у nodeContainsUnsafeCall (під query-шимом)
297
- // був семантично-говорящий call-site, але без дубля логіки з основним сканом.
298
- const isUnsafeCallNode = isUnsafeCall
299
-
300
- /**
301
- * Витягує ім'я ключа з AST `Property.key`. Підтримує `Identifier` (`{ foo: … }`)
302
- * та `Literal` (`{ 'foo': … }` / `{ 5: … }`); інші форми (computed expression тощо) — `null`.
303
- * @param {unknown} key AST `Property.key`
304
- * @returns {string | number | null} ім'я ключа або null
305
- */
306
- function propertyKeyName(key) {
307
- if (!key || typeof key !== 'object') return null
308
- if (key.type === 'Identifier' && typeof key.name === 'string') return key.name
309
- if (key.type === 'Literal' && (typeof key.value === 'string' || typeof key.value === 'number')) return key.value
310
- return null
311
- }
312
-
313
- /**
314
- * Чи містить піддерево вузла рядковий або regex-літерал з `%L` / `%I` / `%s`.
315
- * Покриває:
316
- * - `Literal` зі строковим `value`,
317
- * - `StringLiteral` (oxc),
318
- * - `TemplateLiteral` (через текст quasis),
319
- * - `RegExpLiteral` / `Literal` з `regex.pattern`.
320
- * @param {unknown} root корінь піддерева (зазвичай тіло функції)
321
- * @returns {boolean} true, якщо знайдено pg-format-плейсхолдер
322
- */
323
- function nodeContainsPgFormatPlaceholder(root) {
324
- let found = false
325
- walkAstWithAncestors(root, [], n => {
326
- if (found) return
327
- const t = n.type
328
- if (t === 'Literal' || t === 'StringLiteral') {
329
- if (typeof n.value === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(n.value)) {
330
- found = true
331
- return
332
- }
333
- const regex = n.regex
334
- if (regex && typeof regex.pattern === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(regex.pattern)) {
335
- found = true
336
- return
337
- }
338
- }
339
- if (t === 'RegExpLiteral' && typeof n.pattern === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(n.pattern)) {
340
- found = true
341
- return
342
- }
343
- if (t === 'TemplateLiteral' && PG_FORMAT_PLACEHOLDER_RE.test(templateQuasisText(n))) {
344
- found = true
345
- }
346
- })
347
- return found
348
- }
349
-
350
- /**
351
- * Витягає (name, body) з вузла, що оголошує функцію верхнього рівня:
352
- * - `function format(...) {...}`,
353
- * - `const format = (...) => {...}` / `= function(...) {...}`.
354
- * @param {Record<string, unknown>} node AST node
355
- * @returns {{ name: string, body: unknown } | null} ім'я та тіло, або null
356
- */
357
- function asNamedFunctionDecl(node) {
358
- if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier') {
359
- return { name: node.id.name, body: node.body }
360
- }
361
- if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') {
362
- const init = node.init
363
- if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
364
- return { name: node.id.name, body: init.body }
365
- }
366
- }
367
- return null
368
- }
369
-
370
- /**
371
- * Знаходить визначення pg-format-сумісних шимів у джерелі. Прапорує:
372
- * - функції з іменами `format` / `pgFormat` / `sqlFormat` / `pgFmt`, у тілі яких
373
- * зустрічається літерал/regex з `%L` / `%I` / `%s` — це drop-in pg-format;
374
- * - функції з іменами `quoteLiteral` / `quoteIdent` / `escapeLiteral` / `escapeIdent`
375
- * незалежно від тіла — це pg-format-специфічні API, не потрібні з Bun SQL.
376
- *
377
- * Скан запускається лише в файлах, де є `import { sql|SQL } from 'bun'`, щоб
378
- * не плутати, наприклад, форматер дат чи URL-escape з SQL-шимом.
379
- * @param {string} content вихідний код
380
- * @param {string} [virtualPath] шлях для вибору `lang`
381
- * @returns {{ line: number, snippet: string, kind: 'format_function' | 'quote_helper', name: string }[]} список порушень
382
- */
383
- export function findPgFormatShimDefinitionInText(content, virtualPath = 'scan.ts') {
384
- if (!textHasBunSqlImport(content)) return []
385
- const program = parseProgramOrNull(content, virtualPath)
386
- if (!program) return []
387
-
388
- /** @type {{ line: number, snippet: string, kind: 'format_function' | 'quote_helper', name: string }[]} */
389
- const out = []
390
- walkAstWithAncestors(program, [], node => {
391
- const decl = asNamedFunctionDecl(node)
392
- if (!decl) return
393
- /** @type {'format_function' | 'quote_helper' | null} */
394
- let kind = null
395
- if (QUOTE_HELPER_NAMES.has(decl.name)) {
396
- kind = 'quote_helper'
397
- } else if (PG_FORMAT_SHIM_FUNC_NAMES.has(decl.name) && nodeContainsPgFormatPlaceholder(decl.body)) {
398
- kind = 'format_function'
399
- }
400
- if (!kind) return
401
- out.push({
402
- line: offsetToLine(content, node.start),
403
- snippet: normalizeSnippet(content.slice(node.start, Math.min(node.end, node.start + 240))),
404
- kind,
405
- name: decl.name
406
- })
407
- })
408
- return out
409
- }
410
-
411
- /**
412
- * Знаходить pg-сумісні query-обгортки виду
413
- * `{ query(text, params) { return <sql>.unsafe(text, params) } }`
414
- * у файлах, що імпортують Bun SQL. Така обгортка маскує `unsafe` під
415
- * «безпечним» ім'ям і повертає injection-поверхню в код.
416
- *
417
- * Спрацьовує, коли всі умови виконані:
418
- * - вузол — `Property` з `key.name === 'query'` всередині `ObjectExpression`;
419
- * - значення — функція з 1–2 параметрами, перший — Identifier з типовим
420
- * pg-іменем (`text` / `sql` / `query`);
421
- * - у тілі функції є виклик `<obj>.unsafe(...)`.
422
- * @param {string} content вихідний код
423
- * @param {string} [virtualPath] шлях для вибору `lang`
424
- * @returns {{ line: number, snippet: string }[]} список порушень
425
- */
426
- export function findPgFormatLikeQueryWrapperInText(content, virtualPath = 'scan.ts') {
427
- if (!textHasBunSqlImport(content)) return []
428
- const program = parseProgramOrNull(content, virtualPath)
429
- if (!program) return []
430
-
431
- /** @type {{ line: number, snippet: string }[]} */
432
- const out = []
433
- walkAstWithAncestors(program, [], node => {
434
- if (node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) return
435
- for (const prop of node.properties) {
436
- const queryProp = asPgFormatLikeQueryProp(prop)
437
- if (!queryProp) continue
438
- out.push({
439
- line: offsetToLine(content, queryProp.start),
440
- snippet: normalizeSnippet(content.slice(queryProp.start, queryProp.end))
441
- })
442
- }
443
- })
444
- return out
445
- }
446
-
447
- /**
448
- * Чи є цей вузол `Property` тим самим pg-сумісним `{ query(text, params) { … unsafe … } }`?
449
- * Повертає сам `prop` (для зручного `start`/`end`) або `null`.
450
- * @param {unknown} prop AST вузол `Property`
451
- * @returns {{ start: number, end: number } | null} `prop` як власний рекорд або `null`
452
- */
453
- function asPgFormatLikeQueryProp(prop) {
454
- if (!prop || typeof prop !== 'object' || prop.type !== 'Property') return null
455
- if (propertyKeyName(prop.key) !== 'query') return null
456
- const value = prop.value
457
- if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) return null
458
- if (!hasPgQuerySignature(value.params)) return null
459
- if (!nodeContainsUnsafeCall(value.body)) return null
460
- return { start: prop.start, end: prop.end }
461
- }
462
-
463
- /**
464
- * Чи виглядає сигнатура функції як pg-style `query(text, params?)`: 1–2 параметри,
465
- * перший — Identifier з типовим pg-іменем (`text` / `sql` / `query`).
466
- * @param {unknown} params AST `params` (масив)
467
- * @returns {boolean} true, якщо схоже на pg-обгортку
468
- */
469
- function hasPgQuerySignature(params) {
470
- if (!Array.isArray(params) || params.length < 1 || params.length > 2) return false
471
- const first = params[0]
472
- if (!first || first.type !== 'Identifier' || typeof first.name !== 'string') return false
473
- return PG_QUERY_FIRST_PARAM_RE.test(first.name)
474
- }
475
-
476
- /**
477
- * Чи є у піддереві виклик `<obj>.unsafe(...)`.
478
- * @param {unknown} root корінь піддерева
479
- * @returns {boolean} true, якщо знайдено
480
- */
481
- function nodeContainsUnsafeCall(root) {
482
- let found = false
483
- walkAstWithAncestors(root, [], n => {
484
- if (found) return
485
- if (isUnsafeCallNode(n)) found = true
486
- })
487
- return found
488
- }
489
-
490
- /**
491
- * Знаходить `new SQL(...)` всередині функцій (handler на кожен запит замість singleton).
492
- * @param {string} content вихідний код
493
- * @param {string} [virtualPath] шлях для вибору `lang`
494
- * @returns {{ line: number, snippet: string }[]} список порушень
495
- */
496
- export function findBunSqlPerRequestConnectionInText(content, virtualPath = 'scan.ts') {
497
- const program = parseProgramOrNull(content, virtualPath)
498
- if (!program) return []
499
-
500
- /** @type {{ line: number, snippet: string }[]} */
501
- const out = []
502
- walkAstWithAncestors(program, [], (node, ancestors) => {
503
- if (!isNewSqlConstructor(node)) return
504
- const insideFunction = ancestors.some(n => isFunctionNode(n))
505
- if (!insideFunction) return
506
- out.push({
507
- line: offsetToLine(content, node.start),
508
- snippet: normalizeSnippet(content.slice(node.start, node.end))
509
- })
510
- })
511
- return out
512
- }
513
-
514
- /**
515
- * Знаходить виклики `<obj>.unsafe(...)` без маркера-коментаря `// allow-unsafe: <reason>`
516
- * на тому ж рядку або рядком вище. `sql.unsafe` за замовчуванням заборонено: дозволено
517
- * лише коли значення контролюється кодом (не user input) і потрібно підставити те, що
518
- * не можна параметризувати — назву таблиці/колонки або dynamic SQL/DDL. У всіх інших
519
- * випадках — переробити на tagged template виду `sql` із інтерполяцією значень.
520
- * Маркер-коментар фіксує причину для ревʼюера й одночасно слугує opt-in: без нього
521
- * перевірка падає, навіть якщо у `unsafe` лежить статичний рядок без інтерполяції.
522
- * @param {string} content вихідний код
523
- * @param {string} [virtualPath] шлях для вибору `lang`
524
- * @returns {{ line: number, snippet: string }[]} список порушень
525
- */
526
- export function findBunSqlUnsafeUseWithoutAllowMarkerInText(content, virtualPath = 'scan.ts') {
527
- const parsed = parseProgramAndCommentsOrNull(content, virtualPath)
528
- if (!parsed) return []
529
- const { program, comments } = parsed
530
-
531
- /** @type {{ line: number, snippet: string }[]} */
532
- const out = []
533
- walkAstWithAncestors(program, [], node => {
534
- if (!isUnsafeCall(node)) return
535
- if (hasMarkerCommentNear(node, comments, content, ALLOW_UNSAFE_MARKER_RE)) return
536
- out.push({
537
- line: offsetToLine(content, node.start),
538
- snippet: normalizeSnippet(content.slice(node.start, node.end))
539
- })
540
- })
541
- return out
542
- }
543
-
544
- /**
545
- * Знаходить `<obj>.unsafe(template_literal_with_interpolation)` — навіть із маркером
546
- * `// allow-unsafe`. Шаблонна підстановка `${name}` у `sql.unsafe`-рядок **не екранує**
547
- * identifier'ів (reserved words, спецсимволи) і ніяк не біндить значення — це
548
- * структурна injection-поверхня, яку легко не помітити в ревʼю. Канон — побудувати
549
- * `text` через `@scaleleap/pg-format` `format('%I', name)` (для identifiers) або
550
- * звичайні позиційні `$N`-placeholder'и (для values), і передати в `sql.unsafe(text, [params])`.
551
- *
552
- * Прапорує саме `TemplateLiteral` з `expressions.length > 0`; статичні рядки
553
- * (`Literal`, `StringLiteral`, `TemplateLiteral` без `${...}`) і виклики з готовим
554
- * `text` як змінною — не зачіпає (для них діє основна перевірка allow-unsafe).
555
- * @param {string} content вихідний код
556
- * @param {string} [virtualPath] шлях для вибору `lang`
557
- * @returns {{ line: number, snippet: string }[]} список порушень
558
- */
559
- export function findBunSqlUnsafeWithInterpolatedTemplateInText(content, virtualPath = 'scan.ts') {
560
- const program = parseProgramOrNull(content, virtualPath)
561
- if (!program) return []
562
-
563
- /** @type {{ line: number, snippet: string }[]} */
564
- const out = []
565
- walkAstWithAncestors(program, [], node => {
566
- if (!isUnsafeCall(node)) return
567
- const args = node.arguments
568
- if (!Array.isArray(args) || args.length === 0) return
569
- const first = args[0]
570
- if (!first || first.type !== 'TemplateLiteral') return
571
- const expressions = first.expressions
572
- if (!Array.isArray(expressions) || expressions.length === 0) return
573
- out.push({
574
- line: offsetToLine(content, node.start),
575
- snippet: normalizeSnippet(content.slice(node.start, node.end))
576
- })
577
- })
578
- return out
579
- }
580
-
581
- /**
582
- * Знаходить pg-leftover виклики `<obj>.connect(...)` / `<obj>.end(...)` без маркера
583
- * `// allow-pg-leftover: <reason>` у файлах, де **в цьому ж файлі** є `import { sql|SQL } from 'bun'`.
584
- *
585
- * Скоп навмисно вузький: ці метод-імена занадто загальні (WebSocket, Stream, інші бібліотеки),
586
- * тож сканер обмежений файлами, що вже використовують Bun SQL — там pg-залишок є явним
587
- * багом міграції. У не-Bun-SQL файлах прапоратися не буде, навіть якщо проєкт у цілому
588
- * мігрував.
589
- * @param {string} content вихідний код
590
- * @param {string} [virtualPath] шлях для вибору `lang`
591
- * @returns {{ line: number, snippet: string, methodName: 'connect' | 'end' }[]} список порушень
592
- */
593
- export function findBunSqlPgLeftoverCallInText(content, virtualPath = 'scan.ts') {
594
- if (!textHasBunSqlImport(content)) return []
595
- const parsed = parseProgramAndCommentsOrNull(content, virtualPath)
596
- if (!parsed) return []
597
- const { program, comments } = parsed
598
-
599
- /** @type {{ line: number, snippet: string, methodName: 'connect' | 'end' }[]} */
600
- const out = []
601
- walkAstWithAncestors(program, [], node => {
602
- const m = asPgLeftoverCall(node)
603
- if (!m) return
604
- if (hasMarkerCommentNear(node, comments, content, ALLOW_PG_LEFTOVER_MARKER_RE)) return
605
- out.push({
606
- line: offsetToLine(content, node.start),
607
- snippet: normalizeSnippet(content.slice(node.start, node.end)),
608
- methodName: m.name
609
- })
610
- })
611
- return out
612
- }
613
-
614
- /**
615
- * Знаходить динамічні SQL-списки у TaggedTemplateExpression / TemplateLiteral в контексті
616
- * `IN (...)` або `VALUES (...)`, де серед expressions є виклик `.join(...)`.
617
- * @param {string} content вихідний код
618
- * @param {string} [virtualPath] шлях для вибору `lang`
619
- * @returns {{ line: number, snippet: string }[]} список порушень
620
- */
621
- export function findUnsafeBunSqlDynamicSqlListInText(content, virtualPath = 'scan.ts') {
622
- const program = parseProgramOrNull(content, virtualPath)
623
- if (!program) return []
624
-
625
- /** @type {{ line: number, snippet: string }[]} */
626
- const out = []
627
- walkAstWithAncestors(program, [], node => {
628
- /** @type {unknown} */
629
- let template = null
630
- if (node.type === 'TemplateLiteral') {
631
- template = node
632
- } else if (node.type === 'TaggedTemplateExpression') {
633
- template = node.quasi
634
- }
635
- if (!template || typeof template !== 'object' || template.type !== 'TemplateLiteral') return
636
- if (!isSqlListContextTemplate(template)) return
637
- const expressions = template.expressions
638
- if (!Array.isArray(expressions) || expressions.length === 0) return
639
- if (expressions.every(expr => !isJoinCall(expr))) return
640
- out.push({
641
- line: offsetToLine(content, template.start),
642
- snippet: normalizeSnippet(content.slice(template.start, template.end))
643
- })
644
- })
645
- return out
646
- }
647
-
648
- /**
649
- * Збирає порушення для одного TemplateLiteral вузла: `IN ... ${...}` потребує
650
- * змінної + guard `if (empty) throw`.
651
- * @param {Record<string, unknown>} template TemplateLiteral
652
- * @param {unknown[]} ancestors ancestors з walkAstWithAncestors
653
- * @param {string} content вихідний код
654
- * @param {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} out буфер результатів
655
- */
656
- function collectInListGuardViolationsFromTemplate(template, ancestors, content, out) {
657
- const expressions = template.expressions
658
- if (!Array.isArray(expressions) || expressions.length === 0) return
659
- const quasis = template.quasis
660
- if (!Array.isArray(quasis) || quasis.length === 0) return
661
-
662
- for (const [i, expr] of expressions.entries()) {
663
- const q = quasis[i]
664
- const raw =
665
- q && typeof q === 'object' && q.value && typeof q.value === 'object' && typeof q.value.raw === 'string'
666
- ? q.value.raw
667
- : ''
668
- if (!IN_PLACEHOLDER_END_RE.test(raw)) continue
669
-
670
- const extracted = extractInListVarNameFromExpr(expr)
671
- if ('error' in extracted) {
672
- out.push({
673
- line: offsetToLine(content, template.start),
674
- snippet: normalizeSnippet(content.slice(template.start, template.end)),
675
- reason: extracted.error
676
- })
677
- continue
678
- }
679
-
680
- const place = findEnclosingBlockAndStatementIndex(ancestors)
681
- if (!place || !hasEmptyGuardBefore(place.block, place.index, extracted.name)) {
682
- out.push({
683
- line: offsetToLine(content, template.start),
684
- snippet: normalizeSnippet(content.slice(template.start, template.end)),
685
- reason: 'missing_guard',
686
- name: extracted.name
687
- })
688
- }
689
- }
690
- }
691
-
692
- /**
693
- * Знаходить підстановки списків у `IN (...)`, які:
694
- * - не винесені в окрему змінну (в `${...}` стоїть не Identifier або `sql(<non-Identifier>)`);
695
- * - або винесені, але перед запитом немає перевірки на пустоту з `throw`.
696
- * @param {string} content вихідний код
697
- * @param {string} [virtualPath] шлях для вибору `lang`
698
- * @returns {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} список порушень
699
- */
700
- export function findUnsafeBunSqlInListMissingEmptyGuardInText(content, virtualPath = 'scan.ts') {
701
- const program = parseProgramOrNull(content, virtualPath)
702
- if (!program) return []
703
-
704
- /** @type {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} */
705
- const out = []
706
-
707
- walkAstWithAncestors(program, [], (node, ancestors) => {
708
- /** @type {unknown} */
709
- let template = null
710
- if (node.type === 'TemplateLiteral') {
711
- template = node
712
- } else if (node.type === 'TaggedTemplateExpression') {
713
- template = node.quasi
714
- }
715
-
716
- if (!template || typeof template !== 'object' || template.type !== 'TemplateLiteral') return
717
- if (!isSqlListContextTemplate(template)) return
718
- collectInListGuardViolationsFromTemplate(template, ancestors, content, out)
719
- })
720
-
721
- return out
722
- }
723
-
724
- /**
725
- * Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"`.
726
- * Скан по сирому тексту — без AST, щоб бути дешевим: викликається на кожному
727
- * JS/TS-файлі при зборі ознак для авто-детекту правил.
728
- * @param {string} content вміст файлу
729
- * @returns {boolean} true, якщо є імпорт sql або SQL з модуля bun
730
- */
731
- export function textHasBunSqlImport(content) {
732
- return BUN_SQL_IMPORT_RE.test(content)
733
- }
734
-
735
- /**
736
- * Чи імпортує файл npm-пакет `pg` (`import ... from 'pg'` або `require('pg')`).
737
- * Текстова перевірка — без AST, дешевий pre-filter; для строгої локалізації
738
- * (рядок/snippet) використай `findPgLibImportInText`. Не матчить `pg-format`,
739
- * `pg-pool`, `@types/pg` — лише сам клієнт.
740
- * @param {string} content вміст файлу
741
- * @returns {boolean} true, якщо у файлі є імпорт `'pg'`
742
- */
743
- export function textHasPgLibImport(content) {
744
- return PG_LIB_IMPORT_RE.test(content)
745
- }
746
-
747
- /**
748
- * Знаходить ImportDeclaration / CallExpression `require('pg')` для пакета `pg`
749
- * (саме точна назва, не `pg-format` тощо). Повертає рядок і snippet — щоб у
750
- * повідомленнях `fail` показати конкретне місце.
751
- * @param {string} content вихідний код
752
- * @param {string} [virtualPath] шлях для вибору `lang`
753
- * @returns {{ line: number, snippet: string }[]} список місць, де імпортується `pg`
754
- */
755
- export function findPgLibImportInText(content, virtualPath = 'scan.ts') {
756
- const program = parseProgramOrNull(content, virtualPath)
757
- if (!program) return []
758
-
759
- /** @type {{ line: number, snippet: string }[]} */
760
- const out = []
761
- walkAstWithAncestors(program, [], node => {
762
- if (node.type === 'ImportDeclaration' && getStringLiteralValue(node.source) === 'pg') {
763
- out.push({
764
- line: offsetToLine(content, node.start),
765
- snippet: normalizeSnippet(content.slice(node.start, node.end))
766
- })
767
- return
768
- }
769
- if (node.type === 'CallExpression' && isRequireOfModule(node, 'pg')) {
770
- out.push({
771
- line: offsetToLine(content, node.start),
772
- snippet: normalizeSnippet(content.slice(node.start, node.end))
773
- })
774
- }
775
- })
776
- return out
777
- }
778
-
779
- /**
780
- * Знаходить використання PostgreSQL LISTEN/NOTIFY у коді — сигнал, що проект
781
- * потребує `pg` як виняток (Bun SQL поки не реалізує LISTEN/NOTIFY). Прапорує:
782
- * - `<obj>.query(...)` / `<obj>.queryArray(...)` / `<obj>.queryStream(...)`, де
783
- * перший аргумент — string literal або template literal, що починається з
784
- * `LISTEN ` / `UNLISTEN ` / `NOTIFY ` (case-insensitive);
785
- * - `<obj>.on('notification', ...)` — pg-listener notification-подій (другий
786
- * аргумент — функція; перший — точно рядок `'notification'`);
787
- * - TaggedTemplateExpression виду sql tagged template з LISTEN/UNLISTEN/NOTIFY
788
- * на початку першого quasi — на випадок, якщо хтось використовує Bun
789
- * SQL-tagged-template, а LISTEN/NOTIFY все одно лишається у тексті запиту
790
- * (це не запрацює у Bun SQL, але як сигнал — приймаємо).
791
- *
792
- * Регістр SQL-слів не важливий, провідні пробіли допускаються.
793
- * @param {string} content вихідний код
794
- * @param {string} [virtualPath] шлях для вибору `lang`
795
- * @returns {{ line: number, snippet: string, kind: 'listen_sql' | 'notify_sql' | 'unlisten_sql' | 'notification_listener' }[]} список знахідок
796
- */
797
- export function findPgListenNotifyUsageInText(content, virtualPath = 'scan.ts') {
798
- const program = parseProgramOrNull(content, virtualPath)
799
- if (!program) return []
800
-
801
- /** @type {{ line: number, snippet: string, kind: 'listen_sql' | 'notify_sql' | 'unlisten_sql' | 'notification_listener' }[]} */
802
- const out = []
803
- walkAstWithAncestors(program, [], node => {
804
- const fromCall = listenNotifyFromCallExpression(node)
805
- if (fromCall) {
806
- out.push({
807
- line: offsetToLine(content, node.start),
808
- snippet: normalizeSnippet(content.slice(node.start, node.end)),
809
- kind: fromCall
810
- })
811
- return
812
- }
813
- const fromTagged = listenNotifyFromTaggedTemplate(node)
814
- if (fromTagged) {
815
- out.push({
816
- line: offsetToLine(content, node.start),
817
- snippet: normalizeSnippet(content.slice(node.start, node.end)),
818
- kind: fromTagged
819
- })
820
- }
821
- })
822
- return out
823
- }
824
-
825
- /**
826
- * @param {Record<string, unknown>} node ImportDeclaration.source або CallExpression.arguments[0]
827
- * @returns {string | null} значення string literal або null
828
- */
829
- function getStringLiteralValue(node) {
830
- if (!node || typeof node !== 'object') return null
831
- if ((node.type === 'Literal' || node.type === 'StringLiteral') && typeof node.value === 'string') {
832
- return node.value
833
- }
834
- return null
835
- }
836
-
837
- /**
838
- * Чи це `require('<moduleName>')` — CallExpression з callee Identifier `require`
839
- * і єдиним string-літералом-аргументом.
840
- * @param {Record<string, unknown>} node CallExpression
841
- * @param {string} moduleName очікувана назва модуля (точне співпадіння)
842
- * @returns {boolean} true, якщо це саме require цього модуля
843
- */
844
- function isRequireOfModule(node, moduleName) {
845
- const callee = node.callee
846
- if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return false
847
- const args = node.arguments
848
- if (!Array.isArray(args) || args.length !== 1) return false
849
- return getStringLiteralValue(args[0]) === moduleName
850
- }
851
-
852
- /**
853
- * Аналізує CallExpression на предмет pg-style LISTEN/NOTIFY-запиту або listener'а
854
- * подій `'notification'`. Повертає тип сигналу або `null`.
855
- * @param {Record<string, unknown>} node AST node
856
- * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | 'notification_listener' | null} kind знахідки
857
- */
858
- function listenNotifyFromCallExpression(node) {
859
- if (!node || node.type !== 'CallExpression') return null
860
- const callee = node.callee
861
- if (!callee || callee.type !== 'MemberExpression' || callee.computed) return null
862
- const prop = callee.property
863
- if (!prop || prop.type !== 'Identifier' || typeof prop.name !== 'string') return null
864
- const args = node.arguments
865
- if (!Array.isArray(args) || args.length === 0) return null
866
-
867
- if (prop.name === 'on') {
868
- return getStringLiteralValue(args[0]) === 'notification' ? 'notification_listener' : null
869
- }
870
- if (prop.name === 'query' || prop.name === 'queryArray' || prop.name === 'queryStream') {
871
- return sqlStringStartsWithListenNotify(args[0])
872
- }
873
- return null
874
- }
875
-
876
- /**
877
- * Аналізує TaggedTemplateExpression: якщо перший quasi починається з
878
- * LISTEN/UNLISTEN/NOTIFY — повертає відповідний kind.
879
- * @param {Record<string, unknown>} node AST node
880
- * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | null} kind знахідки
881
- */
882
- function listenNotifyFromTaggedTemplate(node) {
883
- if (!node || node.type !== 'TaggedTemplateExpression') return null
884
- const quasi = node.quasi
885
- if (!quasi || quasi.type !== 'TemplateLiteral') return null
886
- const text = templateQuasisText(quasi)
887
- return kindFromListenNotifyMatch(text)
888
- }
889
-
890
- /**
891
- * Перший аргумент виклику `.query(...)` — це string literal або template literal,
892
- * текст якого починається з LISTEN / UNLISTEN / NOTIFY (case-insensitive)?
893
- * @param {unknown} arg AST node першого аргумента
894
- * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | null} kind знахідки або null
895
- */
896
- function sqlStringStartsWithListenNotify(arg) {
897
- if (!arg || typeof arg !== 'object') return null
898
- if ((arg.type === 'Literal' || arg.type === 'StringLiteral') && typeof arg.value === 'string') {
899
- return kindFromListenNotifyMatch(arg.value)
900
- }
901
- if (arg.type === 'TemplateLiteral') {
902
- return kindFromListenNotifyMatch(templateQuasisText(arg))
903
- }
904
- return null
905
- }
906
-
907
- /**
908
- * Перетворює текст SQL-рядка у kind знахідки (`listen_sql` / `notify_sql` /
909
- * `unlisten_sql`) або `null`, якщо рядок не починається з ключового слова.
910
- * @param {string} text SQL-текст
911
- * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | null} kind знахідки
912
- */
913
- function kindFromListenNotifyMatch(text) {
914
- const m = PG_LISTEN_NOTIFY_SQL_RE.exec(text)
915
- if (!m) return null
916
- const keyword = m[1].toUpperCase()
917
- if (keyword === 'LISTEN') return 'listen_sql'
918
- if (keyword === 'NOTIFY') return 'notify_sql'
919
- return 'unlisten_sql'
920
- }
921
-
922
- /**
923
- * Чи сканувати цей файл за розширенням (JS/TS-сімʼя, без `.d.ts`).
924
- * @param {string} relativePathPosix відносний шлях (posix)
925
- * @returns {boolean} true, якщо розширення підходить для AST-скану
926
- */
927
- export function isBunSqlScanSourceFile(relativePathPosix) {
928
- return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
929
- }
930
-
931
- // Імена відомих SQL-інстансів, для яких перевіряємо .array() без типу.
932
- const SQL_INSTANCE_NAMES = new Set(['sql', 'pgWrite', 'pgRead'])
933
-
934
- // Текст одразу після ${...} починається з `::jsonb`-касту.
935
- const JSONB_CAST_RE = /^\s*::jsonb/u
936
-
937
- /**
938
- * Чи це виклик `JSON.stringify(...)` (JSON.stringify через MemberExpression).
939
- * @param {unknown} node AST node
940
- * @returns {boolean} true, якщо node — виклик `JSON.stringify(...)`.
941
- */
942
- function isJsonStringifyCall(node) {
943
- if (!node || typeof node !== 'object' || node.type !== 'CallExpression') return false
944
- const callee = node.callee
945
- if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
946
- const obj = callee.object
947
- const prop = callee.property
948
- return (
949
- !!obj &&
950
- obj.type === 'Identifier' &&
951
- obj.name === 'JSON' &&
952
- !!prop &&
953
- prop.type === 'Identifier' &&
954
- prop.name === 'stringify'
955
- )
956
- }
957
-
958
- /**
959
- * Знаходить виклики `JSON.stringify(...)::jsonb` всередині SQL template literal-ів.
960
- * Bun SQL серіалізує об'єкти/масиви у JSON автоматично — явний `JSON.stringify`
961
- * перед `::jsonb` призводить до подвійної серіалізації (js-bun-db.mdc).
962
- * @param {string} content вихідний код
963
- * @param {string} [virtualPath] шлях для вибору lang
964
- * @returns {{ line: number, snippet: string }[]} список порушень
965
- */
966
- export function findJsonStringifyBeforeJsonbInText(content, virtualPath = 'scan.ts') {
967
- const program = parseProgramOrNull(content, virtualPath)
968
- if (!program) return []
969
-
970
- /** @type {{ line: number, snippet: string }[]} */
971
- const out = []
972
-
973
- walkAstWithAncestors(program, [], node => {
974
- let template = null
975
- if (node.type === 'TemplateLiteral') template = node
976
- else if (node.type === 'TaggedTemplateExpression') template = node.quasi
977
- if (!template || typeof template !== 'object' || template.type !== 'TemplateLiteral') return
978
-
979
- const expressions = template.expressions
980
- const quasis = template.quasis
981
- if (!Array.isArray(expressions) || !Array.isArray(quasis)) return
982
-
983
- for (const [i, expr] of expressions.entries()) {
984
- // Перевіряємо прямий JSON.stringify(...) і JSON.stringify всередині sql.array(...)
985
- const isDirectStringify = isJsonStringifyCall(expr)
986
- // sql.array(batch.map(r => JSON.stringify(...)), 'jsonb')
987
- const hasSqlArrayStringify =
988
- !isDirectStringify &&
989
- expr.type === 'CallExpression' &&
990
- Array.isArray(expr.arguments) &&
991
- expr.arguments.some(arg => {
992
- if (isJsonStringifyCall(arg)) return true
993
- // .map(r => JSON.stringify(...))
994
- if (arg.type === 'CallExpression' && Array.isArray(arg.arguments)) {
995
- const cb = arg.arguments[0]
996
- if (!cb) return false
997
- const body = cb.type === 'ArrowFunctionExpression' || cb.type === 'FunctionExpression' ? cb.body : null
998
- if (body && isJsonStringifyCall(body)) return true
999
- }
1000
- return false
1001
- })
1002
-
1003
- if (!isDirectStringify && !hasSqlArrayStringify) continue
1004
-
1005
- // Quasi після expr (quasi[i+1]) — текст одразу після закриваючого }
1006
- const nextQuasi = quasis[i + 1]
1007
- const rawAfter =
1008
- nextQuasi && typeof nextQuasi === 'object' && nextQuasi.value && typeof nextQuasi.value.raw === 'string'
1009
- ? nextQuasi.value.raw
1010
- : ''
1011
-
1012
- if (JSONB_CAST_RE.test(rawAfter) || hasSqlArrayStringify) {
1013
- out.push({
1014
- line: offsetToLine(content, expr.start),
1015
- snippet: normalizeSnippet(content.slice(expr.start, expr.end))
1016
- })
1017
- }
1018
- }
1019
- })
1020
- return out
1021
- }
1022
-
1023
- /**
1024
- * Знаходить виклики `sql.array(arr)` / `pgWrite.array(arr)` / `pgRead.array(arr)` без
1025
- * обов'язкового другого аргументу (типу pg-елемента). Без типу Bun не може вивести
1026
- * pg-тип, що призводить до mismatch (js-bun-db.mdc).
1027
- * @param {string} content вихідний код
1028
- * @param {string} [virtualPath] шлях для вибору lang
1029
- * @returns {{ line: number, snippet: string }[]} список порушень
1030
- */
1031
- export function findSqlArrayWithoutTypeArgInText(content, virtualPath = 'scan.ts') {
1032
- const program = parseProgramOrNull(content, virtualPath)
1033
- if (!program) return []
1034
-
1035
- /** @type {{ line: number, snippet: string }[]} */
1036
- const out = []
1037
-
1038
- walkAstWithAncestors(program, [], node => {
1039
- if (!node || node.type !== 'CallExpression') return
1040
- const callee = node.callee
1041
- if (!callee || callee.type !== 'MemberExpression' || callee.computed) return
1042
- const prop = callee.property
1043
- if (!prop || prop.type !== 'Identifier' || prop.name !== 'array') return
1044
- const obj = callee.object
1045
- if (!obj || obj.type !== 'Identifier' || !SQL_INSTANCE_NAMES.has(obj.name)) return
1046
- const args = node.arguments
1047
- if (!Array.isArray(args) || args.length !== 1) return
1048
-
1049
- out.push({
1050
- line: offsetToLine(content, node.start),
1051
- snippet: normalizeSnippet(content.slice(node.start, node.end))
1052
- })
1053
- })
1054
- return out
1055
- }