@nitra/cursor 1.13.54 → 1.13.58

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,30 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.13.58] - 2026-05-20
8
+
9
+ ### Added
10
+
11
+ - `check security`: новий concern **`security.sample_secret`** — placeholder фейкових credential-значень у прикладних файлах має бути `sample-secret`, а не bare `secret`. Причина: `sample-secret` містить підрядок `sample` із вшитого списку `DefaultFalsePositives` TruffleHog і відсіюється сканером гарантовано та незалежно від версії; bare `secret` наразі ігнорується лише тому, що випадково присутнє у словнику `fp_words.txt` — крихка, версієзалежна поведінка. [check.mjs](rules/security/fix/sample_secret/check.mjs) обходить дерево, відбирає прикладні файли (basename із суфіксом `.example`/`.sample`/`.template`/`.dist` чи infix `.example.`/`.sample.`/`.template.`, а також усе всередині каталогів `fixtures`/`fixture`/`__fixtures__`) і порядково шукає `secret` у позиції значення — одразу після `=`, `:` або `=>` з опційними лапками; імена ключів (`client_secret`, `JWT_SECRET`) не чіпаються, бо матч прив'язаний до значення. Решта файлів не сканується — там `secret` майже завжди частина реального коду. Скан текстовий (regex, не AST/Rego): прикладні файли — різнорідні конфіги (`.env`, YAML, JSON, TOML, plain `.dist`) без єдиного AST, а відбір файлів потребує обходу дерева. Зачеплено: [check.mjs](rules/security/fix/sample_secret/check.mjs) і [check.test.mjs](rules/security/fix/sample_secret/check.test.mjs) (новий concern + 9 тестів), [security.mdc](rules/security/security.mdc) (нова секція «Placeholder для секретів — `sample-secret`» та секція «Перевірка»). Bump `security.mdc` `2.0` → `2.1`.
12
+
13
+ ## [1.13.57] - 2026-05-19
14
+
15
+ ### Changed
16
+
17
+ - `check js-bun-db`: новий **hard fail** на `sql.unsafe(template_literal_with_interpolation)` — будь-який виклик з template-літералом, що містить `${...}`-інтерполяцію, тепер падає **навіть з маркером** `// allow-unsafe`. Причина: шаблонна підстановка `${name}` у `sql.unsafe`-рядок не екранує identifier'ів (reserved words, спецсимволи, пробіли в імені) і не біндить значень; такий код виглядає звично через знайому tagged-template-форму, але насправді робить просту строкову конкатенацію без жодних гарантій. Канон — зібрати `text` окремо: identifiers через `@scaleleap/pg-format` `format('%I', name)`, values як позиційні `$N` + другий аргумент `sql.unsafe(text, [params])`. Раніше дозволений приклад `sql.unsafe(\\\`CREATE TABLE \\\${TABLE} (id int)\\\`)`з marker'ом тепер fail — переписати через`format('CREATE TABLE %I (id int)', TABLE)`. Не зачепило:`sql.unsafe('SELECT 1')`(статичний рядок),`sql.unsafe(\\\`SELECT 1\\\`)`(template без інтерполяції),`sql.unsafe(text, [params])`зі змінною`text`. Зачеплено: [bun-sql-scan.mjs](scripts/utils/bun-sql-scan.mjs) (новий експорт`findBunSqlUnsafeWithInterpolatedTemplateInText`, що флагає лише`obj.unsafe(TemplateLiteral)`з`expressions.length > 0`), [check.mjs](rules/js-bun-db/fix/safety/check.mjs) (новий лічильник`unsafeTemplateInterp`+ окреме повідомлення з порадою на`@scaleleap/pg-format`), [check.test.mjs](rules/js-bun-db/fix/safety/check.test.mjs) (попередній DDL-тест переписано на безпечний`format('%I', ...)`-варіант, додано **негативний** тест на template-interp + marker і **позитивний** тест на статичний template без інтерполяції), [js-bun-db.mdc](rules/js-bun-db/js-bun-db.mdc) (нова підсекція «sql.unsafe з template-літералом і ${...}-інтерполяцією — заборонено навіть з маркером» зі зразками поганого/гарного коду; основний приклад DDL у секції unsafe-allowlist переписано на`format`+ готовий`text`). Bump`js-bun-db.mdc` `1.10`→`1.11`.
18
+
19
+ ## [1.13.56] - 2026-05-19
20
+
21
+ ### Changed
22
+
23
+ - `check js-bun-db`: пакет **`pg`** більше не повністю заборонений — додано виключення для **PostgreSQL LISTEN/NOTIFY**, який Bun SQL поки не реалізує. Причина: dev-теми з notifications (черги нотифікацій, інвалідація кешу через `pg_notify`, бот-консьюмери на каналі) досі мають законну потребу у клієнті `pg`, а попереднє правило flat-out забороняло це навіть у файлах, що буквально нічого не роблять, окрім виклику `client.query` з рядком `LISTEN ...` плюс listener `client.on` на події `notification`. Тепер `dependencies.pg` дозволено, **якщо** AST-сканер знаходить у проєкті хоч один сигнал LISTEN/NOTIFY: метод `query` / `queryArray` / `queryStream` зі string- або template-літералом, що починається з `LISTEN`, `UNLISTEN` або `NOTIFY` (case-insensitive), або метод `on` із першим аргументом-рядком `notification`, або tagged template з тегом `sql` і першим quasi, що починається з тих самих ключових слів. Якщо жодного — `fail` з посиланням на нову секцію .mdc. Додатково — **per-file**: будь-який файл з `import 'pg'` (або `require('pg')`) повинен сам містити LISTEN/NOTIFY; звичайні `SELECT`/`INSERT`/`UPDATE` через `pg` лишаються забороненими (переписати на Bun SQL і лишити LISTEN/NOTIFY в окремому модулі). Заборона `pg-format` і `mysql2` не змінилася. Зачеплено: [bun-sql-scan.mjs](scripts/utils/bun-sql-scan.mjs) (нові експорти `textHasPgLibImport`, `findPgLibImportInText`, `findPgListenNotifyUsageInText` + AST-хелпери для розпізнавання pg-style LISTEN/NOTIFY-запитів і `notification`-listener'ів), [check.mjs](rules/js-bun-db/fix/safety/check.mjs) (нова функція `checkPgDependencyAndUsage`, що пробігає по всіх `package.json` і per-file pg-imports; перевірку `pg` повністю переведено з Rego в JS, бо Rego не бачить JS-коду), [package.json.deny.json](rules/js-bun-db/policy/package_json/template/package.json.deny.json) (прибрано `pg`, лишилися `pg-format`/`mysql2`), [package_json_test.rego](rules/js-bun-db/policy/package_json/package_json_test.rego) (`test_deny_pg` → `test_allow_pg_in_dependencies` + новий `test_deny_pg_format`), [check.test.mjs](rules/js-bun-db/fix/safety/check.test.mjs) (5 нових сценаріїв: успіх з LISTEN, успіх з notification-listener, помилка `pg` без LISTEN/NOTIFY, помилка змішаних файлів — один із LISTEN, інший зі звичайними запитами, успіх з `NOTIFY` як виправдання). `.mdc` отримало нову секцію «pg: виключення для LISTEN/NOTIFY» з прикладом окремого `pg-listen.ts`-модуля і явним переліком сигналів, які зважує сканер. Bump `js-bun-db.mdc` `1.9` → `1.10`.
24
+
25
+ ## [1.13.55] - 2026-05-19
26
+
27
+ ### Changed
28
+
29
+ - `check js-bun-db`: правило [js-bun-db.mdc](rules/js-bun-db/js-bun-db.mdc) **пом'якшено** для випадків, де Bun SQL принципово не може допомогти — **динамічних SQL identifiers** (назви schema/table/column/index/role/database) і whitelist-фрагментів типу `ASC`/`DESC`. Раніше для них рекомендувалось будувати рядок шаблонною підстановкою у `sql.unsafe`, але інтерполяція identifier'у в template literal не робить escape (reserved words, спецсимволи) — це слабкий захист. Тепер канон — окремий пакет **`@scaleleap/pg-format`** (scoped форк, не unscoped `pg-format`): виклик типу `format('SELECT * FROM %I', name)` повертає коректно екранований PostgreSQL identifier, далі рядок іде у `sql.unsafe(query, [bindParams])` з обов'язковим маркером `// allow-unsafe: <причина>`. Значення (user input, фільтри, INSERT/UPDATE) — **завжди** через Bun parameters (tagged template або `$N` + `sql.unsafe(text, values)`); `%L` для значень лишається забороненим, як і власні шими `format`/`pgFormat`/`quoteIdent` тощо. Unscoped `pg-format` лишається у [deny-списку](rules/js-bun-db/policy/package_json/template/package.json.deny.json) — виключення стосується **тільки** scoped `@scaleleap/pg-format`. Зачеплено: вступ секції «Заміна на Bun native SQL» (зафіксовано виключення), рядок таблиці ідіом для `%I` (тепер через `@scaleleap/pg-format`, не через `sql.unsafe` з шаблонним рядком), нова секція «Динамічна SQL-структура: @scaleleap/pg-format для identifiers» з прикладами (динамічний `ORDER BY` зі whitelist, multi-row `INSERT` через `VALUES %L`, dynamic `WHERE` через ручні `$N`) і коротка таблиця рішень. AST-сканер [bun-sql-scan.mjs](scripts/utils/bun-sql-scan.mjs) не зачеплений — він знаходить лише **визначення** функцій-шимів (`format` з `%L`/`%I`/`%s` у тілі), а імпорт `format` із `@scaleleap/pg-format` як зовнішня бібліотека не флагається. Bump `js-bun-db.mdc` `1.8` → `1.9`.
30
+
7
31
  ## [1.13.54] - 2026-05-19
8
32
 
9
33
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.13.54",
3
+ "version": "1.13.58",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -53,8 +53,8 @@ async function loadNCursorRules() {
53
53
  */
54
54
  function lintChainHasScript(lintScript, target) {
55
55
  if (!lintScript) return false
56
- const escaped = target.replaceAll(/[.*+?^${}()|[\]\\]/gu, '\\$&')
57
- return new RegExp(`(?:^|\\s)bun\\s+run\\s+${escaped}(?:$|\\s)`, 'u').test(lintScript)
56
+ const escaped = target.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)
57
+ return new RegExp(String.raw`(?:^|\s)bun\s+run\s+${escaped}(?:$|\s)`, 'u').test(lintScript)
58
58
  }
59
59
 
60
60
  /**
@@ -74,6 +74,17 @@ const RULE_SCRIPTS = [
74
74
  { rules: ['image-avif', 'image-compress'], script: 'lint-image', doc: 'image-avif.mdc / image-compress.mdc' }
75
75
  ]
76
76
 
77
+ /**
78
+ * Загортає кожен ідентифікатор у backticks та зʼєднує через роздільник. Винесено
79
+ * окремою функцією, щоб не нестити template literals у `pass`/`fail`-повідомленнях.
80
+ * @param {string[]} items ідентифікатори правил
81
+ * @param {string} sep роздільник (наприклад `, ` або `/`)
82
+ * @returns {string} рядок виду "`a`, `b`"
83
+ */
84
+ function backtickJoin(items, sep) {
85
+ return items.map(r => '`' + r + '`').join(sep)
86
+ }
87
+
77
88
  /**
78
89
  * Описує стан правил-власників скрипта для повідомлень про reason. Повертає або список увімкнених
79
90
  * правил (для passing-кейсу «правило є»), або компактний опис, чому всі вимкнені (для inverse-fail).
@@ -84,7 +95,7 @@ const RULE_SCRIPTS = [
84
95
  function ownerStatus(owners, cursorRules) {
85
96
  const enabled = owners.filter(r => cursorRules.rules.has(r))
86
97
  if (enabled.length > 0) {
87
- return { enabled, reason: `правил${enabled.length === 1 ? 'о' : 'а'} ${enabled.map(r => `\`${r}\``).join(', ')}` }
98
+ return { enabled, reason: `правил${enabled.length === 1 ? 'о' : 'а'} ${backtickJoin(enabled, ', ')}` }
88
99
  }
89
100
  if (owners.length === 1) {
90
101
  const [only] = owners
@@ -93,7 +104,7 @@ function ownerStatus(owners, cursorRules) {
93
104
  }
94
105
  const disabledCount = owners.filter(r => cursorRules.disabled.has(r)).length
95
106
  const note = disabledCount === owners.length ? 'усі власники в disable-rules' : 'жоден власник не активний у rules'
96
- return { enabled, reason: `${owners.map(r => `\`${r}\``).join('/')} — ${note}` }
107
+ return { enabled, reason: `${backtickJoin(owners, '/')} — ${note}` }
97
108
  }
98
109
 
99
110
  /**
@@ -104,7 +115,7 @@ function ownerStatus(owners, cursorRules) {
104
115
  * вимкненому правилі: `n-cursor lint-<id>` ігнорує `.n-cursor.json` і обходить дерево
105
116
  * незалежно від конфігу (як було в cursor-репо: `disable-rules: ["k8s"]` + залишений `lint-k8s`
106
117
  * ламав chain на template-сорцях власного правила).
107
- * @param {{ pass: (msg: string) => void, fail: (msg: string) => void }} reporter
118
+ * @param {{ pass: (msg: string) => void, fail: (msg: string) => void }} reporter callback-и `pass`/`fail` для звіту
108
119
  * @param {Record<string, string>} scripts scripts з package.json
109
120
  * @param {{ rules: Set<string>, disabled: Set<string> }} cursorRules `rules` та `disable-rules`
110
121
  */
@@ -127,7 +138,7 @@ function checkCursorRuleScripts(reporter, scripts, cursorRules) {
127
138
  }
128
139
  if (present) {
129
140
  fail(
130
- `У .n-cursor.json немає активних власників ${owners.map(r => `\`${r}\``).join('/')} — прибери скрипт \`${script}\` з кореневого package.json (див. ${doc})`
141
+ `У .n-cursor.json немає активних власників ${backtickJoin(owners, '/')} — прибери скрипт \`${script}\` з кореневого package.json (див. ${doc})`
131
142
  )
