@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 +24 -0
- package/package.json +1 -1
- package/rules/bun/fix/layout/check.mjs +17 -6
- package/rules/js-bun-db/fix/safety/check.mjs +181 -16
- package/rules/js-bun-db/js-bun-db.mdc +194 -7
- package/rules/js-bun-db/policy/package_json/template/package.json.deny.json +0 -1
- package/rules/security/fix/sample_secret/check.mjs +105 -0
- package/rules/security/security.mdc +17 -2
- package/scripts/utils/bun-sql-scan.mjs +234 -0
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
|
@@ -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(`(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
6
|
-
* (`import { sql, SQL } from 'bun'`, https://bun.com/docs/runtime/sql).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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)
|
|
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<{
|
|
73
|
-
*
|
|
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
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
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 {
|
|
232
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
@@ -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.
|
|
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)
|