@nitra/cursor 1.8.144 → 1.8.147

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
package/bin/n-cursor.js CHANGED
@@ -1036,6 +1036,7 @@ async function runChecks(requestedRules) {
1036
1036
  const scriptPath = join(BUNDLED_SCRIPTS_DIR, `check-${rule}.mjs`)
1037
1037
  console.log(`📋 ${rule}:`)
1038
1038
  try {
1039
+ // eslint-disable-next-line no-unsanitized/method -- rule валідовано проти available, scriptPath будується з фіксованої BUNDLED_SCRIPTS_DIR
1039
1040
  const { check } = await import(scriptPath)
1040
1041
  const code = await check()
1041
1042
  if (code !== 0) totalFailed++
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
 
3
2
  /**
4
3
  * CLI для перейменування розширень YAML (k8s та `.github`). Бізнес-логіка — у **`scripts/rename-yaml-extensions.mjs`**.
@@ -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-lint.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  description: Перевірка JavaScript коду
3
3
  alwaysApply: true
4
- version: '1.14'
4
+ version: '1.15'
5
5
  ---
6
6
 
7
- **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.5.0`** (з ним транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
7
+ **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.6.12`** (з ним транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
8
8
 
9
9
  ```json title=".vscode/extensions.json"
10
10
  {
@@ -25,7 +25,7 @@ version: '1.14'
25
25
  "lint-js": "bunx oxlint --fix && bunx eslint --fix . && bunx jscpd ."
26
26
  },
27
27
  "devDependencies": {
28
- "@nitra/eslint-config": "^3.5.0"
28
+ "@nitra/eslint-config": "^3.6.12"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=24"
@@ -33,7 +33,9 @@ version: '1.14'
33
33
  }
34
34
  ```
35
35
 
36
- У корені має бути **`.oxlintrc.json`** з підключенням **`@e18e/eslint-plugin`** через **`jsPlugins`** і правилом **`e18e/prefer-includes`** зі значенням **`error`**. Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.5.0**), oxlint підвантажує його з **`node_modules`**.
36
+ У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/scripts/utils/oxlint-canonical.json`** (plugins, jsPlugins з **`@e18e/eslint-plugin`**, categories, повний набір **rules** із канону — додаткові записи в **`rules`** дозволені; також **`settings`**, **`env`**, **`globals`**, **`ignorePatterns`**). Оновити можна з репозиторію пакета або скопіювавши файл після **`bun ./scripts/utils/rebuild-oxlint-canonical.mjs`** (джерело правил **`oxlint-rules.tsv`** + скелет **`oxlint-canonical-skeleton.json`**). Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.6.12**), oxlint підвантажує його з **`node_modules`**.
37
+
38
+ Мінімум для розуміння структури (реальний корінь конфігу має збігатися з каноном повністю):
37
39
 
38
40
  ```json title=".oxlintrc.json (фрагмент)"
39
41
  {
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/k8s.mdc CHANGED
@@ -288,13 +288,13 @@ data:
288
288
 
289
289
  ## Deployment: обов'язкові `hpa.yaml`, `pdb.yaml`, `topologySpreadConstraints`
290
290
 
291
- Для **кожного** `kind: Deployment` під **`k8s/`** у тому ж каталозі мають бути **`hpa.yaml`** (HPA) і **`pdb.yaml`** (PDB), а сам Deployment — мати канонічні **`spec.template.spec.topologySpreadConstraints`**. Скрипт звіряє прив'язку за іменами:
291
+ Для **кожного** `kind: Deployment` у каталозі **`…/k8s/…/base/`** (у будь-якому файлі `.yaml`, наприклад **`deploy.yaml`**, **`deployment.yaml`**) у **тому ж каталозі** мають бути **`hpa.yaml`** (HPA) і **`pdb.yaml`** (PDB), а сам Deployment — канонічні **`spec.template.spec.topologySpreadConstraints`**. Інші workload-и (**CronJob**, **Job** тощо) або каталоги без шару **`base`** цими вимогами не охоплюються — **`check k8s`** їх не змушує додавати HPA/PDB поруч. Скрипт звіряє прив’язку за іменами:
292
292
 
293
293
  - **`hpa.yaml`** — `autoscaling/v2`, `HorizontalPodAutoscaler`, `spec.scaleTargetRef.name` **= `metadata.name`** Deployment.
294
294
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
295
295
  - **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
296
296
 
297
- **Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є **Deployment**. У `kustomization.yaml` overlay, який підключає цей `base` (наприклад, `../base` у `resources`), не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться `Deployment`. Перевіряє **`check-k8s.mjs`**.
297
+ **Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є хоча б один **`Deployment`** у YAML під **`…/k8s/…/base/`**. У `kustomization.yaml` overlay, який підключає цей `base` (наприклад, `../base` у `resources`), не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться такий Deployment. Перевіряє **`check-k8s.mjs`**.
298
298
 
299
299
  **Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
300
300
 
package/mdc/vue.mdc CHANGED
@@ -156,7 +156,7 @@ export default {
156
156
  "private": true,
157
157
  "type": "module",
158
158
  "dependencies": {
159
- "vue": "^3.5.0"
159
+ "vue": "^3.6.12"
160
160
  },
161
161
  "devDependencies": {
162
162
  "vite": "^8.0.0",
@@ -224,7 +224,7 @@ export default defineConfig({
224
224
 
225
225
  ## npm_lifecycle_event
226
226
 
227
- у більшості проектів в файлі vite.config.js
227
+ у більшості проектів в файлі vite.config.js
228
228
  є конструкція виду
229
229
 
230
230
  switch (process.env.npm_lifecycle_event) {
@@ -253,7 +253,7 @@ function getProxy(mode) {
253
253
  }
254
254
  ```
255
255
 
256
- і викликати всередині
256
+ і викликати всередині
257
257
 
258
258
  ```javascript title="vite.config.js"
259
259
  export default defineConfig(({ mode, command }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.144",
3
+ "version": "1.8.147",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -39,10 +39,10 @@
39
39
  "rename-yaml-extensions": "bun ./bin/n-cursor.js rename-yaml-extensions"
40
40
  },
41
41
  "dependencies": {
42
- "oxc-parser": "^0.124.0",
42
+ "oxc-parser": "^0.128.0",
43
43
  "yaml": "^2.8.3"
44
44
  },
45
45
  "engines": {
46
- "node": ">=24"
46
+ "node": ">=25"
47
47
  }
48
48
  }
@@ -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,66 @@ 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} true, якщо знайдено `import { sql }` або `import { SQL }` з `"bun"`
61
+ */
62
+ function sourceContentHasBunSqlImport(content, relativePath) {
63
+ return textHasBunSqlImport(contentForVueImportScan(content, relativePath))
64
+ }
65
+
66
+ /**
67
+ * Зчитує `package.json` і додає в `found` усі ключі з `wanted`, що присутні в `dependencies`.
68
+ * @param {string} absPath абсолютний шлях до package.json
69
+ * @param {Set<string>} wanted множина ключів-цілей
70
+ * @param {Set<string>} found буфер знайдених ключів
71
+ * @returns {Promise<void>}
72
+ */
73
+ async function collectFoundDependencyKeysFromPackageJson(absPath, wanted, found) {
74
+ try {
75
+ const parsed = JSON.parse(await readFile(absPath, 'utf8'))
76
+ const deps = parsed?.dependencies
77
+ if (!deps || typeof deps !== 'object' || Array.isArray(deps)) return
78
+ for (const key of wanted) {
79
+ if (Object.hasOwn(deps, key)) {
80
+ found.add(key)
81
+ }
82
+ }
83
+ } catch {
84
+ /* ігноруємо пошкоджені/недоступні package.json */
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Збирає, які з переданих ключів присутні в `dependencies` хоча б одного `package.json`.
54
90
  * @param {string} root абсолютний шлях до кореня репозиторію
55
- * @returns {Promise<boolean>} true, якщо знайдено `dependencies.mssql`
91
+ * @param {string[]} dependencyKeys імена залежностей (наприклад `mssql`, `pg`)
92
+ * @returns {Promise<Set<string>>} множина знайдених ключів
56
93
  */
57
- async function hasMssqlDependencyInAnyPackageJson(root) {
58
- let found = false
94
+ async function collectDependencyKeysPresentInPackageJsonTree(root, dependencyKeys) {
95
+ const wanted = new Set(dependencyKeys)
96
+ /** @type {Set<string>} */
97
+ const found = new Set()
98
+
99
+ /**
100
+ * Обробка одного запису з readdir: рекурсія в підкаталог або зчитування package.json.
101
+ * @param {import('node:fs').Dirent} entry елемент readdir
102
+ * @param {string} dir абсолютний шлях каталогу-власника entry
103
+ * @returns {Promise<void>}
104
+ */
105
+ async function processEntry(entry, dir) {
106
+ const absPath = join(dir, entry.name)
107
+ if (entry.isDirectory()) {
108
+ if (!IGNORED_DIR_NAMES.has(entry.name)) {
109
+ await walk(absPath)
110
+ }
111
+ return
112
+ }
113
+ if (entry.isFile() && entry.name === 'package.json') {
114
+ await collectFoundDependencyKeysFromPackageJson(absPath, wanted, found)
115
+ }
116
+ }
59
117
 
60
118
  /**
61
119
  * Рекурсивний обхід каталогу з пропуском службових директорій.
@@ -63,7 +121,7 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
63
121
  * @returns {Promise<void>}
64
122
  */
65
123
  async function walk(dir) {
66
- if (found) return
124
+ if (found.size === wanted.size) return
67
125
  let entries
68
126
  try {
69
127
  entries = await readdir(dir, { withFileTypes: true })
@@ -71,25 +129,8 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
71
129
  return
72
130
  }
73
131
  for (const entry of entries) {
74
- if (found) return
75
- const absPath = join(dir, entry.name)
76
- if (entry.isDirectory()) {
77
- const isIgnoredDir = IGNORED_DIR_NAMES.has(entry.name)
78
- if (!isIgnoredDir) {
79
- await walk(absPath)
80
- }
81
- } else if (entry.isFile() && entry.name === 'package.json') {
82
- try {
83
- const parsed = JSON.parse(await readFile(absPath, 'utf8'))
84
- const deps = parsed?.dependencies
85
- if (deps && typeof deps === 'object' && !Array.isArray(deps) && Object.hasOwn(deps, 'mssql')) {
86
- found = true
87
- return
88
- }
89
- } catch {
90
- /* ігноруємо пошкоджені/недоступні package.json */
91
- }
92
- }
132
+ if (found.size === wanted.size) return
133
+ await processEntry(entry, dir)
93
134
  }
94
135
  }
95
136
 
@@ -182,11 +223,40 @@ async function updateGqlFactFromFile(absPath, relPath, facts) {
182
223
  }
183
224
  }
184
225
 
226
+ /**
227
+ * Чи сканувати файл на імпорт `sql`/`SQL` з `bun` (ті самі розширення й skip, що для gql).
228
+ * @param {string} relPath шлях posix відносно кореня
229
+ * @param {{ hasBunSqlImport: boolean }} facts агреговані факти
230
+ * @returns {boolean} true, якщо файл варто сканувати
231
+ */
232
+ function shouldScanFileForBunSql(relPath, facts) {
233
+ return !facts.hasBunSqlImport && isGqlScanSourceFile(relPath) && !shouldSkipFileForGqlScan(relPath)
234
+ }
235
+
236
+ /**
237
+ * Оновлює ознаку `hasBunSqlImport` за вмістом файлу.
238
+ * @param {string} absPath абсолютний шлях до файлу
239
+ * @param {string} relPath шлях posix відносно кореня
240
+ * @param {{ hasBunSqlImport: boolean }} facts агреговані факти
241
+ * @returns {Promise<void>}
242
+ */
243
+ async function updateBunSqlFactFromFile(absPath, relPath, facts) {
244
+ try {
245
+ const content = await readFile(absPath, 'utf8')
246
+ if (sourceContentHasBunSqlImport(content, relPath)) {
247
+ facts.hasBunSqlImport = true
248
+ }
249
+ } catch {
250
+ /* ігноруємо пошкоджені/недоступні файли */
251
+ }
252
+ }
253
+
185
254
  /**
186
255
  * Обробляє файл під час обходу дерева.
187
256
  * @param {string} absPath абсолютний шлях до файлу
188
257
  * @param {string} root абсолютний шлях кореня
189
258
  * @param {{
259
+ * hasBunSqlImport: boolean,
190
260
  * hasCapacitorConfig: boolean,
191
261
  * hasDockerfile: boolean,
192
262
  * hasGqlTaggedTemplates: boolean,
@@ -204,6 +274,9 @@ async function processFileEntry(absPath, root, facts) {
204
274
  if (shouldScanFileForGql(rel, facts)) {
205
275
  await updateGqlFactFromFile(absPath, rel, facts)
206
276
  }
277
+ if (shouldScanFileForBunSql(rel, facts)) {
278
+ await updateBunSqlFactFromFile(absPath, rel, facts)
279
+ }
207
280
  }
208
281
 
209
282
  /**
@@ -270,6 +343,7 @@ export function isMonorepoPackage(packageJson) {
270
343
  * hasCapacitorConfig: boolean,
271
344
  * hasDockerfile: boolean,
272
345
  * hasGaWorkflowsDir: boolean,
346
+ * hasBunSqlImport: boolean,
273
347
  * hasGqlTaggedTemplates: boolean,
274
348
  * hasJsLikeSource: boolean,
275
349
  * hasK8sDir: boolean,
@@ -282,6 +356,7 @@ export function isMonorepoPackage(packageJson) {
282
356
  */
283
357
  export async function collectAutoRuleFacts(root) {
284
358
  const facts = {
359
+ hasBunSqlImport: false,
285
360
  hasCapacitorConfig: false,
286
361
  hasDockerfile: false,
287
362
  hasGaWorkflowsDir: existsSync(join(root, '.github', 'workflows')),
@@ -360,7 +435,9 @@ export async function detectAutoRulesAndSkills({
360
435
  )
361
436
  const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
362
437
  const isMonorepo = isMonorepoPackage(packageJsonParsed)
363
- const hasMssqlDependency = await hasMssqlDependencyInAnyPackageJson(root)
438
+ const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'mysql2'])
439
+ const hasMssqlDependency = depHits.has('mssql')
440
+ const hasJsBunDbSignal = depHits.has('pg') || depHits.has('mysql2') || facts.hasBunSqlImport
364
441
 
365
442
  /** @type {string[]} */
366
443
  const detectedRules = []
@@ -400,6 +477,7 @@ export async function detectAutoRulesAndSkills({
400
477
  { enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
401
478
  { enabled: facts.hasJsLikeSource, id: 'js-lint' },
402
479
  { enabled: hasMssqlDependency, id: 'js-mssql' },
480
+ { enabled: hasJsBunDbSignal, id: 'js-bun-db' },
403
481
  { enabled: facts.hasJsLikeSource && !(isMonorepo && facts.hasVueSource && facts.hasTempoDir), id: 'js-pino' },
404
482
  { enabled: facts.hasK8sDir, id: 'k8s' },
405
483
  { enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
@@ -60,7 +60,6 @@ const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml',
60
60
  *
61
61
  * Використовує `git ls-files` з pathspec-магiєю `:(glob)`, щоб не реалізовувати glob engine вручну
62
62
  * і не сканувати файлову систему рекурсивно.
63
- *
64
63
  * @param {string} globPattern glob з workflow (наприклад "files/**" або "image-migration-new/**")
65
64
  * @returns {boolean} true, якщо є хоча б один збіг
66
65
  */
@@ -69,6 +68,7 @@ function gitHasAnyTrackedFileMatchingGlob(globPattern) {
69
68
  if (!p) return false
70
69
  if (p.startsWith('!')) return true
71
70
  try {
71
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- git як стандартне dev-середовище через PATH; альтернативи (хардкод шляху) непортативні
72
72
  const out = execFileSync('git', ['ls-files', '-z', '--', `:(glob)${p}`], { encoding: 'utf8' })
73
73
  return out.length > 0
74
74
  } catch {
@@ -82,7 +82,6 @@ function gitHasAnyTrackedFileMatchingGlob(globPattern) {
82
82
  * У багатьох workflow (особливо лінтерах) `paths` часто містить “широкі” шаблони по розширеннях
83
83
  * (наприклад `*.vue`, `*.php`), які можуть бути відсутні в конкретному репозиторії й це ок.
84
84
  * Запит цієї перевірки — ловити посилання на неіснуючі директорії/шляхи (типово `some-dir/**`).
85
- *
86
85
  * @param {string} p glob з workflow
87
86
  * @returns {boolean} true, якщо треба валідувати наявність файлів
88
87
  */
@@ -96,6 +95,28 @@ function shouldValidateWorkflowPathsGlob(p) {
96
95
  return true
97
96
  }
98
97
 
98
+ /**
99
+ * Перевіряє один glob з `on.<event>.paths` на наявність збігів у репо.
100
+ * @param {string} relPath шлях workflow для повідомлень
101
+ * @param {string} eventName назва події (push / pull_request)
102
+ * @param {unknown} raw сирий елемент масиву paths
103
+ * @param {(msg: string) => void} passFn pass
104
+ * @param {(msg: string) => void} failFn fail
105
+ */
106
+ function verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn) {
107
+ const p = String(raw ?? '').trim()
108
+ if (!p) return
109
+ if (!shouldValidateWorkflowPathsGlob(p)) {
110
+ passFn(`${relPath}: on.${eventName}.paths glob пропущено для перевірки існування: ${JSON.stringify(p)}`)
111
+ return
112
+ }
113
+ if (gitHasAnyTrackedFileMatchingGlob(p)) {
114
+ passFn(`${relPath}: on.${eventName}.paths glob матчитсья: ${JSON.stringify(p)}`)
115
+ } else {
116
+ failFn(`${relPath}: on.${eventName}.paths glob не матчитсья ні на один файл: ${JSON.stringify(p)}`)
117
+ }
118
+ }
119
+
99
120
  /**
100
121
  * Валідує `on.push.paths` / `on.pull_request.paths`: кожен позитивний glob має мати збіги в репозиторії.
101
122
  * @param {string} relPath шлях workflow для повідомлень
@@ -116,17 +137,7 @@ function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn) {
116
137
  for (const [eventName, paths] of candidates) {
117
138
  if (!Array.isArray(paths)) continue
118
139
  for (const raw of paths) {
119
- const p = String(raw ?? '').trim()
120
- if (!p) continue
121
- if (!shouldValidateWorkflowPathsGlob(p)) {
122
- passFn(`${relPath}: on.${eventName}.paths glob пропущено для перевірки існування: ${JSON.stringify(p)}`)
123
- continue
124
- }
125
- if (gitHasAnyTrackedFileMatchingGlob(p)) {
126
- passFn(`${relPath}: on.${eventName}.paths glob матчитсья: ${JSON.stringify(p)}`)
127
- } else {
128
- failFn(`${relPath}: on.${eventName}.paths glob не матчитсья ні на один файл: ${JSON.stringify(p)}`)
129
- }
140
+ verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn)
130
141
  }
131
142
  }
132
143
  }
@@ -138,8 +149,9 @@ function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn) {
138
149
  * @returns {unknown} значення поля або undefined
139
150
  */
140
151
  function getObjKey(obj, key) {
141
- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return
142
- return /** @type {Record<string, unknown>} */ (obj)[key]
152
+ return obj && typeof obj === 'object' && !Array.isArray(obj)
153
+ ? /** @type {Record<string, unknown>} */ (obj)[key]
154
+ : undefined
143
155
  }
144
156
 
145
157
  /**