@7n/rules-lang-js 0.3.1 → 0.4.1

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