@nitra/cursor 1.8.143 → 1.8.145

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/bin/auto-rules.md CHANGED
@@ -22,6 +22,8 @@ js-pino - якщо присутній хоч один js файл, не в мо
22
22
 
23
23
  js-mssql - якщо в хоч одному package.json в секції dependencies присутній пакет mssql
24
24
 
25
+ js-bun-db - якщо в хоч одному package.json в секції dependencies присутній пакет pg або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
26
+
25
27
  k8s - якщо присутня хоч одна директорія k8s
26
28
 
27
29
  nginx-default-tpl - якщо присутній хоч один файл з переліку - default.conf.template, default.conf, nginx.conf
@@ -0,0 +1,118 @@
1
+ ---
2
+ description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
3
+ alwaysApply: true
4
+ version: '1.1'
5
+ ---
6
+
7
+ ## Підтримувані версії баз даних
8
+
9
+ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом, підключаємось як `mysql://`).
10
+
11
+ ## Заміна на Bun native SQL
12
+
13
+ Якщо в проєкті використовуються бібліотеки `pg` або `mysql2`, їх потрібно замінити на Bun native SQL: <https://bun.com/docs/runtime/sql>.
14
+
15
+ - Видалити з `dependencies`: `pg`, `pg-pool`, `pg-native`, `mysql`, `mysql2`.
16
+ - Видалити з коду: усі `import` / `require` цих пакетів та власні обгортки над ними.
17
+ - Замінити на `import { sql, SQL } from 'bun'` — Bun має вбудований клієнт із пулом, prepared statements та tagged templates.
18
+
19
+ ## Підключення (singleton + env)
20
+
21
+ Дефолтний експорт `sql` з `'bun'` сам читає змінні середовища (`DATABASE_URL`, `POSTGRES_URL`, `MYSQL_URL`, `PGHOST`/`PGUSER`/... та `MYSQL_HOST`/`MYSQL_USER`/...) і керує пулом — окремий `Pool` як у `pg` створювати не треба.
22
+
23
+ Для явного конфігу — `new SQL(...)` як **singleton** на рівні модуля, а не на кожен запит:
24
+
25
+ ```javascript
26
+ // db.js
27
+ import { SQL } from 'bun'
28
+
29
+ export const sql = new SQL({
30
+ url: process.env.DATABASE_URL,
31
+ max: 20,
32
+ idleTimeout: 30,
33
+ connectionTimeout: 10
34
+ })
35
+ ```
36
+
37
+ Connection string обирає адаптер автоматично:
38
+
39
+ - `postgres://...` / `postgresql://...` → PostgreSQL
40
+ - `mysql://...` / `mysql2://...` → MySQL/MariaDB
41
+ - `sqlite://...` / `file://...` / `:memory:` → SQLite
42
+
43
+ ## Як виконувати запити (безпечно)
44
+
45
+ Тільки **tagged template** з `${...}` — Bun сам біндить позиційні параметри й захищає від SQL injection:
46
+
47
+ ```javascript
48
+ import { sql } from 'bun'
49
+
50
+ const userId = 42
51
+ const status = 'active'
52
+
53
+ const users = await sql`
54
+ SELECT * FROM users
55
+ WHERE id = ${userId} AND status = ${status}
56
+ `
57
+ ```
58
+
59
+ Об'єктний INSERT/UPDATE та `IN (...)` — через helper `sql(...)`:
60
+
61
+ ```javascript
62
+ const user = { name: 'Alice', email: 'a@example.com' }
63
+
64
+ const [created] = await sql`
65
+ INSERT INTO users ${sql(user)}
66
+ RETURNING *
67
+ `
68
+
69
+ await sql`UPDATE users SET ${sql(user, 'name', 'email')} WHERE id = ${created.id}`
70
+
71
+ const ids = [1, 2, 3]
72
+ await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
73
+ ```
74
+
75
+ Транзакції — через `sql.begin` (auto-commit/rollback), вкладені — через `tx.savepoint`:
76
+
77
+ ```javascript
78
+ await sql.begin(async tx => {
79
+ await tx`INSERT INTO users ${sql(user)}`
80
+ await tx`UPDATE accounts SET balance = balance - ${100} WHERE user_id = ${user.id}`
81
+ })
82
+ ```
83
+
84
+ ## Що НЕ робити
85
+
86
+ ### Не використовувати `sql.unsafe(...)` з конкатенацією
87
+
88
+ ```javascript
89
+ // ❌ конкатенація даних у SQL — SQL injection
90
+ await sql.unsafe(`SELECT * FROM users WHERE id = ${userId}`)
91
+
92
+ // ❌ навіть у tagged template — динамічний список через .join(',')
93
+ await sql`SELECT * FROM users WHERE id IN (${ids.join(',')})`
94
+ ```
95
+
96
+ `sql.unsafe(text, params)` допустимий лише для **статичного** SQL без даних від користувача (наприклад, разовий DDL-скрипт), і обов'язково з масивом параметрів — ніяких `${...}` у самому рядку.
97
+
98
+ Для динамічних списків — `sql([...])` або `sql(rows, 'colA', 'colB')`, **не** `.join(',')`.
99
+
100
+ ### Не створювати підключення на кожен запит
101
+
102
+ ```javascript
103
+ // ❌ нове підключення/інстанс на кожен виклик
104
+ function getUser(id) {
105
+ const sql = new SQL(process.env.DATABASE_URL)
106
+ return sql`SELECT * FROM users WHERE id = ${id}`
107
+ }
108
+ ```
109
+
110
+ `new SQL(...)` має створюватись **один раз** на рівні модуля. Bun сам тримає пул (`max`, `idleTimeout`, `maxLifetime`) — окремих `Pool`/`Client` як у `pg` не потрібно.
111
+
112
+ ### Не лишати `pg` / `mysql2` поряд із Bun SQL
113
+
114
+ Якщо в коді з'явився `import { sql } from 'bun'`, то `pg` та `mysql2` мають бути прибрані і з `dependencies`, і з імпортів — щоб не лишалось двох паралельних шляхів до БД.
115
+
116
+ ## Перевірка
117
+
118
+ `npx @nitra/cursor check js-bun-db`.
package/mdc/js-mssql.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Використання mssql в nodejs
3
3
  alwaysApply: true
4
- version: '1.1'
4
+ version: '1.2'
5
5
  ---
6
6
 
7
7
  ## Підтримувана версія SQL Server