132
143
  }
133
144
  if (inChain) {
@@ -2,12 +2,20 @@
2
2
  * Перевіряє правило js-bun-db.mdc.
3
3
  *
4
4
  * 1) У жодному `package.json` (включно з workspace-пакетами) у `dependencies` не повинно
5
- * бути `pg`, `pg-format` чи `mysql2` — ці бібліотеки треба замінити на Bun native SQL
6
- * (`import { sql, SQL } from 'bun'`, https://bun.com/docs/runtime/sql).
7
- * `pg-format` — ручне форматування SQL через escape; tagged template Bun SQL
8
- * параметризує значення нативно і не лишає простору для injection.
5
+ * бути `pg-format` чи `mysql2` — їх треба замінити на Bun native SQL
6
+ * (`import { sql, SQL } from 'bun'`, https://bun.com/docs/runtime/sql). `pg-format` —
7
+ * ручне форматування SQL через escape; tagged template Bun SQL параметризує значення
8
+ * нативно і не лишає простору для injection. Перевірка цих двох — у Rego-полісі
9
+ * `npm/policy/js_bun_db/package_json/`.
9
10
  *
10
- * 2) Якщо в коді використовується Bun SQL (імпорт `sql`/`SQL` з `'bun'`), додатково
11
+ * 2) Для `pg` діє виключення: Bun SQL поки не реалізує LISTEN/NOTIFY, тож якщо у
12
+ * проекті знайдено реальне використання `LISTEN ...` / `NOTIFY ...` / `UNLISTEN ...`
13
+ * або listener'а `.on('notification', ...)`, dependency `pg` дозволено. Інакше
14
+ * `pg` лишається забороненим — fail з підказкою про виключення. Додатково — per-file:
15
+ * кожен файл з `import ... from 'pg'` повинен сам містити LISTEN/NOTIFY-патерн;
16
+ * звичайні SELECT/INSERT/UPDATE через `pg` (replace на Bun SQL!) не дозволені.
17
+ *
18
+ * 3) Якщо в коді використовується Bun SQL (імпорт `sql`/`SQL` з `'bun'`), додатково
11
19
  * перевіряє небезпечні патерни:
12
20
  * - `new SQL(...)` всередині функції (пул має бути singleton на рівні модуля).
13
21
  * - Будь-який `<obj>.unsafe(...)` без маркера-коментаря `// allow-unsafe: <reason>`
@@ -30,17 +38,27 @@ import {
30
38
  findBunSqlPerRequestConnectionInText,
31
39
  findBunSqlPgLeftoverCallInText,
32
40
  findBunSqlUnsafeUseWithoutAllowMarkerInText,
41
+ findBunSqlUnsafeWithInterpolatedTemplateInText,
33
42
  findPgFormatLikeQueryWrapperInText,
34
43
  findPgFormatShimDefinitionInText,
44
+ findPgLibImportInText,
45
+ findPgListenNotifyUsageInText,
35
46
  findUnsafeBunSqlDynamicSqlListInText,
36
47
  findUnsafeBunSqlInListMissingEmptyGuardInText,
37
48
  isBunSqlScanSourceFile,
38
- textHasBunSqlImport
49
+ textHasBunSqlImport,
50
+ textHasPgLibImport
39
51
  } from '../../../../scripts/utils/bun-sql-scan.mjs'
40
52
  import { findAllPackageJsonPaths } from '../../../../scripts/utils/find-package-json-paths.mjs'
41
53
  import { loadCursorIgnorePaths } from '../../../../scripts/utils/load-cursor-config.mjs'
42
54
  import { walkDir } from '../../../../scripts/utils/walkDir.mjs'
43
55
 
