@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.
- package/CHANGELOG.md +12 -0
- package/package.json +14 -4
- package/rules/bun/bunfig/bunfig.mdc +17 -0
- package/rules/bun/bunfig/bunfig.rego +29 -0
- package/rules/bun/bunfig/concern.json +9 -0
- package/rules/bun/bunfig/template/bunfig.toml.snippet.toml +2 -0
- package/rules/bun/docs/index.md +11 -0
- package/rules/bun/layout/concern.json +16 -0
- package/rules/bun/layout/docs/fix-layout.md +29 -0
- package/rules/bun/layout/docs/main.md +34 -0
- package/rules/bun/layout/fix-layout.mjs +63 -0
- package/rules/bun/layout/layout.mdc +60 -0
- package/rules/bun/layout/main.mjs +53 -0
- package/rules/bun/licensee/concern.json +7 -0
- package/rules/bun/licensee/docs/fix-licensee.md +27 -0
- package/rules/bun/licensee/docs/index.md +12 -0
- package/rules/bun/licensee/docs/main.md +30 -0
- package/rules/bun/licensee/fix-licensee.mjs +31 -0
- package/rules/bun/licensee/main.mjs +68 -0
- package/rules/bun/lint-surface/concern.json +3 -0
- package/rules/bun/lint-surface/lint-surface.mdc +13 -0
- package/rules/bun/main.json +1 -0
- package/rules/bun/main.mdc +11 -0
- package/rules/bun/package_json/concern.json +9 -0
- package/rules/bun/package_json/docs/fix-package_json.md +28 -0
- package/rules/bun/package_json/docs/index.md +9 -0
- package/rules/bun/package_json/fix-package_json.mjs +311 -0
- package/rules/bun/package_json/package_json.mdc +14 -0
- package/rules/bun/package_json/package_json.rego +64 -0
- package/rules/bun/package_json/template/package.json.deny.json +4 -0
- package/rules/js/check/check.mdc +26 -0
- package/rules/js/check/concern.json +19 -0
- package/rules/js/check/docs/eslint-config.md +56 -0
- package/rules/js/check/docs/fix-check.md +48 -0
- package/rules/js/check/docs/index.md +13 -0
- package/rules/js/check/docs/main.md +48 -0
- package/rules/js/check/eslint-config.mjs +262 -0
- package/rules/js/check/fix-check.mjs +82 -0
- package/rules/js/check/main.mjs +310 -0
- package/rules/js/dep-policy/concern.json +4 -0
- package/rules/js/dep-policy/dep-policy.mdc +36 -0
- package/rules/js/dep-policy/docs/main.md +35 -0
- package/rules/js/dep-policy/main.mjs +99 -0
- package/rules/js/docs/index.md +11 -0
- package/rules/js/eslint/concern.json +8 -0
- package/rules/js/eslint/docs/fix-eslint.md +50 -0
- package/rules/js/eslint/docs/fix-worker.md +31 -0
- package/rules/js/eslint/docs/index.md +11 -0
- package/rules/js/eslint/docs/main.md +35 -0
- package/rules/js/eslint/fix-eslint.mjs +160 -0
- package/rules/js/eslint/fix-worker.mjs +139 -0
- package/rules/js/eslint/main.mjs +115 -0
- package/rules/js/file-extensions/concern.json +3 -0
- package/rules/js/file-extensions/file-extensions.mdc +12 -0
- package/rules/js/jscpd_config/concern.json +11 -0
- package/rules/js/jscpd_config/docs/fix-jscpd_config.md +25 -0
- package/rules/js/jscpd_config/docs/index.md +9 -0
- package/rules/js/jscpd_config/fix-jscpd_config.mjs +3 -0
- package/rules/js/jscpd_config/jscpd_config.mdc +42 -0
- package/rules/js/jscpd_config/jscpd_config.rego +44 -0
- package/rules/js/jscpd_config/template/.jscpd.json.snippet.json +7 -0
- package/rules/js/jscpd_duplicates/concern.json +7 -0
- package/rules/js/jscpd_duplicates/docs/main.md +29 -0
- package/rules/js/jscpd_duplicates/main.mjs +69 -0
- package/rules/js/knip/concern.json +7 -0
- package/rules/js/knip/docs/main.md +31 -0
- package/rules/js/knip/knip.mdc +15 -0
- package/rules/js/knip/main.mjs +89 -0
- package/rules/js/lint-findings/concern.json +3 -0
- package/rules/js/lint-findings/docs/main.md +40 -0
- package/rules/js/lint-findings/main.mjs +125 -0
- package/rules/js/main.json +1 -0
- package/rules/js/main.mdc +18 -0
- package/rules/js/package_json/concern.json +9 -0
- package/rules/js/package_json/docs/fix-package_json.md +25 -0
- package/rules/js/package_json/docs/index.md +9 -0
- package/rules/js/package_json/fix-package_json.mjs +3 -0
- package/rules/js/package_json/package_json.mdc +15 -0
- package/rules/js/package_json/package_json.rego +142 -0
- package/rules/js/package_json/template/package.json.snippet.json +6 -0
- package/rules/js/tooling/concern.json +3 -0
- package/rules/js/tooling/data/tooling/knip-canonical.json +30 -0
- package/rules/js/tooling/data/tooling/oxlint-canonical.json +400 -0
- package/rules/js/tooling/docs/main.md +53 -0
- package/rules/js/tooling/main.mjs +183 -0
- package/rules/js/utils_imports/concern.json +4 -0
- package/rules/js/utils_imports/docs/main.md +50 -0
- package/rules/js/utils_imports/main.mjs +185 -0
- package/rules/js/utils_imports/utils_imports.mdc +15 -0
- package/rules/js/vscode_extensions/concern.json +11 -0
- package/rules/js/vscode_extensions/docs/fix-vscode_extensions.md +24 -0
- package/rules/js/vscode_extensions/docs/index.md +11 -0
- package/rules/js/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/js/vscode_extensions/template/extensions.json.snippet.json +6 -0
- package/rules/js/vscode_extensions/vscode_extensions.mdc +11 -0
- package/rules/js/vscode_extensions/vscode_extensions.rego +12 -0
- package/rules/js-bun-db/connection/concern.json +3 -0
- package/rules/js-bun-db/connection/connection.mdc +42 -0
- package/rules/js-bun-db/docs/index.md +11 -0
- package/rules/js-bun-db/lib/bun-sql-scan.mjs +1047 -0
- package/rules/js-bun-db/lib/docs/bun-sql-scan.md +63 -0
- package/rules/js-bun-db/lib/docs/index.md +11 -0
- package/rules/js-bun-db/main.json +1 -0
- package/rules/js-bun-db/main.mdc +8 -0
- package/rules/js-bun-db/package_json/concern.json +9 -0
- package/rules/js-bun-db/package_json/package_json.mdc +31 -0
- package/rules/js-bun-db/package_json/package_json.rego +15 -0
- package/rules/js-bun-db/package_json/template/package.json.deny.json +6 -0
- package/rules/js-bun-db/pg_format_identifiers/concern.json +3 -0
- package/rules/js-bun-db/pg_format_identifiers/pg_format_identifiers.mdc +104 -0
- package/rules/js-bun-db/safety/concern.json +4 -0
- package/rules/js-bun-db/safety/docs/main.md +34 -0
- package/rules/js-bun-db/safety/main.mjs +430 -0
- package/rules/js-bun-db/safety/safety.mdc +458 -0
- package/rules/js-bun-redis/docs/index.md +11 -0
- package/rules/js-bun-redis/imports/concern.json +4 -0
- package/rules/js-bun-redis/imports/docs/main.md +36 -0
- package/rules/js-bun-redis/imports/imports.mdc +47 -0
- package/rules/js-bun-redis/imports/main.mjs +88 -0
- package/rules/js-bun-redis/lib/docs/index.md +11 -0
- package/rules/js-bun-redis/lib/docs/redis-imports.md +227 -0
- package/rules/js-bun-redis/lib/redis-imports.mjs +130 -0
- package/rules/js-bun-redis/main.json +1 -0
- package/rules/js-bun-redis/main.mdc +8 -0
- package/rules/js-bun-redis/package_json/concern.json +9 -0
- package/rules/js-bun-redis/package_json/package_json.mdc +11 -0
- package/rules/js-bun-redis/package_json/package_json.rego +15 -0
- package/rules/js-bun-redis/package_json/template/package.json.deny.json +12 -0
- package/rules/js-mssql/deps/concern.json +4 -0
- package/rules/js-mssql/deps/docs/main.md +33 -0
- package/rules/js-mssql/deps/main.mjs +297 -0
- package/rules/js-mssql/docs/index.md +11 -0
- package/rules/js-mssql/lib/docs/index.md +11 -0
- package/rules/js-mssql/lib/docs/mssql-pool-scan.md +380 -0
- package/rules/js-mssql/lib/mssql-pool-scan.mjs +610 -0
- package/rules/js-mssql/main.json +1 -0
- package/rules/js-mssql/main.mdc +144 -0
- package/rules/js-mssql/mssql-tvp/concern.json +3 -0
- package/rules/js-mssql/mssql-tvp/mssql-tvp.mdc +77 -0
- package/rules/js-mssql/package_json/concern.json +9 -0
- package/rules/js-mssql/package_json/package_json.mdc +9 -0
- package/rules/js-mssql/package_json/package_json.rego +57 -0
- package/rules/js-run/configmap/concern.json +9 -0
- package/rules/js-run/configmap/configmap.mdc +37 -0
- package/rules/js-run/configmap/configmap.rego +21 -0
- package/rules/js-run/configmap/template/configmap.yaml.contains.yml +4 -0
- package/rules/js-run/docs/index.md +11 -0
- package/rules/js-run/jsconfig/concern.json +9 -0
- package/rules/js-run/jsconfig/docs/fix-jsconfig.md +28 -0
- package/rules/js-run/jsconfig/docs/index.md +9 -0
- package/rules/js-run/jsconfig/fix-jsconfig.mjs +119 -0
- package/rules/js-run/jsconfig/jsconfig.mdc +48 -0
- package/rules/js-run/jsconfig/jsconfig.rego +59 -0
- package/rules/js-run/jsconfig/template/jsconfig.json.snippet.json +10 -0
- package/rules/js-run/lib/bunyan-imports.mjs +98 -0
- package/rules/js-run/lib/check-env-scan.mjs +338 -0
- package/rules/js-run/lib/conn-file-rules.mjs +214 -0
- package/rules/js-run/lib/conn-imports-scan.mjs +154 -0
- package/rules/js-run/lib/docs/bunyan-imports.md +121 -0
- package/rules/js-run/lib/docs/check-env-scan.md +438 -0
- package/rules/js-run/lib/docs/conn-file-rules.md +304 -0
- package/rules/js-run/lib/docs/conn-imports-scan.md +208 -0
- package/rules/js-run/lib/docs/index.md +16 -0
- package/rules/js-run/lib/docs/promise-settimeout-scan.md +334 -0
- package/rules/js-run/lib/docs/temporal-scan.md +29 -0
- package/rules/js-run/lib/promise-settimeout-scan.mjs +128 -0
- package/rules/js-run/lib/temporal-scan.mjs +52 -0
- package/rules/js-run/main.json +1 -0
- package/rules/js-run/main.mdc +16 -0
- package/rules/js-run/package_json/concern.json +9 -0
- package/rules/js-run/package_json/package_json.mdc +44 -0
- package/rules/js-run/package_json/package_json.rego +37 -0
- package/rules/js-run/package_json/template/package.json.deny.json +22 -0
- package/rules/js-run/project-structure/concern.json +3 -0
- package/rules/js-run/project-structure/project-structure.mdc +11 -0
- package/rules/js-run/runtime/concern.json +12 -0
- package/rules/js-run/runtime/docs/fix-runtime.md +27 -0
- package/rules/js-run/runtime/docs/main.md +35 -0
- package/rules/js-run/runtime/fix-runtime.mjs +46 -0
- package/rules/js-run/runtime/main.mjs +496 -0
- package/rules/js-run/runtime/runtime.mdc +184 -0
- package/rules/js-run/scope/concern.json +3 -0
- package/rules/js-run/scope/scope.mdc +11 -0
- package/rules/npm-module/docs/index.md +11 -0
- package/rules/npm-module/emit_types_config/concern.json +9 -0
- package/rules/npm-module/emit_types_config/docs/fix-emit_types_config.md +25 -0
- package/rules/npm-module/emit_types_config/docs/index.md +9 -0
- package/rules/npm-module/emit_types_config/emit_types_config.mdc +43 -0
- package/rules/npm-module/emit_types_config/emit_types_config.rego +28 -0
- package/rules/npm-module/emit_types_config/fix-emit_types_config.mjs +5 -0
- package/rules/npm-module/emit_types_config/template/tsconfig.emit-types.json.snippet.json +9 -0
- package/rules/npm-module/header_doc_pointer/concern.json +5 -0
- package/rules/npm-module/header_doc_pointer/docs/main.md +40 -0
- package/rules/npm-module/header_doc_pointer/header_doc_pointer.mdc +18 -0
- package/rules/npm-module/header_doc_pointer/main.mjs +131 -0
- package/rules/npm-module/main.json +1 -0
- package/rules/npm-module/main.mdc +36 -0
- package/rules/npm-module/npm_package_json/concern.json +9 -0
- package/rules/npm-module/npm_package_json/docs/fix-npm_package_json.md +26 -0
- package/rules/npm-module/npm_package_json/docs/index.md +9 -0
- package/rules/npm-module/npm_package_json/fix-npm_package_json.mjs +5 -0
- package/rules/npm-module/npm_package_json/npm_package_json.mdc +57 -0
- package/rules/npm-module/npm_package_json/npm_package_json.rego +73 -0
- package/rules/npm-module/npm_package_json/template/package.json.snippet.json +1 -0
- package/rules/npm-module/package_structure/concern.json +5 -0
- package/rules/npm-module/package_structure/docs/main.md +37 -0
- package/rules/npm-module/package_structure/main.mjs +448 -0
- package/rules/npm-module/package_structure/package_structure.mdc +63 -0
- package/rules/npm-module/root_package_json/concern.json +9 -0
- package/rules/npm-module/root_package_json/docs/fix-root_package_json.md +25 -0
- package/rules/npm-module/root_package_json/docs/index.md +9 -0
- package/rules/npm-module/root_package_json/fix-root_package_json.mjs +5 -0
- package/rules/npm-module/root_package_json/root_package_json.mdc +41 -0
- package/rules/npm-module/root_package_json/root_package_json.rego +28 -0
- package/rules/npm-module/root_package_json/template/package.json.snippet.json +1 -0
- package/rules/npm-module/rule_meta/concern.json +8 -0
- package/rules/npm-module/rule_meta/docs/main.md +35 -0
- package/rules/npm-module/rule_meta/main.mjs +119 -0
- package/rules/npm-module/rule_meta/rule_meta.mdc +11 -0
- package/rules/npm-module/skill_meta/concern.json +5 -0
- package/rules/npm-module/skill_meta/docs/main.md +149 -0
- package/rules/npm-module/skill_meta/main.mjs +91 -0
- package/rules/npm-module/skill_meta/skill_meta.mdc +11 -0
- package/rules/tool-surface/docs/index.md +11 -0
- package/rules/tool-surface/main.json +6 -0
- package/rules/tool-surface/main.mdc +72 -0
- package/rules/vue/composition-api/composition-api.mdc +82 -0
- package/rules/vue/composition-api/concern.json +3 -0
- package/rules/vue/docs/index.md +11 -0
- package/rules/vue/lib/docs/index.md +11 -0
- package/rules/vue/lib/docs/vue-forbidden-imports.md +265 -0
- package/rules/vue/lib/vue-forbidden-imports.mjs +240 -0
- package/rules/vue/main.json +1 -0
- package/rules/vue/main.mdc +18 -0
- package/rules/vue/nheader-layout/concern.json +3 -0
- package/rules/vue/nheader-layout/nheader-layout.mdc +171 -0
- package/rules/vue/package_json/concern.json +9 -0
- package/rules/vue/package_json/package_json.mdc +30 -0
- package/rules/vue/package_json/package_json.rego +140 -0
- package/rules/vue/packages/concern.json +6 -0
- package/rules/vue/packages/docs/index.md +11 -0
- package/rules/vue/packages/docs/main.md +35 -0
- package/rules/vue/packages/main.mjs +575 -0
- package/rules/vue/packages/packages.mdc +56 -0
- package/rules/vue/quasar-ui/concern.json +3 -0
- package/rules/vue/quasar-ui/quasar-ui.mdc +32 -0
- package/rules/vue/structure/concern.json +3 -0
- package/rules/vue/structure/structure.mdc +101 -0
- package/rules/vue/testing/concern.json +3 -0
- package/rules/vue/testing/testing.mdc +40 -0
- package/rules/vue/tfm-translations/concern.json +7 -0
- package/rules/vue/tfm-translations/docs/main.md +29 -0
- package/rules/vue/tfm-translations/main.mjs +55 -0
- package/rules/vue/tfm-translations/tfm-translations.mdc +32 -0
- package/rules/vue/vite-config/concern.json +3 -0
- package/rules/vue/vite-config/vite-config.mdc +153 -0
- package/rules/vue/vite-env/concern.json +3 -0
- 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
|
+
}
|