@@ -160,4 +160,30 @@ WHERE NOT EXISTS (
160
160
  - ліміт на кількість елементів (наприклад 5k/10k — залежить від вашого потоку).
161
161
  - `supplierId`/ID: число/BigInt, валідне та скінченне.
162
162
 
163
+ ## Парсинг значень для `IN (${...})`
164
+
165
+ Якщо `IN (...)` все ж використовується (а не `JOIN` на TVP), значення в `${...}` **обовʼязково** мають бути попередньо приведені числовим парсером і відфільтровані від `NaN`. Це знімає будь-яку можливість SQL injection: SQL-метасимволи в `Number`/`parseInt(...)` перетворюються на `NaN` і відсіюються.
166
+
167
+ ```javascript
168
+ // ❌ НЕ МОЖНА: значення з req.body / зовнішнього джерела без парсингу
169
+ const outIds = pgQ.rows.flatMap(x => x.req_body.Orders.map(o => o.OutletId))
170
+ await pool.query(/* sql */ String.raw`
171
+ SELECT ... WHERE so.OutletId IN (${outIds})
172
+ `)
173
+ ```
174
+
175
+ ```javascript
176
+ // ✅ МОЖНА: parseInt + filter(!isNaN) гарантує, що в SQL потраплять лише числа
177
+ const outIds = pgQ.rows
178
+ .flatMap(x => x.req_body.Orders.map(o => parseInt(o.OutletId)))
179
+ .filter(n => !isNaN(n))
180
+ await pool.request().query`
181
+ SELECT ... WHERE so.OutletId IN (${outIds})
182
+ `
183
+ ```
184
+
185
+ Допустимі парсери: `parseInt(...)`, `parseFloat(...)`, `Number(...)`, `BigInt(...)` або унарний `+x`. Літеральні масиви чисел (`[1, 2, 3]`) теж безпечні — без парсера, але без жодних рядків.
186
+
187
+ Це правило діє і для безпечного `pool.request().query\`...\`` (де mssql сам параметризує масив), і поготів для `pool.query(String.raw\`...\`)` чи `pool.query(\`...\`)`, де такий парсинг — єдиний бар'єр.
188
+
163
189
  Перевірка: `npx @nitra/cursor check js-mssql`.
package/mdc/vue.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Vue
3
3
  alwaysApply: true
4
- version: '1.2'
4
+ version: '1.3'
5
5
  ---
6
6
 
7
7
  # Vue 3 Composition API — правила для .cursorrules
@@ -111,6 +111,7 @@ const additionalInstructions = `
111
111
  - Якість коду: **ESLint** + **eslint-plugin-vue**; форматування — **oxfmt**, див. `text.mdc`.
112
112
  - Збірка та dev-сервер — **Vite**
113
113
  - **Vue Devtools** для дебагу; продакшен-збірка — **`vite build`**, оптимізація асетів і кешування на рівні деплою / CDN.
114
+ - **esbuild заборонено:** у проєкті не має бути залежності `esbuild` і згадок `esbuild` у конфігах/коді. Якщо десь є налаштування або інструкції під `esbuild` — заміни на **rolldown**.
114
115
 
115
116
  ### CI/CD
116
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.143",
3
+ "version": "1.8.145",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Автовизначення правил і skills для `.n-cursor.json` за умовами з `npm/bin/auto-rules.md`.
3
3
  *
4
- * Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source, кореневий
4
+ * Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source,
5
+ * залежності `mssql` / `pg` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
5
6
  * `package.json`) та повертає ідентифікатори правил і skills, які потрібно автододати.
6
7
  *
7
8
  * Також враховує винятки `disable-rules` і `disable-skills`: елементи з цих списків не
@@ -11,11 +12,13 @@ import { existsSync } from 'node:fs'
11
12
  import { readdir, readFile } from 'node:fs/promises'
12
13
  import { basename, join, relative } from 'node:path'
13
14
 
15
+ import { textHasBunSqlImport } from './utils/bun-sql-scan.mjs'
14
16
  import {
15
17
  isGqlScanSourceFile,
16
18
  shouldSkipFileForGqlScan,
17
19
  sourceFileHasGqlTaggedTemplate
18
20
  } from './utils/graphql-gql-scan.mjs'
21
+ import { contentForVueImportScan } from './utils/vue-forbidden-imports.mjs'
19
22
 
20
23
  /** Порядок автододавання правил відповідно до `auto-rules.md`. */
21
24
  export const AUTO_RULE_ORDER = Object.freeze([
@@ -27,6 +30,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
27
30
  'graphql',
28
31
  'js-lint',
29
32
  'js-mssql',
33
+ 'js-bun-db',
30
34
  'js-pino',
31
35
  'k8s',
32
36
  'nginx-default-tpl',
@@ -50,12 +54,25 @@ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
50
54
  const DEFAULT_DISABLED_LIST = Object.freeze([])
51
55
 
52
56
  /**
53
- * Чи є `mssql` у `dependencies` хоча б одного `package.json` у репозиторії.
57
+ * Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"` (після витягування `<script>` у `.vue`).
58
+ * @param {string} content вміст файлу
59
+ * @param {string} relativePath шлях posix відносно кореня
60
+ * @returns {boolean}
61
+ */
62
+ function sourceContentHasBunSqlImport(content, relativePath) {
63
+ return textHasBunSqlImport(contentForVueImportScan(content, relativePath))
64
+ }
65
+
66
+ /**
67
+ * Збирає, які з переданих ключів присутні в `dependencies` хоча б одного `package.json`.
54
68
  * @param {string} root абсолютний шлях до кореня репозиторію
55
- * @returns {Promise<boolean>} true, якщо знайдено `dependencies.mssql`
69
+ * @param {string[]} dependencyKeys імена залежностей (наприклад `mssql`, `pg`)
70
+ * @returns {Promise<Set<string>>} множина знайдених ключів
56
71
  */
57
- async function hasMssqlDependencyInAnyPackageJson(root) {
58
- let found = false
72
+ async function collectDependencyKeysPresentInPackageJsonTree(root, dependencyKeys) {
73
+ const wanted = new Set(dependencyKeys)
74
+ /** @type {Set<string>} */
75
+ const found = new Set()
59
76
 
60
77
  /**
61
78
  * Рекурсивний обхід каталогу з пропуском службових директорій.
@@ -63,7 +80,9 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
63
80
  * @returns {Promise<void>}
64
81
  */
65
82
  async function walk(dir) {
66
- if (found) return
83
+ if (found.size === wanted.size) {
84
+ return
85
+ }
67
86
  let entries
68
87
  try {
69
88
  entries = await readdir(dir, { withFileTypes: true })
@@ -71,7 +90,9 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
71
90
  return
72
91
  }
73
92
  for (const entry of entries) {
74
- if (found) return
93
+ if (found.size === wanted.size) {
94
+ return
95
+ }
75
96
  const absPath = join(dir, entry.name)
76
97
  if (entry.isDirectory()) {
77
98
  const isIgnoredDir = IGNORED_DIR_NAMES.has(entry.name)
@@ -82,9 +103,12 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
82
103
  try {
83
104
  const parsed = JSON.parse(await readFile(absPath, 'utf8'))
84
105
  const deps = parsed?.dependencies
85
- if (deps && typeof deps === 'object' && !Array.isArray(deps) && Object.hasOwn(deps, 'mssql')) {
86
- found = true
87
- return
106
+ if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
107
+ for (const key of wanted) {
108
+ if (Object.hasOwn(deps, key)) {
109
+ found.add(key)
110
+ }
111
+ }
88
112
  }
89
113
  } catch {
90
114
  /* ігноруємо пошкоджені/недоступні package.json */
@@ -182,11 +206,40 @@ async function updateGqlFactFromFile(absPath, relPath, facts) {
182
206
  }
183
207
  }
184
208
 
209
+ /**
210
+ * Чи сканувати файл на імпорт `sql`/`SQL` з `bun` (ті самі розширення й skip, що для gql).
211
+ * @param {string} relPath шлях posix відносно кореня
212
+ * @param {{ hasBunSqlImport: boolean }} facts агреговані факти
213
+ * @returns {boolean}
214
+ */
215
+ function shouldScanFileForBunSql(relPath, facts) {
216
+ return !facts.hasBunSqlImport && isGqlScanSourceFile(relPath) && !shouldSkipFileForGqlScan(relPath)
217
+ }
218
+
219
+ /**
220
+ * Оновлює ознаку `hasBunSqlImport` за вмістом файлу.
221
+ * @param {string} absPath абсолютний шлях до файлу
222
+ * @param {string} relPath шлях posix відносно кореня
223
+ * @param {{ hasBunSqlImport: boolean }} facts агреговані факти
224
+ * @returns {Promise<void>}
225
+ */
226
+ async function updateBunSqlFactFromFile(absPath, relPath, facts) {
227
+ try {
228
+ const content = await readFile(absPath, 'utf8')
229
+ if (sourceContentHasBunSqlImport(content, relPath)) {
230
+ facts.hasBunSqlImport = true
231
+ }
232
+ } catch {
233
+ /* ігноруємо пошкоджені/недоступні файли */
234
+ }
235
+ }
236
+
185
237
  /**
186
238
  * Обробляє файл під час обходу дерева.
187
239
  * @param {string} absPath абсолютний шлях до файлу
188
240
  * @param {string} root абсолютний шлях кореня
189
241
  * @param {{
242
+ * hasBunSqlImport: boolean,
190
243
  * hasCapacitorConfig: boolean,
191
244
  * hasDockerfile: boolean,
192
245
  * hasGqlTaggedTemplates: boolean,
@@ -204,6 +257,9 @@ async function processFileEntry(absPath, root, facts) {
204
257
  if (shouldScanFileForGql(rel, facts)) {
205
258
  await updateGqlFactFromFile(absPath, rel, facts)
206
259
  }
260
+ if (shouldScanFileForBunSql(rel, facts)) {
261
+ await updateBunSqlFactFromFile(absPath, rel, facts)
262
+ }
207
263
  }
208
264
 
209
265
  /**
@@ -270,6 +326,7 @@ export function isMonorepoPackage(packageJson) {
270
326
  * hasCapacitorConfig: boolean,
271
327
  * hasDockerfile: boolean,
272
328
  * hasGaWorkflowsDir: boolean,
329
+ * hasBunSqlImport: boolean,
273
330
  * hasGqlTaggedTemplates: boolean,
274
331
  * hasJsLikeSource: boolean,
275
332
  * hasK8sDir: boolean,
@@ -282,6 +339,7 @@ export function isMonorepoPackage(packageJson) {
282
339
  */
283
340
  export async function collectAutoRuleFacts(root) {
284
341
  const facts = {
342
+ hasBunSqlImport: false,
285
343
  hasCapacitorConfig: false,
286
344
  hasDockerfile: false,
287
345
  hasGaWorkflowsDir: existsSync(join(root, '.github', 'workflows')),
@@ -360,7 +418,9 @@ export async function detectAutoRulesAndSkills({
360
418
  )
361
419
  const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
362
420
  const isMonorepo = isMonorepoPackage(packageJsonParsed)
363
- const hasMssqlDependency = await hasMssqlDependencyInAnyPackageJson(root)
421
+ const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'mysql2'])
422
+ const hasMssqlDependency = depHits.has('mssql')
423
+ const hasJsBunDbSignal = depHits.has('pg') || depHits.has('mysql2') || facts.hasBunSqlImport
364
424
 
365
425
  /** @type {string[]} */
366
426
  const detectedRules = []
@@ -400,6 +460,7 @@ export async function detectAutoRulesAndSkills({
400
460
  { enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
401
461
  { enabled: facts.hasJsLikeSource, id: 'js-lint' },
402
462
  { enabled: hasMssqlDependency, id: 'js-mssql' },
463
+ { enabled: hasJsBunDbSignal, id: 'js-bun-db' },
403
464
  { enabled: facts.hasJsLikeSource && !(isMonorepo && facts.hasVueSource && facts.hasTempoDir), id: 'js-pino' },
404
465
  { enabled: facts.hasK8sDir, id: 'k8s' },
405
466
  { enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Перевіряє правило js-bun-db.mdc.
3
+ *
4
+ * 1) У жодному `package.json` (включно з workspace-пакетами) у `dependencies` не повинно
5
+ * бути `pg` чи `mysql2` — ці бібліотеки треба замінити на Bun native SQL
6
+ * (`import { sql, SQL } from 'bun'`, https://bun.com/docs/runtime/sql).
7
+ *
8
+ * 2) Якщо в коді використовується Bun SQL (імпорт `sql`/`SQL` з `'bun'`), додатково
9
+ * перевіряє небезпечні патерни:
10
+ * - `new SQL(...)` всередині функції (пул має бути singleton на рівні модуля).
11
+ * - `sql.unsafe(\`...${expr}...\`)` (інтерполяція даних у `unsafe` ламає параметризацію).
12
+ * - Динамічні SQL-списки через `.join(',')` у `IN (...)` / `VALUES (...)`
13
+ * (треба `sql([...])`).
14
+ */
15
+ import { existsSync } from 'node:fs'
16
+ import { readFile } from 'node:fs/promises'
17
+ import { join, relative, sep } from 'node:path'
18
+
19
+ import { createCheckReporter } from './utils/check-reporter.mjs'
20
+ import {
21
+ findBunSqlPerRequestConnectionInText,
22
+ findUnsafeBunSqlDynamicSqlListInText,
23
+ findUnsafeBunSqlUnsafeCallInText,
24
+ isBunSqlScanSourceFile,
25
+ textHasBunSqlImport
26
+ } from './utils/bun-sql-scan.mjs'
27
+ import { walkDir } from './utils/walkDir.mjs'
28
+
29
+ /** Імена забороненої залежності у будь-якому `package.json`. */
30
+ const FORBIDDEN_DEPENDENCIES = Object.freeze(['pg', 'mysql2'])
31
+
32
+ /**
33
+ * @param {unknown} v parsed JSON
34
+ * @returns {Record<string, unknown>} object або {}
35
+ */
36
+ function asObject(v) {
37
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return {}
38
+ return /** @type {Record<string, unknown>} */ (v)
39
+ }
40
+
41
+ /**
42
+ * Знаходить всі `package.json` у репозиторії (крім пропущених директорій у walkDir).
43
+ * @param {string} repoRoot абсолютний шлях до кореня репозиторію
44
+ * @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
45
+ */
46
+ async function findAllPackageJsonPaths(repoRoot) {
47
+ /** @type {string[]} */
48
+ const paths = []
49
+ await walkDir(repoRoot, absPath => {
50
+ if (absPath.endsWith(`${sep}package.json`)) {
51
+ paths.push(absPath)
52
+ }
53
+ })
54
+ paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
55
+ return paths
56
+ }
57
+
58
+ /**
59
+ * Збирає абсолютні шляхи JS/TS джерел у репозиторії для скану Bun SQL патернів.
60
+ * @param {string} repoRoot абсолютний шлях до кореня репозиторію
61
+ * @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
62
+ */
63
+ async function findAllSourcePathsForBunSqlScan(repoRoot) {
64
+ /** @type {string[]} */
65
+ const paths = []
66
+ await walkDir(repoRoot, absPath => {
67
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
68
+ if (isBunSqlScanSourceFile(rel)) {
69
+ paths.push(absPath)
70
+ }
71
+ })
72
+ paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
73
+ return paths
74
+ }
75
+
76
+ /**
77
+ * Перевіряє, чи в кореневому `package.json` присутні заборонені пакети у `dependencies`.
78
+ * @param {string[]} pkgJsonPaths абсолютні шляхи всіх `package.json` у репо
79
+ * @param {string} repoRoot абсолютний шлях до кореня
80
+ * @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter
81
+ * @returns {Promise<number>} кількість знайдених порушень
82
+ */
83
+ async function checkForbiddenDependencies(pkgJsonPaths, repoRoot, reporter) {
84
+ const { pass, fail } = reporter
85
+ let bad = 0
86
+ for (const absPath of pkgJsonPaths) {
87
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
88
+ let parsed
89
+ try {
90
+ parsed = JSON.parse(await readFile(absPath, 'utf8'))
91
+ } catch {
92
+ fail(`js-bun-db: ${rel} — невалідний JSON`)
93
+ bad++
94
+ continue
95
+ }
96
+ const deps = asObject(parsed.dependencies)
97
+ for (const name of FORBIDDEN_DEPENDENCIES) {
98
+ if (Object.hasOwn(deps, name)) {
99
+ bad++
100
+ fail(
101
+ `js-bun-db: ${rel}: dependencies.${name} — замінити на Bun native SQL ` +
102
+ `(import { sql, SQL } from 'bun', https://bun.com/docs/runtime/sql) (js-bun-db.mdc)`
103
+ )
104
+ }
105
+ }
106
+ }
107
+ if (bad === 0) {
108
+ pass(`js-bun-db: жоден package.json не містить ${FORBIDDEN_DEPENDENCIES.join(' / ')} у dependencies`)
109
+ }
110
+ return bad
111
+ }
112
+
113
+ /**
114
+ * Сканує JS/TS-джерела на небезпечні патерни Bun SQL.
115
+ * @param {string[]} sourcePaths абсолютні шляхи джерел
116
+ * @param {string} repoRoot абсолютний шлях до кореня
117
+ * @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter
118
+ * @returns {Promise<{ hasBunSqlImport: boolean, perRequest: number, unsafeCall: number, dynamicList: number }>}
119
+ * `hasBunSqlImport` — чи знайдено хоч один `import { sql|SQL } from 'bun'` у джерелах;
120
+ * решта — кількість порушень кожного типу.
121
+ */
122
+ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
123
+ const { fail } = reporter
124
+ let hasBunSqlImport = false
125
+ let perRequest = 0
126
+ let unsafeCall = 0
127
+ let dynamicList = 0
128
+
129
+ for (const absPath of sourcePaths) {
130
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
131
+ const content = await readFile(absPath, 'utf8')
132
+ if (!hasBunSqlImport && textHasBunSqlImport(content)) {
133
+ hasBunSqlImport = true
134
+ }
135
+
136
+ for (const v of findBunSqlPerRequestConnectionInText(content, rel)) {
137
+ perRequest++
138
+ fail(
139
+ `js-bun-db: ${rel}:${v.line} — не створюй new SQL(...) всередині функцій; ` +
140
+ `тримай singleton на рівні модуля (js-bun-db.mdc): ${v.snippet}`
141
+ )
142
+ }
143
+ for (const v of findUnsafeBunSqlUnsafeCallInText(content, rel)) {
144
+ unsafeCall++
145
+ fail(
146
+ `js-bun-db: ${rel}:${v.line} — sql.unsafe(\`...\${...}...\`) недопустимо: ` +
147
+ `використовуй tagged template sql\`...\${value}...\` або sql.unsafe('static', [params]) (js-bun-db.mdc): ${v.snippet}`
148
+ )
149
+ }
150
+ for (const v of findUnsafeBunSqlDynamicSqlListInText(content, rel)) {
151
+ dynamicList++
152
+ fail(
153
+ `js-bun-db: ${rel}:${v.line} — заборонено підставляти у SQL динамічні списки через .join(',') ` +
154
+ `у IN (...) / VALUES (...); використовуй sql([...]) (js-bun-db.mdc): ${v.snippet}`
155
+ )
156
+ }
157
+ }
158
+
159
+ return { hasBunSqlImport, perRequest, unsafeCall, dynamicList }
160
+ }
161
+
162
+ /**
163
+ * Перевіряє відповідність проєкту правилу js-bun-db.mdc
164
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
165
+ */
166
+ export async function check() {
167
+ const reporter = createCheckReporter()
168
+ const { pass } = reporter
169
+
170
+ const repoRoot = process.cwd()
171
+ const rootPkg = join(repoRoot, 'package.json')
172
+ if (!existsSync(rootPkg)) {
173
+ pass('js-bun-db: package.json у корені відсутній — перевірку пропущено')
174
+ return reporter.getExitCode()
175
+ }
176
+
177
+ const pkgJsonPaths = await findAllPackageJsonPaths(repoRoot)
178
+ if (pkgJsonPaths.length === 0) {
179
+ pass('js-bun-db: package.json не знайдено — перевірку пропущено')
180
+ return reporter.getExitCode()
181
+ }
182
+
183
+ await checkForbiddenDependencies(pkgJsonPaths, repoRoot, reporter)
184
+
185
+ const sourcePaths = await findAllSourcePathsForBunSqlScan(repoRoot)
186
+ if (sourcePaths.length === 0) {
187
+ pass('js-bun-db: немає JS/TS файлів для скану патернів Bun SQL')
188
+ return reporter.getExitCode()
189
+ }
190
+
191
+ const { hasBunSqlImport, perRequest, unsafeCall, dynamicList } = await scanSourcesForBunSqlPatterns(
192
+ sourcePaths,
193
+ repoRoot,
194
+ reporter
195
+ )
196
+
197
+ if (!hasBunSqlImport) {
198
+ pass("js-bun-db: Bun SQL не використовується в коді (немає import { sql|SQL } from 'bun')")
199
+ return reporter.getExitCode()
200
+ }
201
+
202
+ if (perRequest === 0) {
203
+ pass('js-bun-db: немає створення new SQL(...) всередині функцій (singleton на рівні модуля)')
204
+ }
205
+ if (unsafeCall === 0) {
206
+ pass('js-bun-db: немає небезпечних викликів sql.unsafe(`...${...}...`)')
207
+ }
208
+ if (dynamicList === 0) {
209
+ pass("js-bun-db: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
210
+ }
211
+
212
+ return reporter.getExitCode()
213
+ }
@@ -18,6 +18,7 @@ import {
18
18
  findSharedMssqlRequestInText,
19
19
  findUnsafeMssqlQueryTemplateCallInText,
20
20
  findUnsafeMssqlDynamicSqlListInText,
21
+ findUnsafeMssqlInListUnparsedInText,
21
22
  isMssqlScanSourceFile
22
23
  } from './utils/mssql-pool-scan.mjs'
23
24
  import { walkDir } from './utils/walkDir.mjs'
@@ -177,6 +178,7 @@ export async function check() {
177
178
  let sharedRequestViolations = 0
178
179
  let unsafeQueryCalls = 0
179
180
  let unsafeDynamicSqlLists = 0
181
+ let unparsedInLists = 0
180
182
  for (const absPath of sourcePaths) {
181
183
  const rel = relative(repoRoot, absPath).split('\\').join('/')
182
184
  const content = await readFile(absPath, 'utf8')
@@ -204,6 +206,12 @@ export async function check() {
204
206
  `js-mssql: ${rel}:${v.line} — заборонено підставляти у SQL динамічні списки через .join(',') (типово IN (...) / VALUES (...)); використовуй TVP (sql.Table) + JOIN/INSERT (js-mssql.mdc): ${v.snippet}`
205
207
  )
206
208
  }
209
+ for (const v of findUnsafeMssqlInListUnparsedInText(content, rel)) {
210
+ unparsedInLists++
211
+ fail(
212
+ `js-mssql: ${rel}:${v.line} — у SQL IN (\${...}) значення мають бути попередньо приведені числовим парсером (parseInt/Number/BigInt/parseFloat) і відфільтровані від NaN, інакше можливий SQL injection (js-mssql.mdc): ${v.snippet}`
213
+ )
214
+ }
207
215
  }
208
216
 
209
217
  if (violations === 0) {
@@ -218,6 +226,9 @@ export async function check() {
218
226
  if (unsafeDynamicSqlLists === 0) {
219
227
  pass("js-mssql: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
220
228
  }
229
+ if (unparsedInLists === 0) {
230
+ pass('js-mssql: немає підстановок IN (${...}) без числового парсера значень')
231
+ }
221
232
  }
222
233
 
223
234
  return reporter.getExitCode()
@@ -24,6 +24,98 @@ import { walkDir } from './utils/walkDir.mjs'
24
24
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
25
25
 
26
26
  const MAJOR_VERSION_RE = /(\d+)/
27
+ const ESBUILD_RE = /\besbuild\b/
28
+
29
+ /**
30
+ * Визначає, чи можна сканувати файл як текст на згадки `esbuild`.
31
+ * @param {string} relPosix відносний шлях у posix-форматі
32
+ * @returns {boolean} true якщо файл варто перевірити
33
+ */
34
+ function isEsbuildScanFile(relPosix) {
35
+ if (
36
+ relPosix.startsWith('node_modules/') ||
37
+ relPosix.startsWith('dist/') ||
38
+ relPosix.startsWith('build/') ||
39
+ relPosix.startsWith('coverage/') ||
40
+ relPosix.startsWith('.git/')
41
+ ) {
42
+ return false
43
+ }
44
+
45
+ const lower = relPosix.toLowerCase()
46
+ if (
47
+ lower === 'bun.lock' ||
48
+ lower === 'bun.lockb' ||
49
+ lower === 'package-lock.json' ||
50
+ lower === 'yarn.lock' ||
51
+ lower === 'pnpm-lock.yaml'
52
+ ) {
53
+ return false
54
+ }
55
+
56
+ return (
57
+ lower.endsWith('.js') ||
58
+ lower.endsWith('.mjs') ||
59
+ lower.endsWith('.cjs') ||
60
+ lower.endsWith('.ts') ||
61
+ lower.endsWith('.tsx') ||
62
+ lower.endsWith('.vue') ||
63
+ lower.endsWith('.json') ||
64
+ lower.endsWith('.jsonc') ||
65
+ lower.endsWith('.yaml') ||
66
+ lower.endsWith('.yml') ||
67
+ lower.endsWith('.md') ||
68
+ lower.endsWith('.mdc')
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Сканує дерево пакета на згадки `esbuild` і підказує заміну на `rolldown`.
74
+ * @param {string} rootDir відносний шлях до пакета
75
+ * @param {string} absPackageRoot абсолютний шлях до кореня пакета
76
+ * @param {string} prefix параметр prefix
77
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
78
+ * @param {(msg: string) => void} fail callback при помилці
79
+ */
80
+ async function checkEsbuildMentions(rootDir, absPackageRoot, prefix, passFn, fail) {
81
+ /** @type {{ rel: string; line: number; snippet: string }[]} */
82
+ const hits = []
83
+
84
+ await walkDir(absPackageRoot, absPath => {
85
+ const rel = relative(absPackageRoot, absPath).split('\\').join('/')
86
+ if (!isEsbuildScanFile(rel)) return
87
+ hits.push({ rel, line: 0, snippet: '' })
88
+ })
89
+
90
+ // ми використали hits як буфер шляхів; зараз перетворимо на реальні співпадіння
91
+ /** @type {{ rel: string; line: number; snippet: string }[]} */
92
+ const matches = []
93
+ for (const { rel } of hits) {
94
+ const content = await readFile(join(absPackageRoot, rel), 'utf8')
95
+ if (!ESBUILD_RE.test(content)) continue
96
+
97
+ const lines = content.split('\n')
98
+ for (let i = 0; i < lines.length; i++) {
99
+ if (ESBUILD_RE.test(lines[i])) {
100
+ matches.push({ rel, line: i + 1, snippet: lines[i].trim() })
101
+ if (matches.length >= 30) break
102
+ }
103
+ }
104
+ if (matches.length >= 30) break
105
+ }
106
+
107
+ if (matches.length === 0) {
108
+ passFn(`${prefix}немає згадок 'esbuild' у джерелах пакета (очікується rolldown)`)
109
+ return
110
+ }
111
+
112
+ for (const m of matches) {
113
+ fail(`${prefix}${m.rel}:${m.line} — знайдено 'esbuild'. Замінити на 'rolldown'. Фрагмент: ${m.snippet}`)
114
+ }
115
+ if (matches.length >= 30) {
116
+ fail(`${prefix}показано перші 30 збігів 'esbuild' (замінити на 'rolldown')`)
117
+ }
118
+ }
27
119
 
28
120
  /**
29
121
  * Формує зрозумілий для людини підпис пакета для повідомлень перевірки.
@@ -107,6 +199,9 @@ async function checkViteConfig(rootDir, prefix, passFn, fail) {
107
199
  return
108
200
  }
109
201
  const content = await readFile(join(rootDir, viteConfig), 'utf8')
202
+ if (ESBUILD_RE.test(content)) {
203
+ fail(`${prefix}${viteConfig} містить 'esbuild' — заміни на 'rolldown'`)
204
+ }
110
205
  const checks = [
111
206
  { token: 'VueMacros', ok: `${viteConfig} використовує VueMacros`, err: `${viteConfig} не містить VueMacros` },
112
207
  { token: 'AutoImport', ok: `${viteConfig} використовує AutoImport`, err: `${viteConfig} не містить AutoImport` }
@@ -175,6 +270,10 @@ async function checkVuePackage(rootDir, fail, passFn) {
175
270
  const devDeps = pkg.devDependencies || {}
176
271
  const allDeps = { ...deps, ...devDeps }
177
272
 
273
+ if (allDeps.esbuild) {
274
+ fail(`${prefix}esbuild заборонено (знайдено: ${allDeps.esbuild}). Замінити на rolldown та прибрати esbuild.`)
275
+ }
276
+
178
277
  checkRequiredDep(deps, 'vue', prefix, passFn, fail, 'vue відсутній в dependencies')
179
278
  checkViteVersion(devDeps, prefix, passFn, fail)
180
279
  checkRequiredDep(
@@ -205,6 +304,7 @@ async function checkVuePackage(rootDir, fail, passFn) {
205
304
 
206
305
  await checkViteConfig(rootDir, prefix, passFn, fail)
207
306
  await checkVueImportViolations(rootDir, join(process.cwd(), rootDir), prefix, passFn, fail)
307
+ await checkEsbuildMentions(rootDir, join(process.cwd(), rootDir), prefix, passFn, fail)
208
308
  }
209
309
 
210
310
  /**
@@ -215,17 +315,6 @@ export async function check() {
215
315
  const reporter = createCheckReporter()
216
316
  const { pass, fail } = reporter
217
317
 
218
- if (existsSync('.vscode/extensions.json')) {
219
- const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
220
- if (ext.recommendations?.includes('Vue.volar')) {
221
- pass('extensions.json містить Vue.volar')
222
- } else {
223
- fail('extensions.json не містить Vue.volar — додай до recommendations')
224
- }
225
- } else {
226
- fail('.vscode/extensions.json не існує')
227
- }
228
-
229
318
  const roots = await getMonorepoPackageRootDirs()
230
319
  /** @type {string[]} */
231
320
  const vueRoots = []
@@ -237,8 +326,23 @@ export async function check() {
237
326
  }
238
327
  }
239
328
 
329
+ if (vueRoots.length > 0) {
330
+ if (existsSync('.vscode/extensions.json')) {
331
+ const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
332
+ if (ext.recommendations?.includes('Vue.volar')) {
333
+ pass('extensions.json містить Vue.volar')
334
+ } else {
335
+ fail('extensions.json не містить Vue.volar — додай до recommendations')
336
+ }
337
+ } else {
338
+ fail('.vscode/extensions.json не існує (для Vue-проєкту потрібна рекомендація Vue.volar)')
339
+ }
340
+ } else {
341
+ pass('Vue.volar: пропущено (у repo немає пакетів з vue у dependencies)')
342
+ }
343
+
240
344
  if (vueRoots.length === 0) {
241
- fail('vue не знайдено в dependencies жодного пакета (корінь репо та каталоги з кореневого workspaces)')
345
+ pass('vue не знайдено в dependencies жодного пакета (перевірка vue пропущена)')
242
346
  return reporter.getExitCode()
243
347
  }
244
348
 
@@ -0,0 +1,294 @@
1
+ /**
2
+ * AST-сканер небезпечних патернів Bun SQL (`import { sql, SQL } from 'bun'`).
3
+ *
4
+ * Знаходить:
5
+ * - `new SQL(...)` всередині функції — пул має бути singleton на рівні модуля,
6
+ * а не на кожен виклик handler-а.
7
+ * - Виклик `sql.unsafe(\`...${expr}...\`)` з даними у TemplateLiteral —
8
+ * `sql.unsafe` приймає лише статичний SQL (плюс масив параметрів); інтерполяція
9
+ * у текст руйнує параметризацію і відкриває SQL injection.
10
+ * - Динамічні SQL-списки у tagged template `sql\`... IN (${arr.join(',')}) ...\``:
11
+ * навіть «через tagged template» у запит потрапляє готовий шматок SQL замість
12
+ * параметризованих значень — треба `sql([...])`.
13
+ *
14
+ * Семантика — через **oxc-parser**, без regex по тексту коду.
15
+ * Якщо файл не парситься / містить синтаксичні помилки — повертаємо порожній
16
+ * результат: спочатку треба полагодити синтаксис, потім перезапустити перевірку.
17
+ */
18
+ import { parseSync } from 'oxc-parser'
19
+
20
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
21
+ const SQL_LIST_CONTEXT_RE = /\b(in|values)\b\s*\(/iu
22
+ const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
23
+
24
+ /**
25
+ * Мова для Oxc за шляхом файлу (розширення).
26
+ * @param {string} filePath віртуальний або реальний шлях до файлу
27
+ * @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
28
+ */
29
+ function langFromPath(filePath) {
30
+ const lower = filePath.toLowerCase()
31
+ if (lower.endsWith('.tsx')) return 'tsx'
32
+ if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) return 'ts'
33
+ if (lower.endsWith('.jsx')) return 'jsx'
34
+ return 'js'
35
+ }
36
+
37
+ /**
38
+ * Номер рядка (1-based) за зміщенням у буфері.
39
+ * @param {string} content повний текст файлу
40
+ * @param {number} offset байтове зміщення початку фрагмента
41
+ * @returns {number} номер рядка від 1
42
+ */
43
+ function offsetToLine(content, offset) {
44
+ let line = 1
45
+ const n = Math.min(offset, content.length)
46
+ for (let i = 0; i < n; i++) {
47
+ if (content.codePointAt(i) === 10) line++
48
+ }
49
+ return line
50
+ }
51
+
52
+ /**
53
+ * Стискає пробіли для повідомлення про порушення.
54
+ * @param {string} s фрагмент коду
55
+ * @returns {string} скорочений однорядковий рядок
56
+ */
57
+ function normalizeSnippet(s) {
58
+ return s.replaceAll(/\s+/gu, ' ').trim().slice(0, 180)
59
+ }
60
+
61
+ /**
62
+ * Чи є вузол функцією.
63
+ * @param {unknown} node AST node
64
+ * @returns {boolean} true, якщо це будь-який вузол-функція
65
+ */
66
+ function isFunctionNode(node) {
67
+ return (
68
+ !!node &&
69
+ typeof node === 'object' &&
70
+ typeof node.type === 'string' &&
71
+ (node.type === 'FunctionDeclaration' ||
72
+ node.type === 'FunctionExpression' ||
73
+ node.type === 'ArrowFunctionExpression')
74
+ )
75
+ }
76
+
77
+ /**
78
+ * Рекурсивний обхід AST з предками, щоб визначати контекст (всередині функції чи ні).
79
+ * @param {unknown} node поточний вузол
80
+ * @param {unknown[]} ancestors масив предків від кореня до parent
81
+ * @param {(n: Record<string, unknown>, ancestors: unknown[]) => void} visit відвідувач для вузлів з `type`
82
+ * @returns {void}
83
+ */
84
+ function walkAstWithAncestors(node, ancestors, visit) {
85
+ if (!node || typeof node !== 'object') return
86
+ if (Array.isArray(node)) {
87
+ for (const item of node) walkAstWithAncestors(item, ancestors, visit)
88
+ return
89
+ }
90
+
91
+ const rec = /** @type {Record<string, unknown>} */ (node)
92
+ if (typeof rec.type === 'string') {
93
+ visit(rec, ancestors)
94
+ ancestors = [...ancestors, rec]
95
+ }
96
+
97
+ for (const key of Object.keys(node)) {
98
+ if (key === 'parent') continue
99
+ const v = rec[key]
100
+ if (v && typeof v === 'object') {
101
+ walkAstWithAncestors(v, ancestors, visit)
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Парсить файл та повертає program або null, якщо є синтаксичні помилки.
108
+ * @param {string} content вихідний код
109
+ * @param {string} virtualPath шлях для вибору `lang`
110
+ * @returns {unknown | null} `result.program` або null
111
+ */
112
+ function parseProgramOrNull(content, virtualPath) {
113
+ const lang = langFromPath(virtualPath || 'scan.ts')
114
+ let result
115
+ try {
116
+ result = parseSync(virtualPath || 'scan.ts', content, { lang, sourceType: 'module' })
117
+ } catch {
118
+ return null
119
+ }
120
+ if (result.errors?.length) return null
121
+ return result.program
122
+ }
123
+
124
+ /**
125
+ * Чи це `new SQL(...)` (Identifier callee з імʼям `SQL`).
126
+ * @param {unknown} node AST node
127
+ * @returns {boolean} true, якщо це `new SQL(...)`
128
+ */
129
+ function isNewSqlConstructor(node) {
130
+ if (!node || node.type !== 'NewExpression') return false
131
+ const callee = node.callee
132
+ return !!callee && callee.type === 'Identifier' && callee.name === 'SQL'
133
+ }
134
+
135
+ /**
136
+ * Чи це виклик `<obj>.unsafe(...)` з TemplateLiteral як першим аргументом і expressions усередині нього.
137
+ * Допустимий лише `sql.unsafe('static text', [params])`; з `${...}` у TemplateLiteral — небезпечно.
138
+ * @param {unknown} node AST node
139
+ * @returns {boolean} true для небезпечного `sql.unsafe(\`... ${x} ...\`)`
140
+ */
141
+ function isUnsafeCallWithInterpolatedTemplate(node) {
142
+ if (!node || node.type !== 'CallExpression') return false
143
+ const callee = node.callee
144
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
145
+ const prop = callee.property
146
+ if (!prop || prop.type !== 'Identifier' || prop.name !== 'unsafe') return false
147
+ const args = node.arguments
148
+ if (!Array.isArray(args) || args.length === 0) return false
149
+ const first = args[0]
150
+ if (!first || first.type !== 'TemplateLiteral') return false
151
+ const expressions = first.expressions
152
+ return Array.isArray(expressions) && expressions.length > 0
153
+ }
154
+
155
+ /**
156
+ * Чи це `.join(...)` виклик (типово для динамічних списків у SQL).
157
+ * @param {unknown} node AST node
158
+ * @returns {boolean} true, якщо це CallExpression `*.join(...)`
159
+ */
160
+ function isJoinCall(node) {
161
+ if (!node || node.type !== 'CallExpression') return false
162
+ const callee = node.callee
163
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
164
+ const prop = callee.property
165
+ return !!prop && prop.type === 'Identifier' && prop.name === 'join'
166
+ }
167
+
168
+ /**
169
+ * Текст quasis у TemplateLiteral (без expressions).
170
+ * @param {unknown} template TemplateLiteral
171
+ * @returns {string} обʼєднаний raw-текст
172
+ */
173
+ function templateQuasisText(template) {
174
+ if (!template || template.type !== 'TemplateLiteral') return ''
175
+ const quasis = template.quasis
176
+ if (!Array.isArray(quasis) || quasis.length === 0) return ''
177
+ let out = ''
178
+ for (const q of quasis) {
179
+ if (!q || typeof q !== 'object') continue
180
+ const value = q.value
181
+ if (!value || typeof value !== 'object') continue
182
+ if (typeof value.raw === 'string') out += value.raw
183
+ }
184
+ return out
185
+ }
186
+
187
+ /**
188
+ * Чи виглядає TemplateLiteral як SQL-контекст зі списком (IN/VALUES (...)).
189
+ * @param {unknown} template TemplateLiteral
190
+ * @returns {boolean} true, якщо текст містить `IN (` або `VALUES (`
191
+ */
192
+ function isSqlListContextTemplate(template) {
193
+ return SQL_LIST_CONTEXT_RE.test(templateQuasisText(template))
194
+ }
195
+
196
+ /**
197
+ * Знаходить `new SQL(...)` всередині функцій (handler на кожен запит замість singleton).
198
+ * @param {string} content вихідний код
199
+ * @param {string} [virtualPath] шлях для вибору `lang`
200
+ * @returns {{ line: number, snippet: string }[]} список порушень
201
+ */
202
+ export function findBunSqlPerRequestConnectionInText(content, virtualPath = 'scan.ts') {
203
+ const program = parseProgramOrNull(content, virtualPath)
204
+ if (!program) return []
205
+
206
+ /** @type {{ line: number, snippet: string }[]} */
207
+ const out = []
208
+ walkAstWithAncestors(program, [], (node, ancestors) => {
209
+ if (!isNewSqlConstructor(node)) return
210
+ const insideFunction = ancestors.some(n => isFunctionNode(n))
211
+ if (!insideFunction) return
212
+ out.push({
213
+ line: offsetToLine(content, node.start),
214
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
215
+ })
216
+ })
217
+ return out
218
+ }
219
+
220
+ /**
221
+ * Знаходить виклики `sql.unsafe(\`...${...}...\`)` (TemplateLiteral з expressions).
222
+ * @param {string} content вихідний код
223
+ * @param {string} [virtualPath] шлях для вибору `lang`
224
+ * @returns {{ line: number, snippet: string }[]} список порушень
225
+ */
226
+ export function findUnsafeBunSqlUnsafeCallInText(content, virtualPath = 'scan.ts') {
227
+ const program = parseProgramOrNull(content, virtualPath)
228
+ if (!program) return []
229
+
230
+ /** @type {{ line: number, snippet: string }[]} */
231
+ const out = []
232
+ walkAstWithAncestors(program, [], node => {
233
+ if (!isUnsafeCallWithInterpolatedTemplate(node)) return
234
+ out.push({
235
+ line: offsetToLine(content, node.start),
236
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
237
+ })
238
+ })
239
+ return out
240
+ }
241
+
242
+ /**
243
+ * Знаходить динамічні SQL-списки у TaggedTemplateExpression / TemplateLiteral в контексті
244
+ * `IN (...)` або `VALUES (...)`, де серед expressions є виклик `.join(...)`.
245
+ * @param {string} content вихідний код
246
+ * @param {string} [virtualPath] шлях для вибору `lang`
247
+ * @returns {{ line: number, snippet: string }[]} список порушень
248
+ */
249
+ export function findUnsafeBunSqlDynamicSqlListInText(content, virtualPath = 'scan.ts') {
250
+ const program = parseProgramOrNull(content, virtualPath)
251
+ if (!program) return []
252
+
253
+ /** @type {{ line: number, snippet: string }[]} */
254
+ const out = []
255
+ walkAstWithAncestors(program, [], node => {
256
+ /** @type {unknown} */
257
+ let template = null
258
+ if (node.type === 'TemplateLiteral') {
259
+ template = node
260
+ } else if (node.type === 'TaggedTemplateExpression') {
261
+ template = node.quasi
262
+ }
263
+ if (!template || typeof template !== 'object' || template.type !== 'TemplateLiteral') return
264
+ if (!isSqlListContextTemplate(template)) return
265
+ const expressions = template.expressions
266
+ if (!Array.isArray(expressions) || expressions.length === 0) return
267
+ if (!expressions.some(expr => isJoinCall(expr))) return
268
+ out.push({
269
+ line: offsetToLine(content, template.start),
270
+ snippet: normalizeSnippet(content.slice(template.start, template.end))
271
+ })
272
+ })
273
+ return out
274
+ }
275
+
276
+ /**
277
+ * Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"`.
278
+ * Скан по сирому тексту — без AST, щоб бути дешевим: викликається на кожному
279
+ * JS/TS-файлі при зборі ознак для авто-детекту правил.
280
+ * @param {string} content вміст файлу
281
+ * @returns {boolean}
282
+ */
283
+ export function textHasBunSqlImport(content) {
284
+ return BUN_SQL_IMPORT_RE.test(content)
285
+ }
286
+
287
+ /**
288
+ * Чи сканувати цей файл за розширенням (JS/TS-сімʼя, без `.d.ts`).
289
+ * @param {string} relativePathPosix відносний шлях (posix)
290
+ * @returns {boolean} true, якщо розширення підходить для AST-скану
291
+ */
292
+ export function isBunSqlScanSourceFile(relativePathPosix) {
293
+ return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
294
+ }
@@ -12,6 +12,9 @@
12
12
  * повторно використовувати між запитами.
13
13
  * - небезпечні “динамічні списки” в SQL, коли в TemplateLiteral/TaggedTemplateExpression
14
14
  * підставляють рядки, зібрані через `.join(',')` (типово для `IN (...)` або `VALUES (...)`).
15
+ * - підстановки `IN (${expr})`, де `expr` не пройшов числовий парсер (parseInt/Number/BigInt
16
+ * /parseFloat або унарний `+`) і не є літеральним масивом чисел — навіть у tagged template
17
+ * це додатковий шар захисту від SQL injection (див. js-mssql.mdc).
15
18
  *
16
19
  * Семантика береться з **oxc-parser** по AST, щоб не покладатися на regex.
17
20
  * Якщо файл не парситься або містить синтаксичні помилки — повертаємо порожній
@@ -21,6 +24,8 @@ import { parseSync } from 'oxc-parser'
21
24
 
22
25
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
23
26
  const SQL_LIST_CONTEXT_RE = /\b(in|values)\b\s*\(/iu
27
+ const IN_PLACEHOLDER_END_RE = /\bin\s*\(\s*$/iu
28
+ const NUMERIC_PARSE_FN_NAMES = new Set(['parseInt', 'parseFloat', 'Number', 'BigInt'])
24
29
 
25
30
  /**
26
31
  * Мова для Oxc за шляхом файлу (розширення).
@@ -346,6 +351,175 @@ export function findUnsafeMssqlDynamicSqlListInText(content, virtualPath = 'scan
346
351
  return out
347
352
  }
348
353
 
354
+ /**
355
+ * Чи елементи літерального масиву — лише числові (numeric/bigint) літерали.
356
+ * Такі масиви безпечні в `IN (...)` навіть без явного парсера.
357
+ * @param {unknown} node AST node
358
+ * @returns {boolean} true, якщо це непорожній масив суто числових літералів
359
+ */
360
+ function isLiteralNumericArrayExpression(node) {
361
+ if (!node || typeof node !== 'object' || node.type !== 'ArrayExpression') return false
362
+ const elements = node.elements
363
+ if (!Array.isArray(elements) || elements.length === 0) return false
364
+ return elements.every(el => {
365
+ if (!el || typeof el !== 'object') return false
366
+ if (el.type === 'NumericLiteral' || el.type === 'BigIntLiteral') return true
367
+ if (el.type === 'Literal' && (typeof el.value === 'number' || typeof el.value === 'bigint')) {
368
+ return true
369
+ }
370
+ return false
371
+ })
372
+ }
373
+
374
+ /**
375
+ * Чи містить піддерево виклик числового парсера (parseInt/parseFloat/Number/BigInt)
376
+ * або унарний `+` (приведення до Number). Це сигнал, що значення гарантовано числове
377
+ * і не може містити SQL-метасимволи.
378
+ * @param {unknown} node AST node
379
+ * @returns {boolean} true, якщо знайдено числовий парсер у піддереві
380
+ */
381
+ function subtreeHasNumericParseCall(node) {
382
+ if (!node || typeof node !== 'object') return false
383
+ if (Array.isArray(node)) return node.some(subtreeHasNumericParseCall)
384
+
385
+ if (node.type === 'CallExpression') {
386
+ const callee = node.callee
387
+ if (callee && callee.type === 'Identifier' && NUMERIC_PARSE_FN_NAMES.has(callee.name)) {
388
+ return true
389
+ }
390
+ if (callee && callee.type === 'MemberExpression' && !callee.computed) {
391
+ const prop = callee.property
392
+ if (prop && prop.type === 'Identifier' && NUMERIC_PARSE_FN_NAMES.has(prop.name)) {
393
+ return true
394
+ }
395
+ }
396
+ }
397
+ if (node.type === 'UnaryExpression' && node.operator === '+') return true
398
+
399
+ for (const key of Object.keys(node)) {
400
+ if (key === 'parent') continue
401
+ const v = node[key]
402
+ if (v && typeof v === 'object' && subtreeHasNumericParseCall(v)) return true
403
+ }
404
+ return false
405
+ }
406
+
407
+ /**
408
+ * Збирає всі VariableDeclarator-вузли в AST (для трасування Identifier-ів до їх init).
409
+ * @param {unknown} programNode AST root (Program)
410
+ * @returns {Array<Record<string, unknown>>} список VariableDeclarator-ів
411
+ */
412
+ function collectVariableDeclarators(programNode) {
413
+ /** @type {Array<Record<string, unknown>>} */
414
+ const out = []
415
+ walkAstWithAncestors(programNode, [], node => {
416
+ if (node.type === 'VariableDeclarator') out.push(node)
417
+ })
418
+ return out
419
+ }
420
+
421
+ /**
422
+ * Чи виглядає вираз, який підставляється в `IN (${...})`, як «безпечно розпарсений»:
423
+ * - літеральний масив чисел;
424
+ * - саме піддерево містить виклик числового парсера (parseInt/Number/BigInt/parseFloat/+x);
425
+ * - Identifier, чий init у файлі рекурсивно задовольняє ці умови.
426
+ *
427
+ * Якщо для Identifier немає видимого init (наприклад параметр функції чи import),
428
+ * вираз вважається не парсованим — потрібен явний парсер на місці підстановки.
429
+ *
430
+ * @param {unknown} expr вираз з template.expressions
431
+ * @param {Array<Record<string, unknown>>} declarators VariableDeclarator-и файлу
432
+ * @param {Set<string>} [seen] іменa Identifier-ів, що вже трасуються (анти-цикл)
433
+ * @returns {boolean} true, якщо вираз можна вважати безпечно числовим
434
+ */
435
+ function isInListExpressionParsed(expr, declarators, seen = new Set()) {
436
+ if (!expr || typeof expr !== 'object') return false
437
+ if (isLiteralNumericArrayExpression(expr)) return true
438
+ if (subtreeHasNumericParseCall(expr)) return true
439
+
440
+ if (expr.type === 'Identifier' && typeof expr.name === 'string') {
441
+ if (seen.has(expr.name)) return false
442
+ const nextSeen = new Set(seen)
443
+ nextSeen.add(expr.name)
444
+ const inits = declarators
445
+ .filter(d => {
446
+ const id = d.id
447
+ return (
448
+ !!id &&
449
+ typeof id === 'object' &&
450
+ id.type === 'Identifier' &&
451
+ id.name === expr.name &&
452
+ !!d.init
453
+ )
454
+ })
455
+ .map(d => d.init)
456
+ if (inits.length === 0) return false
457
+ return inits.every(init => isInListExpressionParsed(init, declarators, nextSeen))
458
+ }
459
+
460
+ return false
461
+ }
462
+
463
+ /**
464
+ * Знаходить підстановки `IN (${expr})` у TemplateLiteral, де `expr` не пройшов числовий парсер.
465
+ *
466
+ * Навіть у безпечному `pool.request().query\`...\`` краще явно парсити значення (parseInt/
467
+ * Number/BigInt/parseFloat) та фільтрувати NaN — це гарантує, що жодний елемент не може
468
+ * містити SQL-метасимволи, навіть якщо колись query-функція або обгортка зміняться. У
469
+ * небезпечних контекстах (наприклад `pool.query(String.raw\`...\`)`) це єдиний бар'єр від SQL
470
+ * injection.
471
+ *
472
+ * Випадки `${arr.join(',')}` свідомо ігноруються — їх ловить
473
+ * {@link findUnsafeMssqlDynamicSqlListInText}.
474
+ *
475
+ * @param {string} content вихідний код
476
+ * @param {string} [virtualPath] шлях для вибору `lang`
477
+ * @returns {{ line: number, snippet: string }[]} список порушень
478
+ */
479
+ export function findUnsafeMssqlInListUnparsedInText(content, virtualPath = 'scan.ts') {
480
+ const lang = langFromPath(virtualPath || 'scan.ts')
481
+ let result
482
+ try {
483
+ result = parseSync(virtualPath, content, { lang, sourceType: 'module' })
484
+ } catch {
485
+ return []
486
+ }
487
+ if (result.errors?.length) return []
488
+
489
+ const declarators = collectVariableDeclarators(result.program)
490
+
491
+ /** @type {{ line: number, snippet: string }[]} */
492
+ const out = []
493
+ walkAstWithAncestors(result.program, [], node => {
494
+ if (node.type !== 'TemplateLiteral') return
495
+ const quasis = node.quasis
496
+ const expressions = node.expressions
497
+ if (!Array.isArray(quasis) || !Array.isArray(expressions) || expressions.length === 0) return
498
+
499
+ for (let i = 0; i < expressions.length; i++) {
500
+ const q = quasis[i]
501
+ const rawText =
502
+ q && typeof q === 'object' && q.value && typeof q.value === 'object' && typeof q.value.raw === 'string'
503
+ ? q.value.raw
504
+ : ''
505
+ if (!IN_PLACEHOLDER_END_RE.test(rawText)) continue
506
+
507
+ const expr = expressions[i]
508
+ if (!expr || typeof expr !== 'object') continue
509
+ if (isJoinCall(expr)) continue
510
+ if (isInListExpressionParsed(expr, declarators)) continue
511
+
512
+ const startOffset = typeof expr.start === 'number' ? expr.start : node.start
513
+ out.push({
514
+ line: offsetToLine(content, startOffset),
515
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
516
+ })
517
+ }
518
+ })
519
+
520
+ return out
521
+ }
522
+
349
523
  /**
350
524
  * Чи сканувати цей файл за розширенням (JS/TS-сім'я).
351
525
  * @param {string} relativePathPosix відносний шлях (posix)