@nitra/cursor 1.8.150 → 1.8.151

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/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.151",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -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)