@nitra/cursor 1.8.209 → 1.8.210

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.8.210] - 2026-05-08
8
+
9
+ ### Added
10
+
11
+ - `js-bun-db` v1.6: правило тепер забороняє локальні pg-format-сумісні шими у
12
+ файлах з Bun SQL.
13
+ - Розділ `## pg-format: повне видалення, без шимів` у `npm/mdc/js-bun-db.mdc`:
14
+ типові ідіоми `format(...)` → tagged template, заборонений drop-in `format()`
15
+ і `pg`-сумісна `query(text, params)`-обгортка над `sql.unsafe(...)`.
16
+ - Два нові AST-детектори у `npm/scripts/utils/bun-sql-scan.mjs`:
17
+ `findPgFormatShimDefinitionInText` (функції `format` / `pgFormat` /
18
+ `sqlFormat` / `pgFmt` з `%L`/`%I`/`%s` у тілі, плюс `quoteLiteral` /
19
+ `quoteIdent` / `escapeLiteral` / `escapeIdent` без додаткової перевірки)
20
+ та `findPgFormatLikeQueryWrapperInText` (`{ query(text, params) { ...
21
+ <obj>.unsafe(...) ... } }`). Скан запускається лише у файлах з
22
+ `import { sql|SQL } from 'bun'`.
23
+ - `npm/scripts/check-js-bun-db.mjs` рапортує `pgFormatShim` / `queryWrapper` —
24
+ окремі лічильники й `pass`-рядки, без зміни існуючих перевірок.
25
+
7
26
  ## [1.8.209] - 2026-05-08
8
27
 
9
28
  ### Removed
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.5'
4
+ version: '1.6'
5
5
  ---
6
6
 
7
7
  ## Підтримувані версії баз даних
@@ -18,6 +18,58 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
18
18
 
19
19
  `pg-format` — це ручне форматування SQL через escape (`format('... %L ...', value)`); такі рядки легко поламати неправильним типом, locale-залежним escape або забутим `%L`. Tagged template Bun SQL параметризує значення нативно (`sql\`... ${value} ...\``) і не лишає простору для injection — окремий «форматер» не потрібен.
20
20
 
