@nitra/cursor 1.8.158 → 1.8.160
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/.claude-template/commands/n-check.md +11 -0
- package/.claude-template/npm-CLAUDE.md +23 -0
- package/.claude-template/settings.template.json +26 -0
- package/CHANGELOG.md +23 -0
- package/bin/n-cursor.js +40 -1
- package/mdc/js-bun-db.mdc +27 -6
- package/package.json +2 -1
- package/schemas/n-cursor.json +5 -0
- package/scripts/check-js-bun-db.mjs +13 -6
- package/scripts/claude-stop-hook.mjs +71 -0
- package/scripts/sync-claude-config.mjs +232 -0
- package/scripts/utils/ast-scan-utils.mjs +23 -0
- package/scripts/utils/bun-sql-scan.mjs +57 -19
- package/scripts/utils/oxlint-canonical-skeleton.json +1 -1
- package/scripts/utils/oxlint-canonical.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
Запустити всі програмні перевірки правил (`npx @nitra/cursor check`)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# n-check
|
|
7
|
+
|
|
8
|
+
Запусти `npx @nitra/cursor check` і пройдися по результатах.
|
|
9
|
+
|
|
10
|
+
- Якщо є помилки — виправи відповідно до правила, на яке вказує перевірка.
|
|
11
|
+
- Якщо все чисто — підтверди коротким повідомленням і переходь до наступного кроку.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!-- Файл генерується автоматично через `npx @nitra/cursor`. Не редагуй вручну. -->
|
|
2
|
+
|
|
3
|
+
# Робота в `npm/`
|
|
4
|
+
|
|
5
|
+
Path-scoped нагадування для агента: підвантажується автоматично, коли редагуємо файли під `npm/`.
|
|
6
|
+
|
|
7
|
+
## Перед коміт-релевантними змінами в `npm/`
|
|
8
|
+
|
|
9
|
+
1. Підвищ `version` у `npm/package.json` (build-bump, не більше одного кроку відносно `HEAD`).
|
|
10
|
+
2. Додай запис у `npm/CHANGELOG.md` форматом Keep a Changelog: `## [версія] - YYYY-MM-DD` + секції `### Added/Changed/Fixed/Removed`.
|
|
11
|
+
|
|
12
|
+
Без обох пунктів `npx @nitra/cursor check npm-module` падає, а `Stop` hook блокує завершення ходу.
|
|
13
|
+
|
|
14
|
+
## Перевірка локально
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @nitra/cursor check npm-module
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Джерело правил
|
|
21
|
+
|
|
22
|
+
- `.cursor/rules/n-npm-module.mdc` — повний текст правила
|
|
23
|
+
- `npm/scripts/check-npm-module.mjs` — алгоритм перевірки
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(bun *)",
|
|
5
|
+
"Bash(bun run *)",
|
|
6
|
+
"Bash(bun test *)",
|
|
7
|
+
"Bash(bunx *)",
|
|
8
|
+
"Bash(npx --no @nitra/cursor *)",
|
|
9
|
+
"Bash(npx @nitra/cursor *)"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"hooks": {
|
|
13
|
+
"Stop": [
|
|
14
|
+
{
|
|
15
|
+
"matcher": "",
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "npx --no @nitra/cursor stop-hook",
|
|
20
|
+
"timeout": 60
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.8.160] - 2026-05-01
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `js-bun-db.mdc` (v1.4): `sql.unsafe(...)` тепер заборонено за замовчуванням — допустимо лише для підстановки назви таблиці/колонки чи dynamic SQL/DDL з code-controlled значенням; інакше переробляємо на tagged template `sql\`...${value}...\``. Кожен легітимний виклик має супроводжуватись маркером `// allow-unsafe: <причина>` на тому ж рядку або рядком вище.
|
|
12
|
+
- `check-js-bun-db.mjs`: замість вузької перевірки `sql.unsafe(\`...${expr}...\`)` тепер сканер `findBunSqlUnsafeUseWithoutAllowMarkerInText` падає на будь-якому `<obj>.unsafe(...)` без маркера-коментаря з непорожньою причиною (line- або block-коментар на тому ж рядку чи безпосередньо перед викликом).
|
|
13
|
+
- `ast-scan-utils.mjs`: додано `parseProgramAndCommentsOrNull` — окремий вхід для перевірок, яким потрібні коментарі поряд з AST.
|
|
14
|
+
|
|
15
|
+
## [1.8.159] - 2026-05-01
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Інтеграція з Claude Code: новий каталог `npm/.claude-template/` із `settings.template.json` (Stop hook + permissions allowlist), `npm-CLAUDE.md` (path-scoped нагадування для роботи в `npm/`) і slash-команду `/n-check`.
|
|
20
|
+
- `sync-claude-config.mjs`: під час `npx @nitra/cursor` синхронізує `.claude/settings.json` (merge — користувацькі поля зберігаються, наші hooks ідентифікуються маркером і перезаписуються), `npm/CLAUDE.md` і slash-команди checks.
|
|
21
|
+
- Subcommand `npx @nitra/cursor stop-hook` — точка входу Stop hook Claude Code (читає stdin, виходить 0 при `stop_hook_active=true` для захисту від рекурсії, інакше викликає `check`).
|
|
22
|
+
- Поле `claude-config` у `.n-cursor.json` (default `true`) для опт-ауту.
|
|
23
|
+
- Тести `npm/tests/sync-claude-config.test.mjs` — merge allow-list/hooks, інтеграція, ідемпотентність, опт-аут (12 кейсів).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- `npm/schemas/n-cursor.json`: додано опис поля `claude-config`.
|
|
28
|
+
- `npm/package.json`: `.claude-template` додано в масив `files`, щоб публікувався з пакетом.
|
|
29
|
+
|
|
7
30
|
## [1.8.158] - 2026-05-01
|
|
8
31
|
|
|
9
32
|
### Changed
|
package/bin/n-cursor.js
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
* якщо в корені вже є `.n-cursor.json`, спочатку зчитується конфіг і за потреби дописується `$schema`
|
|
10
10
|
* `npx \@nitra/cursor check bun` — перевірити лише вказані правила (ігнорує AGENTS.md)
|
|
11
11
|
* `npx \@nitra/cursor rename-yaml-extensions` — k8s `*.yml` → `*.yaml`, `.github` `*.yaml` → `*.yml` (опції: `--dry-run`, `--root=…`; див. bin/rename-yaml-extensions.mjs)
|
|
12
|
+
* `npx \@nitra/cursor stop-hook` — точка входу Stop hook Claude Code (читає stdin, виходить 0 при `stop_hook_active`,
|
|
13
|
+
* інакше викликає `check`); прописується автоматично в `.claude/settings.json`
|
|
14
|
+
*
|
|
15
|
+
* Claude Code інтеграція: під час синку, окрім `.cursor/rules` і `.claude/commands` (з skills), CLI ще раз
|
|
16
|
+
* синхронізує `.claude/settings.json` (hooks + permissions; merge — користувацькі поля зберігаються),
|
|
17
|
+
* `npm/CLAUDE.md` (path-scoped нагадування для роботи в `npm/`) і slash-команди checks (`/n-check`).
|
|
18
|
+
* Опт-аут — поле `claude-config: false` у `.n-cursor.json`.
|
|
12
19
|
*
|
|
13
20
|
* Якщо у корені репозиторію немає .n-cursor.json, спочатку перейменовується за наявності nitra-cursor.json;
|
|
14
21
|
* у `.cursor/rules` файли `nitra-*.mdc` перейменовуються на `n-*.mdc`; інакше конфіг створюється автоматично
|
|
@@ -48,7 +55,9 @@ import { fileURLToPath } from 'node:url'
|
|
|
48
55
|
|
|
49
56
|
import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
|
|
50
57
|
import { detectAutoRulesAndSkills, mergeConfigWithAutoDetected, normalizeIdList } from '../scripts/auto-rules.mjs'
|
|
58
|
+
import { runStopHookCli } from '../scripts/claude-stop-hook.mjs'
|
|
51
59
|
import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
|
|
60
|
+
import { syncClaudeConfig } from '../scripts/sync-claude-config.mjs'
|
|
52
61
|
import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitra-cursor-and-install.mjs'
|
|
53
62
|
import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
|
|
54
63
|
import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
|
|
@@ -1097,6 +1106,7 @@ async function runSync() {
|
|
|
1097
1106
|
const config = await runSyncStep('❌ ', () => readConfig({ bundledMdcDir, bundledSkillsDir }))
|
|
1098
1107
|
|
|
1099
1108
|
const { rules, skills, version, ignore } = config
|
|
1109
|
+
const claudeConfigEnabled = config['claude-config'] !== false
|
|
1100
1110
|
const bundledVer = await readBundledVersionAt(effectivePackageRoot)
|
|
1101
1111
|
if (bundledVer) {
|
|
1102
1112
|
const line =
|
|
@@ -1160,6 +1170,25 @@ async function runSync() {
|
|
|
1160
1170
|
syncClaudeMd(/** @type {string[] | undefined} */ (ignore))
|
|
1161
1171
|
)
|
|
1162
1172
|
|
|
1173
|
+
await runSyncStep('❌ Не вдалося синхронізувати Claude-конфіг: ', async () => {
|
|
1174
|
+
const result = await syncClaudeConfig({
|
|
1175
|
+
projectRoot: cwd(),
|
|
1176
|
+
bundledPackageRoot: effectivePackageRoot,
|
|
1177
|
+
enabled: claudeConfigEnabled
|
|
1178
|
+
})
|
|
1179
|
+
if (!claudeConfigEnabled) {
|
|
1180
|
+
console.log('🤖 Claude-конфіг: пропущено (claude-config: false у .n-cursor.json)')
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
const parts = []
|
|
1184
|
+
if (result.settings) parts.push('.claude/settings.json')
|
|
1185
|
+
if (result.npmClaudeMd) parts.push('npm/CLAUDE.md')
|
|
1186
|
+
if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
|
|
1187
|
+
if (parts.length > 0) {
|
|
1188
|
+
console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
|
|
1189
|
+
}
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1163
1192
|
console.log(`\n✨ Готово: ${successCount} завантажено, ${failCount} з помилками\n`)
|
|
1164
1193
|
if (failCount > 0) {
|
|
1165
1194
|
throw new Error(`Не вдалося завантажити ${failCount} з ${rules.length} правил`)
|
|
@@ -1185,6 +1214,14 @@ try {
|
|
|
1185
1214
|
|
|
1186
1215
|
break
|
|
1187
1216
|
}
|
|
1217
|
+
case 'stop-hook': {
|
|
1218
|
+
// Викликається з .claude/settings.json як Stop hook Claude Code.
|
|
1219
|
+
// Прокидає `check` і поважає stop_hook_active, щоб не зациклюватись.
|
|
1220
|
+
const code = await runStopHookCli()
|
|
1221
|
+
process.exitCode = code
|
|
1222
|
+
|
|
1223
|
+
break
|
|
1224
|
+
}
|
|
1188
1225
|
case undefined:
|
|
1189
1226
|
case '': {
|
|
1190
1227
|
await runSync()
|
|
@@ -1193,7 +1230,9 @@ try {
|
|
|
1193
1230
|
}
|
|
1194
1231
|
default: {
|
|
1195
1232
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1196
|
-
console.error(
|
|
1233
|
+
console.error(
|
|
1234
|
+
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, stop-hook`
|
|
1235
|
+
)
|
|
1197
1236
|
process.exitCode = 1
|
|
1198
1237
|
}
|
|
1199
1238
|
}
|
package/mdc/js-bun-db.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.4'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Підтримувані версії баз даних
|
|
@@ -116,22 +116,43 @@ await sql.begin(async tx => {
|
|
|
116
116
|
await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
##
|
|
119
|
+
## `sql.unsafe(...)` за замовчуванням заборонено
|
|
120
|
+
|
|
121
|
+
Будь-який виклик `sql.unsafe(...)` (так само `tx.unsafe(...)` всередині `sql.begin`) **заборонено**, окрім випадків, коли **обидві** умови виконані:
|
|
122
|
+
|
|
123
|
+
1. значення підставляється з **коду** — константа, конфіг, whitelist; **не з user input**;
|
|
124
|
+
2. треба підставити те, що **не можна параметризувати** через tagged template:
|
|
125
|
+
- назву **таблиці**,
|
|
126
|
+
- назву **колонки**,
|
|
127
|
+
- **dynamic SQL / DDL** (`CREATE`, `ALTER`, `DROP`, multi-statement migration, серверні `SET`/`SHOW` і подібне).
|
|
128
|
+
|
|
129
|
+
В усіх інших випадках — переробити на звичайний tagged template `sql\`...\${value}...\``: значення біндяться як параметри й injection не лишається.
|
|
120
130
|
|
|
121
|
-
|
|
131
|
+
Кожен легітимний `sql.unsafe(...)` має супроводжуватись **маркером-коментарем** з причиною — на тому ж рядку (trailing) або на рядку безпосередньо перед викликом. Маркер — opt-in для перевірки `js-bun-db` і слід для ревʼюера:
|
|
122
132
|
|
|
123
133
|
```javascript
|
|
124
|
-
//
|
|
134
|
+
// allow-unsafe: DDL — назву таблиці параметризувати не можна
|
|
135
|
+
await sql.unsafe(`CREATE TABLE ${tableName} (id int)`)
|
|
136
|
+
|
|
137
|
+
await sql.unsafe('SELECT pg_advisory_lock($1)', [lockId]) // allow-unsafe: pg_advisory_lock — окремий шлях, без tagged template
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Формат маркера: `allow-unsafe: <непорожня причина>` у line- або block-коментарі. Без причини (`// allow-unsafe:`) і без маркера взагалі — **fail** перевірки.
|
|
141
|
+
|
|
142
|
+
❌ Заборонені кейси (треба переробити на tagged template):
|
|
143
|
+
|
|
144
|
+
```javascript
|
|
145
|
+
// ❌ дані від користувача — параметризуй через tagged template
|
|
125
146
|
await sql.unsafe(`SELECT * FROM users WHERE id = ${userId}`)
|
|
126
147
|
|
|
127
148
|
// ❌ навіть у tagged template — динамічний список через .join(',')
|
|
128
149
|
await sql`SELECT * FROM users WHERE id IN (${ids.join(',')})`
|
|
129
150
|
```
|
|
130
151
|
|
|
131
|
-
`sql.unsafe(text, params)` допустимий лише для **статичного** SQL без даних від користувача (наприклад, разовий DDL-скрипт), і обов'язково з масивом параметрів — ніяких `${...}` у самому рядку.
|
|
132
|
-
|
|
133
152
|
Для динамічних списків — `sql([...])` або `sql(rows, 'colA', 'colB')`, **не** `.join(',')`.
|
|
134
153
|
|
|
154
|
+
## Що НЕ робити
|
|
155
|
+
|
|
135
156
|
### Не створювати підключення на кожен запит
|
|
136
157
|
|
|
137
158
|
```javascript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.160",
|
|
4
4
|
"description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"schemas",
|
|
31
31
|
"scripts",
|
|
32
32
|
"skills",
|
|
33
|
+
".claude-template",
|
|
33
34
|
"AGENTS.template.md",
|
|
34
35
|
"CHANGELOG.md"
|
|
35
36
|
],
|
package/schemas/n-cursor.json
CHANGED
|
@@ -54,6 +54,11 @@
|
|
|
54
54
|
"version": {
|
|
55
55
|
"type": "string",
|
|
56
56
|
"description": "Застаріле поле, ігнорується CLI. Правила завжди копіюються з каталогу mdc/ установленого пакету (node_modules або кеш npx); змініть версію через оновлення залежності."
|
|
57
|
+
},
|
|
58
|
+
"claude-config": {
|
|
59
|
+
"type": "boolean",
|
|
60
|
+
"description": "Чи синхронізувати `.claude/settings.json` (hooks + permissions, merge зі збереженням користувацьких полів), `npm/CLAUDE.md` (path-scoped нагадування для роботи в `npm/`) і slash-команди checks. За замовчуванням true.",
|
|
61
|
+
"default": true
|
|
57
62
|
}
|
|
58
63
|
},
|
|
59
64
|
"required": ["rules"]
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
* 2) Якщо в коді використовується Bun SQL (імпорт `sql`/`SQL` з `'bun'`), додатково
|
|
11
11
|
* перевіряє небезпечні патерни:
|
|
12
12
|
* - `new SQL(...)` всередині функції (пул має бути singleton на рівні модуля).
|
|
13
|
-
* -
|
|
13
|
+
* - Будь-який `<obj>.unsafe(...)` без маркера-коментаря `// allow-unsafe: <reason>`
|
|
14
|
+
* на тому ж рядку або рядком вище. `sql.unsafe` за замовчуванням заборонено;
|
|
15
|
+
* допустимий лише для підстановки назви таблиці/колонки чи dynamic SQL/DDL,
|
|
16
|
+
* коли значення контролюється кодом (не user input) — в інших випадках
|
|
17
|
+
* переробляємо на tagged template `sql\`...\${value}...\``.
|
|
14
18
|
* - Динамічні SQL-списки через `.join(',')` у `IN (...)` / `VALUES (...)`
|
|
15
19
|
* (треба `sql([...])`).
|
|
16
20
|
*/
|
|
@@ -21,9 +25,9 @@ import { join, relative, sep } from 'node:path'
|
|
|
21
25
|
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
22
26
|
import {
|
|
23
27
|
findBunSqlPerRequestConnectionInText,
|
|
28
|
+
findBunSqlUnsafeUseWithoutAllowMarkerInText,
|
|
24
29
|
findUnsafeBunSqlDynamicSqlListInText,
|
|
25
30
|
findUnsafeBunSqlInListMissingEmptyGuardInText,
|
|
26
|
-
findUnsafeBunSqlUnsafeCallInText,
|
|
27
31
|
isBunSqlScanSourceFile,
|
|
28
32
|
textHasBunSqlImport
|
|
29
33
|
} from './utils/bun-sql-scan.mjs'
|
|
@@ -155,11 +159,14 @@ function scanFileForBunSqlPatterns(content, rel, fail, counts) {
|
|
|
155
159
|
`тримай singleton на рівні модуля (js-bun-db.mdc): ${v.snippet}`
|
|
156
160
|
)
|
|
157
161
|
}
|
|
158
|
-
for (const v of
|
|
162
|
+
for (const v of findBunSqlUnsafeUseWithoutAllowMarkerInText(content, rel)) {
|
|
159
163
|
counts.unsafeCall++
|
|
160
164
|
fail(
|
|
161
|
-
`js-bun-db: ${rel}:${v.line} — sql.unsafe(
|
|
162
|
-
|
|
165
|
+
`js-bun-db: ${rel}:${v.line} — sql.unsafe(...) заборонено за замовчуванням; ` +
|
|
166
|
+
`допустимо лише для підстановки назви таблиці/колонки чи dynamic SQL/DDL з code-controlled значенням, ` +
|
|
167
|
+
`інакше переробити на tagged template sql\`...\${value}...\`. ` +
|
|
168
|
+
`Якщо випадок легітимний — додай маркер "// allow-unsafe: <причина>" на тому ж рядку або рядком вище ` +
|
|
169
|
+
`(js-bun-db.mdc): ${v.snippet}`
|
|
163
170
|
)
|
|
164
171
|
}
|
|
165
172
|
for (const v of findUnsafeBunSqlDynamicSqlListInText(content, rel)) {
|
|
@@ -245,7 +252,7 @@ export async function check() {
|
|
|
245
252
|
pass('js-bun-db: немає створення new SQL(...) всередині функцій (singleton на рівні модуля)')
|
|
246
253
|
}
|
|
247
254
|
if (unsafeCall === 0) {
|
|
248
|
-
pass('js-bun-db:
|
|
255
|
+
pass('js-bun-db: усі sql.unsafe(...) або відсутні, або супроводжуються маркером "// allow-unsafe: <причина>"')
|
|
249
256
|
}
|
|
250
257
|
if (dynamicList === 0) {
|
|
251
258
|
pass("js-bun-db: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop-hook для Claude Code: запускається hook'ом із `.claude/settings.json` після того,
|
|
3
|
+
* як агент сигналізує завершення ходу. Прозоро прокидає `npx @nitra/cursor check`
|
|
4
|
+
* і повертає його exit code, щоб помилки правил блокували завершення.
|
|
5
|
+
*
|
|
6
|
+
* Захист від нескінченної рекурсії: якщо stdin містить `"stop_hook_active": true`
|
|
7
|
+
* (Claude Code позначає цей прапорець, коли hook сам спричинив повторний Stop),
|
|
8
|
+
* виходимо з кодом 0 без повторного запуску перевірок.
|
|
9
|
+
*
|
|
10
|
+
* Виклик з `bin/n-cursor.js`:
|
|
11
|
+
* `npx --no @nitra/cursor stop-hook`
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Зчитує stdin до EOF як utf8 рядок. Якщо stdin порожній (TTY) — повертає '' одразу.
|
|
17
|
+
* @returns {Promise<string>} вміст stdin
|
|
18
|
+
*/
|
|
19
|
+
function readStdin() {
|
|
20
|
+
return new Promise(resolve => {
|
|
21
|
+
if (process.stdin.isTTY) {
|
|
22
|
+
resolve('')
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
let data = ''
|
|
26
|
+
process.stdin.setEncoding('utf8')
|
|
27
|
+
process.stdin.on('data', chunk => {
|
|
28
|
+
data += chunk
|
|
29
|
+
})
|
|
30
|
+
process.stdin.on('end', () => resolve(data))
|
|
31
|
+
process.stdin.on('error', () => resolve(data))
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Чи stdin вказує, що поточний Stop вже виник через попередній Stop hook
|
|
37
|
+
* (Claude Code передає `stop_hook_active: true`). У такому випадку повторний
|
|
38
|
+
* запуск перевірок створив би нескінченний цикл — пропускаємо.
|
|
39
|
+
* @param {string} stdin сирий вміст stdin
|
|
40
|
+
* @returns {boolean} true, якщо рекурсивний виклик
|
|
41
|
+
*/
|
|
42
|
+
export function isRecursiveStopHookCall(stdin) {
|
|
43
|
+
if (!stdin) {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const obj = JSON.parse(stdin)
|
|
48
|
+
return obj?.stop_hook_active === true
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Точка входу. Викликається з `bin/n-cursor.js` коли argv[0] === 'stop-hook'.
|
|
56
|
+
* @returns {Promise<number>} exit code (0 — OK / пропуск, 1 — помилки правил)
|
|
57
|
+
*/
|
|
58
|
+
export async function runStopHookCli() {
|
|
59
|
+
const stdin = await readStdin()
|
|
60
|
+
if (isRecursiveStopHookCall(stdin)) {
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
return new Promise(resolve => {
|
|
64
|
+
const child = spawn('npx', ['--no', '@nitra/cursor', 'check'], { stdio: 'inherit' })
|
|
65
|
+
child.on('exit', code => resolve(code ?? 1))
|
|
66
|
+
child.on('error', err => {
|
|
67
|
+
process.stderr.write(`stop-hook: не вдалося запустити npx @nitra/cursor check — ${err.message}\n`)
|
|
68
|
+
resolve(1)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Синхронізує конфігурацію Claude Code (`.claude/settings.json`, `npm/CLAUDE.md`,
|
|
3
|
+
* slash-команди для checks) у поточний проєкт із темплейтів пакету
|
|
4
|
+
* `npm/.claude-template/`.
|
|
5
|
+
*
|
|
6
|
+
* Архітектура:
|
|
7
|
+
* - `settings.json` — **merge**: користувацькі поля зберігаються; наші hooks
|
|
8
|
+
* ідентифікуються командою-маркером (`MANAGED_HOOK_COMMAND_MARKER`) і
|
|
9
|
+
* перезаписуються; permissions.allow зливається через union (із дедублікацією).
|
|
10
|
+
* - `npm/CLAUDE.md` — **fully owned**: завжди перезаписується; пропускається,
|
|
11
|
+
* якщо в проєкті немає каталогу `npm/`.
|
|
12
|
+
* - `.claude/commands/n-check.md` — fully owned slash-команда.
|
|
13
|
+
*
|
|
14
|
+
* Опт-аут — `claude-config: false` у `.n-cursor.json`.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/** Маркер у command нашого managed-hook'а — за ним відрізняємо свої записи від користувацьких */
|
|
21
|
+
export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
|
|
22
|
+
|
|
23
|
+
const CLAUDE_DIR = '.claude'
|
|
24
|
+
const CLAUDE_SETTINGS_FILE = `${CLAUDE_DIR}/settings.json`
|
|
25
|
+
const CLAUDE_COMMANDS_DIR = `${CLAUDE_DIR}/commands`
|
|
26
|
+
const NPM_CLAUDE_MD_FILE = 'npm/CLAUDE.md'
|
|
27
|
+
const TEMPLATE_DIR_NAME = '.claude-template'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} HookEntry
|
|
31
|
+
* @property {string} type
|
|
32
|
+
* @property {string} command
|
|
33
|
+
* @property {number} [timeout]
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {object} HookGroup
|
|
38
|
+
* @property {string} [matcher]
|
|
39
|
+
* @property {HookEntry[]} hooks
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {object} ClaudeSettings
|
|
44
|
+
* @property {{ allow?: string[] }} [permissions]
|
|
45
|
+
* @property {Record<string, HookGroup[]>} [hooks]
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Чи hook-група містить лише наші managed-команди (за маркером).
|
|
50
|
+
* @param {HookGroup} group
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function isManagedHookGroup(group) {
|
|
54
|
+
if (!group?.hooks?.length) {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
return group.hooks.every(h => typeof h?.command === 'string' && h.command.includes(MANAGED_HOOK_COMMAND_MARKER))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Зливає список allow-permissions: union існуючого і темплейтного без дублікатів,
|
|
62
|
+
* порядок — спочатку існуючі (щоб не міняти користувацький порядок), потім нові.
|
|
63
|
+
* @param {string[] | undefined} existing
|
|
64
|
+
* @param {string[] | undefined} fromTemplate
|
|
65
|
+
* @returns {string[]}
|
|
66
|
+
*/
|
|
67
|
+
export function mergeAllowList(existing, fromTemplate) {
|
|
68
|
+
const out = []
|
|
69
|
+
const seen = new Set()
|
|
70
|
+
for (const arr of [existing ?? [], fromTemplate ?? []]) {
|
|
71
|
+
for (const item of arr) {
|
|
72
|
+
if (typeof item !== 'string' || seen.has(item)) {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
seen.add(item)
|
|
76
|
+
out.push(item)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Зливає hooks-секцію: для кожної події в темплейті видаляємо managed-групи
|
|
84
|
+
* з існуючої конфігурації і додаємо актуальні з темплейту. Немені події в
|
|
85
|
+
* темплейті не чіпаються.
|
|
86
|
+
* @param {Record<string, HookGroup[]> | undefined} existing
|
|
87
|
+
* @param {Record<string, HookGroup[]> | undefined} fromTemplate
|
|
88
|
+
* @returns {Record<string, HookGroup[]>}
|
|
89
|
+
*/
|
|
90
|
+
export function mergeHooks(existing, fromTemplate) {
|
|
91
|
+
/** @type {Record<string, HookGroup[]>} */
|
|
92
|
+
const out = {}
|
|
93
|
+
for (const [event, groups] of Object.entries(existing ?? {})) {
|
|
94
|
+
out[event] = Array.isArray(groups) ? groups.slice() : []
|
|
95
|
+
}
|
|
96
|
+
for (const [event, templateGroups] of Object.entries(fromTemplate ?? {})) {
|
|
97
|
+
const existingGroups = (out[event] ?? []).filter(g => !isManagedHookGroup(g))
|
|
98
|
+
out[event] = [...existingGroups, ...(templateGroups ?? [])]
|
|
99
|
+
if (out[event].length === 0) {
|
|
100
|
+
delete out[event]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Повертає об'єднаний об'єкт settings.json.
|
|
108
|
+
* @param {ClaudeSettings | undefined} existing
|
|
109
|
+
* @param {ClaudeSettings} template
|
|
110
|
+
* @returns {ClaudeSettings}
|
|
111
|
+
*/
|
|
112
|
+
export function mergeSettings(existing, template) {
|
|
113
|
+
/** @type {ClaudeSettings} */
|
|
114
|
+
const merged = { ...(existing ?? {}) }
|
|
115
|
+
const mergedAllow = mergeAllowList(existing?.permissions?.allow, template.permissions?.allow)
|
|
116
|
+
if (mergedAllow.length > 0) {
|
|
117
|
+
merged.permissions = { ...(existing?.permissions ?? {}), allow: mergedAllow }
|
|
118
|
+
}
|
|
119
|
+
const mergedHooks = mergeHooks(existing?.hooks, template.hooks)
|
|
120
|
+
if (Object.keys(mergedHooks).length > 0) {
|
|
121
|
+
merged.hooks = mergedHooks
|
|
122
|
+
} else {
|
|
123
|
+
delete merged.hooks
|
|
124
|
+
}
|
|
125
|
+
return merged
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Читає JSON-файл; якщо файл відсутній або не валідний — повертає `undefined`.
|
|
130
|
+
* @param {string} path
|
|
131
|
+
* @returns {Promise<ClaudeSettings | undefined>}
|
|
132
|
+
*/
|
|
133
|
+
async function readJsonOrUndefined(path) {
|
|
134
|
+
if (!existsSync(path)) {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Синхронізує `.claude/settings.json` за темплейтом, зберігаючи решту
|
|
146
|
+
* користувацьких полів.
|
|
147
|
+
* @param {string} projectRoot корінь проєкту, куди писати
|
|
148
|
+
* @param {string} templateDir каталог `.claude-template/` усередині пакету
|
|
149
|
+
* @returns {Promise<{ written: boolean, path: string }>}
|
|
150
|
+
*/
|
|
151
|
+
export async function syncClaudeSettings(projectRoot, templateDir) {
|
|
152
|
+
const templatePath = join(templateDir, 'settings.template.json')
|
|
153
|
+
if (!existsSync(templatePath)) {
|
|
154
|
+
return { written: false, path: '' }
|
|
155
|
+
}
|
|
156
|
+
const template = /** @type {ClaudeSettings} */ (JSON.parse(await readFile(templatePath, 'utf8')))
|
|
157
|
+
const settingsPath = join(projectRoot, CLAUDE_SETTINGS_FILE)
|
|
158
|
+
const existing = await readJsonOrUndefined(settingsPath)
|
|
159
|
+
const merged = mergeSettings(existing, template)
|
|
160
|
+
await mkdir(join(projectRoot, CLAUDE_DIR), { recursive: true })
|
|
161
|
+
await writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8')
|
|
162
|
+
return { written: true, path: CLAUDE_SETTINGS_FILE }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Копіює `npm/CLAUDE.md` з темплейту, якщо в проєкті є каталог `npm/`.
|
|
167
|
+
* @param {string} projectRoot
|
|
168
|
+
* @param {string} templateDir
|
|
169
|
+
* @returns {Promise<{ written: boolean, path: string }>}
|
|
170
|
+
*/
|
|
171
|
+
export async function syncNpmClaudeMd(projectRoot, templateDir) {
|
|
172
|
+
if (!existsSync(join(projectRoot, 'npm'))) {
|
|
173
|
+
return { written: false, path: '' }
|
|
174
|
+
}
|
|
175
|
+
const templatePath = join(templateDir, 'npm-CLAUDE.md')
|
|
176
|
+
if (!existsSync(templatePath)) {
|
|
177
|
+
return { written: false, path: '' }
|
|
178
|
+
}
|
|
179
|
+
const content = await readFile(templatePath, 'utf8')
|
|
180
|
+
await writeFile(join(projectRoot, NPM_CLAUDE_MD_FILE), content, 'utf8')
|
|
181
|
+
return { written: true, path: NPM_CLAUDE_MD_FILE }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Копіює всі slash-команди з `templateDir/commands/` у `.claude/commands/`.
|
|
186
|
+
* Команди ідентифікуються тим, що вони лежать у темплейті — не перетинаються
|
|
187
|
+
* з командами скілів (n-fix, n-lint, ...).
|
|
188
|
+
* @param {string} projectRoot
|
|
189
|
+
* @param {string} templateDir
|
|
190
|
+
* @returns {Promise<string[]>} масив відносних шляхів записаних файлів
|
|
191
|
+
*/
|
|
192
|
+
export async function syncClaudeCommands(projectRoot, templateDir) {
|
|
193
|
+
const commandsTemplateDir = join(templateDir, 'commands')
|
|
194
|
+
if (!existsSync(commandsTemplateDir)) {
|
|
195
|
+
return []
|
|
196
|
+
}
|
|
197
|
+
const targetDir = join(projectRoot, CLAUDE_COMMANDS_DIR)
|
|
198
|
+
await mkdir(targetDir, { recursive: true })
|
|
199
|
+
const written = []
|
|
200
|
+
for (const name of await readdir(commandsTemplateDir)) {
|
|
201
|
+
if (!name.endsWith('.md')) {
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
const content = await readFile(join(commandsTemplateDir, name), 'utf8')
|
|
205
|
+
await writeFile(join(targetDir, name), content, 'utf8')
|
|
206
|
+
written.push(`${CLAUDE_COMMANDS_DIR}/${name}`)
|
|
207
|
+
}
|
|
208
|
+
return written
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Виконує повну синхронізацію Claude Code-конфігу з темплейту пакету в проєкт.
|
|
213
|
+
* Використовується з `bin/n-cursor.js` після інших синків.
|
|
214
|
+
* @param {object} options
|
|
215
|
+
* @param {string} options.projectRoot корінь проєкту-споживача
|
|
216
|
+
* @param {string} options.bundledPackageRoot корінь установленого `@nitra/cursor`
|
|
217
|
+
* @param {boolean} options.enabled чи увімкнено sync (з `.n-cursor.json` `claude-config`)
|
|
218
|
+
* @returns {Promise<{ settings: boolean, npmClaudeMd: boolean, commands: string[] }>}
|
|
219
|
+
*/
|
|
220
|
+
export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled }) {
|
|
221
|
+
if (!enabled) {
|
|
222
|
+
return { settings: false, npmClaudeMd: false, commands: [] }
|
|
223
|
+
}
|
|
224
|
+
const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
|
|
225
|
+
if (!existsSync(templateDir)) {
|
|
226
|
+
return { settings: false, npmClaudeMd: false, commands: [] }
|
|
227
|
+
}
|
|
228
|
+
const settings = await syncClaudeSettings(projectRoot, templateDir)
|
|
229
|
+
const npmClaudeMd = await syncNpmClaudeMd(projectRoot, templateDir)
|
|
230
|
+
const commands = await syncClaudeCommands(projectRoot, templateDir)
|
|
231
|
+
return { settings: settings.written, npmClaudeMd: npmClaudeMd.written, commands }
|
|
232
|
+
}
|
|
@@ -112,6 +112,29 @@ export function parseProgramOrNull(content, virtualPath) {
|
|
|
112
112
|
return result.program
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Парсить файл і повертає `{ program, comments }` або null. Окремий вхід для перевірок,
|
|
117
|
+
* яким потрібні коментарі (наприклад, маркер `// allow-unsafe: ...` біля виклику) —
|
|
118
|
+
* базовий `parseProgramOrNull` свідомо лишається без коментарів, щоб не змінювати API.
|
|
119
|
+
* @param {string} content вихідний код
|
|
120
|
+
* @param {string} virtualPath шлях для вибору `lang` (також для діагностики)
|
|
121
|
+
* @returns {{ program: unknown, comments: { type: 'Line' | 'Block', value: string, start: number, end: number }[] } | null}
|
|
122
|
+
*/
|
|
123
|
+
export function parseProgramAndCommentsOrNull(content, virtualPath) {
|
|
124
|
+
const lang = langFromPath(virtualPath || 'scan.ts')
|
|
125
|
+
let result
|
|
126
|
+
try {
|
|
127
|
+
result = parseSync(virtualPath || 'scan.ts', content, { lang, sourceType: 'module' })
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
if (result.errors?.length) return null
|
|
132
|
+
return {
|
|
133
|
+
program: result.program,
|
|
134
|
+
comments: Array.isArray(result.comments) ? result.comments : []
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
/**
|
|
116
139
|
* Чи це `.join(...)` виклик (типово для динамічних списків у SQL).
|
|
117
140
|
* @param {unknown} node AST node
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* Знаходить:
|
|
5
5
|
* - `new SQL(...)` всередині функції — пул має бути singleton на рівні модуля,
|
|
6
6
|
* а не на кожен виклик handler-а.
|
|
7
|
-
* -
|
|
8
|
-
* `sql.unsafe`
|
|
9
|
-
*
|
|
7
|
+
* - Будь-який виклик `<obj>.unsafe(...)` без маркера-коментаря `// allow-unsafe: <reason>`
|
|
8
|
+
* на тому ж рядку або рядком вище. `sql.unsafe` за замовчуванням заборонено: дозволено
|
|
9
|
+
* тільки якщо значення контролюється кодом (не user input) і потрібно підставити
|
|
10
|
+
* назву таблиці/колонки або dynamic SQL/DDL. Інакше — переробити на tagged template
|
|
11
|
+
* `sql\`...\${value}...\``. Маркер фіксує цю причину для ревʼюера.
|
|
10
12
|
* - Динамічні SQL-списки у tagged template `sql\`... IN (${arr.join(',')}) ...\``:
|
|
11
13
|
* навіть «через tagged template» у запит потрапляє готовий шматок SQL замість
|
|
12
14
|
* параметризованих значень — треба `sql([...])`.
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
isSqlListContextTemplate,
|
|
22
24
|
normalizeSnippet,
|
|
23
25
|
offsetToLine,
|
|
26
|
+
parseProgramAndCommentsOrNull,
|
|
24
27
|
parseProgramOrNull,
|
|
25
28
|
walkAstWithAncestors
|
|
26
29
|
} from './ast-scan-utils.mjs'
|
|
@@ -28,6 +31,9 @@ import {
|
|
|
28
31
|
const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
|
|
29
32
|
const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
|
|
30
33
|
const IN_PLACEHOLDER_END_RE = /\bin\s*(\(\s*)?$/iu
|
|
34
|
+
// `// allow-unsafe: <reason>` — `allow-unsafe`, двокрапка, **непорожня** причина.
|
|
35
|
+
// Без причини маркер не приймається: ціль — лишити слід для ревʼюера, а не «німий» прапорець.
|
|
36
|
+
const ALLOW_UNSAFE_MARKER_RE = /\ballow-unsafe\s*:\s*\S+/u
|
|
31
37
|
|
|
32
38
|
/**
|
|
33
39
|
* @param {unknown} node AST node
|
|
@@ -192,23 +198,46 @@ function isNewSqlConstructor(node) {
|
|
|
192
198
|
}
|
|
193
199
|
|
|
194
200
|
/**
|
|
195
|
-
* Чи це виклик `<obj>.unsafe(...)`
|
|
196
|
-
*
|
|
201
|
+
* Чи це виклик `<obj>.unsafe(...)` (будь-який обʼєкт, не тільки `sql`).
|
|
202
|
+
* Файл сканується лише якщо є `import { sql|SQL } from 'bun'`, тож у практиці це
|
|
203
|
+
* або `sql.unsafe`, або `tx.unsafe` всередині `sql.begin(async tx => ...)` —
|
|
204
|
+
* обидва однаково небезпечні, тому розрізняти імʼя обʼєкта не треба.
|
|
197
205
|
* @param {unknown} node AST node
|
|
198
|
-
* @returns {boolean} true для
|
|
206
|
+
* @returns {boolean} true для будь-якого `<obj>.unsafe(...)`
|
|
199
207
|
*/
|
|
200
|
-
function
|
|
208
|
+
function isUnsafeCall(node) {
|
|
201
209
|
if (!node || node.type !== 'CallExpression') return false
|
|
202
210
|
const callee = node.callee
|
|
203
211
|
if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
|
|
204
212
|
const prop = callee.property
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
return !!prop && prop.type === 'Identifier' && prop.name === 'unsafe'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Чи є біля виклику `<obj>.unsafe(...)` маркер-коментар `// allow-unsafe: <reason>`
|
|
218
|
+
* (або `/* allow-unsafe: <reason> *\/`) на тому ж рядку, що й початок виклику,
|
|
219
|
+
* або на рядку, що передує початку виклику. Це навмисно строга суміжність:
|
|
220
|
+
* відірваний коментар через порожній рядок не зараховується — щоб маркер
|
|
221
|
+
* стояв саме біля виклику, а не «загубився десь вище».
|
|
222
|
+
* @param {{ start: number }} callNode виклик `<obj>.unsafe(...)`
|
|
223
|
+
* @param {{ type: 'Line' | 'Block', value: string, start: number, end: number }[]} comments коментарі з парсера
|
|
224
|
+
* @param {string} content вихідний код
|
|
225
|
+
* @returns {boolean} true, якщо маркер знайдено
|
|
226
|
+
*/
|
|
227
|
+
function hasAllowUnsafeMarkerNear(callNode, comments, content) {
|
|
228
|
+
const callStartLine = offsetToLine(content, callNode.start)
|
|
229
|
+
for (const c of comments) {
|
|
230
|
+
if (!c || (c.type !== 'Line' && c.type !== 'Block')) continue
|
|
231
|
+
if (typeof c.value !== 'string' || !ALLOW_UNSAFE_MARKER_RE.test(c.value)) continue
|
|
232
|
+
const startLine = offsetToLine(content, c.start)
|
|
233
|
+
const endLine = offsetToLine(content, c.end)
|
|
234
|
+
// trailing-коментар на тому ж рядку (`sql.unsafe(...) // allow-unsafe: ...`)
|
|
235
|
+
if (startLine === callStartLine) return true
|
|
236
|
+
// коментар на рядку, що безпосередньо передує виклику — для блокових
|
|
237
|
+
// коментарів важливим є саме `endLine`, бо block може займати кілька рядків.
|
|
238
|
+
if (endLine === callStartLine - 1) return true
|
|
239
|
+
}
|
|
240
|
+
return false
|
|
212
241
|
}
|
|
213
242
|
|
|
214
243
|
/**
|
|
@@ -236,19 +265,28 @@ export function findBunSqlPerRequestConnectionInText(content, virtualPath = 'sca
|
|
|
236
265
|
}
|
|
237
266
|
|
|
238
267
|
/**
|
|
239
|
-
* Знаходить виклики
|
|
268
|
+
* Знаходить виклики `<obj>.unsafe(...)` без маркера-коментаря `// allow-unsafe: <reason>`
|
|
269
|
+
* на тому ж рядку або рядком вище. `sql.unsafe` за замовчуванням заборонено: дозволено
|
|
270
|
+
* лише коли значення контролюється кодом (не user input) і потрібно підставити те, що
|
|
271
|
+
* не можна параметризувати — назву таблиці/колонки або dynamic SQL/DDL. У всіх інших
|
|
272
|
+
* випадках — переробити на tagged template `sql\`...\${value}...\``.
|
|
273
|
+
*
|
|
274
|
+
* Маркер-коментар фіксує причину для ревʼюера й одночасно слугує opt-in: без нього
|
|
275
|
+
* перевірка падає, навіть якщо у `unsafe` лежить статичний рядок без інтерполяції.
|
|
240
276
|
* @param {string} content вихідний код
|
|
241
277
|
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
242
278
|
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
243
279
|
*/
|
|
244
|
-
export function
|
|
245
|
-
const
|
|
246
|
-
if (!
|
|
280
|
+
export function findBunSqlUnsafeUseWithoutAllowMarkerInText(content, virtualPath = 'scan.ts') {
|
|
281
|
+
const parsed = parseProgramAndCommentsOrNull(content, virtualPath)
|
|
282
|
+
if (!parsed) return []
|
|
283
|
+
const { program, comments } = parsed
|
|
247
284
|
|
|
248
285
|
/** @type {{ line: number, snippet: string }[]} */
|
|
249
286
|
const out = []
|
|
250
287
|
walkAstWithAncestors(program, [], node => {
|
|
251
|
-
if (!
|
|
288
|
+
if (!isUnsafeCall(node)) return
|
|
289
|
+
if (hasAllowUnsafeMarkerNear(node, comments, content)) return
|
|
252
290
|
out.push({
|
|
253
291
|
line: offsetToLine(content, node.start),
|
|
254
292
|
snippet: normalizeSnippet(content.slice(node.start, node.end))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
3
|
-
"plugins": ["unicorn", "oxc", "import", "jsdoc", "promise", "node"],
|
|
3
|
+
"plugins": ["unicorn", "oxc", "import", "jsdoc", "promise", "node", "vue"],
|
|
4
4
|
"jsPlugins": ["@e18e/eslint-plugin"],
|
|
5
5
|
"categories": {},
|
|
6
6
|
"rules": {},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
3
|
-
"plugins": ["unicorn", "oxc", "import", "jsdoc", "promise", "node"],
|
|
3
|
+
"plugins": ["unicorn", "oxc", "import", "jsdoc", "promise", "node", "vue"],
|
|
4
4
|
"jsPlugins": ["@e18e/eslint-plugin"],
|
|
5
5
|
"categories": {},
|
|
6
6
|
"rules": {
|