56
+ // Дешеві pre-filter regex'и для AST-сканера LISTEN/NOTIFY: уникаємо парсингу
57
+ // файлів, у яких ніяких сигналів немає. Винесено в модульний скоуп, щоб не
58
+ // перекомпілювати RegExp на кожному виклику `collectPgUsageForFile`.
59
+ const LISTEN_NOTIFY_KEYWORD_RE = /\b(LISTEN|UNLISTEN|NOTIFY)\b/iu
60
+ const NOTIFICATION_LITERAL_RE = /['"`]notification['"`]/u
61
+
44
62
  /**
45
63
  * Збирає абсолютні шляхи JS/TS джерел у репозиторії для скану Bun SQL патернів.
46
64
  * @param {string} repoRoot абсолютний шлях до кореня репозиторію
@@ -65,19 +83,32 @@ async function findAllSourcePathsForBunSqlScan(repoRoot, ignorePaths) {
65
83
  }
66
84
 
67
85
  /**
68
- * Сканує JS/TS-джерела на небезпечні патерни Bun SQL.
86
+ * Сканує JS/TS-джерела на небезпечні патерни Bun SQL і збирає метадані про
87
+ * використання `pg`/LISTEN-NOTIFY (для виключення dependency `pg`).
69
88
  * @param {string[]} sourcePaths абсолютні шляхи джерел
70
89
  * @param {string} repoRoot абсолютний шлях до кореня
71
90
  * @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter колбеки pass і fail з перевірки
72
- * @returns {Promise<{ hasBunSqlImport: boolean, perRequest: number, unsafeCall: number, dynamicList: number, inListGuard: number, pgLeftover: number, pgFormatShim: number, queryWrapper: number }>}
73
- * `hasBunSqlImport` — чи знайдено хоч один `import { sql|SQL } from 'bun'` у джерелах;
74
- * решта — кількість порушень кожного типу.
91
+ * @returns {Promise<{
92
+ * hasBunSqlImport: boolean,
93
+ * perRequest: number,
94
+ * unsafeCall: number,
95
+ * dynamicList: number,
96
+ * inListGuard: number,
97
+ * pgLeftover: number,
98
+ * pgFormatShim: number,
99
+ * queryWrapper: number,
100
+ * pgUsage: { rel: string, imports: { line: number, snippet: string }[], listenNotify: { line: number, snippet: string, kind: string }[] }[]
101
+ * }>}
102
+ * `hasBunSqlImport` — чи є хоч один `import { sql|SQL } from 'bun'`;
103
+ * `pgUsage` — список файлів, що або імпортують `'pg'`, або містять LISTEN/NOTIFY-патерн
104
+ * (інші — пропущено, щоб не тримати в пам'яті метадані про всі файли).
75
105
  */
76
106
  async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
77
107
  const { fail } = reporter
78
108
  const counts = {
79
109
  perRequest: 0,
80
110
  unsafeCall: 0,
111
+ unsafeTemplateInterp: 0,
81
112
  dynamicList: 0,
82
113
  inListGuard: 0,
83
114
  pgLeftover: 0,
@@ -85,6 +116,8 @@ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
85
116
  queryWrapper: 0
86
117
  }
87
118
  let hasBunSqlImport = false
119
+ /** @type {{ rel: string, imports: { line: number, snippet: string }[], listenNotify: { line: number, snippet: string, kind: string }[] }[]} */
120
+ const pgUsage = []
88
121
 
89
122
  for (const absPath of sourcePaths) {
90
123
  const rel = relative(repoRoot, absPath).split('\\').join('/')
@@ -93,9 +126,30 @@ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
93
126
  hasBunSqlImport = true
94
127
  }
95
128
  scanFileForBunSqlPatterns(content, rel, fail, counts)
129
+ collectPgUsageForFile(content, rel, pgUsage)
96
130
  }
97
131
 
98
- return { hasBunSqlImport, ...counts }
132
+ return { hasBunSqlImport, pgUsage, ...counts }
133
+ }
134
+
135
+ /**
136
+ * Якщо у файлі є імпорт `'pg'` АБО LISTEN/NOTIFY-патерн — додає запис у `pgUsage`.
137
+ * Файли без жодного сигналу не зберігаються, щоб уникнути зайвої пам'яті.
138
+ * @param {string} content вміст файлу
139
+ * @param {string} rel posix-шлях відносно кореня
140
+ * @param {{ rel: string, imports: { line: number, snippet: string }[], listenNotify: { line: number, snippet: string, kind: string }[] }[]} pgUsage акумулятор
141
+ * @returns {void}
142
+ */
143
+ function collectPgUsageForFile(content, rel, pgUsage) {
144
+ // Дешевий pre-filter за текстом: AST-парсинг тільки коли файл містить
145
+ // або імпорт `'pg'`, або хоча б одне зі слів LISTEN / NOTIFY / UNLISTEN /
146
+ // 'notification' — інакше LISTEN/NOTIFY у ньому точно немає.
147
+ const mayHaveListenNotify = LISTEN_NOTIFY_KEYWORD_RE.test(content) || NOTIFICATION_LITERAL_RE.test(content)
148
+ if (!textHasPgLibImport(content) && !mayHaveListenNotify) return
149
+ const imports = findPgLibImportInText(content, rel)
150
+ const listenNotify = findPgListenNotifyUsageInText(content, rel)
151
+ if (imports.length === 0 && listenNotify.length === 0) return
152
+ pgUsage.push({ rel, imports, listenNotify })
99
153
  }
100
154
 