21
+ ## `pg-format`: повне видалення, без шимів
22
+
23
+ Міграція з `pg-format` — це **зміна стилю запитів**, а не збереження API. У проєкті після переходу на Bun SQL **заборонено** залишати:
24
+
25
+ - функцію з іменем `format` (чи `pgFormat`, `sqlFormat`, `pgFmt`), що приймає шаблон з `%L` / `%I` / `%s` і значення;
26
+ - допоміжні `quoteLiteral`, `quoteIdent`, `escapeLiteral`, `escapeIdent` як публічні експорти модуля;
27
+ - обгортки `pgRead.query(text, params)` / `pgWrite.query(text, params)` / `db.query(text, params)`, які складають SQL-рядок (з або без `format`) і викликають `sql.unsafe(text, params)` — це повертає injection-поверхню, від якої ми йдемо, тільки під «зручним» іменем.
28
+
29
+ Замість цього всі точки використання потрібно перевести на tagged template `sql\`...\${value}...\``. Кожне `${value}` стає окремим параметром bind, без рядкового екранування.
30
+
31
+ ### Типові ідіоми `pg-format` → Bun SQL
32
+
33
+ | Було (`pg-format`) | Стало (Bun SQL) |
34
+ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
35
+ | `format('... WHERE id = %L', id)` | `sql\`... WHERE id = ${id}\`` |
36
+ | `format('... IN (%L)', ids)` | `sql\`... IN ${sql(ids)}\`` (з guard на пустоту перед запитом) |
37
+ | `format('INSERT ... VALUES %L', [row])` (1 рядок) | `sql\`INSERT ... VALUES (${a}, ${b}, ...)\`` |
38
+ | `format('INSERT ... VALUES %L', rows)` (N рядків) | `sql\`INSERT INTO t ${sql(rows, 'a', 'b')}\`` або `unnest($1::T[], $2::T[]) AS t(a, b)` |
39
+ | `format('MERGE ... USING (VALUES %L) AS d(...)')` | `sql\`MERGE ... USING (SELECT * FROM unnest(${arrA}::A[], ${arrB}::B[]) AS t(a, b)) AS d\`` |
40
+ | `format('... %I ...', tableName)` (whitelist) | `sql.unsafe(\`... \${tableName} ...\`)` з маркером `// allow-unsafe: <причина>` і whitelist'ом |
41
+
42
+ Для multi-row `VALUES` у `MERGE` / `INSERT` з конкретними типами — паралельні масиви по колонках і `unnest($1::TYPE[], $2::TYPE[], ...) AS t(col1, col2, ...)`. Кожна колонка передається одним параметром-масивом; типи задаються кастом масиву (`::uuid[]`, `::bigint[]`, `::numeric[]`, `::text[]`, …).
43
+
44
+ ### Заборонений «drop-in» шим
45
+
46
+ ```javascript
47
+ // ❌ pg-format-сумісний шим, що ховає `unsafe` під «безпечним» іменем
48
+ export function format(fmt, ...args) {
49
+ let i = 0
50
+ return fmt.replaceAll(/%[LIs]/g, () => quoteLiteral(args[i++]))
51
+ }
52
+
53
+ // ❌ і його типовий call-site — той самий injection-вектор, що і прямий sql.unsafe із конкатенацією
54
+ await sql.unsafe(format('... WHERE id = %L', userId))
55
+ ```
56
+
57
+ ```javascript
58
+ // ❌ pg-сумісна обгортка над Bun SQL — ще один прихований `unsafe`
59
+ export const pgWrite = {
60
+ query(text, params) {
61
+ return sql.unsafe(text, params)
62
+ }
63
+ }
64
+ ```
65
+
66
+ ```javascript
67
+ // ✅ напряму tagged template — параметризація через wire-protocol bind
68
+ await sql`... WHERE id = ${userId}`
69
+ ```
70
+
71
+ Виняток для шиму `format()` під час поетапної міграції допускається **тільки** в окремому commit'і з TODO-маркером і дедлайном; готовий код у main з таким шимом — `fail` правила (детектори: `pg-format shim`, `query wrapper over unsafe`).
72
+
21
73
  ## Підключення (singleton + env)
22
74
 
23
75
  Дефолтний експорт `sql` з `'bun'` сам читає змінні середовища (`DATABASE_URL`, `POSTGRES_URL`, `MYSQL_URL`, `PGHOST`/`PGUSER`/... та `MYSQL_HOST`/`MYSQL_USER`/...) і керує пулом — окремий `Pool` як у `pg` створювати не треба.
