@nitra/cursor 1.8.210 → 1.8.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,66 @@
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.213] - 2026-05-09
8
+
9
+ ### Added
10
+
11
+ - Нове правило `js-bun-redis` (`npm/mdc/js-bun-redis.mdc`): заміна `ioredis` /
12
+ `node-redis` (включно з кореневим `redis` v4 і підпакетами `@redis/*`) на
13
+ Bun native Redis (`import { redis } from 'bun'`,
14
+ <https://bun.com/docs/runtime/redis>).
15
+ - AST-сканер `npm/scripts/utils/redis-imports.mjs` (`oxc-parser`) ловить
16
+ `import` / `require` / динамічний `import()` пакетів `ioredis`, `node-redis`,
17
+ `redis`, підшляхів `ioredis/...` / `redis/...` і `@redis/*`. Не зачіпає
18
+ сторонні `redis-*` (наприклад, `redis-mock`).
19
+ - `npm/scripts/check-js-bun-redis.mjs` запускає AST-скан по JS/TS-джерелах і
20
+ доступний як `npx @nitra/cursor check js-bun-redis`.
21
+ - Rego-полісі `npm/policy/js_bun_redis/package_json/` — заборона
22
+ `ioredis` / `node-redis` / `redis` / `@redis/*` у `dependencies` будь-якого
23
+ `package.json` у дереві; зареєстрована таргетом у
24
+ `npm/scripts/lint-conftest.mjs` (`bun run lint-conftest`).
25
+ - Авто-увімкнення правила в `.n-cursor.json`: `npm/scripts/auto-rules.mjs`
26
+ додає `js-bun-redis`, якщо в `dependencies` хоч одного `package.json` є
27
+ `ioredis` або `node-redis` (умова — у `npm/bin/auto-rules.md`).
28
+ - Тести: `npm/tests/redis-imports.test.mjs` (AST-сканер) і нові кейси у
29
+ `npm/tests/auto-rules.test.mjs` (детект `ioredis` / `node-redis`).
30
+
31
+ ## [1.8.212] - 2026-05-08
32
+
33
+ ### Changed
34
+
35
+ - `npm/skills/taze/SKILL.md`: повний workflow замість шаблону-заглушки. Тепер
36
+ скіл бекапить `package.json`/`bun.lock`, виконує `bunx taze -w -r latest` +
37
+ `bun install`, виявляє major-стрибки порівнянням з бекапом, тягне breaking
38
+ changes з CHANGELOG модуля або git-діфу `node_modules` (з фолбеком на
39
+ встановлення старої версії в `/tmp`), шукає використання зачепленого API в
40
+ коді через `rg`, рефакторить несумісні місця (нетривіальні міграції — TODO),
41
+ прибирає тимчасові файли і віддає структурований звіт користувачу.
42
+
43
+ ## [1.8.211] - 2026-05-08
44
+
45
+ ### Added
46
+
47
+ - Окремий шлях автодетекту для скілів — `npm/bin/auto-skills.md` +
48
+ `npm/scripts/auto-skills.mjs` (`detectAutoSkills`). Скіли отримують свій
49
+ словник умов (`skill - [rules]`), залежний від уже виявлених правил, тож не
50
+ дублюють файлові ознаки з `auto-rules.md`.
51
+ - Нові авто-скіли: `publish-telegram` (завжди) і `taze` (за правилом `bun`).
52
+ - `npm/tests/auto-skills.test.mjs` — окремі тести `detectAutoSkills`
53
+ (завжди-додавані, залежності від rule-id, `disable-skills`, фільтр за
54
+ `availableSkills`).
55
+
56
+ ### Changed
57
+
58
+ - `npm/scripts/auto-rules.mjs`: `detectAutoRulesAndSkills` → `detectAutoRules`
59
+ (повертає лише `{ rules }`); прибрано `AUTO_SKILL_ORDER` і скіл-логіку.
60
+ `mergeConfigWithAutoDetected` лишається спільним і приймає вже виявлені
61
+ rules+skills, тож публічний контракт `.n-cursor.json` не змінився.
62
+ - `npm/bin/n-cursor.js` тепер послідовно викликає `detectAutoRules` і
63
+ `detectAutoSkills` (скіли отримують `detectedRules` як вхід).
64
+ - `npm/bin/auto-rules.md` залишає тільки правила; секція скілів винесена в
65
+ `auto-skills.md` з посиланням з `auto-rules.md`.
66
+
7
67
  ## [1.8.210] - 2026-05-08
8
68
 
9
69
  ### Added
@@ -18,7 +78,7 @@
18
78
  `sqlFormat` / `pgFmt` з `%L`/`%I`/`%s` у тілі, плюс `quoteLiteral` /
19
79
  `quoteIdent` / `escapeLiteral` / `escapeIdent` без додаткової перевірки)
20
80
  та `findPgFormatLikeQueryWrapperInText` (`{ query(text, params) { ...
21
- <obj>.unsafe(...) ... } }`). Скан запускається лише у файлах з
81
+ <obj>.unsafe(...) ... } }`). Скан запускається лише у файлах з
22
82
  `import { sql|SQL } from 'bun'`.
23
83
  - `npm/scripts/check-js-bun-db.mjs` рапортує `pgFormatShim` / `queryWrapper` —
24
84
  окремі лічильники й `pass`-рядки, без зміни існуючих перевірок.
@@ -69,10 +129,10 @@
69
129
  (prettier-залежність, `@nitra/eslint-config ≥ 3.9.2`),
70
130
  `checkPackageJsonTypeModule` для root, `checkEnginesNode/Bun` для root,
71
131
  канонічний `lint-js`-скрипт, валідація `lint-js.yml` (`verifyLintJsWorkflowStructure`
72
- + fallback). Лишилися — `.oxlintrc.json` canonical-snapshot, VSCode-розширення,
73
- workspace-ітерація для `type: "module"` і engines, дубль JS-кроків у `lint.yml`,
74
- `.jscpd.json`. Прибрано непотрібні імпорти `parseWorkflowYaml`,
75
- `verifyLintJsWorkflowStructure` і `OXLINT_FIX_RE`.
132
+ - fallback). Лишилися — `.oxlintrc.json` canonical-snapshot, VSCode-розширення,
133
+ workspace-ітерація для `type: "module"` і engines, дубль JS-кроків у `lint.yml`,
134
+ `.jscpd.json`. Прибрано непотрібні імпорти `parseWorkflowYaml`,
135
+ `verifyLintJsWorkflowStructure` і `OXLINT_FIX_RE`.
76
136
  - `npm/scripts/check-js-run.mjs` — без перевірок `bunyan` / `@nitra/bunyan` у
77
137
  залежностях, canonical `jsconfig.json` через `deepEqualJson`,
78
138
  `OTEL_RESOURCE_ATTRIBUTES` у `configmap.yaml`. Лишилися AST-скан коду
@@ -184,7 +244,7 @@
184
244
  - `check-k8s.mjs` (автоконверт `image-replace` patches → `images:`): тепер працює і для `patches[i].patch` із **кількома** ops, а не лише з одинокою image-replace op. Сканує всі ops у патчі, конвертує **кожну** `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у запис `images:`; якщо всі ops патча конвертовано — `patches[i]` видаляється повністю; інакше inline `patch:` переписується через `parseDocument` без конвертованих ops зі збереженням block-literal scalar (`|-`) і вихідного порядку решти ops. Реалізовано через нові функції `tryParseJson6902Array` (≥ 1 op, замість `tryParseSingleJson6902Array`) і `rewriteInlinePatchWithoutOps`; `imageReplaceDeploymentPatchInfo` повертає `{ deployName, totalOps, ops: [{ containerIndex, newImage, opIndex }] }` (раніше — одиничний `{ deployName, containerIndex, newImage }` лише за `length === 1`); `applyConversionsToDoc` групує конвертації по індексу патча й вирізає ops або сам патч за потреби. Сортування решти ops після видалення лишається поза цією зміною — за нього відповідає окрема перевірка `kustomizationInlinePatchOpsSortedViolation`.