101
155
  /**
@@ -124,6 +178,16 @@ function scanFileForBunSqlPatterns(content, rel, fail, counts) {
124
178
  `(js-bun-db.mdc): ${v.snippet}`
125
179
  )
126
180
  }
181
+ for (const v of findBunSqlUnsafeWithInterpolatedTemplateInText(content, rel)) {
182
+ counts.unsafeTemplateInterp++
183
+ fail(
184
+ `js-bun-db: ${rel}:${v.line} — sql.unsafe(\`...\${x}...\`) з template-літералом і \${...}-інтерполяцією ` +
185
+ `заборонено навіть з allow-unsafe маркером: шаблонна підстановка identifier'у не екранує (reserved words, ` +
186
+ `спецсимволи), а значення не біндяться. Збери text через @scaleleap/pg-format format('%I', name) для ` +
187
+ `identifiers або позиційні $N для values, потім sql.unsafe(text, [params]). Деталі — секція ` +
188
+ `«Динамічна SQL-структура» в js-bun-db.mdc: ${v.snippet}`
189
+ )
190
+ }
127
191
  for (const v of findBunSqlPgLeftoverCallInText(content, rel)) {
128
192
  counts.pgLeftover++
129
193
  fail(
@@ -170,6 +234,71 @@ function scanFileForBunSqlPatterns(content, rel, fail, counts) {
170
234
  }
171
235
  }
172
236
 
237
+ /**
238
+ * Перевіряє виключення `pg` для LISTEN/NOTIFY: по кожному `package.json` з
239
+ * `dependencies.pg` — чи є у проекті хоч одне використання LISTEN/NOTIFY-патерну;
240
+ * додатково — кожен файл з `import 'pg'` повинен сам містити LISTEN/NOTIFY (інакше
241
+ * звичайні SELECT/INSERT/UPDATE через `pg` ховаються за легітимним dependency).
242
+ * @param {string[]} pkgJsonPaths абсолютні шляхи до всіх package.json
243
+ * @param {string} repoRoot абсолютний шлях до кореня
244
+ * @param {{ rel: string, imports: { line: number, snippet: string }[], listenNotify: { line: number, snippet: string, kind: string }[] }[]} pgUsage метадані з scanSourcesForBunSqlPatterns
245
+ * @param {{ fail: (m: string) => void }} reporter колбек fail для повідомлень
246
+ * @returns {Promise<{ pgDepFails: number, pgImportFails: number, pgDepsFound: number, hasAnyListenNotify: boolean, listenNotifyEvidence: string | null }>}
247
+ * counters і метадані для підсумкового `pass`-повідомлення (де саме знайдено перший LISTEN/NOTIFY).
248
+ */
249
+ async function checkPgDependencyAndUsage(pkgJsonPaths, repoRoot, pgUsage, reporter) {
250
+ const { fail } = reporter
251
+ let pgDepFails = 0
252
+ let pgImportFails = 0
253
+ let pgDepsFound = 0
254
+
255
+ const firstWithListenNotify = pgUsage.find(u => u.listenNotify.length > 0)
256
+ const hasAnyListenNotify = !!firstWithListenNotify
257
+ const listenNotifyEvidence = firstWithListenNotify
258
+ ? `${firstWithListenNotify.rel}:${firstWithListenNotify.listenNotify[0].line}`
259
+ : null
260
+
261
+ for (const absPkgPath of pkgJsonPaths) {
262
+ const relPkg = relative(repoRoot, absPkgPath).split('\\').join('/')
263
+ let pkg
264
+ try {
265
+ pkg = JSON.parse(await readFile(absPkgPath, 'utf8'))
266
+ } catch {
267
+ // невалідний JSON у package.json — це проблема інших правил, тут пропускаємо
268
+ continue
269
+ }
270
+ if (!pkg || typeof pkg !== 'object') continue
271
+ const deps = pkg.dependencies
272
+ if (!deps || typeof deps !== 'object' || !Object.hasOwn(deps, 'pg')) continue
273
+ pgDepsFound++
274
+ if (!hasAnyListenNotify) {
275
+ pgDepFails++
276
+ fail(
277
+ `js-bun-db: ${relPkg}: dependencies.pg заборонено — у проекті не знайдено LISTEN / NOTIFY / UNLISTEN ` +
278
+ `(або listener'а .on('notification', ...)). Bun SQL покриває звичайні запити; ` +
279
+ `\`pg\` дозволений лише як виняток для LISTEN/NOTIFY (js-bun-db.mdc, ` +
280
+ `секція «pg для LISTEN/NOTIFY»)`
281
+ )
282
+ }
283
+ }
284
+
285
+ for (const f of pgUsage) {
286
+ if (f.imports.length === 0) continue
287
+ if (f.listenNotify.length > 0) continue
288
+ for (const imp of f.imports) {
289
+ pgImportFails++
290
+ fail(
291
+ `js-bun-db: ${f.rel}:${imp.line} — import 'pg' дозволено лише у файлах з LISTEN / NOTIFY / UNLISTEN ` +
292
+ `або .on('notification', ...). Перенеси звичайні запити на Bun SQL ` +
293
+ `(import { sql } from 'bun'), а LISTEN/NOTIFY-логіку лиши в окремому модулі ` +
294
+ `(js-bun-db.mdc): ${imp.snippet}`
295
+ )
296
+ }
297
+ }
298
+
299
+ return { pgDepFails, pgImportFails, pgDepsFound, hasAnyListenNotify, listenNotifyEvidence }
300
+ }
301
+
173
302
  /**
174
303
  * Будує повідомлення `fail` для порушення `findUnsafeBunSqlInListMissingEmptyGuardInText`
175
304
  * залежно від `reason` (різні діагностики однакового сімейства).
@@ -218,9 +347,9 @@ export async function check() {
218
347
  return reporter.getExitCode()
219
348
  }
220
349
 
221
- // Перевірку `dependencies` (заборона `pg` / `pg-format` / `mysql2`) перенесено
222
- // в Rego-полісі `npm/policy/js_bun_db/package_json/`; `npx @nitra/cursor check`
223
- // запускає її по всіх workspace-`package.json`. Тут лишився лише AST-скан коду.
350
+ // Заборону `pg-format` / `mysql2` у `dependencies` тримає Rego-поліс
351
+ // `npm/policy/js_bun_db/package_json/`. `pg` оброблено тут — як виняток для
352
+ // LISTEN/NOTIFY (Rego не бачить JS-коду, тож не може зважити сигнал).
224
353
 
225
354
  const sourcePaths = await findAllSourcePathsForBunSqlScan(repoRoot, ignorePaths)
226
355
  if (sourcePaths.length === 0) {
@@ -228,8 +357,38 @@ export async function check() {
228
357
  return reporter.getExitCode()
229
358
  }
230
359
 
231
- const { hasBunSqlImport, perRequest, unsafeCall, dynamicList, inListGuard, pgLeftover, pgFormatShim, queryWrapper } =
232
- await scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter)
360
+ const {
361
+ hasBunSqlImport,
362
+ pgUsage,
363
+ perRequest,
364
+ unsafeCall,
365
+ unsafeTemplateInterp,
366
+ dynamicList,
367
+ inListGuard,
368
+ pgLeftover,
369
+ pgFormatShim,
370
+ queryWrapper
371
+ } = await scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter)
372
+
373
+ const { pgDepFails, pgImportFails, pgDepsFound, listenNotifyEvidence } = await checkPgDependencyAndUsage(
374
+ pkgJsonPaths,
375
+ repoRoot,
376
+ pgUsage,
377
+ reporter
378
+ )
379
+ if (pgDepFails === 0) {
380
+ if (pgDepsFound === 0) {
381
+ pass('js-bun-db: dependencies.pg відсутнє у жодному package.json')
382
+ } else {
383
+ pass(
384
+ `js-bun-db: dependencies.pg виправдано LISTEN/NOTIFY у коді (виключення з js-bun-db.mdc; ` +
385
+ `доказ: ${listenNotifyEvidence})`
386
+ )
387
+ }
388
+ }
389
+ if (pgImportFails === 0) {
390
+ pass("js-bun-db: усі `import 'pg'` або відсутні, або у файлах з LISTEN/NOTIFY")
391
+ }
233
392
 
234
393
  if (!hasBunSqlImport) {
235
394
  pass("js-bun-db: Bun SQL не використовується в коді (немає import { sql|SQL } from 'bun')")
@@ -242,6 +401,12 @@ export async function check() {
242
401
  if (unsafeCall === 0) {
243
402
  pass('js-bun-db: усі sql.unsafe(...) або відсутні, або супроводжуються маркером "// allow-unsafe: <причина>"')
244
403
  }
404
+ if (unsafeTemplateInterp === 0) {
405
+ pass(
406
+ 'js-bun-db: немає sql.unsafe(template literal з інтерполяцією) ' +
407
+ '(identifiers через @scaleleap/pg-format %I, values — позиційні $N)'
408
+ )
409
+ }
245
410
  if (pgLeftover === 0) {
246
411
  pass(
247
412
  'js-bun-db: немає pg-leftover викликів .connect()/.end() у файлах з Bun SQL ' +
@@ -2,7 +2,7 @@
2
2
  description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
3
3
  globs: "**/package.json,**/src/conn/**"
4
4
  alwaysApply: false
5
- version: '1.8'
5
+ version: '1.11'
6
6
  ---
7
7
 
8
8
  ## Підтримувані версії баз даних
@@ -13,13 +13,15 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
13
13
 
14
14
  Якщо в проєкті використовуються бібліотеки `pg`, `pg-format` або `mysql2`, їх потрібно замінити на Bun native SQL: <https://bun.com/docs/runtime/sql>.
15
15
 
16
- - Видалити з `dependencies`: `pg`, `pg-pool`, `pg-native`, `pg-format`, `mysql`, `mysql2`.
16
+ - Видалити з `dependencies`: `pg-pool`, `pg-native`, `pg-format`, `mysql`, `mysql2`.
17
17
  - Видалити з коду: усі `import` / `require` цих пакетів та власні обгортки над ними.
18
18
  - Замінити на `import { sql, SQL } from 'bun'` — Bun має вбудований клієнт із пулом, prepared statements та tagged templates.
19
19
 
20
- Канон заборонених `dependencies` (`pg`, `pg-format`, `mysql2`): [package.json.deny.json](./policy/package_json/template/package.json.deny.json).
20
+ Канон заборонених `dependencies` (`pg-format`, `mysql2`): [package.json.deny.json](./policy/package_json/template/package.json.deny.json). Сам `pg` із денилисту прибрано — він має одне легітимне виключення (LISTEN/NOTIFY), яке зважує AST-сканер; деталі — `## pg: виключення для LISTEN/NOTIFY`.
21
21
 
22
- `pg-format` — це ручне форматування SQL через escape (`format('... %L ...', value)`); такі рядки легко поламати неправильним типом, locale-залежним escape або забутим `%L`. Tagged template Bun SQL параметризує значення нативно (`sql\`... ${value} ...\``) і не лишає простору для injection — окремий «форматер» не потрібен.
22
+ `pg-format` (unscoped) — це ручне форматування SQL через escape (`format('... %L ...', value)`); такі рядки легко поламати неправильним типом, locale-залежним escape або забутим `%L`. Tagged template Bun SQL параметризує значення нативно (`sql\`... ${value} ...\``) і не лишає простору для injection — окремий «форматер» **для значень** не потрібен.
23
+
24
+ Виключення є **лише** для **динамічних identifiers** (назви схем / таблиць / колонок / індексів / ролей / БД) і whitelist-фрагментів типу `ASC`/`DESC`: Bun SQL їх параметризувати не вміє, тож тут дозволено окремий пакет **`@scaleleap/pg-format`** (scoped форк, не unscoped `pg-format`) — деталі й приклади у `## Динамічна SQL-структура: @scaleleap/pg-format для identifiers`.
23
25
 
24
26
  ## `pg-format`: повне видалення, без шимів
25
27
 
@@ -40,7 +42,7 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
40
42
  | `format('INSERT ... VALUES %L', [row])` (1 рядок) | `sql\`INSERT ... VALUES (${a}, ${b}, ...)\`` |
41
43
  | `format('INSERT ... VALUES %L', rows)` (N рядків) | `sql\`INSERT INTO t ${sql(rows, 'a', 'b')}\`` або `unnest($1::T[], $2::T[]) AS t(a, b)` |
42
44
  | `format('MERGE ... USING (VALUES %L) AS d(...)')` | `sql\`MERGE ... USING (SELECT * FROM unnest(${arrA}::A[], ${arrB}::B[]) AS t(a, b)) AS d\`` |
43
- | `format('... %I ...', tableName)` (whitelist) | `sql.unsafe(\`... \${tableName} ...\`)` з маркером `// allow-unsafe: <причина>` і whitelist'ом |
45
+ | `format('... %I ...', tableName)` (whitelist) | `@scaleleap/pg-format`: `format('%I', name)` + `sql.unsafe(text, [params])` з маркером |
44
46
 
45
47
  Для multi-row `VALUES` у `MERGE` / `INSERT` з конкретними типами — паралельні масиви по колонках і `unnest($1::TYPE[], $2::TYPE[], ...) AS t(col1, col2, ...)`. Кожна колонка передається одним параметром-масивом; типи задаються кастом масиву (`::uuid[]`, `::bigint[]`, `::numeric[]`, `::text[]`, …).
46
48
 
@@ -73,6 +75,160 @@ await sql`... WHERE id = ${userId}`
73
75
 
74
76
  Виняток для шиму `format()` під час поетапної міграції допускається **тільки** в окремому commit'і з TODO-маркером і дедлайном; готовий код у main з таким шимом — `fail` правила (детектори: `pg-format shim`, `query wrapper over unsafe`).
75
77
 
78
+ ## `pg`: виключення для LISTEN/NOTIFY
79
+
80
+ Bun SQL **поки не реалізує PostgreSQL LISTEN/NOTIFY** (асинхронні нотифікації через `pg_notify` / `LISTEN <channel>`). Тому якщо проєкт справді користується LISTEN/NOTIFY, npm-пакет `pg` дозволено тримати в `dependencies` **виключно** для LISTEN/NOTIFY-клієнта. Усі інші запити (SELECT/INSERT/UPDATE/DELETE/migration) — далі через Bun SQL.
81
+
82
+ Перевірка `pg` зважує цей сигнал автоматично (тому `pg` прибрано з [denylist](./policy/package_json/template/package.json.deny.json) — Rego не бачить JS-коду, тож зважування LISTEN/NOTIFY перенесено в `check-js-bun-db`).
83
+
84
+ ### Як перевірка визначає, що LISTEN/NOTIFY у проєкті є
85
+
86
+ AST-сканер шукає будь-який із сигналів:
87
+
88
+ - `client.query('LISTEN <channel>')` / `client.query('UNLISTEN *')` / `client.query('NOTIFY <channel>, ...')` — string- або template-literal-аргумент, що починається з `LISTEN` / `UNLISTEN` / `NOTIFY` (case-insensitive, leading whitespace допускається). Також покриті `queryArray` / `queryStream`.
89
+ - `client.on('notification', handler)` — listener на pg-події `notification`.
90
+ - TaggedTemplateExpression `<tag>\`LISTEN ...\`` — на випадок, якщо хтось загорнув LISTEN у власний tagged template.
91
+
92
+ Якщо хоч один сигнал є — `dependencies.pg` зважено як виправдане; інакше — `fail` із посиланням на цю секцію.
93
+
94
+ ### Правила для файлів з `import 'pg'`
95
+
96
+ Кожен файл, який імпортує `'pg'`, повинен **сам** містити один із LISTEN/NOTIFY-сигналів. Сценарій «один файл слухає, інший виконує `SELECT * FROM users`» — теж `fail`: звичайні запити через `pg` треба переписати на Bun SQL, а LISTEN/NOTIFY-логіку лишити в окремому модулі.
97
+
98
+ ### Приклад — окремий модуль для LISTEN
99
+
100
+ ```javascript
101
+ // src/db/pg-listen.ts — єдине місце, де живе import 'pg'
102
+ import { Client } from 'pg'
103
+
104
+ const listener = new Client({ connectionString: process.env.DATABASE_URL })
105
+
106
+ // allow-pg-leftover: pg LISTEN-клієнт не керується Bun SQL пулом
107
+ await listener.connect()
108
+ await listener.query('LISTEN orders_channel')
109
+ listener.on('notification', msg => {
110
+ // обробка нотифікації
111
+ })
112
+ ```
113
+
114
+ ```javascript
115
+ // src/db/users.ts — звичайні запити, через Bun SQL
116
+ import { sql } from 'bun'
117
+
118
+ export const getUser = id => sql`SELECT * FROM users WHERE id = ${id}`
119
+ ```
120
+
121
+ `pg-listen.ts` буде дозволений завдяки `LISTEN orders_channel` і `.on('notification', ...)`; `users.ts` не має імпорту `'pg'`, тож вільно живе з Bun SQL. `client.connect()` у файлі з Bun SQL потребував би маркер `// allow-pg-leftover: ...`; у файлі, де **Bun SQL не імпортовано**, pg-leftover-сканер не спрацьовує (див. `## Прибирати pg-leftover виклики`), але маркер як коментар-причина — корисний для рев'ю.
122
+
123
+ ### Що лишається забороненим
124
+
125
+ - `import 'pg'` у файлі без LISTEN/NOTIFY — `fail` з повідомленням «перенеси на Bun SQL, лиши LISTEN в окремому модулі».
126
+ - `dependencies.pg` без жодного LISTEN/NOTIFY-сигналу у проєкті — `fail` навіть якщо `pg` нібито «потрібен історично».
127
+ - `pg-format` (unscoped) — лишається у [denylist](./policy/package_json/template/package.json.deny.json); виключення для LISTEN/NOTIFY стосується **тільки** самого `pg`.
128
+ - `pg-pool`, `pg-native`, `mysql`, `mysql2` — виключень немає, видаляти повністю.
129
+
130
+ ## Динамічна SQL-структура: `@scaleleap/pg-format` для identifiers
131
+
132
+ Bun SQL **не вміє** параметризувати назви схем, таблиць, колонок, індексів, ролей, БД — а `sql\`SELECT * FROM ${table}\`` забіндив би це як значення і зламав би синтаксис. Для **динамічних identifiers** дозволено окремий пакет:
133
+
134
+ ```bash
135
+ bun add @scaleleap/pg-format
136
+ ```
137
+
138
+ ⚠️ Це **scoped `@scaleleap/pg-format`**, а не unscoped `pg-format` (той у [deny-списку](./policy/package_json/template/package.json.deny.json)). Беремо форк `@scaleleap` **тільки** заради `%I` / `%s`-можливостей; значення все одно проходять через Bun parameters, **не** через `%L`.
139
+
140
+ ### Дозволений патерн
141
+
142
+ - **`%I`** — escape SQL identifier (schema / table / column / index / role / database).
143
+ - **`%s`** — raw fragment, **тільки** для whitelist-значень (`ASC` / `DESC`, тип JOIN'у тощо).
144
+ - Значення — позиційні параметри `$1, $2, …`, які передаються другим аргументом у `sql.unsafe(query, [bindParams])`.
145
+ - На рядку виклику `sql.unsafe(...)` обов'язковий маркер `// allow-unsafe: <причина>` (див. `## sql.unsafe(...) за замовчуванням заборонено`).
146
+
147
+ ```javascript
148
+ import format from '@scaleleap/pg-format'
149
+ import { sql } from 'bun'
150
+
151
+ const allowedColumns = new Set(['created_at', 'email', 'name'])
152
+ if (!allowedColumns.has(sortBy)) throw new Error('Invalid sort column')
153
+
154
+ const direction = sortDir === 'asc' ? 'ASC' : 'DESC'
155
+
156
+ const query = format(
157
+ 'SELECT * FROM %I.%I ORDER BY %I %s LIMIT $1',
158
+ schemaName,
159
+ tableName,
160
+ sortBy,
161
+ direction
162
+ )
163
+ // allow-unsafe: динамічні schema/table/column; значення біндяться через $N
164
+ const rows = await sql.unsafe(query, [limit])
165
+ ```
166
+
167
+ Multi-row `INSERT` через `VALUES %L` теж типовий легітимний кейс, але передавай значення колонок як паралельні масиви через `unnest(...)` Bun SQL — `format('VALUES %L', rows)` лишай тільки коли альтернатива з `unnest` неможлива:
168
+
169
+ ```javascript
170
+ const query = format(
171
+ /* sql */ `
172
+ INSERT INTO "order".delivery_status (order_id, status, changed_at)
173
+ SELECT v.order_id::uuid, v.status, v.changed_at::timestamptz
174
+ FROM (VALUES %L) AS v(order_id, status, changed_at)
175
+ `,
176
+ values
177
+ )
178
+ // allow-unsafe: multi-row VALUES для бекфілу; values формуються з валідованого input
179
+ await sql.unsafe(query)
180
+ ```
181
+
182
+ ### Заборонено й після підключення `@scaleleap/pg-format`
183
+
184
+ - **`%L` для user input** — це повернення `pg-format`-стилю. Завжди bind через Bun (`sql\`... = ${value}\``) або позиційний параметр `$N` + `sql.unsafe(query, [params])`.
185
+ - Збирати весь `WHERE` через `format(...)` з `%L` — користуйся whitelist полів і ручним складанням `$N`-placeholder'ів (приклад нижче).
186
+ - Власні функції `format` / `pgFormat` / `sqlFormat` / `pgFmt` з тілом, що містить `%L` / `%I` / `%s`, — `fail` сканера (це шим, а не імпорт з бібліотеки).
187
+ - Експортовані `quoteLiteral` / `quoteIdent` / `escapeLiteral` / `escapeIdent` — `fail` сканера (pg-format-специфічні API замість Bun parameters).
188
+
189
+ ### Dynamic `WHERE` — без `format(...)`, через whitelist + `$N`
190
+
191
+ ```javascript
192
+ const conditions = []
193
+ const values = []
194
+
195
+ if (email) {
196
+ values.push(email)
197
+ conditions.push(`email = $${values.length}`)
198
+ }
199
+ if (status) {
200
+ values.push(status)
201
+ conditions.push(`status = $${values.length}`)
202
+ }
203
+
204
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
205
+ const query = `SELECT * FROM users ${where}`
206
+ // allow-unsafe: динамічний WHERE з whitelist-полів; значення біндяться через $N
207
+ const rows = await sql.unsafe(query, values)
208
+ ```
209
+
210
+ ### Коротка таблиця рішень
211
+
212
+ | Сценарій | Що використовувати |
213
+ | --------------------------------- | ---------------------------------------------------- |
214
+ | `WHERE id = ${...}` | Bun SQL tagged template |
215
+ | `INSERT` одного рядка | Bun SQL tagged template |
216
+ | `INSERT` масиву (object/colset) | Bun SQL helper `sql(rows, 'a', 'b')` або `unnest` |
217
+ | `UPDATE field = ${value}` | Bun SQL tagged template |
218
+ | Динамічна назва schema / table | `@scaleleap/pg-format` `%I` + `sql.unsafe(q, [...])` |
219
+ | Динамічна назва колонки | `@scaleleap/pg-format` `%I` + bind |
220
+ | Динамічний `ORDER BY column` | whitelist + `%I` |
221
+ | `ASC` / `DESC`, тип JOIN'у | whitelist + `%s` |
222
+ | Динамічний `WHERE` (полів багато) | whitelist + ручні `$N` + `sql.unsafe(text, vals)` |
223
+ | Сирий migration / DDL | `sql.unsafe(text)` з `// allow-unsafe: <причина>` |
224
+ | User input як value | **тільки** Bun parameters / `$N` bind |
225
+
226
+ Головне правило:
227
+
228
+ - **SQL values** → Bun SQL parameters (tagged template `${value}` або `$N` + `sql.unsafe(text, values)`).
229
+ - **SQL identifiers** → `@scaleleap/pg-format` `%I` (schema, table, column, index, role, database).
230
+ - **SQL fragments** (`ASC`/`DESC` тощо) → whitelist + `%s`.
231
+
76
232
  ## Підключення (singleton + env)