@@ -197,6 +249,8 @@ function getUser(id) {
197
249
 
198
250
  Якщо в коді з'явився `import { sql } from 'bun'`, то `pg`, `pg-format` та `mysql2` мають бути прибрані і з `dependencies`, і з імпортів — щоб не лишалось двох паралельних шляхів до БД та ручного форматування поряд із параметризованими template literal.
199
251
 
252
+ Те саме стосується **локальних шимів**: будь-який модуль, що експортує `format`, `pgRead`, `pgWrite`, `query(text, params)`, `quoteLiteral`, `quoteIdent` як обгортку над `sql.unsafe(...)`, потрібно переписати — всі call-site на tagged template, сам шим видалити (див. `## pg-format: повне видалення, без шимів`).
253
+
200
254
  ## Перевірка
201
255
 
202
256
  `npx @nitra/cursor check js-bun-db`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.209",
3
+ "version": "1.8.210",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -30,6 +30,8 @@ import {
30
30
  findBunSqlPerRequestConnectionInText,
31
31
  findBunSqlPgLeftoverCallInText,
32
32
  findBunSqlUnsafeUseWithoutAllowMarkerInText,
33
+ findPgFormatLikeQueryWrapperInText,
34
+ findPgFormatShimDefinitionInText,
33
35
  findUnsafeBunSqlDynamicSqlListInText,
34
36
  findUnsafeBunSqlInListMissingEmptyGuardInText,
35
37
  isBunSqlScanSourceFile,
@@ -67,13 +69,21 @@ async function findAllSourcePathsForBunSqlScan(repoRoot, ignorePaths) {
67
69
  * @param {string[]} sourcePaths абсолютні шляхи джерел
68
70
  * @param {string} repoRoot абсолютний шлях до кореня
69
71
  * @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter колбеки pass і fail з перевірки
70
- * @returns {Promise<{ hasBunSqlImport: boolean, perRequest: number, unsafeCall: number, dynamicList: number }>}
72
+ * @returns {Promise<{ hasBunSqlImport: boolean, perRequest: number, unsafeCall: number, dynamicList: number, inListGuard: number, pgLeftover: number, pgFormatShim: number, queryWrapper: number }>}
71
73
  * `hasBunSqlImport` — чи знайдено хоч один `import { sql|SQL } from 'bun'` у джерелах;
72
74
  * решта — кількість порушень кожного типу.
73
75
  */
74
76
  async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
75
77
  const { fail } = reporter
76
- const counts = { perRequest: 0, unsafeCall: 0, dynamicList: 0, inListGuard: 0, pgLeftover: 0 }
78
+ const counts = {
79
+ perRequest: 0,
80
+ unsafeCall: 0,
81
+ dynamicList: 0,
82
+ inListGuard: 0,
83
+ pgLeftover: 0,
84
+ pgFormatShim: 0,
85
+ queryWrapper: 0
86
+ }
77
87
  let hasBunSqlImport = false
78
88
 
79
89
  for (const absPath of sourcePaths) {
@@ -93,7 +103,7 @@ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
93
103
  * @param {string} content вміст файлу
94
104
  * @param {string} rel posix-шлях відносно `repoRoot`
95
105
  * @param {(msg: string) => void} fail callback при помилці
96
- * @param {{ perRequest: number, unsafeCall: number, dynamicList: number, inListGuard: number, pgLeftover: number }} counts акумулятори
106
+ * @param {{ perRequest: number, unsafeCall: number, dynamicList: number, inListGuard: number, pgLeftover: number, pgFormatShim: number, queryWrapper: number }} counts акумулятори
97
107
  * @returns {void}
98
108
  */
99
109
  function scanFileForBunSqlPatterns(content, rel, fail, counts) {
@@ -134,6 +144,30 @@ function scanFileForBunSqlPatterns(content, rel, fail, counts) {
134
144
  counts.inListGuard++
135
145
  fail(messageForBunSqlInListGuard(rel, v))
136
146
  }
147
+ for (const v of findPgFormatShimDefinitionInText(content, rel)) {
148
+ counts.pgFormatShim++
149
+ if (v.kind === 'format_function') {
150
+ fail(
151
+ `js-bun-db: ${rel}:${v.line} — функція ${JSON.stringify(v.name)} виглядає як pg-format-сумісний шим ` +
152
+ `(тіло містить %L / %I / %s). Видали шим і переведи всі call-site на tagged template ` +
153
+ `sql\`...\${value}...\` (js-bun-db.mdc): ${v.snippet}`
154
+ )
155
+ } else {
156
+ fail(
157
+ `js-bun-db: ${rel}:${v.line} — ${JSON.stringify(v.name)} — це pg-format-специфічний escape-хелпер; ` +
158
+ `з Bun SQL він не потрібен (параметризація через tagged template), видали і перепиши call-site ` +
159
+ `(js-bun-db.mdc): ${v.snippet}`
160
+ )
161
+ }
162
+ }
163
+ for (const v of findPgFormatLikeQueryWrapperInText(content, rel)) {
164
+ counts.queryWrapper++
165
+ fail(
166
+ `js-bun-db: ${rel}:${v.line} — query(text, params)-обгортка над <obj>.unsafe(...) — це прихований ` +
167
+ `pg-сумісний шим. Видали обгортку (pgRead/pgWrite/db.query) і переведи всі call-site на tagged template ` +
168
+ `sql\`...\${value}...\` (js-bun-db.mdc): ${v.snippet}`
169
+ )
170
+ }
137
171
  }
138
172
 
139
173
  /**
@@ -194,7 +228,7 @@ export async function check() {
194
228
  return reporter.getExitCode()
195
229
  }
196
230
 
197
- const { hasBunSqlImport, perRequest, unsafeCall, dynamicList, inListGuard, pgLeftover } =
231
+ const { hasBunSqlImport, perRequest, unsafeCall, dynamicList, inListGuard, pgLeftover, pgFormatShim, queryWrapper } =
198
232
  await scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter)
199
233
 
200
234
  if (!hasBunSqlImport) {
@@ -220,6 +254,12 @@ export async function check() {
220
254
  if (inListGuard === 0) {
221
255
  pass('js-bun-db: усі IN-списки винесені у змінні та мають перевірку на пустоту з throw')
222
256
  }
257
+ if (pgFormatShim === 0) {
258
+ pass('js-bun-db: немає pg-format-сумісних шимів (format/quoteLiteral/quoteIdent/...) у файлах з Bun SQL')
259
+ }
260
+ if (queryWrapper === 0) {
261
+ pass('js-bun-db: немає query(text, params)-обгорток над unsafe(...) у файлах з Bun SQL')
262
+ }
223
263
 
224
264
  return reporter.getExitCode()
225
265
  }
@@ -25,6 +25,7 @@ import {
25
25
  offsetToLine,
26
26
  parseProgramAndCommentsOrNull,
27
27
  parseProgramOrNull,
28
+ templateQuasisText,
28
29
  walkAstWithAncestors
29
30
  } from './ast-scan-utils.mjs'
30
31
 
@@ -43,6 +44,18 @@ const ALLOW_PG_LEFTOVER_MARKER_RE = /\ballow-pg-leftover\s*:\s*\S+/u
43
44
  // формально існують і там, тому опт-аут маркером лишається доречним.
44
45
  const PG_LEFTOVER_METHOD_NAMES = new Set(['connect', 'end'])
45
46
 
47
+ // pg-format placeholders — `%L` (literal), `%I` (identifier), `%s` (raw string).
48
+ // Якщо у тілі функції з підозрілим іменем зустрічається такий літерал/regex —
49
+ // це pg-format-сумісний шим (drop-in замінник pg-format поверх Bun SQL).
50
+ const PG_FORMAT_PLACEHOLDER_RE = /%[LIs]/u
51
+ // Імена функцій-кандидатів на pg-format-шим. Спрацьовує лише у поєднанні
52
+ // з наявністю `%L` / `%I` / `%s` у тілі — щоб не плутати з невинним `format(date)`.
53
+ const PG_FORMAT_SHIM_FUNC_NAMES = new Set(['format', 'pgFormat', 'sqlFormat', 'pgFmt'])
54
+ // Імена quote/escape-хелперів — самі по собі сильний сигнал pg-format-шиму,
55
+ // без додаткової перевірки тіла. Це pg-format-специфічні API, нерідко публікуються
56
+ // як named export з модуля-обгортки.
57
+ const QUOTE_HELPER_NAMES = new Set(['quoteLiteral', 'quoteIdent', 'escapeLiteral', 'escapeIdent'])
58
+
46
59
  /**
47
60
  * @param {unknown} node AST node
48
61
  * @param {string} name імʼя змінної
@@ -267,6 +280,185 @@ function asPgLeftoverCall(node) {
267
280
  return { name: /** @type {'connect' | 'end'} */ (prop.name) }
268
281
  }
269
282
 
283
+ /**
284
+ * Чи це CallExpression `<obj>.unsafe(...)` (для пошуку в тілі query-шиму).
285
+ * Дублює `isUnsafeCall` з основного скану, але локально — щоб не залежати
286
+ * від порядку оголошень у файлі.
287
+ * @param {unknown} node AST node
288
+ * @returns {boolean} true для `<obj>.unsafe(...)`
289
+ */
290
+ function isUnsafeCallNode(node) {
291
+ if (!node || node.type !== 'CallExpression') return false
292
+ const callee = node.callee
293
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
294
+ const prop = callee.property
295
+ return !!prop && prop.type === 'Identifier' && prop.name === 'unsafe'
296
+ }
297
+
298
+ /**
299
+ * Чи містить піддерево вузла рядковий або regex-літерал з `%L` / `%I` / `%s`.
300
+ * Покриває:
301
+ * - `Literal` зі строковим `value`,
302
+ * - `StringLiteral` (oxc),
303
+ * - `TemplateLiteral` (через текст quasis),
304
+ * - `RegExpLiteral` / `Literal` з `regex.pattern`.
305
+ * @param {unknown} root корінь піддерева (зазвичай тіло функції)
306
+ * @returns {boolean} true, якщо знайдено pg-format-плейсхолдер
307
+ */
308
+ function nodeContainsPgFormatPlaceholder(root) {
309
+ let found = false
310
+ walkAstWithAncestors(root, [], n => {
311
+ if (found) return
312
+ const t = n.type
313
+ if (t === 'Literal' || t === 'StringLiteral') {
314
+ if (typeof n.value === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(n.value)) {
315
+ found = true
316
+ return
317
+ }
318
+ const regex = n.regex
319
+ if (regex && typeof regex.pattern === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(regex.pattern)) {
320
+ found = true
321
+ return
322
+ }
323
+ }
324
+ if (t === 'RegExpLiteral' && typeof n.pattern === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(n.pattern)) {
325
+ found = true
326
+ return
327
+ }
328
+ if (t === 'TemplateLiteral') {
329
+ if (PG_FORMAT_PLACEHOLDER_RE.test(templateQuasisText(n))) {
330
+ found = true
331
+ }
332
+ }
333
+ })
334
+ return found
335
+ }
336
+
337
+ /**
338
+ * Витягає (name, body) з вузла, що оголошує функцію верхнього рівня:
339
+ * - `function format(...) {...}`,
340
+ * - `const format = (...) => {...}` / `= function(...) {...}`.
341
+ * @param {Record<string, unknown>} node AST node
342
+ * @returns {{ name: string, body: unknown } | null} ім'я та тіло, або null
343
+ */
344
+ function asNamedFunctionDecl(node) {
345
+ if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier') {
346
+ return { name: node.id.name, body: node.body }
347
+ }
348
+ if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') {
349
+ const init = node.init
350
+ if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
351
+ return { name: node.id.name, body: init.body }
352
+ }
353
+ }
354
+ return null
355
+ }
356
+
357
+ /**
358
+ * Знаходить визначення pg-format-сумісних шимів у джерелі. Прапорує:
359
+ * - функції з іменами `format` / `pgFormat` / `sqlFormat` / `pgFmt`, у тілі яких
360
+ * зустрічається літерал/regex з `%L` / `%I` / `%s` — це drop-in pg-format;
361
+ * - функції з іменами `quoteLiteral` / `quoteIdent` / `escapeLiteral` / `escapeIdent`
362
+ * незалежно від тіла — це pg-format-специфічні API, не потрібні з Bun SQL.
363
+ *
364
+ * Скан запускається лише в файлах, де є `import { sql|SQL } from 'bun'`, щоб
365
+ * не плутати, наприклад, форматер дат чи URL-escape з SQL-шимом.
366
+ * @param {string} content вихідний код
367
+ * @param {string} [virtualPath] шлях для вибору `lang`
368
+ * @returns {{ line: number, snippet: string, kind: 'format_function' | 'quote_helper', name: string }[]} список порушень
369
+ */
370
+ export function findPgFormatShimDefinitionInText(content, virtualPath = 'scan.ts') {
371
+ if (!textHasBunSqlImport(content)) return []
372
+ const program = parseProgramOrNull(content, virtualPath)
373
+ if (!program) return []
374
+
375
+ /** @type {{ line: number, snippet: string, kind: 'format_function' | 'quote_helper', name: string }[]} */
376
+ const out = []
377
+ walkAstWithAncestors(program, [], node => {
378
+ const decl = asNamedFunctionDecl(node)
379
+ if (!decl) return
380
+ /** @type {'format_function' | 'quote_helper' | null} */
381
+ let kind = null
382
+ if (QUOTE_HELPER_NAMES.has(decl.name)) {
383
+ kind = 'quote_helper'
384
+ } else if (PG_FORMAT_SHIM_FUNC_NAMES.has(decl.name) && nodeContainsPgFormatPlaceholder(decl.body)) {
385
+ kind = 'format_function'
386
+ }
387
+ if (!kind) return
388
+ out.push({
389
+ line: offsetToLine(content, node.start),
390
+ snippet: normalizeSnippet(content.slice(node.start, Math.min(node.end, node.start + 240))),
391
+ kind,
392
+ name: decl.name
393
+ })
394
+ })
395
+ return out
396
+ }
397
+
398
+ /**
399
+ * Знаходить pg-сумісні query-обгортки виду
400
+ * `{ query(text, params) { return <sql>.unsafe(text, params) } }`
401
+ * у файлах, що імпортують Bun SQL. Така обгортка маскує `unsafe` під
402
+ * «безпечним» ім'ям і повертає injection-поверхню в код.
403
+ *
404
+ * Спрацьовує, коли всі умови виконані:
405
+ * - вузол — `Property` з `key.name === 'query'` всередині `ObjectExpression`;
406
+ * - значення — функція з 1–2 параметрами, перший — Identifier з типовим
407
+ * pg-іменем (`text` / `sql` / `query`);
408
+ * - у тілі функції є виклик `<obj>.unsafe(...)`.
409
+ *
410
+ * @param {string} content вихідний код
411
+ * @param {string} [virtualPath] шлях для вибору `lang`
412
+ * @returns {{ line: number, snippet: string }[]} список порушень
413
+ */
414
+ export function findPgFormatLikeQueryWrapperInText(content, virtualPath = 'scan.ts') {
415
+ if (!textHasBunSqlImport(content)) return []
416
+ const program = parseProgramOrNull(content, virtualPath)
417
+ if (!program) return []
418
+
419
+ /** @type {{ line: number, snippet: string }[]} */
420
+ const out = []
421
+ walkAstWithAncestors(program, [], node => {
422
+ if (node.type !== 'ObjectExpression') return
423
+ const properties = node.properties
424
+ if (!Array.isArray(properties)) return
425
+ for (const prop of properties) {
426
+ if (!prop || prop.type !== 'Property') continue
427
+ const key = prop.key
428
+ const keyName =
429
+ key && key.type === 'Identifier' ? key.name : key && key.type === 'Literal' ? key.value : null
430
+ if (keyName !== 'query') continue
431
+ const value = prop.value
432
+ if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) continue
433
+ const params = value.params
434
+ const firstName = Array.isArray(params) && params[0]?.type === 'Identifier' ? params[0].name : null
435
+ const looksLikePgQuery =
436
+ Array.isArray(params) && params.length >= 1 && params.length <= 2 && /^(text|sql|query)$/u.test(firstName || '')
437
+ if (!looksLikePgQuery) continue
438
+ if (!nodeContainsUnsafeCall(value.body)) continue
439
+ out.push({
440
+ line: offsetToLine(content, prop.start),
441
+ snippet: normalizeSnippet(content.slice(prop.start, prop.end))
442
+ })
443
+ }
444
+ })
445
+ return out
446
+ }
447
+
448
+ /**
449
+ * Чи є у піддереві виклик `<obj>.unsafe(...)`.
450
+ * @param {unknown} root корінь піддерева
451
+ * @returns {boolean} true, якщо знайдено
452
+ */
453
+ function nodeContainsUnsafeCall(root) {
454
+ let found = false
455
+ walkAstWithAncestors(root, [], n => {
456
+ if (found) return
457
+ if (isUnsafeCallNode(n)) found = true
458
+ })
459
+ return found
460
+ }
461
+
270
462
  /**
271
463
  * Знаходить `new SQL(...)` всередині функцій (handler на кожен запит замість singleton).
272
464
  * @param {string} content вихідний код