185
245
  - `mdc/k8s.mdc` (v1.26 → v1.27): уточнено крок 1 авто-перевірки в розділі «Зміна image — через `images:`, не через `patches[]`» — тепер описує і випадок, коли в `patches[i].patch` лишаються не-image ops (їх зберігає, у вихідному порядку, без коментарів).
186
246
  - `check-js-lint.mjs` + `mdc/js-lint.mdc` (v1.16 → v1.17): мінімум `@nitra/eslint-config` піднято з `^3.8.0` до `^3.9.2`. Обґрунтування: з 3.9.2 у `getConfig` вбудовано ignore для `**/adr/**`, тож ADR-документи не валідуються ESLint, і консьюмерам не треба додавати цей glob у `eslint.config.js` локально. `nitraEslintConfigMeetsMinVersion` тепер повертає `false` для діапазонів `^3.8.x`–`^3.9.1`; `workspace:*` лишається ok без змін. Pass/fail-повідомлення `checkPackageJsonLintDeps` оновлено під новий мінімум; `for...in`-бан з 3.8.0 згадується як накопичена відмінність. Тести `nitraEslintConfigMeetsMinVersion` розширено: `^3.9.2`/`^3.9.10`/`^3.10.0`/`^4.0.0` — ok; `^3.9.1`/`^3.8.0`/`^3.6.12`/`^3.4.3` — ні.
187
- - `bin/n-cursor.js` (`reexecIfPackageVersionChanged` + `spawnSync`-виклик): `process.env.NITRA_CURSOR_REEXEC` і `...process.env` замінено на `env.NITRA_CURSOR_REEXEC` і `...env` з `node:process` (`import { cwd, env } from 'node:process'`). Підстава: правило `js-run.mdc` забороняє прямий `process.env.*` у Node-коді; `NITRA_CURSOR_REEXEC` — опційна змінна (виставляється лише при re-exec), тож імпорт `env` з `node:process` (а не з `@nitra/check-env`) — канонічна форма для опційних. Поведінка не змінена; раніше `npm/scripts/check-js-run.mjs` помилявся на `bin/n-cursor.js:1136` (правило `process-env`), тепер intergation-test `check-* на реальному репозиторії` проходить.
247
+ - `bin/n-cursor.js` (`reexecIfPackageVersionChanged` + `spawnSync`-виклик): `process.env.NITRA_CURSOR_REEXEC` і `...process.env` замінено на `env.NITRA_CURSOR_REEXEC` і `...env` з `node:process` (`import { cwd, env } from 'node:process'`). Підстава: правило `js-run.mdc` забороняє прямий `process.env.*` у Node-коді; `NITRA_CURSOR_REEXEC` — опційна змінна (виставляється лише при re-exec), тож імпорт `env` з `node:process` (а не з `@nitra/check-env`) — канонічна форма для опційних. Поведінка не змінена; раніше `npm/scripts/check-js-run.mjs` помилявся на `bin/n-cursor.js:1136` (правило `process-env`), тепер integration-test `check-* на реальному репозиторії` проходить.
188
248
 
189
249
  ### Added
190
250
 
package/bin/auto-rules.md CHANGED
@@ -1,6 +1,6 @@
1
- # Авто вмикання правил та скілів
1
+ # Авто вмикання правил
2
2
 
3
- В цьому файлі описані умови, по яким повинні скіли та правила автододаватись в конфіг
3
+ В цьому файлі описані умови, по яким повинні правила автододаватись в конфіг. Умови автододавання скілів — у `auto-skills.md` (виносить логіку зі словника правил у пару «правило → скіл»).
4
4
 
5
5
  ## Правила, які автоматично додається до .n-cursor.json
6
6
 
@@ -34,6 +34,8 @@ js-mssql - якщо в хоч одному package.json в секції dependen
34
34
 
35
35
  js-bun-db - якщо в хоч одному package.json в секції dependencies присутній пакет pg, pg-format або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
36
36
 
37
+ js-bun-redis - якщо в хоч одному package.json в секції dependencies присутній пакет ioredis або node-redis
38
+
37
39
  k8s - якщо присутня хоч одна директорія k8s
38
40
 
39
41
  nginx-default-tpl - якщо присутній хоч один файл з переліку - default.conf.template, default.conf, nginx.conf
@@ -50,16 +52,6 @@ text - завжди
50
52
 
51
53
  vue - якщо присутній хоч один vue файл
52
54
 
53
- ## Скіли, які автоматично додається до .n-cursor.json
54
-
55
- abie-kustomize - якщо в кореневому package.json в секції "repository" присутній текст "<https://github.com/abinbevefes/**/>"
56
-
57
- fix - завжди
58
-
59
- lint - завжди
60
-
61
55
  ## Виключення
62
56
 
63
57
  Якщо в .n-cursor.json задано в секції disable-rules правило, то воно автоматично додаватись не повинно.
64
-
65
- Якщо в .n-cursor.json задано в секції disable-skills скіл, то він автоматично додаватись не повинен.
@@ -0,0 +1,21 @@
1
+ # Авто вмикання скілів
2
+
3
+ В цьому файлі описані умови, по яким повинні скіли автододаватись в конфіг.
4
+
5
+ ## Скіли, які автоматично додається до .n-cursor.json
6
+
7
+ Синтаксис `skill - [rules]` означає: скіл `skill` варто автододати лише якщо всі правила у списку `[rules]` уже додані до конфігу автодетектом (з `auto-rules.md`). Так залежність не дублює вихідну умову правила.
8
+
9
+ abie-kustomize - [abie]
10
+
11
+ fix - завжди
12
+
13
+ lint - завжди
14
+
15
+ publish-telegram - завжди
16
+
17
+ taze - [bun]
18
+
19
+ ## Виключення
20
+
21
+ Якщо в .n-cursor.json задано в секції disable-skills скіл, то він автоматично додаватись не повинен.
package/bin/n-cursor.js CHANGED
@@ -58,12 +58,13 @@ import { fileURLToPath } from 'node:url'
58
58
 
59
59
  import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
60
60
  import {
61
- detectAutoRulesAndSkills,
61
+ detectAutoRules,
62
62
  detectLegacyRuleIds,
63
63
  mergeConfigWithAutoDetected,
64
64
  normalizeIdList,
65
65
  RULE_MIGRATIONS
66
66
  } from '../scripts/auto-rules.mjs'