77
233
 
78
234
  Дефолтний експорт `sql` з `'bun'` сам читає змінні середовища (`DATABASE_URL`, `POSTGRES_URL`, `MYSQL_URL`, `PGHOST`/`PGUSER`/... та `MYSQL_HOST`/`MYSQL_USER`/...) і керує пулом — окремий `Pool` як у `pg` створювати не треба.
@@ -186,14 +342,45 @@ await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
186
342
  Кожен легітимний `sql.unsafe(...)` має супроводжуватись **маркером-коментарем** з причиною — на тому ж рядку (trailing) або на рядку безпосередньо перед викликом. Маркер — opt-in для перевірки `js-bun-db` і слід для ревʼюера:
187
343
 
188
344
  ```javascript
189
- // allow-unsafe: DDL — назву таблиці параметризувати не можна
190
- await sql.unsafe(`CREATE TABLE ${tableName} (id int)`)
345
+ import format from '@scaleleap/pg-format'
346
+
347
+ const query = format('CREATE TABLE %I (id int)', tableName)
348
+ // allow-unsafe: DDL — назву таблиці параметризувати не можна; ідентифікатор екранує pg-format
349
+ await sql.unsafe(query)
191
350
 
192
351
  await sql.unsafe('SELECT pg_advisory_lock($1)', [lockId]) // allow-unsafe: pg_advisory_lock — окремий шлях, без tagged template
193
352
  ```
