@nitra/cursor 1.8.150 → 1.8.152

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.
@@ -18,6 +18,14 @@ The primary development rules are stored in the Cursor rules directory:
18
18
  {{name}}
19
19
  {{/skills}}
20
20
 
21
+ ## Commands
22
+
23
+ Generated from the root `package.json` on each `npx @nitra/cursor` sync. Prefer `bun run <script>` for project scripts.
24
+
25
+ {{#commands}}
26
+ {{name}}
27
+ {{/commands}}
28
+
21
29
  ## Instructions for all agents
22
30
 
23
31
  Before making changes, read the relevant rule files for the area you are working on.
package/README.md CHANGED
@@ -63,7 +63,7 @@ CLI автоматично (команда завантаження правил
63
63
  1. Знайде або створить `.n-cursor.json` у поточній директорії (із полем `$schema` на JSON Schema пакету; якщо файл уже є без коректного `$schema`, поле буде додано або оновлено при зчитуванні конфігу)
64
64
  2. Створить директорію `.cursor/rules/`, якщо її ще немає
65
65
  3. Скопіює кожне з перелічених у конфігу правило з `mdc/` установленого пакету і збереже файли з префіксом `n-`
66
- 4. Після оновлення файлів на диску згенерує в корені проєкту **`AGENTS.md`**: повний вміст береться з шаблону пакету `AGENTS.template.md`, а список правил у шаблоні формується з **усіх наявних файлів `*.mdc`** у `.cursor/rules/` (відсортовано за ім’ям)
66
+ 4. Після оновлення файлів на диску згенерує в корені проєкту **`AGENTS.md`**: повний вміст береться з шаблону пакету `AGENTS.template.md`, а список правил у шаблоні формується з **усіх наявних файлів `*.mdc`** у `.cursor/rules/` (відсортовано за ім’ям); секція команд — з **`package.json`** кореня (див. `{{#commands}}` у шаблоні).
67
67
 
68
68
  ## Приклад виводу
69
69
 
@@ -114,13 +114,18 @@ npm/
114
114
 
115
115
  Під час запуску CLI тіло між `{{#services}}` і `{{/services}}` повторюється для кожного `*.mdc` у `.cursor/rules/`; у `{{name}}` підставляється вже готовий markdown-рядок (наприклад `- .cursor/rules/n-text.mdc`).
116
116
 
117
- 3. Після змін у шаблоні перевірте локально: у тестовому репозиторії з `.n-cursor.json` виконайте `npx`/`bunx` на зібраному пакеті або `node npm/bin/n-cursor.js` з кореня того репозиторію і переконайтеся, що **`AGENTS.md`** виглядає як очікується.
117
+ 3. Для секції **Skills** використовуйте блок **`{{#skills}}` `{{/skills}}`** з тим самим `{{name}}`: рядки формуються з каталогів у `.cursor/skills/` (див. також `buildSkillBulletItems` у `bin/n-cursor.js`).
118
+
119
+ 4. Для секції **Commands** використовуйте **`{{#commands}}` … `{{/commands}}`**: список генерується з кореневого **`package.json`** (поле `scripts` — відомі ключі у фіксованому порядку, плюс додаткові `lint-*`) та завжди доповнюється рядками про **`npx @nitra/cursor`** і **`npx @nitra/cursor check`**. Логіка винесена в **`npm/scripts/build-agents-commands.mjs`**.
120
+
121
+ 5. Після змін у шаблоні перевірте локально: у тестовому репозиторії з `.n-cursor.json` виконайте `npx`/`bunx` на зібраному пакеті або `node npm/bin/n-cursor.js` з кореня того репозиторію і переконайтеся, що **`AGENTS.md`** виглядає як очікується.
118
122
 
119
123
  ### Логіка в коді CLI
120
124
 
121
125
  - Шлях до шаблону: поруч із `mdc/`, тобто `…/node_modules/@nitra/cursor/AGENTS.template.md` після встановлення пакету.
122
126
  - Оновлення **`AGENTS.md`** виконується **після** циклу завантаження правил, щоб список відображав актуальний вміст `.cursor/rules/` на диску.
123
127
  - Якщо каталогу `.cursor/rules/` немає або в ньому немає `*.mdc`, блок `{{#services}}` стає порожнім; решта шаблону все одно записується в **`AGENTS.md`**.
128
+ - Секція **`commands`** залежить лише від **`package.json` у корені cwd**; якщо файлу немає або `scripts` відсутній, у блоці лишаються мінімальні рядки (`bun i`, виклики CLI).
124
129
 
125
130
  ## Мета проекту
126
131
 
package/bin/n-cursor.js CHANGED
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * Файл AGENTS.md у корені: щоразу повністю перезаписується змістом з AGENTS.template.md
20
20
  * пакету; список правил у шаблоні будується з файлів *.mdc у .cursor/rules поточного проєкту.
21
+ * Секція команд — з кореневого package.json (scripts) та фіксовані рядки про CLI синхрону/перевірок.
21
22
  *
22
23
  * Після завантаження: у .cursor/rules видаляються файли *.mdc з префіксом «n-» (керовані
23
24
  * пакетом), яких немає у списку rules у .n-cursor.json. Інші .mdc у цій директорії залишаються.
@@ -45,6 +46,7 @@ import { basename, dirname, join } from 'node:path'
45
46
  import { cwd } from 'node:process'
46
47
  import { fileURLToPath } from 'node:url'
47
48
 
49
+ import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
48
50
  import { detectAutoRulesAndSkills, mergeConfigWithAutoDetected, normalizeIdList } from '../scripts/auto-rules.mjs'
49
51
  import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
50
52
  import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitra-cursor-and-install.mjs'
@@ -424,19 +426,21 @@ function expandMustacheSection(template, section, items, prop) {
424
426
  }
425
427
 
426
428
  /**
427
- * Підставляє у вміст AGENTS.template.md список шляхів до файлів правил і skills
429
+ * Підставляє у вміст AGENTS.template.md список шляхів до файлів правил, skills і команд з package.json
428
430
  * @param {string} templateText вміст AGENTS.template.md
429
431
  * @param {string[]} mdcBasenames імена файлів (*.mdc) з .cursor/rules
430
432
  * @param {{ name: string }[]} skillItems рядки для секції Skills
433
+ * @param {{ name: string }[]} commandItems рядки для секції commands
431
434
  * @returns {string} готовий markdown для AGENTS.md
432
435
  */
433
- function renderAgentsTemplate(templateText, mdcBasenames, skillItems) {
436
+ function renderAgentsTemplate(templateText, mdcBasenames, skillItems, commandItems) {
434
437
  let result = templateText
435
438
  const serviceItems = mdcBasenames.map(mdcName => ({
436
439
  name: `- ${RULES_DIR}/${mdcName}`
437
440
  }))
438
441
  result = expandMustacheSection(result, 'services', serviceItems, 'name')
439
442
  result = expandMustacheSection(result, 'skills', skillItems, 'name')
443
+ result = expandMustacheSection(result, 'commands', commandItems, 'name')
440
444
  return result
441
445
  }
442
446
 
@@ -650,7 +654,8 @@ async function syncAgentsMd(agentsTemplatePath = BUNDLED_AGENTS_TEMPLATE_PATH) {
650
654
  const templateText = await readFile(agentsTemplatePath, 'utf8')
651
655
  const mdcFiles = await listProjectRulesMdcFiles()
652
656
  const skillItems = await buildSkillBulletItems()
653
- const body = renderAgentsTemplate(templateText, mdcFiles, skillItems)
657
+ const commandItems = await buildAgentsCommandBulletItems(cwd())
658
+ const body = renderAgentsTemplate(templateText, mdcFiles, skillItems, commandItems)
654
659
  const agentsPath = join(cwd(), AGENTS_FILE)
655
660
  const hadFile = existsSync(agentsPath)
656
661
  const out = body.endsWith('\n') ? body : `${body}\n`
package/mdc/js-bun-db.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
3
3
  alwaysApply: true
4
- version: '1.1'
4
+ version: '1.2'
5
5
  ---
6
6
 
7
7
  ## Підтримувані версії баз даних
@@ -72,6 +72,22 @@ const ids = [1, 2, 3]
72
72
  await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
73
73
  ```
74
74
 
75
+ ## `IN (...)`: значення з template literal — тільки через змінну + guard на пустоту
76
+
77
+ Якщо список для `IN (...)` підставляється через `${...}` у template literal, його **потрібно**:
78
+
79
+ - винести в **окрему змінну** (не підставляти вираз напряму в `${...}`);
80
+ - **перевірити на пустоту** перед запитом і **throw** (щоб не виконувати некоректний SQL або запит з неочікуваною семантикою).
81
+
82
+ Приклад:
83
+
84
+ ```javascript
85
+ const ids = inputIds.map(Number).filter(n => Number.isFinite(n))
86
+ if (!ids.length) throw new Error('ids is empty')
87
+
88
+ await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
89
+ ```
90
+
75
91
  Транзакції — через `sql.begin` (auto-commit/rollback), вкладені — через `tx.savepoint`:
76
92
 
77
93
  ```javascript
@@ -81,6 +97,23 @@ await sql.begin(async tx => {
81
97
  })
82
98
  ```
83
99
 
100
+ ## Коментар під час виправлення SQL injection
101
+
102
+ Коли виправляєш місце з потенційним **SQL injection** (наприклад, заміна конкатенації/`.join(',')` на `sql(ids)` або перехід з `sql.unsafe(...)` на tagged template), **додай поруч короткий коментар** з описом причини.
103
+
104
+ Вимоги до коментаря:
105
+
106
+ - пояснити **що саме було небезпечно** (конкатенація, підмішування user input, динамічний `IN (...)`, тощо);
107
+ - пояснити **чому новий варіант безпечний** (параметризація через tagged template / `sql(...)`);
108
+ - без “романів”: 1–2 рядки, достатньо для ревʼю.
109
+
110
+ Приклад:
111
+
112
+ ```javascript
113
+ // SQLi fix: не конкатенуємо значення в `IN (...)`; Bun parameterize через `sql(ids)`.
114
+ await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
115
+ ```
116
+
84
117
  ## Що НЕ робити
85
118
 
86
119
  ### Не використовувати `sql.unsafe(...)` з конкатенацією
package/mdc/js-mssql.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Використання mssql в nodejs
3
3
  alwaysApply: true
4
- version: '1.2'
4
+ version: '1.3'
5
5
  ---
6
6
 
7
7
  ## Підтримувана версія SQL Server
@@ -54,6 +54,23 @@ const result = await pool.request().query`
54
54
 
55
55
  Ключове: pool.request().query\...`— бекті́ки післяquery`, без круглих дужок. Це той самий tagged template, тільки контекст — конкретний пул, а не глобальний.
56
56
 
57
+ ## Коментар під час виправлення SQL injection
58
+
59
+ Коли виправляєш місце з потенційним **SQL injection** (наприклад, заміна `query(\`...\`)` на `query\`...\`` або прибирання динамічних списків/конкатенації), **додай поруч короткий коментар** з описом причини.
60
+
61
+ Вимоги до коментаря:
62
+
63
+ - вказати **що було небезпечно** (звичайна інтерполяція в рядок, конкатенація, динамічний список);
64
+ - вказати **чому новий варіант безпечний** (tagged template / параметризація / TVP);
65
+ - 1–2 рядки, без дублювання очевидного.
66
+
67
+ Приклад:
68
+
69
+ ```javascript
70
+ // SQLi fix: query`...` (tagged template) параметризує значення; query(`...`) небезпечний через інтерполяцію.
71
+ await pool.request().query`SELECT * FROM users WHERE id = ${userId}`
72
+ ```
73
+
57
74
  ## Що НЕ робити
58
75
 
59
76
  ### Не робити `query(\`...\`)`
@@ -166,6 +183,11 @@ WHERE NOT EXISTS (
166
183
 
167
184
  Якщо `IN (...)` все ж використовується (а не `JOIN` на TVP), значення в `${...}` **обовʼязково** мають бути попередньо приведені числовим парсером і відфільтровані від `NaN`. Це знімає будь-яку можливість SQL injection: SQL-метасимволи в `Number`/`parseInt(...)` перетворюються на `NaN` і відсіюються.
168
185
 
186
+ Додатково:
187
+
188
+ - значення для `IN (${...})` потрібно **винести в окрему змінну** перед запитом (не підставляти вираз напряму в `${...}`);
189
+ - цю змінну потрібно **перевірити на пустоту** і якщо список порожній — **throw error** (щоб не виконувати некоректний запит).
190
+
169
191
  ```javascript
170
192
  // ❌ НЕ МОЖНА: значення з req.body / зовнішнього джерела без парсингу
171
193
  const outIds = pgQ.rows.flatMap(x => x.req_body.Orders.map(o => o.OutletId))
@@ -176,9 +198,11 @@ await pool.query(/* sql */ String.raw`
176
198
 
177
199
  ```javascript
178
200
  // ✅ МОЖНА: parseInt + filter(!isNaN) гарантує, що в SQL потраплять лише числа
201
+ // і перед запитом робимо guard на пустоту, щоб не виконувати некоректний SQL.
179
202
  const outIds = pgQ.rows
180
203
  .flatMap(x => x.req_body.Orders.map(o => parseInt(o.OutletId)))
181
204
  .filter(n => !isNaN(n))
205
+ if (!outIds.length) throw new Error('outIds is empty')
182
206
  await pool.request().query`
183
207
  SELECT ... WHERE so.OutletId IN (${outIds})
184
208
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.150",
3
+ "version": "1.8.152",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Формує markdown-рядки для секції «Команди» у AGENTS.md.
3
+ *
4
+ * Джерело істини — `package.json` у корені цільового репозиторію: з поля `scripts` беруться відомі ключі
5
+ * у стабільному порядку, додатково — усі `lint-*`, яких не було в основному списку.
6
+ *
7
+ * Наприкінці завжди додаються рядки про CLI `@nitra/cursor` (синхрон правил / programmatic check),
8
+ * на початку — рекомендована команда `bun i` за конвенціями monorepo.
9
+ */
10
+ import { existsSync } from 'node:fs'
11
+ import { readFile } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+
14
+ const PACKAGE_NAME = '@nitra/cursor'
15
+ const AGENTS_MD = 'AGENTS.md'
16
+
17
+ /** Порядок виводу скриптів із `package.json` (лише ті, що реально існують). */
18
+ const SCRIPT_KEYS_ORDER = /** @type {const} */ ([
19
+ 'test',
20
+ 'lint',
21
+ 'lint-js',
22
+ 'lint-text',
23
+ 'lint-ga',
24
+ 'lint-k8s',
25
+ 'lint-docker',
26
+ 'start',
27
+ 'dev',
28
+ 'build'
29
+ ])
30
+
31
+ /**
32
+ * Зчитує `scripts` з `package.json` у `projectRoot` або повертає порожній об'єкт.
33
+ * @param {string} projectRoot абсолютний шлях до кореня репозиторію
34
+ * @returns {Promise<Record<string, string>>} об'єкт скриптів
35
+ */
36
+ async function readPackageScripts(projectRoot) {
37
+ const pkgPath = join(projectRoot, 'package.json')
38
+ if (!existsSync(pkgPath)) {
39
+ return {}
40
+ }
41
+ try {
42
+ const raw = await readFile(pkgPath, 'utf8')
43
+ const pkg = JSON.parse(raw)
44
+ if (pkg && typeof pkg === 'object' && pkg.scripts && typeof pkg.scripts === 'object') {
45
+ return /** @type {Record<string, string>} */ (pkg.scripts)
46
+ }
47
+ } catch {
48
+ // некоректний JSON або IO — секція команд лишиться з мінімумом (bun i + npx)
49
+ }
50
+ return {}
51
+ }
52
+
53
+ /**
54
+ * Повертає елементи для Mustache-секції `commands` у AGENTS.template.md.
55
+ * @param {string} projectRoot абсолютний шлях до кореня репозиторію (зазвичай `process.cwd()`)
56
+ * @returns {Promise<{ name: string }[]>} рядки з полем `name` для `expandMustacheSection`
57
+ */
58
+ export async function buildAgentsCommandBulletItems(projectRoot) {
59
+ const scripts = await readPackageScripts(projectRoot)
60
+ const items = /** @type {{ name: string }[]} */ ([])
61
+
62
+ items.push({ name: `- **Залежності**: \`bun i\`` })
63
+
64
+ const added = new Set()
65
+
66
+ for (const key of SCRIPT_KEYS_ORDER) {
67
+ if (typeof scripts[key] === 'string' && scripts[key].length > 0) {
68
+ items.push({ name: `- **${key}**: \`bun run ${key}\`` })
69
+ added.add(key)
70
+ }
71
+ }
72
+
73
+ const lintExtraKeys = Object.keys(scripts)
74
+ .filter(k => k.startsWith('lint-') && !added.has(k) && typeof scripts[k] === 'string')
75
+ .toSorted((a, b) => a.localeCompare(b))
76
+
77
+ for (const key of lintExtraKeys) {
78
+ items.push({ name: `- **${key}**: \`bun run ${key}\`` })
79
+ added.add(key)
80
+ }
81
+
82
+ items.push({
83
+ name: `- **Оновити правила та ${AGENTS_MD}** (після змін у правилах/шаблоні CLI): \`npx ${PACKAGE_NAME}\``
84
+ })
85
+ items.push({ name: `- **Перевірки правил (programmatic)**: \`npx ${PACKAGE_NAME} check\`` })
86
+
87
+ return items
88
+ }
@@ -20,6 +20,7 @@ import { createCheckReporter } from './utils/check-reporter.mjs'
20
20
  import {
21
21
  findBunSqlPerRequestConnectionInText,
22
22
  findUnsafeBunSqlDynamicSqlListInText,
23
+ findUnsafeBunSqlInListMissingEmptyGuardInText,
23
24
  findUnsafeBunSqlUnsafeCallInText,
24
25
  isBunSqlScanSourceFile,
25
26
  textHasBunSqlImport
@@ -125,6 +126,7 @@ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
125
126
  let perRequest = 0
126
127
  let unsafeCall = 0
127
128
  let dynamicList = 0
129
+ let inListGuard = 0
128
130
 
129
131
  for (const absPath of sourcePaths) {
130
132
  const rel = relative(repoRoot, absPath).split('\\').join('/')
@@ -154,9 +156,28 @@ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
154
156
  `у IN (...) / VALUES (...); використовуй sql([...]) (js-bun-db.mdc): ${v.snippet}`
155
157
  )
156
158
  }
159
+ for (const v of findUnsafeBunSqlInListMissingEmptyGuardInText(content, rel)) {
160
+ inListGuard++
161
+ if (v.reason === 'missing_guard') {
162
+ fail(
163
+ `js-bun-db: ${rel}:${v.line} — перед IN-списком ${JSON.stringify(v.name)} потрібна перевірка на пустоту ` +
164
+ `з throw (наприклад if (!${v.name}.length) throw ...), інакше можливі некоректні запити (js-bun-db.mdc): ${v.snippet}`
165
+ )
166
+ } else if (v.reason === 'sql_helper_not_var') {
167
+ fail(
168
+ `js-bun-db: ${rel}:${v.line} — IN-список у \${sql(...)} має підставлятись зі змінної (Identifier) ` +
169
+ `після валідації на пустоту + throw (js-bun-db.mdc): ${v.snippet}`
170
+ )
171
+ } else {
172
+ fail(
173
+ `js-bun-db: ${rel}:${v.line} — значення для IN (...) у template literal треба винести в окрему змінну ` +
174
+ `і перевірити на пустоту (throw), не підставляти вираз напряму (js-bun-db.mdc): ${v.snippet}`
175
+ )
176
+ }
177
+ }
157
178
  }
158
179
 
159
- return { hasBunSqlImport, perRequest, unsafeCall, dynamicList }
180
+ return { hasBunSqlImport, perRequest, unsafeCall, dynamicList, inListGuard }
160
181
  }
161
182
 
162
183
  /**
@@ -188,7 +209,7 @@ export async function check() {
188
209
  return reporter.getExitCode()
189
210
  }
190
211
 
191
- const { hasBunSqlImport, perRequest, unsafeCall, dynamicList } = await scanSourcesForBunSqlPatterns(
212
+ const { hasBunSqlImport, perRequest, unsafeCall, dynamicList, inListGuard } = await scanSourcesForBunSqlPatterns(
192
213
  sourcePaths,
193
214
  repoRoot,
194
215
  reporter
@@ -208,6 +229,9 @@ export async function check() {
208
229
  if (dynamicList === 0) {
209
230
  pass("js-bun-db: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
210
231
  }
232
+ if (inListGuard === 0) {
233
+ pass('js-bun-db: усі IN-списки винесені у змінні та мають перевірку на пустоту з throw')
234
+ }
211
235
 
212
236
  return reporter.getExitCode()
213
237
  }
@@ -19,6 +19,7 @@ import {
19
19
  findUnsafeMssqlQueryTemplateCallInText,
20
20
  findUnsafeMssqlDynamicSqlListInText,
21
21
  findUnsafeMssqlInListUnparsedInText,
22
+ findUnsafeMssqlInListMissingEmptyGuardInText,
22
23
  isMssqlScanSourceFile
23
24
  } from './utils/mssql-pool-scan.mjs'
24
25
  import { walkDir } from './utils/walkDir.mjs'
@@ -203,6 +204,20 @@ function scanMssqlOneSourceFile(rel, content, counters, fail) {
203
204
  `js-mssql: ${rel}:${v.line} — у SQL IN (\${...}) значення мають бути попередньо приведені числовим парсером (parseInt/Number/BigInt/parseFloat) і відфільтровані від NaN, інакше можливий SQL injection (js-mssql.mdc): ${v.snippet}`
204
205
  )
205
206
  }
207
+ for (const v of findUnsafeMssqlInListMissingEmptyGuardInText(content, rel)) {
208
+ counters.inListGuardViolations++
209
+ if (v.reason === 'missing_guard') {
210
+ fail(
211
+ `js-mssql: ${rel}:${v.line} — перед IN-списком ${JSON.stringify(v.name)} потрібна перевірка на пустоту з throw ` +
212
+ `(наприклад if (!${v.name}.length) throw ...), інакше можливі некоректні запити (js-mssql.mdc): ${v.snippet}`
213
+ )
214
+ } else {
215
+ fail(
216
+ `js-mssql: ${rel}:${v.line} — значення для IN (\${...}) у template literal треба винести в окрему змінну ` +
217
+ `і перевірити на пустоту (throw), не підставляти вираз напряму (js-mssql.mdc): ${v.snippet}`
218
+ )
219
+ }
220
+ }
206
221
  }
207
222
 
208
223
  /**
@@ -226,6 +241,9 @@ function reportZeroMssqlSourceViolations(counters, pass) {
226
241
  if (counters.unparsedInLists === 0) {
227
242
  pass(`js-mssql: немає підстановок IN (\${...}) без числового парсера значень`)
228
243
  }
244
+ if (counters.inListGuardViolations === 0) {
245
+ pass('js-mssql: усі IN-списки винесені у змінні та мають перевірку на пустоту з throw')
246
+ }
229
247
  }
230
248
 
231
249
  /**
@@ -247,7 +265,8 @@ async function auditMssqlSources(repoRoot, pass, fail) {
247
265
  sharedRequestViolations: 0,
248
266
  unsafeQueryCalls: 0,
249
267
  unsafeDynamicSqlLists: 0,
250
- unparsedInLists: 0
268
+ unparsedInLists: 0,
269
+ inListGuardViolations: 0
251
270
  }
252
271
  for (const absPath of sourcePaths) {
253
272
  const rel = relative(repoRoot, absPath).split('\\').join('/')
@@ -27,6 +27,157 @@ import {
27
27
 
28
28
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
29
29
  const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
30
+ const IN_PLACEHOLDER_END_RE = /\bin\s*(\(\s*)?$/iu
31
+
32
+ /**
33
+ * @param {unknown} node AST node
34
+ * @param {string} name імʼя змінної
35
+ * @returns {boolean} true, якщо це MemberExpression `${name}.length`
36
+ */
37
+ function isLengthMember(node, name) {
38
+ return (
39
+ !!node &&
40
+ typeof node === 'object' &&
41
+ node.type === 'MemberExpression' &&
42
+ !node.computed &&
43
+ node.object &&
44
+ node.object.type === 'Identifier' &&
45
+ node.object.name === name &&
46
+ node.property &&
47
+ node.property.type === 'Identifier' &&
48
+ node.property.name === 'length'
49
+ )
50
+ }
51
+
52
+ /**
53
+ * @param {unknown} node AST node
54
+ * @returns {boolean} true, якщо це числовий 0-літерал
55
+ */
56
+ function isZeroNumberLiteral(node) {
57
+ return (
58
+ !!node &&
59
+ typeof node === 'object' &&
60
+ ((node.type === 'NumericLiteral' && node.value === 0) || (node.type === 'Literal' && node.value === 0))
61
+ )
62
+ }
63
+
64
+ /**
65
+ * @param {unknown} node AST node
66
+ * @returns {boolean} true, якщо це Identifier з імʼям `sql`
67
+ */
68
+ function isSqlHelperIdentifier(node) {
69
+ return !!node && typeof node === 'object' && node.type === 'Identifier' && node.name === 'sql'
70
+ }
71
+
72
+ /**
73
+ * Витягає імʼя змінної списку для `IN ...`:
74
+ * - `${ids}` → `ids`
75
+ * - `${sql(ids)}` → `ids`
76
+ * @param {unknown} expr template expression
77
+ * @returns {{ name: string } | { error: 'not_var' } | { error: 'sql_helper_not_var' }} імʼя змінної або причина відмови
78
+ */
79
+ function extractInListVarNameFromExpr(expr) {
80
+ if (!expr || typeof expr !== 'object') return { error: 'not_var' }
81
+ if (expr.type === 'Identifier' && typeof expr.name === 'string') return { name: expr.name }
82
+
83
+ if (expr.type === 'CallExpression' && isSqlHelperIdentifier(expr.callee)) {
84
+ const args = expr.arguments
85
+ if (!Array.isArray(args) || args.length === 0) return { error: 'sql_helper_not_var' }
86
+ const first = args[0]
87
+ if (first && typeof first === 'object' && first.type === 'Identifier' && typeof first.name === 'string') {
88
+ return { name: first.name }
89
+ }
90
+ return { error: 'sql_helper_not_var' }
91
+ }
92
+
93
+ return { error: 'not_var' }
94
+ }
95
+
96
+ /**
97
+ * Чи містить тест if-умови перевірку “список порожній”.
98
+ * Підтримує базові форми:
99
+ * - `if (!ids.length) ...`
100
+ * - `if (ids.length === 0) ...` / `<= 0` / `< 1`
101
+ * @param {unknown} test IfStatement.test
102
+ * @param {string} name імʼя змінної списку
103
+ * @returns {boolean} true, якщо це перевірка на пустоту списку
104
+ */
105
+ function isEmptyListTest(test, name) {
106
+ if (!test || typeof test !== 'object') return false
107
+
108
+ if (test.type === 'UnaryExpression' && test.operator === '!') {
109
+ const arg = test.argument
110
+ if (!arg || typeof arg !== 'object') return false
111
+ return isLengthMember(arg, name)
112
+ }
113
+
114
+ if (test.type === 'BinaryExpression') {
115
+ const { left, right, operator } = test
116
+ if (!['===', '==', '<=', '<'].includes(operator)) return false
117
+ if (isLengthMember(left, name) && isZeroNumberLiteral(right)) return true
118
+ // допускаємо `0 === ids.length` теж
119
+ if (isZeroNumberLiteral(left) && isLengthMember(right, name) && (operator === '===' || operator === '==')) return true
120
+ }
121
+
122
+ return false
123
+ }
124
+
125
+ /**
126
+ * Чи є в consequent (або в його BlockStatement) ThrowStatement.
127
+ * @param {unknown} consequent IfStatement.consequent
128
+ * @returns {boolean} true, якщо consequent містить throw
129
+ */
130
+ function consequentHasThrow(consequent) {
131
+ if (!consequent || typeof consequent !== 'object') return false
132
+ if (consequent.type === 'ThrowStatement') return true
133
+ if (consequent.type === 'BlockStatement' && Array.isArray(consequent.body)) {
134
+ return consequent.body.some(s => s && typeof s === 'object' && s.type === 'ThrowStatement')
135
+ }
136
+ return false
137
+ }
138
+
139
+ /**
140
+ * Шукає “guard” `if (empty) throw` перед statementIndex у межах того ж BlockStatement.
141
+ * @param {unknown} block BlockStatement
142
+ * @param {number} statementIndex індекс statement, перед яким шукаємо guard
143
+ * @param {string} name імʼя змінної списку
144
+ * @returns {boolean} true, якщо guard знайдено
145
+ */
146
+ function hasEmptyGuardBefore(block, statementIndex, name) {
147
+ if (!block || typeof block !== 'object' || block.type !== 'BlockStatement') return false
148
+ const body = block.body
149
+ if (!Array.isArray(body)) return false
150
+ for (let i = 0; i < statementIndex; i++) {
151
+ const st = body[i]
152
+ if (!st || typeof st !== 'object') continue
153
+ if (st.type !== 'IfStatement') continue
154
+ if (!isEmptyListTest(st.test, name)) continue
155
+ if (!consequentHasThrow(st.consequent)) continue
156
+ return true
157
+ }
158
+ return false
159
+ }
160
+
161
+ /**
162
+ * Знаходить найближчий enclosing BlockStatement і statement всередині нього.
163
+ * @param {unknown[]} ancestors ancestors масив з walkAstWithAncestors
164
+ * @returns {{ block: unknown, index: number } | null} block+індекс statement або null
165
+ */
166
+ function findEnclosingBlockAndStatementIndex(ancestors) {
167
+ if (!Array.isArray(ancestors) || ancestors.length === 0) return null
168
+
169
+ // statement — перший зверху вузол, який лежить у block.body
170
+ // шукаємо пару (block, statement), де statement ∈ block.body
171
+ for (let i = ancestors.length - 1; i >= 1; i--) {
172
+ const maybeStatement = ancestors[i]
173
+ const maybeBlock = ancestors[i - 1]
174
+ if (!maybeBlock || typeof maybeBlock !== 'object' || maybeBlock.type !== 'BlockStatement') continue
175
+ if (!Array.isArray(maybeBlock.body)) continue
176
+ const idx = maybeBlock.body.indexOf(maybeStatement)
177
+ if (idx !== -1) return { block: maybeBlock, index: idx }
178
+ }
179
+ return null
180
+ }
30
181
 
31
182
  /**
32
183
  * Чи це `new SQL(...)` (Identifier callee з імʼям `SQL`).
@@ -139,6 +290,80 @@ export function findUnsafeBunSqlDynamicSqlListInText(content, virtualPath = 'sca
139
290
  return out
140
291
  }
141
292
 
293
+ /**
294
+ * Збирає порушення для одного TemplateLiteral вузла: `IN ... ${...}` потребує
295
+ * змінної + guard `if (empty) throw`.
296
+ * @param {Record<string, unknown>} template TemplateLiteral
297
+ * @param {unknown[]} ancestors ancestors з walkAstWithAncestors
298
+ * @param {string} content вихідний код
299
+ * @param {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} out буфер результатів
300
+ */
301
+ function collectInListGuardViolationsFromTemplate(template, ancestors, content, out) {
302
+ const expressions = template.expressions
303
+ const quasis = template.quasis
304
+ if (!Array.isArray(expressions) || expressions.length === 0) return
305
+ if (!Array.isArray(quasis) || quasis.length === 0) return
306
+
307
+ for (const [i, expr] of expressions.entries()) {
308
+ const q = quasis[i]
309
+ const raw =
310
+ q && typeof q === 'object' && q.value && typeof q.value === 'object' && typeof q.value.raw === 'string' ? q.value.raw : ''
311
+ if (!IN_PLACEHOLDER_END_RE.test(raw)) continue
312
+
313
+ const extracted = extractInListVarNameFromExpr(expr)
314
+ if ('error' in extracted) {
315
+ out.push({
316
+ line: offsetToLine(content, template.start),
317
+ snippet: normalizeSnippet(content.slice(template.start, template.end)),
318
+ reason: extracted.error
319
+ })
320
+ continue
321
+ }
322
+
323
+ const place = findEnclosingBlockAndStatementIndex(ancestors)
324
+ if (!place || !hasEmptyGuardBefore(place.block, place.index, extracted.name)) {
325
+ out.push({
326
+ line: offsetToLine(content, template.start),
327
+ snippet: normalizeSnippet(content.slice(template.start, template.end)),
328
+ reason: 'missing_guard',
329
+ name: extracted.name
330
+ })
331
+ }
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Знаходить підстановки списків у `IN (...)`, які:
337
+ * - не винесені в окрему змінну (в `${...}` стоїть не Identifier або `sql(<non-Identifier>)`);
338
+ * - або винесені, але перед запитом немає перевірки на пустоту з `throw`.
339
+ * @param {string} content вихідний код
340
+ * @param {string} [virtualPath] шлях для вибору `lang`
341
+ * @returns {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} список порушень
342
+ */
343
+ export function findUnsafeBunSqlInListMissingEmptyGuardInText(content, virtualPath = 'scan.ts') {
344
+ const program = parseProgramOrNull(content, virtualPath)
345
+ if (!program) return []
346
+
347
+ /** @type {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} */
348
+ const out = []
349
+
350
+ walkAstWithAncestors(program, [], (node, ancestors) => {
351
+ /** @type {unknown} */
352
+ let template = null
353
+ if (node.type === 'TemplateLiteral') {
354
+ template = node
355
+ } else if (node.type === 'TaggedTemplateExpression') {
356
+ template = node.quasi
357
+ }
358
+
359
+ if (!template || typeof template !== 'object' || template.type !== 'TemplateLiteral') return
360
+ if (!isSqlListContextTemplate(template)) return
361
+ collectInListGuardViolationsFromTemplate(template, ancestors, content, out)
362
+ })
363
+
364
+ return out
365
+ }
366
+
142
367
  /**
143
368
  * Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"`.
144
369
  * Скан по сирому тексту — без AST, щоб бути дешевим: викликається на кожному
@@ -36,6 +36,113 @@ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
36
36
  const IN_PLACEHOLDER_END_RE = /\bin\s*\(\s*$/iu
37
37
  const NUMERIC_PARSE_FN_NAMES = new Set(['parseInt', 'parseFloat', 'Number', 'BigInt'])
38
38
 
39
+ /**
40
+ * Чи містить тест if-умови перевірку “список порожній”.
41
+ * Підтримує базові форми:
42
+ * - `if (!ids.length) ...`
43
+ * - `if (ids.length === 0) ...` / `<= 0` / `< 1`
44
+ *
45
+ * @param {unknown} test IfStatement.test
46
+ * @param {string} name імʼя змінної списку
47
+ * @returns {boolean}
48
+ */
49
+ function isEmptyListTest(test, name) {
50
+ if (!test || typeof test !== 'object') return false
51
+
52
+ if (test.type === 'UnaryExpression' && test.operator === '!') {
53
+ const arg = test.argument
54
+ if (!arg || typeof arg !== 'object') return false
55
+ if (arg.type === 'MemberExpression' && !arg.computed) {
56
+ const obj = arg.object
57
+ const prop = arg.property
58
+ return !!obj && obj.type === 'Identifier' && obj.name === name && !!prop && prop.type === 'Identifier' && prop.name === 'length'
59
+ }
60
+ }
61
+
62
+ if (test.type === 'BinaryExpression') {
63
+ const { left, right, operator } = test
64
+ const isLen = node =>
65
+ !!node &&
66
+ typeof node === 'object' &&
67
+ node.type === 'MemberExpression' &&
68
+ !node.computed &&
69
+ node.object &&
70
+ node.object.type === 'Identifier' &&
71
+ node.object.name === name &&
72
+ node.property &&
73
+ node.property.type === 'Identifier' &&
74
+ node.property.name === 'length'
75
+ const isZero = node =>
76
+ !!node &&
77
+ typeof node === 'object' &&
78
+ ((node.type === 'NumericLiteral' && node.value === 0) || (node.type === 'Literal' && node.value === 0))
79
+
80
+ if (!['===', '==', '<=', '<'].includes(operator)) return false
81
+ if (isLen(left) && isZero(right)) return true
82
+ // допускаємо `0 === ids.length` теж
83
+ if (isZero(left) && isLen(right) && (operator === '===' || operator === '==')) return true
84
+ }
85
+
86
+ return false
87
+ }
88
+
89
+ /**
90
+ * Чи є в consequent (або в його BlockStatement) ThrowStatement.
91
+ * @param {unknown} consequent IfStatement.consequent
92
+ * @returns {boolean}
93
+ */
94
+ function consequentHasThrow(consequent) {
95
+ if (!consequent || typeof consequent !== 'object') return false
96
+ if (consequent.type === 'ThrowStatement') return true
97
+ if (consequent.type === 'BlockStatement' && Array.isArray(consequent.body)) {
98
+ return consequent.body.some(s => s && typeof s === 'object' && s.type === 'ThrowStatement')
99
+ }
100
+ return false
101
+ }
102
+
103
+ /**
104
+ * Шукає “guard” `if (empty) throw` перед statementIndex у межах того ж BlockStatement.
105
+ * @param {unknown} block BlockStatement
106
+ * @param {number} statementIndex індекс statement, перед яким шукаємо guard
107
+ * @param {string} name імʼя змінної списку
108
+ * @returns {boolean}
109
+ */
110
+ function hasEmptyGuardBefore(block, statementIndex, name) {
111
+ if (!block || typeof block !== 'object' || block.type !== 'BlockStatement') return false
112
+ const body = block.body
113
+ if (!Array.isArray(body)) return false
114
+ for (let i = 0; i < statementIndex; i++) {
115
+ const st = body[i]
116
+ if (!st || typeof st !== 'object') continue
117
+ if (st.type !== 'IfStatement') continue
118
+ if (!isEmptyListTest(st.test, name)) continue
119
+ if (!consequentHasThrow(st.consequent)) continue
120
+ return true
121
+ }
122
+ return false
123
+ }
124
+
125
+ /**
126
+ * Знаходить найближчий enclosing BlockStatement і statement всередині нього.
127
+ * @param {unknown[]} ancestors ancestors масив з walkAstWithAncestors
128
+ * @returns {{ block: unknown, index: number } | null}
129
+ */
130
+ function findEnclosingBlockAndStatementIndex(ancestors) {
131
+ if (!Array.isArray(ancestors) || ancestors.length === 0) return null
132
+
133
+ // statement — перший зверху вузол, який лежить у block.body
134
+ // шукаємо пару (block, statement), де statement ∈ block.body
135
+ for (let i = ancestors.length - 1; i >= 1; i--) {
136
+ const maybeStatement = ancestors[i]
137
+ const maybeBlock = ancestors[i - 1]
138
+ if (!maybeBlock || typeof maybeBlock !== 'object' || maybeBlock.type !== 'BlockStatement') continue
139
+ if (!Array.isArray(maybeBlock.body)) continue
140
+ const idx = maybeBlock.body.indexOf(maybeStatement)
141
+ if (idx !== -1) return { block: maybeBlock, index: idx }
142
+ }
143
+ return null
144
+ }
145
+
39
146
  /**
40
147
  * Чи це `new sql.ConnectionPool(...)` або `new mssql.ConnectionPool(...)`.
41
148
  * @param {unknown} node AST node
@@ -381,6 +488,47 @@ function collectInListUnparsedFromTemplate(node, content, declarators, out) {
381
488
  }
382
489
  }
383
490
 
491
+ /**
492
+ * Збирає порушення для одного TemplateLiteral: якщо у `IN (${...})`:
493
+ * - `${...}` не є Identifier (значення не винесені у змінну);
494
+ * - або це Identifier, але перед запитом немає guard `if (empty) throw`.
495
+ *
496
+ * @param {Record<string, unknown>} node TemplateLiteral
497
+ * @param {unknown[]} ancestors ancestors від walkAstWithAncestors
498
+ * @param {string} content вихідний код
499
+ * @param {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} out буфер результатів
500
+ */
501
+ function collectInListMissingEmptyGuardFromTemplate(node, ancestors, content, out) {
502
+ if (node.type !== 'TemplateLiteral') return
503
+ const quasis = node.quasis
504
+ const expressions = node.expressions
505
+ if (!Array.isArray(quasis) || !Array.isArray(expressions) || expressions.length === 0) return
506
+
507
+ for (const [i, expr] of expressions.entries()) {
508
+ if (!IN_PLACEHOLDER_END_RE.test(quasiRawText(quasis[i]))) continue
509
+ if (!expr || typeof expr !== 'object') continue
510
+
511
+ if (expr.type !== 'Identifier' || typeof expr.name !== 'string') {
512
+ out.push({
513
+ line: offsetToLine(content, node.start),
514
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
515
+ reason: 'not_var'
516
+ })
517
+ continue
518
+ }
519
+
520
+ const place = findEnclosingBlockAndStatementIndex(ancestors)
521
+ if (!place || !hasEmptyGuardBefore(place.block, place.index, expr.name)) {
522
+ out.push({
523
+ line: offsetToLine(content, node.start),
524
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
525
+ reason: 'missing_guard',
526
+ name: expr.name
527
+ })
528
+ }
529
+ }
530
+ }
531
+
384
532
  /**
385
533
  * Знаходить підстановки IN (вираз) у TemplateLiteral, де вираз не пройшов числовий парсер.
386
534
  *
@@ -410,6 +558,33 @@ export function findUnsafeMssqlInListUnparsedInText(content, virtualPath = 'scan
410
558
  return out
411
559
  }
412
560
 
561
+ /**
562
+ * Знаходить підстановки списків у `IN (${...})`, які:
563
+ * - не винесені в окрему змінну (в `${...}` стоїть не Identifier);
564
+ * - або винесені, але перед запитом немає перевірки на пустоту з `throw`.
565
+ *
566
+ * @param {string} content вихідний код
567
+ * @param {string} [virtualPath] шлях для вибору мови парсера (lang)
568
+ * @returns {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} список порушень
569
+ */
570
+ export function findUnsafeMssqlInListMissingEmptyGuardInText(content, virtualPath = 'scan.ts') {
571
+ const lang = langFromPath(virtualPath || 'scan.ts')
572
+ let result
573
+ try {
574
+ result = parseSync(virtualPath, content, { lang, sourceType: 'module' })
575
+ } catch {
576
+ return []
577
+ }
578
+ if (result.errors?.length) return []
579
+
580
+ /** @type {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} */
581
+ const out = []
582
+ walkAstWithAncestors(result.program, [], (node, ancestors) =>
583
+ collectInListMissingEmptyGuardFromTemplate(node, ancestors, content, out)
584
+ )
585
+ return out
586
+ }
587
+
413
588
  /**
414
589
  * Чи сканувати цей файл за розширенням (JS/TS-сім'я).
415
590
  * @param {string} relativePathPosix відносний шлях (posix)