67
+ import { detectAutoSkills } from '../scripts/auto-skills.mjs'
67
68
  import { runStopHookCli } from '../scripts/claude-stop-hook.mjs'
68
69
  import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
69
70
  import { runLintGaCli } from '../scripts/lint-ga.mjs'
@@ -250,19 +251,22 @@ async function readConfig(paths = {}) {
250
251
  const rootPkg = await readRootPackageJsonSafe()
251
252
  const disableRules = normalizeIdList(parsedConfig['disable-rules'])
252
253
  const disableSkills = normalizeIdList(parsedConfig['disable-skills'])
253
- const autoDetected = await detectAutoRulesAndSkills({
254
+ const autoDetectedRules = await detectAutoRules({
254
255
  root: cwd(),
255
256
  availableRules,
256
- availableSkills,
257
257
  packageJsonParsed: rootPkg,
258
- disableRules,
258
+ disableRules
259
+ })
260
+ const autoDetectedSkills = detectAutoSkills({
261
+ availableSkills,
262
+ detectedRules: autoDetectedRules.rules,
259
263
  disableSkills
260
264
  })
261
265
 
262
266
  const merged = mergeConfigWithAutoDetected({
263
267
  config: parsedConfig,
264
- detectedRules: autoDetected.rules,
265
- detectedSkills: autoDetected.skills
268
+ detectedRules: autoDetectedRules.rules,
269
+ detectedSkills: autoDetectedSkills.skills
266
270
  })
267
271
 
268
272
  const rest = Object.fromEntries(Object.entries(parsedConfig).filter(([k]) => k !== '$schema'))
@@ -283,16 +287,19 @@ async function readConfig(paths = {}) {
283
287
 
284
288
  if (!existsSync(configPath)) {
285
289
  const rootPkg = await readRootPackageJsonSafe()
286
- const autoDetected = await detectAutoRulesAndSkills({
290
+ const autoDetectedRules = await detectAutoRules({
287
291
  root: cwd(),
288
292
  availableRules,
289
- availableSkills,
290
293
  packageJsonParsed: rootPkg
291
294
  })
295
+ const autoDetectedSkills = detectAutoSkills({
296
+ availableSkills,
297
+ detectedRules: autoDetectedRules.rules
298
+ })
292
299
  const defaultConfig = sortConfigIdArrays({
293
300
  $schema: CONFIG_SCHEMA_URL,
294
- rules: autoDetected.rules,
295
- skills: autoDetected.skills
301
+ rules: autoDetectedRules.rules,
302
+ skills: autoDetectedSkills.skills
296
303
  })
297
304
  await writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8')
298
305
  console.log(
@@ -1127,7 +1134,7 @@ async function readBundledVersionAt(packageRoot) {
1127
1134
  * Якщо `upgradeNitraCursorToLatestAndBunInstall` встановив у `node_modules/@nitra/cursor` версію,
1128
1135
  * відмінну від тієї, з якої стартував поточний процес (наприклад, з npx-кешу), запускає бінар нової
1129
1136
  * версії через `spawnSync` і завершує поточний процес із успадкованим exit-кодом. Re-exec потрібен,
1130
- * бо ES-модулі вже завантажені у V8 (RULE_MIGRATIONS, detectAutoRulesAndSkills тощо) і нова логіка
1137
+ * бо ES-модулі вже завантажені у V8 (RULE_MIGRATIONS, detectAutoRules тощо) і нова логіка
1131
1138
  * без повної заміни процесу не підхопиться. Захист від нескінченного циклу — env `NITRA_CURSOR_REEXEC=1`.
1132
1139
  * @param {string} effectivePackageRoot шлях, повернутий `upgradeNitraCursorToLatestAndBunInstall`
1133
1140
  * @returns {Promise<void>} повертається лише якщо re-exec не потрібен (інакше викликає `process.exit`)
package/mdc/js-bun-db.mdc CHANGED
@@ -26,7 +26,7 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
26
26
  - допоміжні `quoteLiteral`, `quoteIdent`, `escapeLiteral`, `escapeIdent` як публічні експорти модуля;
27
27
  - обгортки `pgRead.query(text, params)` / `pgWrite.query(text, params)` / `db.query(text, params)`, які складають SQL-рядок (з або без `format`) і викликають `sql.unsafe(text, params)` — це повертає injection-поверхню, від якої ми йдемо, тільки під «зручним» іменем.
28
28
 
29
- Замість цього всі точки використання потрібно перевести на tagged template `sql\`...\${value}...\``. Кожне `${value}` стає окремим параметром bind, без рядкового екранування.
29
+ Замість цього всі точки використання потрібно перевести на tagged template `sql\`...\${value}...\``. Кожне`${value}` стає окремим параметром bind, без рядкового екранування.
30
30
 
31
31
  ### Типові ідіоми `pg-format` → Bun SQL
32
32
 
@@ -0,0 +1,21 @@
1
+ ---
2
+ description: Використання Redis/Valkey з Bun
3
+ alwaysApply: true
4
+ version: '1.0'
5
+ ---
6
+
7
+ ## Підтримувані версії redis
8
+
9
+ Redis 7.2+
10
+
11
+ ## Заміна на Bun native Redis
12
+
13
+ Якщо в проєкті використовуються бібліотеки `ioredis`, `node-redis`, їх потрібно замінити на Bun native Redis: <https://bun.com/docs/runtime/redis>.
14
+
15
+ - Видалити з `dependencies`: `ioredis`, `node-redis`.
16
+ - Видалити з коду: усі `import` / `require` цих пакетів та власні обгортки над ними.
17
+ - Замінити на `import { redis } from 'bun'`
18
+
19
+ ## Перевірка
20
+
21
+ `npx @nitra/cursor check js-bun-redis`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.210",
3
+ "version": "1.8.213",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,37 @@
1
+ # Перевірка `dependencies` для правила `js-bun-redis.mdc` — паралель до
2
+ # `npm/policy/js_bun_db/package_json/package_json.rego`.
3
+ #
4
+ # Запуск (локально, для будь-якого `package.json` у дереві):
5
+ # conftest test path/to/package.json -p npm/policy/js_bun_redis \
6
+ # --namespace js_bun_redis.package_json
7
+ #
8
+ # Перевіряє: у `dependencies` не повинно бути `ioredis`, `node-redis`,
9
+ # `redis` або жодного з підпакетів `@redis/*` — заміна на Bun native Redis
10
+ # (https://bun.com/docs/runtime/redis).
11
+ #
12
+ # AST-скан коду (`import` / `require` / dynamic `import()` тих самих пакетів)
13
+ # лишається у `npm/scripts/check-js-bun-redis.mjs` (потребує `oxc-parser`).
14
+ #
15
+ # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
16
+ # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`
17
+ # (.cursor/rules/conftest.mdc). Лінт — `bun run lint-rego` (regal).
18
+ package js_bun_redis.package_json
19
+
20
+ import rego.v1
21
+
22
+ forbidden_dependencies := {
23
+ "ioredis",
24
+ "node-redis",
25
+ "redis",
26
+ "@redis/client",
27
+ "@redis/json",
28
+ "@redis/search",
29
+ "@redis/time-series",
30
+ "@redis/bloom",
31
+ }
32
+
33
+ deny contains msg if {
34
+ some pkg_name in forbidden_dependencies
35
+ pkg_name in object.keys(object.get(input, "dependencies", {}))
36
+ msg := sprintf("dependencies містить заборонений %q — заміни на Bun native Redis (js-bun-redis.mdc)", [pkg_name])
37
+ }
@@ -1,13 +1,16 @@
1
1
  /**
2
- * Автовизначення правил і skills для `.n-cursor.json` за умовами з `npm/bin/auto-rules.md`.
2
+ * Автовизначення правил для `.n-cursor.json` за умовами з `npm/bin/auto-rules.md`.
3
3
  *
4
4
  * Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source,
5
- * залежності `mssql` / `pg` / `pg-format` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
6
- * `package.json`, `config.yaml` з рядком `metadata_directory: metadata` для hasura)
7
- * та повертає ідентифікатори правил і skills, які потрібно автододати.
5
+ * залежності `mssql` / `pg` / `pg-format` / `mysql2` / `ioredis` / `node-redis` у `package.json`,
6
+ * імпорт `sql`/`SQL` з `bun`, кореневий `package.json`, `config.yaml` з рядком
7
+ * `metadata_directory: metadata` для hasura) та повертає ідентифікатори правил, які потрібно автододати.
8
8
  *
9
- * Також враховує винятки `disable-rules` і `disable-skills`: елементи з цих списків не
10
- * додаються автоматично.
9
+ * Враховує винятки `disable-rules`: елементи зі списку не додаються автоматично.
10
+ *
11
+ * Автодетект скілів — у `./auto-skills.mjs` (умови — у `npm/bin/auto-skills.md`).
12
+ * `mergeConfigWithAutoDetected` нижче приймає вже виявлені rules і skills і вливає
13
+ * їх у конфіг із поправкою на legacy-id (`migrateRuleIds`).
11
14
  */
12
15
  import { existsSync } from 'node:fs'
13
16
  import { readdir, readFile } from 'node:fs/promises'
@@ -36,6 +39,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
36
39
  'js-lint',
37
40
  'js-mssql',
38
41
  'js-bun-db',
42
+ 'js-bun-redis',
39
43
  'js-run',
40
44
  'k8s',
41
45
  'nginx-default-tpl',
@@ -47,9 +51,6 @@ export const AUTO_RULE_ORDER = Object.freeze([
47
51
  'vue'
48
52
  ])
49
53
 
50
- /** Порядок автододавання skills відповідно до `auto-rules.md`. */
51
- export const AUTO_SKILL_ORDER = Object.freeze(['abie-kustomize', 'fix', 'lint'])
52
-
53
54
  /**
54
55
  * Карта міграції застарілих rule-id у `.n-cursor.json` на актуальні.
55
56
  * Застосовується автоматично при читанні конфігу (як для `rules`, так і для `disable-rules`).
@@ -575,29 +576,23 @@ function resolveRuleDependencies(detectedRules, addRule) {
575
576
  }
576
577
 
577
578
  /**
578
- * Визначає авто-правила та skills згідно з `auto-rules.md`.
579
+ * Визначає авто-правила згідно з `auto-rules.md`.
579
580
  * @param {object} params параметри аналізу
580
581
  * @param {string} params.root абсолютний шлях до кореня репозиторію
581
582
  * @param {string[]} params.availableRules перелік доступних правил з пакету
582
- * @param {string[]} params.availableSkills перелік доступних skills з пакету
583
583
  * @param {unknown} params.packageJsonParsed кореневий package.json (розпарсений) або null
584
584
  * @param {string[]} [params.disableRules] список `disable-rules` з конфігу
585
- * @param {string[]} [params.disableSkills] список `disable-skills` з конфігу
586
- * @returns {Promise<{ rules: string[], skills: string[] }>} списки id у стабільному порядку
585
+ * @returns {Promise<{ rules: string[] }>} список id у стабільному порядку (за `AUTO_RULE_ORDER`)
587
586
  */
588
- export async function detectAutoRulesAndSkills({
587
+ export async function detectAutoRules({
589
588
  root,
590
589
  availableRules,
591
- availableSkills,
592
590
  packageJsonParsed,
593
- disableRules = DEFAULT_DISABLED_LIST,
594
- disableSkills = DEFAULT_DISABLED_LIST
591
+ disableRules = DEFAULT_DISABLED_LIST
595
592
  }) {
596
593
  const facts = await collectAutoRuleFacts(root)
597
594
  const normalizedRules = new Set(availableRules.map(r => r.trim().toLowerCase()))
598
- const normalizedSkills = new Set(availableSkills.map(s => s.trim().toLowerCase()))
599
595
  const disableRulesSet = new Set(disableRules)
600
- const disableSkillsSet = new Set(disableSkills)
601
596
 
602
597
  const packageJsonExists = existsSync(join(root, 'package.json'))
603
598
  const npmDirExists = existsSync(join(root, 'npm'))
@@ -608,16 +603,22 @@ export async function detectAutoRulesAndSkills({
608
603
  : null
609
604
  )
610
605
  const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
611
- const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'pg-format', 'mysql2'])
606
+ const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, [
607
+ 'mssql',
608
+ 'pg',
609
+ 'pg-format',
610
+ 'mysql2',
611
+ 'ioredis',
612
+ 'node-redis'
613
+ ])
612
614
  const hasMssqlDependency = depHits.has('mssql')
613
615
  const hasJsBunDbSignal =
614
616
  depHits.has('pg') || depHits.has('pg-format') || depHits.has('mysql2') || facts.hasBunSqlImport
617
+ const hasJsBunRedisSignal = depHits.has('ioredis') || depHits.has('node-redis')
615
618
  const hasNestedNodePackage = await hasNestedPackageJsonWithoutViteDevDependency(root)
616
619
 
617
620
  /** @type {string[]} */
618
621
  const detectedRules = []
619
- /** @type {string[]} */
620
- const detectedSkills = []
621
622
 
622
623
  /**
623
624
  * Додає правило до результату, якщо воно доступне і не в disable-списку.
@@ -631,18 +632,6 @@ export async function detectAutoRulesAndSkills({
631
632
  detectedRules.push(ruleId)
632
633
  }
633
634
 
634
- /**
635
- * Додає skill до результату, якщо він доступний і не в disable-списку.
636
- * @param {string} skillId id skill
637
- * @returns {void}
638
- */
639
- function addSkill(skillId) {
640
- if (!normalizedSkills.has(skillId) || disableSkillsSet.has(skillId) || detectedSkills.includes(skillId)) {
641
- return
642
- }
643
- detectedSkills.push(skillId)
644
- }
645
-
646
635
  const autoRuleChecks = [
647
636
  { enabled: isAbie, id: 'abie' },
648
637
  { enabled: packageJsonExists, id: 'bun' },
@@ -654,6 +643,7 @@ export async function detectAutoRulesAndSkills({
654
643
  { enabled: facts.hasJsLikeSource, id: 'js-lint' },
655
644
  { enabled: hasMssqlDependency, id: 'js-mssql' },
656
645
  { enabled: hasJsBunDbSignal, id: 'js-bun-db' },
646
+ { enabled: hasJsBunRedisSignal, id: 'js-bun-redis' },
657
647
  { enabled: hasNestedNodePackage, id: 'js-run' },
658
648
  { enabled: facts.hasK8sDir, id: 'k8s' },
659
649
  { enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
@@ -673,20 +663,8 @@ export async function detectAutoRulesAndSkills({
673
663
  }
674
664
  resolveRuleDependencies(detectedRules, addRule)
675
665
 
676
- const autoSkillChecks = [
677
- { enabled: isAbie, id: 'abie-kustomize' },
678
- { enabled: true, id: 'fix' },
679
- { enabled: true, id: 'lint' }
680
- ]
681
- for (const item of autoSkillChecks) {
682
- if (item.enabled) {
683
- addSkill(item.id)
684
- }
685
- }
686
-
687
666
  const rules = AUTO_RULE_ORDER.filter(ruleId => detectedRules.includes(ruleId))
688
- const skills = AUTO_SKILL_ORDER.filter(skillId => detectedSkills.includes(skillId))
689
- return { rules, skills }
667
+ return { rules }
690
668
  }
691
669
 
692
670
  /**
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Автовизначення skills для `.n-cursor.json` за умовами з `npm/bin/auto-skills.md`.
3
+ *
4
+ * Скіли автододаються залежно від уже виявлених правил (auto-rules) — щоб не дублювати
5
+ * умови, які вже формалізовані для відповідного правила. Наприклад:
6
+ *
7
+ * - `abie-kustomize - [abie]` — додається разом з правилом `abie`
8
+ * - `taze - [bun]` — додається разом з правилом `bun`
9
+ *
10
+ * Скіли без секції `[rules]` у `auto-skills.md` (`fix`, `lint`, `publish-telegram`)
11
+ * додаються завжди, якщо доступні в пакеті й не у `disable-skills`.
12
+ */
13
+
14
+ /** Порядок автододавання skills відповідно до `auto-skills.md`. */
15
+ export const AUTO_SKILL_ORDER = Object.freeze(['abie-kustomize', 'fix', 'lint', 'publish-telegram', 'taze'])
16
+
17
+ /**
18
+ * Залежність скілів від правил (`auto-skills.md` синтаксис `skill - [rules]`).
19
+ * Ключ варто автододати, коли всі правила-залежності вже додані до конфігу автодетектом.
20
+ */
21
+ export const AUTO_SKILL_RULE_DEPENDENCIES = Object.freeze(
22
+ /** @type {Record<string, readonly string[]>} */ ({
23
+ 'abie-kustomize': Object.freeze(['abie']),
24
+ taze: Object.freeze(['bun'])
25
+ })
26
+ )
27
+
28
+ /** Скіли без залежностей — додаються завжди (рядок «завжди» в `auto-skills.md`). */
29
+ const ALWAYS_ON_SKILLS = Object.freeze(['fix', 'lint', 'publish-telegram'])
30
+
31
+ const DEFAULT_DISABLED_LIST = Object.freeze([])
32
+
33
+ /**
34
+ * Визначає авто-skills згідно з `auto-skills.md`.
35
+ * @param {object} params параметри
36
+ * @param {string[]} params.availableSkills перелік доступних skills із пакету (id без префікса n-)
37
+ * @param {string[]} params.detectedRules id правил, виявлених auto-rules (вхідні залежності)
38
+ * @param {string[]} [params.disableSkills] список `disable-skills` з конфігу
39
+ * @returns {{ skills: string[] }} список id у стабільному порядку (за `AUTO_SKILL_ORDER`)
40
+ */
41
+ export function detectAutoSkills({ availableSkills, detectedRules, disableSkills = DEFAULT_DISABLED_LIST }) {
42
+ const normalizedSkills = new Set(availableSkills.map(s => s.trim().toLowerCase()))
43
+ const disableSkillsSet = new Set(disableSkills)
44
+ const detectedRulesSet = new Set(detectedRules)
45
+
46
+ /** @type {string[]} */
47
+ const detected = []
48
+
49
+ /**
50
+ * Додає skill до результату, якщо він доступний і не в disable-списку.
51
+ * @param {string} skillId id skill
52
+ * @returns {void}
53
+ */
54
+ function addSkill(skillId) {
55
+ if (!normalizedSkills.has(skillId) || disableSkillsSet.has(skillId) || detected.includes(skillId)) {
56
+ return
57
+ }
58
+ detected.push(skillId)
59
+ }
60
+
61
+ for (const skillId of ALWAYS_ON_SKILLS) {
62
+ addSkill(skillId)
63
+ }
64
+
65
+ for (const [skillId, deps] of Object.entries(AUTO_SKILL_RULE_DEPENDENCIES)) {
66
+ if (deps.every(d => detectedRulesSet.has(d))) {
67
+ addSkill(skillId)
68
+ }
69
+ }
70
+
71
+ return { skills: AUTO_SKILL_ORDER.filter(id => detected.includes(id)) }
72
+ }
@@ -148,9 +148,8 @@ async function checkEnvFile(relPath, expected, reporter) {
148
148
  const value = m[1].trim()
149
149
  const parsed = parseInternalHasuraEndpoint(value)
150
150
  if (!parsed.ok) {
151
-
152
151
  const example =
153
- "https://<service>.<namespace>.svc.<cluster>.internal:<port> або http://<service>.<namespace>.svc.cluster.local:<port>"
152
+ 'https://<service>.<namespace>.svc.<cluster>.internal:<port> або http://<service>.<namespace>.svc.cluster.local:<port>'
154
153
  fail(
155
154
  `${relPath}: HASURA_GRAPHQL_ENDPOINT="${value}" — потрібен внутрішній кластерний URL виду ${example} (hasura.mdc)`
156
155
  )
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Перевіряє правило `js-bun-redis.mdc`.
3
+ *
4
+ * Заборонено в JS/TS-джерелах будь-який `import` / `require` / динамічний `import()` пакетів
5
+ * `ioredis`, `node-redis`, `redis` (та підпакетів `@redis/*`, підшляхів `ioredis/...` /
6
+ * `redis/...`). Замість них треба використовувати Bun native Redis:
7
+ * `import { redis } from 'bun'` (<https://bun.com/docs/runtime/redis>).
8
+ *
9
+ * Перевірку `dependencies` (заборона `ioredis` / `node-redis` / `redis` / `@redis/*` у будь-якому
10
+ * `package.json`) винесено в Rego-полісі `npm/policy/js_bun_redis/package_json/`; її запускає
11
+ * `bun run lint-conftest`. Тут лишився AST-скан коду через `oxc-parser`.
12
+ */
13
+ import { existsSync } from 'node:fs'
14
+ import { readFile } from 'node:fs/promises'
15
+ import { join, relative } from 'node:path'
16
+
17
+ import { createCheckReporter } from './utils/check-reporter.mjs'
18
+ import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
19
+ import { findRedisImportsInText, isRedisScanSourceFile, shouldSkipFileForRedisScan } from './utils/redis-imports.mjs'
20
+ import { walkDir } from './utils/walkDir.mjs'
21
+
22
+ /**
23
+ * Збирає абсолютні шляхи JS/TS джерел у репозиторії для скану заборонених redis-імпортів.
24
+ * @param {string} repoRoot абсолютний шлях до кореня репозиторію
25
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
26
+ * @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
27
+ */
28
+ async function findAllSourcePathsForRedisScan(repoRoot, ignorePaths) {
29
+ /** @type {string[]} */
30
+ const paths = []
31
+ await walkDir(
32
+ repoRoot,
33
+ absPath => {
34
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
35
+ if (isRedisScanSourceFile(rel) && !shouldSkipFileForRedisScan(rel)) {
36
+ paths.push(absPath)
37
+ }
38
+ },
39
+ ignorePaths
40
+ )
41
+ paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
42
+ return paths
43
+ }
44
+
45
+ /**
46
+ * Сканує JS/TS-джерела на заборонені імпорти/require пакетів `ioredis` / `node-redis` / `redis`.
47
+ * @param {string[]} sourcePaths абсолютні шляхи джерел
48
+ * @param {string} repoRoot абсолютний шлях до кореня
49
+ * @param {(msg: string) => void} fail callback при помилці
50
+ * @returns {Promise<number>} кількість знайдених порушень
51
+ */
52
+ async function scanSourcesForRedisImports(sourcePaths, repoRoot, fail) {
53
+ let violations = 0
54
+ for (const absPath of sourcePaths) {
55
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
56
+ const content = await readFile(absPath, 'utf8')
57
+ for (const v of findRedisImportsInText(content, rel)) {
58
+ violations++
59
+ fail(
60
+ `js-bun-redis: ${rel}:${v.line} — заміни '${v.module}' на Bun native Redis ` +
61
+ `(import { redis } from 'bun', https://bun.com/docs/runtime/redis): ${v.snippet}`
62
+ )
63
+ }
64
+ }
65
+ return violations
66
+ }
67
+
68
+ /**
69
+ * Перевіряє відповідність проєкту правилу `js-bun-redis.mdc`.
70
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
71
+ */
72
+ export async function check() {
73
+ const reporter = createCheckReporter()
74
+ const { pass, fail } = reporter
75
+
76
+ const repoRoot = process.cwd()
77
+ if (!existsSync(join(repoRoot, 'package.json'))) {
78
+ pass('js-bun-redis: package.json у корені відсутній — перевірку пропущено')
79
+ return reporter.getExitCode()
80
+ }
81
+
82
+ const ignorePaths = await loadCursorIgnorePaths(repoRoot)
83
+ const sourcePaths = await findAllSourcePathsForRedisScan(repoRoot, ignorePaths)
84
+ if (sourcePaths.length === 0) {
85
+ pass('js-bun-redis: немає JS/TS файлів для скану імпортів ioredis / node-redis / redis')
86
+ return reporter.getExitCode()
87
+ }
88
+
89
+ const violations = await scanSourcesForRedisImports(sourcePaths, repoRoot, fail)
90
+ if (violations === 0) {
91
+ pass(
92
+ "js-bun-redis: немає імпортів 'ioredis' / 'node-redis' / 'redis' / '@redis/*' у джерелах " +
93
+ '(використовується Bun native Redis або redis взагалі не задіяно)'
94
+ )
95
+ }
96
+
97
+ return reporter.getExitCode()
98
+ }
@@ -479,7 +479,7 @@ async function validateKustomizationResourcesSortedAlphabetically(root, yamlFile
479
479
  }
480
480
 
481
481
  /**
482
- * Лексичне порівняння двох тuplіе рядків через `localeCompare('en', { sensitivity: 'base' })`.
482
+ * Лексичне порівняння двох tuple рядків через `localeCompare('en', { sensitivity: 'base' })`.
483
483
  * Менший за довжиною список доповнюється порожніми рядками.
484
484
  * @param {string[]} a перший tuple
485
485
  * @param {string[]} b другий tuple
@@ -594,7 +594,7 @@ function jsonPointerPathsAreDisjoint(paths) {
594
594
  }
595
595
 
596
596
  /**
597
- * Парсить рядок JSON6902-патчa в плоский масив операцій `{ op, path }` (без значень).
597
+ * Парсить рядок JSON6902-патча в плоский масив операцій `{ op, path }` (без значень).
598
598
  * Повертає `null`, якщо це не YAML-масив об'єктів з полями `op`/`path` як рядки.
599
599
  * @param {string} raw тіло inline `patch:` (literal block scalar)
600
600
  * @returns {{ op: string, path: string }[] | null} нормалізований список ops або `null` за невідповідного формату
@@ -183,6 +183,12 @@ const TARGETS = [
183
183
  rule: 'js-bun-db',
184
184
  walk: { match: rel => rel.endsWith('/package.json') || rel === 'package.json' }
185
185
  },
186
+ {
187
+ namespace: 'js_bun_redis.package_json',
188
+ policyDir: 'js_bun_redis',
189
+ rule: 'js-bun-redis',
190
+ walk: { match: rel => rel.endsWith('/package.json') || rel === 'package.json' }
191
+ },
186
192
  {
187
193
  namespace: 'js_run.package_json',
188
194
  policyDir: 'js_run',
@@ -257,7 +263,10 @@ function collectFiles(root, match) {
257
263
  continue
258
264
  }
259
265
  if (!e.isFile()) continue
260
- const rel = abs.slice(root.length + 1).split(sep).join('/')
266
+ const rel = abs
267
+ .slice(root.length + 1)
268
+ .split(sep)
269
+ .join('/')
261
270
  if (match(rel)) out.push(rel)
262
271
  }
263
272
  }
@@ -297,11 +306,10 @@ function runConftestForTarget(conftestBin, target, files) {
297
306
  return 0
298
307
  }
299
308
  console.log(`\n▶ conftest (${target.namespace} — ${files.length} файл(ів))`)
300
- const r = spawnSync(
301
- conftestBin,
302
- ['test', ...files, '-p', policyAbs, '--namespace', target.namespace, '--no-color'],
303
- { stdio: 'inherit', env: process.env }
304
- )
309
+ const r = spawnSync(conftestBin, ['test', ...files, '-p', policyAbs, '--namespace', target.namespace, '--no-color'], {
310
+ stdio: 'inherit',
311
+ env: process.env
312
+ })
305
313
  if (r.error) {
306
314
  console.error(`❌ Не вдалося запустити conftest: ${r.error.message}`)
307
315
  return 1
@@ -425,8 +425,7 @@ export function findPgFormatLikeQueryWrapperInText(content, virtualPath = 'scan.
425
425
  for (const prop of properties) {
426
426
  if (!prop || prop.type !== 'Property') continue
427
427
  const key = prop.key
428
- const keyName =
429
- key && key.type === 'Identifier' ? key.name : key && key.type === 'Literal' ? key.value : null
428
+ const keyName = key && key.type === 'Identifier' ? key.name : key && key.type === 'Literal' ? key.value : null
430
429
  if (keyName !== 'query') continue
431
430
  const value = prop.value
432
431
  if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) continue
@@ -100,7 +100,7 @@ function collectNamedExportNames(program) {
100
100
  (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') &&
101
101
  decl.id &&
102
102
  typeof decl.id === 'object' &&
103
- typeof /** @type {Record<string, unknown>} */ (decl.id).name === 'string'
103
+ typeof (/** @type {Record<string, unknown>} */ (decl.id).name) === 'string'
104
104
  ) {
105
105
  out.push(/** @type {string} */ (/** @type {Record<string, unknown>} */ (decl.id).name))
106
106
  }
@@ -128,7 +128,11 @@ function hasDefaultExport(program) {
128
128
  const body = /** @type {Record<string, unknown>} */ (program).body
129
129
  if (!Array.isArray(body)) return false
130
130
  for (const node of body) {
131
- if (node && typeof node === 'object' && /** @type {Record<string, unknown>} */ (node).type === 'ExportDefaultDeclaration') {
131
+ if (
132
+ node &&
133
+ typeof node === 'object' &&
134
+ /** @type {Record<string, unknown>} */ (node).type === 'ExportDefaultDeclaration'
135
+ ) {
132
136
  return true
133
137
  }
134
138
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Знаходить імпорти з `ioredis` та `node-redis` (та підшляхів `redis/...`) у джерелах —
3
+ * їх треба замінити на Bun native Redis (`import { redis } from 'bun'`) згідно з
4
+ * `js-bun-redis.mdc` (<https://bun.com/docs/runtime/redis>).
5
+ *
6
+ * Семантика береться з **oxc-parser** (`module.staticImports`) — без regex по тілу файлу.
7
+ * Додатково по AST програми ловимо `require('ioredis')` і динамічний `import('ioredis')`,
8
+ * щоб правило працювало і у CommonJS, і при динамічному `import` у межах одного файлу.
9
+ *
10
+ * `node-redis` публікується під рядом імен:
11
+ * - кореневий пакет `redis` (саме так його імпортують у v4+);
12
+ * - історичний `node-redis` (рідше);
13
+ * - підпакети, які тягнуться разом: `@redis/client`, `@redis/json`, `@redis/search`,
14
+ * `@redis/time-series`, `@redis/bloom` — їх теж треба прибирати разом із основним
15
+ * клієнтом, щоб не лишилось «половини» інтеграції після переходу на Bun.
16
+ *
17
+ * Сканер не вимагає, щоб файл компілювався: при синтаксичних помилках повертається порожній
18
+ * результат — спочатку треба полагодити синтаксис, потім перезапустити перевірку.
19
+ */
20
+ import { parseSync } from 'oxc-parser'
21
+
22
+ import { langFromPath, normalizeSnippet, offsetToLine } from './ast-scan-utils.mjs'
23
+
24
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
25
+ const FORBIDDEN_MODULE_NAMES = new Set([
26
+ 'ioredis',
27
+ 'node-redis',
28
+ 'redis',
29
+ '@redis/client',
30
+ '@redis/json',
31
+ '@redis/search',
32
+ '@redis/time-series',
33
+ '@redis/bloom'
34
+ ])
35
+
36
+ /**
37
+ * Чи є рядок-специфікатор імпорту забороненим (`ioredis`, `node-redis`, `redis`, `redis/...`,
38
+ * `ioredis/...`, `@redis/<sub>`).
39
+ *
40
+ * Використовуємо префікс-збіг для `ioredis/` та `redis/` — щоб ловити підшляхи
41
+ * (`ioredis/built/utils`, `redis/dist/...`), але не зачепити сторонні пакети
42
+ * на кшталт `redis-mock`, які треба валідувати окремо.
43
+ * @param {string} mod рядкове значення з `import '...'` / `require('...')`
44
+ * @returns {boolean} true, якщо такий specifier треба викинути на користь Bun native Redis
45
+ */
46
+ function isForbiddenRedisModule(mod) {
47
+ if (FORBIDDEN_MODULE_NAMES.has(mod)) return true
48
+ return mod.startsWith('ioredis/') || mod.startsWith('redis/') || mod.startsWith('@redis/')
49
+ }
50
+
51
+ /**
52
+ * Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
53
+ * @param {Record<string, unknown> | null | undefined} node вузол AST
54
+ * @returns {string | null} ім'я модуля з аргументу, інакше `null`
55
+ */
56
+ function requireCallModule(node) {
57
+ if (!node || node.type !== 'CallExpression') return null
58
+ const callee = node.callee
59
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return null
60
+ const arg = node.arguments?.[0]
61
+ if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
62
+ return arg.value
63
+ }
64
+
65
+ /**
66
+ * Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
67
+ * @param {Record<string, unknown> | null | undefined} node вузол AST
68
+ * @returns {string | null} ім'я модуля, інакше `null`
69
+ */
70
+ function dynamicImportModule(node) {
71
+ if (!node || node.type !== 'ImportExpression') return null
72
+ const src = node.source
73
+ if (!src || src.type !== 'Literal' || typeof src.value !== 'string') return null
74
+ return src.value
75
+ }
76
+
77
+ /**
78
+ * Простий рекурсивний обхід AST: заходимо в усі обʼєкти/масиви, щоб знайти require/import-вузли.
79
+ * @param {unknown} node корінь або під-вузол AST
80
+ * @param {(n: unknown) => void} visit виклик для кожного обʼєкта-вузла
81
+ * @returns {void}
82
+ */
83
+ function walkAst(node, visit) {
84
+ if (!node || typeof node !== 'object') return
85
+ if (Array.isArray(node)) {
86
+ for (const item of node) walkAst(item, visit)
87
+ return
88
+ }
89
+ if (typeof node.type === 'string') {
90
+ visit(node)
91
+ }
92
+ for (const key of Object.keys(node)) {
93
+ if (key !== 'parent') {
94
+ const v = node[key]
95
+ if (v && typeof v === 'object') walkAst(v, visit)
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Знаходить заборонені імпорти/require з `ioredis` / `node-redis` у тексті.
102
+ * @param {string} content вихідний код
103
+ * @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/foo.ts`)
104
+ * @returns {{ line: number, snippet: string, module: string }[]} список порушень
105
+ */
106
+ export function findRedisImportsInText(content, virtualPath = 'scan.ts') {
107
+ const pathForLang = virtualPath || 'scan.ts'
108
+ const lang = langFromPath(pathForLang)
109
+ let result
110
+ try {
111
+ result = parseSync(pathForLang, content, { lang, sourceType: 'module' })
112
+ } catch {
113
+ return []
114
+ }
115
+ if (result.errors?.length) {
116
+ return []
117
+ }
118
+
119
+ /** @type {{ line: number, snippet: string, module: string }[]} */
120
+ const out = []
121
+
122
+ for (const imp of result.module?.staticImports ?? []) {
123
+ const mod = imp.moduleRequest?.value
124
+ if (typeof mod === 'string' && isForbiddenRedisModule(mod)) {
125
+ out.push({
126
+ line: offsetToLine(content, imp.start),
127
+ snippet: normalizeSnippet(content.slice(imp.start, imp.end)),
128
+ module: mod
129
+ })
130
+ }
131
+ }
132
+
133
+ walkAst(result.program, node => {
134
+ const reqMod = requireCallModule(node)
135
+ if (reqMod && isForbiddenRedisModule(reqMod)) {
136
+ out.push({
137
+ line: offsetToLine(content, node.start),
138
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
139
+ module: reqMod
140
+ })
141
+ return
142
+ }
143
+ const dynMod = dynamicImportModule(node)
144
+ if (dynMod && isForbiddenRedisModule(dynMod)) {
145
+ out.push({
146
+ line: offsetToLine(content, node.start),
147
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
148
+ module: dynMod
149
+ })
150
+ }
151
+ })
152
+
153
+ return out
154
+ }
155
+
156
+ /**
157
+ * Чи сканувати цей файл за розширенням (JS/TS-сімʼя).
158
+ * @param {string} relativePath відносний шлях до файлу
159
+ * @returns {boolean} `true`, якщо розширення підходить для пошуку імпорту
160
+ */
161
+ export function isRedisScanSourceFile(relativePath) {
162
+ return SOURCE_FILE_RE.test(relativePath)
163
+ }
164
+
165
+ /**
166
+ * Чи слід пропустити файл під час обходу пакета (декларації типів — лише типи, не виконувані).
167
+ * @param {string} relativePosix шлях з posix-слешами
168
+ * @returns {boolean} `true`, якщо файл не сканувати
169
+ */
170
+ export function shouldSkipFileForRedisScan(relativePosix) {
171
+ return relativePosix.endsWith('.d.ts')
172
+ }
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: n-taze
3
+ description: >-
4
+ Оновлення версій модулів проекту з аналізом major-змін і автоматичним
5
+ рефакторингом несумісного коду
6
+ ---
7
+
8
+ # n-taze — Оновлення версій проекту
9
+
10
+ ## Мета
11
+
12
+ Оновити всі модулі проекту до останніх версій, виявити major-оновлення, перевірити сумісність змін з кодом проекту і за потреби зрефакторити несумісні місця.
13
+
14
+ ## Передумови
15
+
16
+ - Чисте робоче дерево (`git status` без незакомічених змін у `package.json` / `bun.lock` / `node_modules`) — інакше різницю не відрізнити від оновлення.
17
+ - Встановлений `bun` і доступний `bunx`.
18
+ - Запуск з кореня проекту (де лежить `package.json` / `bun.lock`).
19
+
20
+ ## Workflow
21
+
22
+ ### 1. Зафіксувати стартовий стан
23
+
24
+ Перед оновленням зберегти список поточних версій, щоб потім обчислити, які модулі стрибнули через major:
25
+
26
+ ```bash
27
+ cp package.json package.json.taze-bak
28
+ cp bun.lock bun.lock.taze-bak
29
+ ```
30
+
31
+ (У monorepo — також усі `*/package.json` воркспейсів. Файли тимчасові, видалити в кінці.)
32
+
33
+ ### 2. Запустити оновлення
34
+
35
+ ```bash
36
+ bunx taze -w -r latest
37
+ bun install
38
+ ```
39
+
40
+ - `-w` — записати нові версії у `package.json`.
41
+ - `-r` — рекурсивно по всіх воркспейсах.
42
+ - `latest` — піднімати навіть major.
43
+
44
+ ### 3. Виявити major-оновлення
45
+
46
+ Порівняти `package.json.taze-bak` з оновленим `package.json` (а також `bun.lock.taze-bak` ↔ `bun.lock` для транзитивних). Для кожної залежності, у якої змінилась перша значуща цифра semver (`1.x.x → 2.x.x`, `0.4.x → 0.5.x`, `0.0.x → 0.1.x`) — додати в список «потребує перевірки».
47
+
48
+ Minor/patch — пропускати, їх вважаємо сумісними.
49
+
50
+ ### 4. Зібрати breaking changes по кожному major-оновленню
51
+
52
+ Для кожного модуля зі списку зібрати фактичні відмінності одним з джерел (у порядку пріоритету):
53
+
54
+ 1. **CHANGELOG / Releases репозиторію модуля** — найшвидше і найточніше. Адресу репозиторію взяти з `package.json` модуля у `node_modules/<name>/package.json` (поле `repository`). Дістати релізи між старою і новою версією.
55
+ 2. **Git-різниця в `node_modules/<name>`** — якщо CHANGELOG відсутній або неінформативний, порівняти попередню версію (з кешу bun: `~/.bun/install/cache/<name>@<old-version>/`) з новою (`node_modules/<name>/`) через `diff -r` по `dist/` / `*.d.ts` / публічних entry-points з `exports`.
56
+ 3. **Якщо немає кешованої старої версії** — встановити її окремо в тимчасову теку (`mkdir -p /tmp/taze-old && cd /tmp/taze-old && bun add <name>@<old-version>`) і порівняти.
57
+
58
+ Цікавлять: видалені/перейменовані експорти, змінені сигнатури функцій, змінені типи, змінена поведінка за замовчуванням, видалені CLI-прапорці.
59
+
60
+ ### 5. Перевірити сумісність з кодом проекту
61
+
62
+ Для кожного breaking change знайти його використання в коді проекту:
63
+
64
+ ```bash
65
+ rg -n "<імпорт|функція|опція>" --type ts --type js --type vue
66
+ ```
67
+
68
+ Класифікувати:
69
+
70
+ - **сумісно** — проект не використовує зачеплене API → нічого не робити.
71
+ - **несумісно** — використання знайдено → перейти до п. 6.
72
+
73
+ ### 6. Рефакторинг несумісних місць
74
+
75
+ Для кожного несумісного місця — застосувати міграцію згідно з changelog модуля (перейменувати імпорт, оновити сигнатуру виклику, замінити видалену опцію еквівалентом тощо). Після правок:
76
+
77
+ ```bash
78
+ bun run lint
79
+ bun run typecheck # якщо є
80
+ bun test # якщо є
81
+ ```
82
+
83
+ Якщо міграція нетривіальна або неоднозначна — **не вгадувати**, залишити TODO у коді з посиланням на CHANGELOG і винести в підсумковий звіт як ручну дію.
84
+
85
+ ### 7. Прибрати тимчасові файли
86
+
87
+ ```bash
88
+ rm package.json.taze-bak bun.lock.taze-bak
89
+ ```
90
+
91
+ (І решту бекапів воркспейсів, якщо створювались.)
92
+
93
+ ### 8. Звіт користувачу
94
+
95
+ Коротко в одному повідомленні:
96
+
97
+ - **Оновлено (minor/patch):** кількість пакетів, без деталей.
98
+ - **Major-оновлення:** список `<name>: <old> → <new>` з посиланням на release notes.
99
+ - **Зрефакторено автоматично:** список файлів і коротко що саме змінено.
100
+ - **Потребує ручного втручання:** список TODO з причиною (нетривіальна міграція / неоднозначність / падіння тестів).
101
+ - **Стан перевірок:** `lint` / `typecheck` / `test` — pass/fail з номером рядка, де впало.
102
+
103
+ ## Примітка
104
+
105
+ - Не запускати `bun run lint` паралельно з іншими ESLint-задачами — діє правило з кореневого `CLAUDE.md`.
106
+ - Якщо проект — `npm/` пакет цього репо, після змін у `package.json` / коді треба підняти `version` і додати запис у `CHANGELOG.md` згідно з `npm/CLAUDE.md`.
107
+ - При великій кількості major-оновлень розбити PR по одному модулю на коміт — щоб `git bisect` залишався корисним.