194
353
 
195
354
  Формат маркера: `allow-unsafe: <непорожня причина>` у line- або block-коментарі. Без причини (`// allow-unsafe:`) і без маркера взагалі — **fail** перевірки.
196
355
 
356
+ ### `sql.unsafe` з template-літералом і `${...}`-інтерполяцією — заборонено навіть з маркером
357
+
358
+ `sql.unsafe(\`...\${x}...\`)` — окремий **hard fail**, який не знімається маркером `// allow-unsafe`. Шаблонна підстановка`${x}` у `sql.unsafe`-рядок:
359
+
360
+ - **не екранує** identifier'ів (reserved words, спецсимволи, пробіли в імені);
361
+ - **не біндить** значень (вони потрапляють у запит сирим текстом, як injection-вектор);
362
+ - виглядає «безпечно» через знайому tagged-template-форму, але не має жодних гарантій Bun SQL.
363
+
364
+ Канон — побудувати `text` окремо, потім передати в `sql.unsafe(text, [params])`:
365
+
366
+ - для **identifiers** — `@scaleleap/pg-format` `format('%I', name)` (екранує спецсимволи, reserved words);
367
+ - для **values** — позиційні `$1`, `$2`, … як placeholder'и в тексті + масив значень другим аргументом;
368
+ - для **fragments** з whitelist (`ASC`/`DESC`) — `format('%s', whitelistedValue)`.
369
+
370
+ ```javascript
371
+ // ❌ template-літерал з ${...} — fail навіть з allow-unsafe
372
+ // allow-unsafe: DDL
373
+ await sql.unsafe(`CREATE TABLE ${tableName} (id int)`)
374
+
375
+ // ✅ format('%I', ...) екранує identifier, sql.unsafe приймає готовий text
376
+ import format from '@scaleleap/pg-format'
377
+ const query = format('CREATE TABLE %I (id int)', tableName)
378
+ // allow-unsafe: DDL — назву таблиці параметризувати не можна
379
+ await sql.unsafe(query)
380
+ ```
381
+
382
+ Статичні `sql.unsafe(\`SELECT 1\`)` (без `${...}`) і`sql.unsafe(text, [params])` зі змінною `text`, зібраною заздалегідь, — допустимі (за наявності`// allow-unsafe`-маркера).
383
+
197
384
  ❌ Заборонені кейси (треба переробити на tagged template):
198
385
 
199
386
  ```javascript
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "dependencies": {
3
- "pg": "заміни на Bun native SQL (js-bun-db.mdc)",
4
3
  "pg-format": "заміни на Bun native SQL — без ручного форматування (js-bun-db.mdc)",
5
4
  "mysql2": "заміни на Bun native SQL (js-bun-db.mdc)"
6
5
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * FS-частина правила `security`: concern `sample_secret`.
3
+ *
4
+ * Перевіряє, що фейкові credential-значення у *прикладних* файлах записані як
5
+ * канонічний placeholder `sample-secret`, а не як bare `secret`.
6
+ *
7
+ * `sample-secret` містить підрядок `sample`, який є у вшитому списку
8
+ * `DefaultFalsePositives` TruffleHog — таке значення сканер відсіює
9
+ * гарантовано й незалежно від версії. Bare `secret` наразі не репортиться
10
+ * лише тому, що випадково присутнє у словнику `fp_words.txt`; це крихка,
11
+ * версієзалежна поведінка, на яку не варто покладатися.
12
+ *
13
+ * Прикладними вважаються файли, чий basename має суфікс `.example` / `.sample`
14
+ * / `.template` / `.dist` або infix `.example.` / `.sample.` / `.template.`, а
15
+ * також будь-які файли всередині каталогів `fixtures` / `fixture` /
16
+ * `__fixtures__`. Решта файлів не сканується — там `secret` майже завжди
17
+ * частина реального коду, а не placeholder.
18
+ *
19
+ * Порушенням є лише `secret` у *позиції значення* — одразу після `=`, `:` чи
20
+ * `=>` (з опційними лапками). Імена ключів (`client_secret`, `JWT_SECRET`) не
21
+ * чіпаються: матч прив'язаний до значення, не до ключа.
22
+ *
23
+ * Чому regex, а не AST: прикладні файли — різнорідні конфіги (`.env`, YAML,
24
+ * JSON, TOML, plain `.dist`), єдиного AST для них немає, тож скан порядковий.
25
+ * Чому JS, а не Rego: щоб знайти прикладні файли, треба обійти дерево
26
+ * (`readdir`), а вміст — неструктурований текст (conftest парсить лише
27
+ * структуровані документи).
28
+ */
29
+ import { readFile } from 'node:fs/promises'
30
+ import { relative, sep } from 'node:path'
31
+
32
+ import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mjs'
33
+ import { walkDir } from '../../../../scripts/utils/walkDir.mjs'
34
+
35
+ /** Суфікс basename'а прикладного файлу (`config.example`, `.env.dist`). */
36
+ const EXAMPLE_SUFFIX_RE = /\.(?:example|sample|template|dist)$/iu
37
+
38
+ /** Infix у basename'і (`docker-compose.example.yml`, `app.config.sample.json`). */
39
+ const EXAMPLE_INFIX_RE = /\.(?:example|sample|template)\./iu
40
+
41
+ /** Сегмент шляху з фікстурами (`fixtures/`, `fixture/`, `__fixtures__/`). */
42
+ const FIXTURE_DIR_RE = /(?:^|\/)(?:__fixtures__|fixtures?)(?:\/|$)/u
43
+
44
+ /**
45
+ * Bare-`secret` у позиції значення: після `=`, `:` або `=>` (опційні лапки), а
46
+ * далі лише пробіли / завершальна пунктуація / коментар до кінця рядка. Прив'язка
47
+ * до `$` гарантує, що `secret` — увесь токен значення (`secret-key`, `secretValue`
48
+ * не матчаться); прив'язка до `[:=]` відсікає імена ключів (`client_secret`).
49
+ * Регістронезалежно.
50
+ */
51
+ const VALUE_SECRET_RE = /[:=]>?\s*(['"]?)secret\1[\s,;}\])]*(?:(?:#|\/\/).*)?$/iu
52
+
53
+ /**
54
+ * Чи є файл «прикладним» — таким, де `secret` очікувано є placeholder'ом.
55
+ * @param {string} relPosix відносний шлях від cwd у posix-форматі
56
+ * @returns {boolean} `true`, якщо файл треба сканувати
57
+ */
58
+ function isExampleFile(relPosix) {
59
+ const base = relPosix.slice(relPosix.lastIndexOf('/') + 1)
60
+ return EXAMPLE_SUFFIX_RE.test(base) || EXAMPLE_INFIX_RE.test(base) || FIXTURE_DIR_RE.test(relPosix)
61
+ }
62
+
63
+ /**
64
+ * @returns {Promise<number>} exit-код перевірки (0 — OK, 1 — є bare `secret`)
65
+ */
66
+ export async function check() {
67
+ const reporter = createCheckReporter()
68
+ const { pass, fail } = reporter
69
+ const cwd = process.cwd()
70
+
71
+ /** @type {Array<{ abs: string, rel: string }>} */
72
+ const examples = []
73
+ await walkDir(cwd, abs => {
74
+ const rel = relative(cwd, abs).split(sep).join('/')
75
+ if (isExampleFile(rel)) examples.push({ abs, rel })
76
+ })
77
+ examples.sort((a, b) => a.rel.localeCompare(b.rel))
78
+
79
+ if (examples.length === 0) {
80
+ pass('прикладних файлів не знайдено — placeholder перевіряти нема де')
81
+ return reporter.getExitCode()
82
+ }
83
+
84
+ let violations = 0
85
+ for (const { abs, rel } of examples) {
86
+ let content
87
+ try {
88
+ content = await readFile(abs, 'utf8')
89
+ } catch {
90
+ continue
91
+ }
92
+ const lines = content.split('\n')
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const line = lines[i].endsWith('\r') ? lines[i].slice(0, -1) : lines[i]
95
+ if (!VALUE_SECRET_RE.test(line)) continue
96
+ violations++
97
+ fail(`${rel}:${i + 1}: \`${line.trim()}\` — заміни placeholder \`secret\` на \`sample-secret\` (security.mdc)`)
98
+ }
99
+ }
100
+
101
+ if (violations === 0) {
102
+ pass(`прикладні файли (${examples.length}) не містять bare \`secret\``)
103
+ }
104
+ return reporter.getExitCode()
105
+ }
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: Локальний та CI-секюріті-лінт через TruffleHog — скрипт `lint-security`, `.trufflehog-exclude`, інтеграція в агрегований `lint`
2
+ description: Локальний та CI-секюріті-лінт через TruffleHog — скрипт `lint-security`, `.trufflehog-exclude`, інтеграція в агрегований `lint`; канонічний placeholder `sample-secret` у прикладних файлах
3
3
  globs: "**/.trufflehog-exclude,**/package.json,**/.github/workflows/**/*.yml"
4
4
  alwaysApply: false
5
- version: '2.0'
5
+ version: '2.1'
6
6
  ---
7
7
 
8
8
  [TruffleHog](https://github.com/trufflesecurity/trufflehog) — глобальний CLI (як `shellcheck`, `conftest`); **не** додавай до `dependencies`/`devDependencies`.
@@ -35,3 +35,18 @@ Workflow обовʼязковий — забезпечує незалежний
35
35
  - Канон: [lint-security.yml.snippet.yml](./policy/lint_security_yml/template/lint-security.yml.snippet.yml)
36
36
 
37
37
  Перевіряється policy `security.lint_security_yml`: серед `uses:` має бути крок з `trufflesecurity/trufflehog@main`. Універсальні workflow-перевірки (checkout, permissions, persist-credentials) — у `ga.workflow_common`. Для повного скану історії потрібен `fetch-depth: 0`.
38
+
39
+ ## Placeholder для секретів — `sample-secret`
40
+
41
+ Фейкові credential-значення у **прикладних файлах** (`.env.example`, `.env.dist`, `*.example`, `*.sample`, `*.template`, вміст каталогів `fixtures/`) пиши як `sample-secret`, а не як bare `secret`.
42
+
43
+ `sample-secret` містить підрядок `sample` із вшитого списку `DefaultFalsePositives` TruffleHog — таке значення сканер відсіює **гарантовано** й незалежно від версії. Bare `secret` наразі не репортиться лише тому, що випадково присутнє у словнику `fp_words.txt`; це крихка, версієзалежна поведінка.
44
+
45
+ - Правильно: `DB_PASSWORD=sample-secret`, `password: "sample-secret"`
46
+ - Неправильно: `DB_PASSWORD=secret`, `password: "secret"`
47
+
48
+ Перевіряється лише `secret` у позиції значення (після `=`, `:`, `=>`); імена ключів на кшталт `client_secret` не чіпаються. Concern `security.sample_secret` — деталі скану в `fix/sample_secret/check.mjs`.
49
+
50
+ ## Перевірка
51
+
52
+ `npx @nitra/cursor check security`
@@ -31,6 +31,16 @@ import {
31
31
 
32
32
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
33
33
  const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
34
+ // Імпорт із npm-пакета `pg` — будь-яка з форм: default, named, namespace, side-effect,
35
+ // а також `require('pg')`. `pg-format`/`pg-pool` свідомо НЕ матчаться: на них діє
36
+ // окрема заборона (denylist) і свої повідомлення. Виключення для LISTEN/NOTIFY
37
+ // стосується лише самого клієнта `pg`.
38
+ const PG_LIB_IMPORT_RE = /(?:\bimport\b[\s\S]*?\bfrom\s*["']pg["']|\brequire\s*\(\s*["']pg["']\s*\))/u
39
+ // Першоквазі-рядок або string literal, що починається з LISTEN / UNLISTEN / NOTIFY
40
+ // (case-insensitive), з опційним leading whitespace. Сигнал, що в коді запит
41
+ // `LISTEN ch` / `NOTIFY ch, 'msg'` / `UNLISTEN *` — це функціонал, якого Bun SQL
42
+ // поки не має, тож у проекті лишається легітимна потреба у клієнті `pg`.
43
+ const PG_LISTEN_NOTIFY_SQL_RE = /^\s*(LISTEN|UNLISTEN|NOTIFY)\b/iu
34
44
  const IN_PLACEHOLDER_END_RE = /\bin\s*(\(\s*)?$/iu
35
45
  // `// allow-unsafe: <reason>` — `allow-unsafe`, двокрапка, **непорожня** причина.
36
46
  // Без причини маркер не приймається: ціль — лишити слід для ревʼюера, а не «німий» прапорець.
@@ -531,6 +541,43 @@ export function findBunSqlUnsafeUseWithoutAllowMarkerInText(content, virtualPath
531
541
  return out
532
542
  }
533
543
 
544
+ /**
545
+ * Знаходить `<obj>.unsafe(template_literal_with_interpolation)` — навіть із маркером
546
+ * `// allow-unsafe`. Шаблонна підстановка `${name}` у `sql.unsafe`-рядок **не екранує**
547
+ * identifier'ів (reserved words, спецсимволи) і ніяк не біндить значення — це
548
+ * структурна injection-поверхня, яку легко не помітити в ревʼю. Канон — побудувати
549
+ * `text` через `@scaleleap/pg-format` `format('%I', name)` (для identifiers) або
550
+ * звичайні позиційні `$N`-placeholder'и (для values), і передати в `sql.unsafe(text, [params])`.
551
+ *
552
+ * Прапорує саме `TemplateLiteral` з `expressions.length > 0`; статичні рядки
553
+ * (`Literal`, `StringLiteral`, `TemplateLiteral` без `${...}`) і виклики з готовим
554
+ * `text` як змінною — не зачіпає (для них діє основна перевірка allow-unsafe).
555
+ * @param {string} content вихідний код
556
+ * @param {string} [virtualPath] шлях для вибору `lang`
557
+ * @returns {{ line: number, snippet: string }[]} список порушень
558
+ */
559
+ export function findBunSqlUnsafeWithInterpolatedTemplateInText(content, virtualPath = 'scan.ts') {
560
+ const program = parseProgramOrNull(content, virtualPath)
561
+ if (!program) return []
562
+
563
+ /** @type {{ line: number, snippet: string }[]} */
564
+ const out = []
565
+ walkAstWithAncestors(program, [], node => {
566
+ if (!isUnsafeCall(node)) return
567
+ const args = node.arguments
568
+ if (!Array.isArray(args) || args.length === 0) return
569
+ const first = args[0]
570
+ if (!first || first.type !== 'TemplateLiteral') return
571
+ const expressions = first.expressions
572
+ if (!Array.isArray(expressions) || expressions.length === 0) return
573
+ out.push({
574
+ line: offsetToLine(content, node.start),
575
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
576
+ })
577
+ })
578
+ return out
579
+ }
580
+
534
581
  /**
535
582
  * Знаходить pg-leftover виклики `<obj>.connect(...)` / `<obj>.end(...)` без маркера
536
583
  * `// allow-pg-leftover: <reason>` у файлах, де **в цьому ж файлі** є `import { sql|SQL } from 'bun'`.
@@ -685,6 +732,193 @@ export function textHasBunSqlImport(content) {
685
732
  return BUN_SQL_IMPORT_RE.test(content)
686
733
  }
687
734
 
735
+ /**
736
+ * Чи імпортує файл npm-пакет `pg` (`import ... from 'pg'` або `require('pg')`).
737
+ * Текстова перевірка — без AST, дешевий pre-filter; для строгої локалізації
738
+ * (рядок/snippet) використай `findPgLibImportInText`. Не матчить `pg-format`,
739
+ * `pg-pool`, `@types/pg` — лише сам клієнт.
740
+ * @param {string} content вміст файлу
741
+ * @returns {boolean} true, якщо у файлі є імпорт `'pg'`
742
+ */
743
+ export function textHasPgLibImport(content) {
744
+ return PG_LIB_IMPORT_RE.test(content)
745
+ }
746
+
747
+ /**
748
+ * Знаходить ImportDeclaration / CallExpression `require('pg')` для пакета `pg`
749
+ * (саме точна назва, не `pg-format` тощо). Повертає рядок і snippet — щоб у
750
+ * повідомленнях `fail` показати конкретне місце.
751
+ * @param {string} content вихідний код
752
+ * @param {string} [virtualPath] шлях для вибору `lang`
753
+ * @returns {{ line: number, snippet: string }[]} список місць, де імпортується `pg`
754
+ */
755
+ export function findPgLibImportInText(content, virtualPath = 'scan.ts') {
756
+ const program = parseProgramOrNull(content, virtualPath)
757
+ if (!program) return []
758
+
759
+ /** @type {{ line: number, snippet: string }[]} */
760
+ const out = []
761
+ walkAstWithAncestors(program, [], node => {
762
+ if (node.type === 'ImportDeclaration' && getStringLiteralValue(node.source) === 'pg') {
763
+ out.push({
764
+ line: offsetToLine(content, node.start),
765
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
766
+ })
767
+ return
768
+ }
769
+ if (node.type === 'CallExpression' && isRequireOfModule(node, 'pg')) {
770
+ out.push({
771
+ line: offsetToLine(content, node.start),
772
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
773
+ })
774
+ }
775
+ })
776
+ return out
777
+ }
778
+
779
+ /**
780
+ * Знаходить використання PostgreSQL LISTEN/NOTIFY у коді — сигнал, що проект
781
+ * потребує `pg` як виняток (Bun SQL поки не реалізує LISTEN/NOTIFY). Прапорує:
782
+ * - `<obj>.query(...)` / `<obj>.queryArray(...)` / `<obj>.queryStream(...)`, де
783
+ * перший аргумент — string literal або template literal, що починається з
784
+ * `LISTEN ` / `UNLISTEN ` / `NOTIFY ` (case-insensitive);
785
+ * - `<obj>.on('notification', ...)` — pg-listener notification-подій (другий
786
+ * аргумент — функція; перший — точно рядок `'notification'`);
787
+ * - TaggedTemplateExpression виду sql tagged template з LISTEN/UNLISTEN/NOTIFY
788
+ * на початку першого quasi — на випадок, якщо хтось використовує Bun
789
+ * SQL-tagged-template, а LISTEN/NOTIFY все одно лишається у тексті запиту
790
+ * (це не запрацює у Bun SQL, але як сигнал — приймаємо).
791
+ *
792
+ * Регістр SQL-слів не важливий, провідні пробіли допускаються.
793
+ * @param {string} content вихідний код
794
+ * @param {string} [virtualPath] шлях для вибору `lang`
795
+ * @returns {{ line: number, snippet: string, kind: 'listen_sql' | 'notify_sql' | 'unlisten_sql' | 'notification_listener' }[]} список знахідок
796
+ */
797
+ export function findPgListenNotifyUsageInText(content, virtualPath = 'scan.ts') {
798
+ const program = parseProgramOrNull(content, virtualPath)
799
+ if (!program) return []
800
+
801
+ /** @type {{ line: number, snippet: string, kind: 'listen_sql' | 'notify_sql' | 'unlisten_sql' | 'notification_listener' }[]} */
802
+ const out = []
803
+ walkAstWithAncestors(program, [], node => {
804
+ const fromCall = listenNotifyFromCallExpression(node)
805
+ if (fromCall) {
806
+ out.push({
807
+ line: offsetToLine(content, node.start),
808
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
809
+ kind: fromCall
810
+ })
811
+ return
812
+ }
813
+ const fromTagged = listenNotifyFromTaggedTemplate(node)
814
+ if (fromTagged) {
815
+ out.push({
816
+ line: offsetToLine(content, node.start),
817
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
818
+ kind: fromTagged
819
+ })
820
+ }
821
+ })
822
+ return out
823
+ }
824
+
825
+ /**
826
+ * @param {Record<string, unknown>} node ImportDeclaration.source або CallExpression.arguments[0]
827
+ * @returns {string | null} значення string literal або null
828
+ */
829
+ function getStringLiteralValue(node) {
830
+ if (!node || typeof node !== 'object') return null
831
+ if ((node.type === 'Literal' || node.type === 'StringLiteral') && typeof node.value === 'string') {
832
+ return node.value
833
+ }
834
+ return null
835
+ }
836
+
837
+ /**
838
+ * Чи це `require('<moduleName>')` — CallExpression з callee Identifier `require`
839
+ * і єдиним string-літералом-аргументом.
840
+ * @param {Record<string, unknown>} node CallExpression
841
+ * @param {string} moduleName очікувана назва модуля (точне співпадіння)
842
+ * @returns {boolean} true, якщо це саме require цього модуля
843
+ */
844
+ function isRequireOfModule(node, moduleName) {
845
+ const callee = node.callee
846
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return false
847
+ const args = node.arguments
848
+ if (!Array.isArray(args) || args.length !== 1) return false
849
+ return getStringLiteralValue(args[0]) === moduleName
850
+ }
851
+
852
+ /**
853
+ * Аналізує CallExpression на предмет pg-style LISTEN/NOTIFY-запиту або listener'а
854
+ * подій `'notification'`. Повертає тип сигналу або `null`.
855
+ * @param {Record<string, unknown>} node AST node
856
+ * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | 'notification_listener' | null} kind знахідки
857
+ */
858
+ function listenNotifyFromCallExpression(node) {
859
+ if (!node || node.type !== 'CallExpression') return null
860
+ const callee = node.callee
861
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed) return null
862
+ const prop = callee.property
863
+ if (!prop || prop.type !== 'Identifier' || typeof prop.name !== 'string') return null
864
+ const args = node.arguments
865
+ if (!Array.isArray(args) || args.length === 0) return null
866
+
867
+ if (prop.name === 'on') {
868
+ return getStringLiteralValue(args[0]) === 'notification' ? 'notification_listener' : null
869
+ }
870
+ if (prop.name === 'query' || prop.name === 'queryArray' || prop.name === 'queryStream') {
871
+ return sqlStringStartsWithListenNotify(args[0])
872
+ }
873
+ return null
874
+ }
875
+
876
+ /**
877
+ * Аналізує TaggedTemplateExpression: якщо перший quasi починається з
878
+ * LISTEN/UNLISTEN/NOTIFY — повертає відповідний kind.
879
+ * @param {Record<string, unknown>} node AST node
880
+ * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | null} kind знахідки
881
+ */
882
+ function listenNotifyFromTaggedTemplate(node) {
883
+ if (!node || node.type !== 'TaggedTemplateExpression') return null
884
+ const quasi = node.quasi
885
+ if (!quasi || quasi.type !== 'TemplateLiteral') return null
886
+ const text = templateQuasisText(quasi)
887
+ return kindFromListenNotifyMatch(text)
888
+ }
889
+
890
+ /**
891
+ * Перший аргумент виклику `.query(...)` — це string literal або template literal,
892
+ * текст якого починається з LISTEN / UNLISTEN / NOTIFY (case-insensitive)?
893
+ * @param {unknown} arg AST node першого аргумента
894
+ * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | null} kind знахідки або null
895
+ */
896
+ function sqlStringStartsWithListenNotify(arg) {
897
+ if (!arg || typeof arg !== 'object') return null
898
+ if ((arg.type === 'Literal' || arg.type === 'StringLiteral') && typeof arg.value === 'string') {
899
+ return kindFromListenNotifyMatch(arg.value)
900
+ }
901
+ if (arg.type === 'TemplateLiteral') {
902
+ return kindFromListenNotifyMatch(templateQuasisText(arg))
903
+ }
904
+ return null
905
+ }
906
+
907
+ /**
908
+ * Перетворює текст SQL-рядка у kind знахідки (`listen_sql` / `notify_sql` /
909
+ * `unlisten_sql`) або `null`, якщо рядок не починається з ключового слова.
910
+ * @param {string} text SQL-текст
911
+ * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | null} kind знахідки
912
+ */
913
+ function kindFromListenNotifyMatch(text) {
914
+ const m = PG_LISTEN_NOTIFY_SQL_RE.exec(text)
915
+ if (!m) return null
916
+ const keyword = m[1].toUpperCase()
917
+ if (keyword === 'LISTEN') return 'listen_sql'
918
+ if (keyword === 'NOTIFY') return 'notify_sql'
919
+ return 'unlisten_sql'
920
+ }
921
+
688
922
  /**
689
923
  * Чи сканувати цей файл за розширенням (JS/TS-сімʼя, без `.d.ts`).
690
924
  * @param {string} relativePathPosix відносний шлях (posix)