@nitra/cursor 1.8.145 → 1.8.150

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/bin/n-cursor.js CHANGED
@@ -561,7 +561,7 @@ function buildClaudeLintParallelismSectionLines() {
561
561
  '## Лінт і ESLint (без паралельних запусків)',
562
562
  '',
563
563
  'Щоб не запускати **кілька** одночасних **`eslint`** (і не перевантажувати диск/CPU), **заборонено** стартувати `bun run lint` / `lint-js` / `eslint` **паралельно** в різних Bash-задачах, **фонових** shells чи **субагентах** (Task тощо). Має бути **один** послідовний прогон на сесію; команда **`/n-lint`** — **не** ділити на паралельні підзадачі. Деталі: `.cursor/skills/n-lint/SKILL.md`.',
564
- '',
564
+ ''
565
565
  ]
566
566
  }
567
567
 
@@ -1036,6 +1036,7 @@ async function runChecks(requestedRules) {
1036
1036
  const scriptPath = join(BUNDLED_SCRIPTS_DIR, `check-${rule}.mjs`)
1037
1037
  console.log(`📋 ${rule}:`)
1038
1038
  try {
1039
+ // eslint-disable-next-line no-unsanitized/method -- rule валідовано проти available, scriptPath будується з фіксованої BUNDLED_SCRIPTS_DIR
1039
1040
  const { check } = await import(scriptPath)
1040
1041
  const code = await check()
1041
1042
  if (code !== 0) totalFailed++
@@ -1,5 +1,3 @@
1
- #!/usr/bin/env node
2
-
3
1
  /**
4
2
  * CLI для перейменування розширень YAML (k8s та `.github`). Бізнес-логіка — у **`scripts/rename-yaml-extensions.mjs`**.
5
3
  *
package/mdc/docker.mdc CHANGED
@@ -189,8 +189,10 @@ jobs:
189
189
  ```yaml title=".hadolint.yaml"
190
190
  ignored:
191
191
  - DL3007
192
+ - DL3008
192
193
  - DL3018
193
194
  ```
195
+
194
196
  Де DL3007 - «Не використовуй тег latest у FROM»
195
197
  Де DL3018 - «Піни версії пакетів у apk add»
196
198
 
package/mdc/js-lint.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  description: Перевірка JavaScript коду
3
3
  alwaysApply: true
4
- version: '1.14'
4
+ version: '1.15'
5
5
  ---
6
6
 
7
- **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.5.0`** (з ним транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
7
+ **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.6.12`** (з ним транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
8
8
 
9
9
  ```json title=".vscode/extensions.json"
10
10
  {
@@ -25,7 +25,7 @@ version: '1.14'
25
25
  "lint-js": "bunx oxlint --fix && bunx eslint --fix . && bunx jscpd ."
26
26
  },
27
27
  "devDependencies": {
28
- "@nitra/eslint-config": "^3.5.0"
28
+ "@nitra/eslint-config": "^3.6.12"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=24"
@@ -33,7 +33,9 @@ version: '1.14'
33
33
  }
34
34
  ```
35
35
 
36
- У корені має бути **`.oxlintrc.json`** з підключенням **`@e18e/eslint-plugin`** через **`jsPlugins`** і правилом **`e18e/prefer-includes`** зі значенням **`error`**. Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.5.0**), oxlint підвантажує його з **`node_modules`**.
36
+ У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/scripts/utils/oxlint-canonical.json`** (plugins, jsPlugins з **`@e18e/eslint-plugin`**, categories, повний набір **rules** із канону — додаткові записи в **`rules`** дозволені; також **`settings`**, **`env`**, **`globals`**, **`ignorePatterns`**). Оновити можна з репозиторію пакета або скопіювавши файл після **`bun ./scripts/utils/rebuild-oxlint-canonical.mjs`** (джерело правил **`oxlint-rules.tsv`** + скелет **`oxlint-canonical-skeleton.json`**). Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.6.12**), oxlint підвантажує його з **`node_modules`**.
37
+
38
+ Мінімум для розуміння структури (реальний корінь конфігу має збігатися з каноном повністю):
37
39
 
38
40
  ```json title=".oxlintrc.json (фрагмент)"
39
41
  {
package/mdc/js-mssql.mdc CHANGED
@@ -17,6 +17,7 @@ version: '1.2'
17
17
  ## Як виконувати запити (безпечно)
18
18
 
19
19
  tagged template треба викликати на request-обʼєкті цього пулу:
20
+
20
21
  ```javascript
21
22
  javascript// db.js
22
23
  import sql from 'mssql';
@@ -58,6 +59,7 @@ const result = await pool.request().query`
58
59
  ### Не робити `query(\`...\`)`
59
60
 
60
61
  javascript// ❌ Це не tagged template — це конкатенація рядка перед викликом
62
+
61
63
  ```javascript
62
64
  await pool.request().query(`SELECT * FROM users WHERE id = ${userId}`);
63
65
  // ↑ круглі дужки замість бекті́ків = звичайна інтерполяція = SQL injection
package/mdc/k8s.mdc CHANGED
@@ -288,13 +288,13 @@ data:
288
288
 
289
289
  ## Deployment: обов'язкові `hpa.yaml`, `pdb.yaml`, `topologySpreadConstraints`
290
290
 
291
- Для **кожного** `kind: Deployment` під **`k8s/`** у тому ж каталозі мають бути **`hpa.yaml`** (HPA) і **`pdb.yaml`** (PDB), а сам Deployment — мати канонічні **`spec.template.spec.topologySpreadConstraints`**. Скрипт звіряє прив'язку за іменами:
291
+ Для **кожного** `kind: Deployment` у каталозі **`…/k8s/…/base/`** (у будь-якому файлі `.yaml`, наприклад **`deploy.yaml`**, **`deployment.yaml`**) у **тому ж каталозі** мають бути **`hpa.yaml`** (HPA) і **`pdb.yaml`** (PDB), а сам Deployment — канонічні **`spec.template.spec.topologySpreadConstraints`**. Інші workload-и (**CronJob**, **Job** тощо) або каталоги без шару **`base`** цими вимогами не охоплюються — **`check k8s`** їх не змушує додавати HPA/PDB поруч. Скрипт звіряє прив’язку за іменами:
292
292
 
293
293
  - **`hpa.yaml`** — `autoscaling/v2`, `HorizontalPodAutoscaler`, `spec.scaleTargetRef.name` **= `metadata.name`** Deployment.
294
294
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
295
295
  - **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
296
296
 
297
- **Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є **Deployment**. У `kustomization.yaml` overlay, який підключає цей `base` (наприклад, `../base` у `resources`), не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться `Deployment`. Перевіряє **`check-k8s.mjs`**.
297
+ **Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є хоча б один **`Deployment`** у YAML під **`…/k8s/…/base/`**. У `kustomization.yaml` overlay, який підключає цей `base` (наприклад, `../base` у `resources`), не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться такий Deployment. Перевіряє **`check-k8s.mjs`**.
298
298
 
299
299
  **Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
300
300
 
@@ -314,7 +314,7 @@ data:
314
314
 
315
315
  ### Прод-оверрайди у `kustomization.yaml`
316
316
 
317
- Оскільки `base/` (і dev-like середовища) тримають dev-значення (`1`/`1`/`0`), у **кожному** прод-накладенні `kustomization.yaml` у `patches[]` **обов'язково** має бути перевизначення:
317
+ Оскільки `base/` (і dev-like середовища) тримають dev-значення (`1`/`1`/`0`), у **кожному** прод-накладенні `kustomization.yaml` у `patches[]` **обов'язково** має бути перевизначення **лише якщо** цей оверлей наслідує base-дерево, де є **Deployment** і **HPA/PDB** (тобто base реально дає dev-like HPA/PDB, які треба підняти в проді):
318
318
 
319
319
  - для `HorizontalPodAutoscaler`: `spec.minReplicas` **і** `spec.maxReplicas` (щоб у проді вийшло ≥2).
320
320
  - для `PodDisruptionBudget`: `spec.minAvailable` (щоб у проді вийшло ≥1).
package/mdc/vue.mdc CHANGED
@@ -156,7 +156,7 @@ export default {
156
156
  "private": true,
157
157
  "type": "module",
158
158
  "dependencies": {
159
- "vue": "^3.5.0"
159
+ "vue": "^3.6.12"
160
160
  },
161
161
  "devDependencies": {
162
162
  "vite": "^8.0.0",
@@ -224,7 +224,7 @@ export default defineConfig({
224
224
 
225
225
  ## npm_lifecycle_event
226
226
 
227
- у більшості проектів в файлі vite.config.js
227
+ у більшості проектів в файлі vite.config.js
228
228
  є конструкція виду
229
229
 
230
230
  switch (process.env.npm_lifecycle_event) {
@@ -253,7 +253,7 @@ function getProxy(mode) {
253
253
  }
254
254
  ```
255
255
 
256
- і викликати всередині
256
+ і викликати всередині
257
257
 
258
258
  ```javascript title="vite.config.js"
259
259
  export default defineConfig(({ mode, command }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.145",
3
+ "version": "1.8.150",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -39,10 +39,10 @@
39
39
  "rename-yaml-extensions": "bun ./bin/n-cursor.js rename-yaml-extensions"
40
40
  },
41
41
  "dependencies": {
42
- "oxc-parser": "^0.124.0",
42
+ "oxc-parser": "^0.128.0",
43
43
  "yaml": "^2.8.3"
44
44
  },
45
45
  "engines": {
46
- "node": ">=24"
46
+ "node": ">=25"
47
47
  }
48
48
  }
@@ -57,12 +57,34 @@ const DEFAULT_DISABLED_LIST = Object.freeze([])
57
57
  * Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"` (після витягування `<script>` у `.vue`).
58
58
  * @param {string} content вміст файлу
59
59
  * @param {string} relativePath шлях posix відносно кореня
60
- * @returns {boolean}
60
+ * @returns {boolean} true, якщо знайдено `import { sql }` або `import { SQL }` з `"bun"`
61
61
  */
62
62
  function sourceContentHasBunSqlImport(content, relativePath) {
63
63
  return textHasBunSqlImport(contentForVueImportScan(content, relativePath))
64
64
  }
65
65
 
66
+ /**
67
+ * Зчитує `package.json` і додає в `found` усі ключі з `wanted`, що присутні в `dependencies`.
68
+ * @param {string} absPath абсолютний шлях до package.json
69
+ * @param {Set<string>} wanted множина ключів-цілей
70
+ * @param {Set<string>} found буфер знайдених ключів
71
+ * @returns {Promise<void>}
72
+ */
73
+ async function collectFoundDependencyKeysFromPackageJson(absPath, wanted, found) {
74
+ try {
75
+ const parsed = JSON.parse(await readFile(absPath, 'utf8'))
76
+ const deps = parsed?.dependencies
77
+ if (!deps || typeof deps !== 'object' || Array.isArray(deps)) return
78
+ for (const key of wanted) {
79
+ if (Object.hasOwn(deps, key)) {
80
+ found.add(key)
81
+ }
82
+ }
83
+ } catch {
84
+ /* ігноруємо пошкоджені/недоступні package.json */
85
+ }
86
+ }
87
+
66
88
  /**
67
89
  * Збирає, які з переданих ключів присутні в `dependencies` хоча б одного `package.json`.
68
90
  * @param {string} root абсолютний шлях до кореня репозиторію
@@ -74,15 +96,32 @@ async function collectDependencyKeysPresentInPackageJsonTree(root, dependencyKey
74
96
  /** @type {Set<string>} */
75
97
  const found = new Set()
76
98
 
99
+ /**
100
+ * Обробка одного запису з readdir: рекурсія в підкаталог або зчитування package.json.
101
+ * @param {import('node:fs').Dirent} entry елемент readdir
102
+ * @param {string} dir абсолютний шлях каталогу-власника entry
103
+ * @returns {Promise<void>}
104
+ */
105
+ async function processEntry(entry, dir) {
106
+ const absPath = join(dir, entry.name)
107
+ if (entry.isDirectory()) {
108
+ if (!IGNORED_DIR_NAMES.has(entry.name)) {
109
+ await walk(absPath)
110
+ }
111
+ return
112
+ }
113
+ if (entry.isFile() && entry.name === 'package.json') {
114
+ await collectFoundDependencyKeysFromPackageJson(absPath, wanted, found)
115
+ }
116
+ }
117
+
77
118
  /**
78
119
  * Рекурсивний обхід каталогу з пропуском службових директорій.
79
120
  * @param {string} dir абсолютний шлях каталогу
80
121
  * @returns {Promise<void>}
81
122
  */
82
123
  async function walk(dir) {
83
- if (found.size === wanted.size) {
84
- return
85
- }
124
+ if (found.size === wanted.size) return
86
125
  let entries
87
126
  try {
88
127
  entries = await readdir(dir, { withFileTypes: true })
@@ -90,30 +129,8 @@ async function collectDependencyKeysPresentInPackageJsonTree(root, dependencyKey
90
129
  return
91
130
  }
92
131
  for (const entry of entries) {
93
- if (found.size === wanted.size) {
94
- return
95
- }
96
- const absPath = join(dir, entry.name)
97
- if (entry.isDirectory()) {
98
- const isIgnoredDir = IGNORED_DIR_NAMES.has(entry.name)
99
- if (!isIgnoredDir) {
100
- await walk(absPath)
101
- }
102
- } else if (entry.isFile() && entry.name === 'package.json') {
103
- try {
104
- const parsed = JSON.parse(await readFile(absPath, 'utf8'))
105
- const deps = parsed?.dependencies
106
- if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
107
- for (const key of wanted) {
108
- if (Object.hasOwn(deps, key)) {
109
- found.add(key)
110
- }
111
- }
112
- }
113
- } catch {
114
- /* ігноруємо пошкоджені/недоступні package.json */
115
- }
116
- }
132
+ if (found.size === wanted.size) return
133
+ await processEntry(entry, dir)
117
134
  }
118
135
  }
119
136
 
@@ -210,7 +227,7 @@ async function updateGqlFactFromFile(absPath, relPath, facts) {
210
227
  * Чи сканувати файл на імпорт `sql`/`SQL` з `bun` (ті самі розширення й skip, що для gql).
211
228
  * @param {string} relPath шлях posix відносно кореня
212
229
  * @param {{ hasBunSqlImport: boolean }} facts агреговані факти
213
- * @returns {boolean}
230
+ * @returns {boolean} true, якщо файл варто сканувати
214
231
  */
215
232
  function shouldScanFileForBunSql(relPath, facts) {
216
233
  return !facts.hasBunSqlImport && isGqlScanSourceFile(relPath) && !shouldSkipFileForGqlScan(relPath)
@@ -376,9 +376,7 @@ function extractNitraObjectBodySource(source) {
376
376
  * @returns {boolean} **true**, якщо в тілі є **iosCocoaPods**…**:** **true**
377
377
  */
378
378
  function nitraObjectBodyStringAllowsCocoaPodsExempt(objectBody) {
379
- return (
380
- RE_COCOAPODS_EXEMPT_SPM.test(objectBody) === true || RE_COCOAPODS_EXEMPT_ALLOW.test(objectBody) === true
381
- )
379
+ return RE_COCOAPODS_EXEMPT_SPM.test(objectBody) === true || RE_COCOAPODS_EXEMPT_ALLOW.test(objectBody) === true
382
380
  }
383
381
 
384
382
  /**
@@ -443,7 +441,9 @@ export async function check() {
443
441
  const { byPath, anyCapacitor } = acc
444
442
 
445
443
  if (!isCapacitorRelevantForCheck(root, anyCapacitor)) {
446
- pass('Capacitor не виявлено (без capacitor.config у корені, без @capacitor/ у package.json) — check capacitor пропущено')
444
+ pass(
445
+ 'Capacitor не виявлено (без capacitor.config у корені, без @capacitor/ у package.json) — check capacitor пропущено'
446
+ )
447
447
  return getExitCode()
448
448
  }
449
449
 
@@ -60,7 +60,6 @@ const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml',
60
60
  *
61
61
  * Використовує `git ls-files` з pathspec-магiєю `:(glob)`, щоб не реалізовувати glob engine вручну
62
62
  * і не сканувати файлову систему рекурсивно.
63
- *
64
63
  * @param {string} globPattern glob з workflow (наприклад "files/**" або "image-migration-new/**")
65
64
  * @returns {boolean} true, якщо є хоча б один збіг
66
65
  */
@@ -69,6 +68,7 @@ function gitHasAnyTrackedFileMatchingGlob(globPattern) {
69
68
  if (!p) return false
70
69
  if (p.startsWith('!')) return true
71
70
  try {
71
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- git як стандартне dev-середовище через PATH; альтернативи (хардкод шляху) непортативні
72
72
  const out = execFileSync('git', ['ls-files', '-z', '--', `:(glob)${p}`], { encoding: 'utf8' })
73
73
  return out.length > 0
74
74
  } catch {
@@ -82,7 +82,6 @@ function gitHasAnyTrackedFileMatchingGlob(globPattern) {
82
82
  * У багатьох workflow (особливо лінтерах) `paths` часто містить “широкі” шаблони по розширеннях
83
83
  * (наприклад `*.vue`, `*.php`), які можуть бути відсутні в конкретному репозиторії й це ок.
84
84
  * Запит цієї перевірки — ловити посилання на неіснуючі директорії/шляхи (типово `some-dir/**`).
85
- *
86
85
  * @param {string} p glob з workflow
87
86
  * @returns {boolean} true, якщо треба валідувати наявність файлів
88
87
  */
@@ -96,6 +95,28 @@ function shouldValidateWorkflowPathsGlob(p) {
96
95
  return true
97
96
  }
98
97
 
98
+ /**
99
+ * Перевіряє один glob з `on.<event>.paths` на наявність збігів у репо.
100
+ * @param {string} relPath шлях workflow для повідомлень
101
+ * @param {string} eventName назва події (push / pull_request)
102
+ * @param {unknown} raw сирий елемент масиву paths
103
+ * @param {(msg: string) => void} passFn pass
104
+ * @param {(msg: string) => void} failFn fail
105
+ */
106
+ function verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn) {
107
+ const p = String(raw ?? '').trim()
108
+ if (!p) return
109
+ if (!shouldValidateWorkflowPathsGlob(p)) {
110
+ passFn(`${relPath}: on.${eventName}.paths glob пропущено для перевірки існування: ${JSON.stringify(p)}`)
111
+ return
112
+ }
113
+ if (gitHasAnyTrackedFileMatchingGlob(p)) {
114
+ passFn(`${relPath}: on.${eventName}.paths glob матчитсья: ${JSON.stringify(p)}`)
115
+ } else {
116
+ failFn(`${relPath}: on.${eventName}.paths glob не матчитсья ні на один файл: ${JSON.stringify(p)}`)
117
+ }
118
+ }
119
+
99
120
  /**
100
121
  * Валідує `on.push.paths` / `on.pull_request.paths`: кожен позитивний glob має мати збіги в репозиторії.
101
122
  * @param {string} relPath шлях workflow для повідомлень
@@ -116,17 +137,7 @@ function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn) {
116
137
  for (const [eventName, paths] of candidates) {
117
138
  if (!Array.isArray(paths)) continue
118
139
  for (const raw of paths) {
119
- const p = String(raw ?? '').trim()
120
- if (!p) continue
121
- if (!shouldValidateWorkflowPathsGlob(p)) {
122
- passFn(`${relPath}: on.${eventName}.paths glob пропущено для перевірки існування: ${JSON.stringify(p)}`)
123
- continue
124
- }
125
- if (gitHasAnyTrackedFileMatchingGlob(p)) {
126
- passFn(`${relPath}: on.${eventName}.paths glob матчитсья: ${JSON.stringify(p)}`)
127
- } else {
128
- failFn(`${relPath}: on.${eventName}.paths glob не матчитсья ні на один файл: ${JSON.stringify(p)}`)
129
- }
140
+ verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn)
130
141
  }
131
142
  }
132
143
  }
@@ -138,8 +149,9 @@ function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn) {
138
149
  * @returns {unknown} значення поля або undefined
139
150
  */
140
151
  function getObjKey(obj, key) {
141
- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return
142
- return /** @type {Record<string, unknown>} */ (obj)[key]
152
+ return obj && typeof obj === 'object' && !Array.isArray(obj)
153
+ ? /** @type {Record<string, unknown>} */ (obj)[key]
154
+ : undefined
143
155
  }
144
156
 
145
157
  /**
@@ -77,7 +77,7 @@ async function findAllSourcePathsForBunSqlScan(repoRoot) {
77
77
  * Перевіряє, чи в кореневому `package.json` присутні заборонені пакети у `dependencies`.
78
78
  * @param {string[]} pkgJsonPaths абсолютні шляхи всіх `package.json` у репо
79
79
  * @param {string} repoRoot абсолютний шлях до кореня
80
- * @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter
80
+ * @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter колбеки pass і fail з перевірки
81
81
  * @returns {Promise<number>} кількість знайдених порушень
82
82
  */
83
83
  async function checkForbiddenDependencies(pkgJsonPaths, repoRoot, reporter) {
@@ -114,7 +114,7 @@ async function checkForbiddenDependencies(pkgJsonPaths, repoRoot, reporter) {
114
114
  * Сканує JS/TS-джерела на небезпечні патерни Bun SQL.
115
115
  * @param {string[]} sourcePaths абсолютні шляхи джерел
116
116
  * @param {string} repoRoot абсолютний шлях до кореня
117
- * @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter
117
+ * @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter колбеки pass і fail з перевірки
118
118
  * @returns {Promise<{ hasBunSqlImport: boolean, perRequest: number, unsafeCall: number, dynamicList: number }>}
119
119
  * `hasBunSqlImport` — чи знайдено хоч один `import { sql|SQL } from 'bun'` у джерелах;
120
120
  * решта — кількість порушень кожного типу.
@@ -203,7 +203,7 @@ export async function check() {
203
203
  pass('js-bun-db: немає створення new SQL(...) всередині функцій (singleton на рівні модуля)')
204
204
  }
205
205
  if (unsafeCall === 0) {
206
- pass('js-bun-db: немає небезпечних викликів sql.unsafe(`...${...}...`)')
206
+ pass('js-bun-db: немає небезпечних викликів sql.unsafe з інтерполяцією в шаблонному рядку')
207
207
  }
208
208
  if (dynamicList === 0) {
209
209
  pass("js-bun-db: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
@@ -2,18 +2,28 @@
2
2
  * Перевіряє лінт JavaScript за правилом js-lint.mdc.
3
3
  *
4
4
  * Канонічний `lint-js`, flat ESLint з getConfig і ignore для auto-imports, рекомендації VSCode,
5
- * `.oxlintrc.json` з `jsPlugins` (`@e18e/eslint-plugin`) і правилом `e18e/prefer-includes: error`,
6
- * `@nitra/eslint-config` у devDependencies мінімум **3.5.0** (транзитивний `@e18e/eslint-plugin` для oxlint), `.jscpd.json`
7
- * (gitignore, exitCode, reporters, minLines), workflow `lint-js.yml` (checkout@v6, setup-bun-deps,
8
- * bunx без --fix), без prettier, `engines.node` >= 24, `"type": "module"` у кореневому
9
- * і всіх workspace `package.json`. Дубль перевірки JS у `lint.yml` заборонено.
5
+ * `.oxlintrc.json` має збігатися з каноном oxlint у пакеті (`npm/scripts/utils/oxlint-canonical.json`):
6
+ * plugins, jsPlugins, categories, усі правила з канону (додаткові записи в `rules` дозволені), settings, env,
7
+ * globals, ignorePatterns. `@nitra/eslint-config` у devDependencies мінімум **3.6.12** (транзитивний
8
+ * `@e18e/eslint-plugin` для oxlint), `.jscpd.json` (gitignore, exitCode, reporters, minLines), workflow
9
+ * `lint-js.yml` (checkout@v6, setup-bun-deps, bunx без --fix), без prettier, `engines.node` >= 24,
10
+ * `"type": "module"` у кореневому і всіх workspace `package.json`. Дубль перевірки JS у `lint.yml` — заборонено.
10
11
  */
11
12
  import { existsSync } from 'node:fs'
12
13
  import { readFile } from 'node:fs/promises'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
13
16
 
14
17
  import { parseWorkflowYaml, verifyLintJsWorkflowStructure } from './utils/gha-workflow.mjs'
15
18
  import { createCheckReporter } from './utils/check-reporter.mjs'
16
19
 
20
+ /** Шлях до канонічного oxlint JSON у цьому пакеті (для перевірки та тестів). */
21
+ export const OXLINT_CANONICAL_JSON_PATH = join(
22
+ dirname(fileURLToPath(import.meta.url)),
23
+ 'utils',
24
+ 'oxlint-canonical.json'
25
+ )
26
+
17
27
  /** Очікуваний локальний скрипт. */
18
28
  export const CANONICAL_LINT_JS = 'bunx oxlint --fix && bunx eslint --fix . && bunx jscpd .'
19
29
 
@@ -43,9 +53,9 @@ export function isCanonicalLintJs(script) {
43
53
  }
44
54
 
45
55
  /**
46
- * Чи діапазон `@nitra/eslint-config` у `package.json` передбачає версію з транзитивним `@e18e/eslint-plugin` (>=3.5.0).
56
+ * Чи діапазон `@nitra/eslint-config` у `package.json` передбачає версію з транзитивним `@e18e/eslint-plugin` (>=3.6.12).
47
57
  * @param {unknown} versionSpec значення `devDependencies['@nitra/eslint-config']`
48
- * @returns {boolean} true для `workspace:*` або першої semver у рядку >= 3.5.0
58
+ * @returns {boolean} true для `workspace:*` або першої semver у рядку >= 3.6.12
49
59
  */
50
60
  export function nitraEslintConfigDeclaresE18eTransitive(versionSpec) {
51
61
  const s = String(versionSpec).trim()
@@ -64,29 +74,97 @@ export function nitraEslintConfigDeclaresE18eTransitive(versionSpec) {
64
74
  }
65
75
 
66
76
  /**
67
- * Перевіряє потрібні поля `.oxlintrc.json` для розширення e18e (js-lint.mdc).
68
- * @param {unknown} cfg корінь JSON-конфігурації oxlint
69
- * @returns {{ ok: boolean, failures: string[] }} `ok` і перелік повідомлень для `fail`
77
+ * Рекурсивне порівняння фрагментів канону oxlint (масиви порядок як у каноні; об’єкти — той самий набір ключів і вкладеність).
78
+ * @param {unknown} actual значення з `.oxlintrc.json`
79
+ * @param {unknown} expected значення з канону
80
+ * @returns {boolean} true, якщо значення збігаються за правилами канону
81
+ */
82
+ function deepEqualOxlintCanonical(actual, expected) {
83
+ if (expected === null || typeof expected !== 'object') {
84
+ return actual === expected
85
+ }
86
+ if (Array.isArray(expected)) {
87
+ return Array.isArray(actual) && JSON.stringify(actual) === JSON.stringify(expected)
88
+ }
89
+ if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) {
90
+ return false
91
+ }
92
+ const exp = /** @type {Record<string, unknown>} */ (expected)
93
+ const act = /** @type {Record<string, unknown>} */ (actual)
94
+ const expKeys = Object.keys(exp)
95
+ const actKeys = Object.keys(act)
96
+ if (expKeys.length !== actKeys.length) {
97
+ return false
98
+ }
99
+ for (const k of expKeys) {
100
+ if (!(k in act) || !deepEqualOxlintCanonical(act[k], exp[k])) {
101
+ return false
102
+ }
103
+ }
104
+ return true
105
+ }
106
+
107
+ /**
108
+ * Безпечний доступ як до plain-object запису.
109
+ * @param {unknown} v будь-яке значення
110
+ * @returns {Record<string, unknown>} запис або пустий обʼєкт, якщо `v` не plain-object
111
+ */
112
+ function asRecordOrEmpty(v) {
113
+ return v && typeof v === 'object' && !Array.isArray(v) ? /** @type {Record<string, unknown>} */ (v) : {}
114
+ }
115
+
116
+ /**
117
+ * Звіряє блок `rules`: кожне правило з канону має точне збіжне значення в actual.
118
+ * @param {unknown} expected канонічне значення для `rules`
119
+ * @param {unknown} actual поточне значення для `rules`
120
+ * @param {string[]} failures буфер для помилок
121
+ */
122
+ function compareOxlintRules(expected, actual, failures) {
123
+ const er = asRecordOrEmpty(expected)
124
+ const ar = asRecordOrEmpty(actual)
125
+ for (const ruleKey of Object.keys(er)) {
126
+ if (ar[ruleKey] !== er[ruleKey]) {
127
+ failures.push(
128
+ `.oxlintrc.json: rules["${ruleKey}"] очікується ${JSON.stringify(er[ruleKey])}, зараз ${JSON.stringify(ar[ruleKey])}`
129
+ )
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Перевіряє `.oxlintrc.json` проти канону пакета `@nitra/cursor` (усі правила з канону та інші поля з `oxlint-canonical.json`).
136
+ * Додаткові ключі лише в `rules` дозволені; інші поля мають збігатися з каноном.
137
+ * @param {unknown} cfg корінь JSON з `.oxlintrc.json`
138
+ * @param {unknown} canonical розпарений `oxlint-canonical.json`
139
+ * @returns {{ ok: boolean, failures: string[] }} статус і повідомлення для `fail`
70
140
  */
71
- export function verifyOxlintRcE18e(cfg) {
141
+ export function verifyOxlintRcAgainstCanonical(cfg, canonical) {
72
142
  const failures = []
73
143
  if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
74
144
  return { ok: false, failures: ['.oxlintrc.json: корінь має бути значенням типу object'] }
75
145
  }
76
- const o = /** @type {Record<string, unknown>} */ (cfg)
77
- const jsPlugins = o.jsPlugins
78
- if (!Array.isArray(jsPlugins) || !jsPlugins.includes('@e18e/eslint-plugin')) {
79
- failures.push('.oxlintrc.json: jsPlugins має містити "@e18e/eslint-plugin"')
146
+ if (!canonical || typeof canonical !== 'object' || Array.isArray(canonical)) {
147
+ return { ok: false, failures: ['внутрішня помилка: канон oxlint має бути object'] }
80
148
  }
81
- const rules = o.rules
82
- if (!rules || typeof rules !== 'object' || Array.isArray(rules)) {
83
- failures.push('.oxlintrc.json: поле rules має бути значенням типу object')
84
- } else {
85
- const r = /** @type {Record<string, unknown>} */ (rules)
86
- if (r['e18e/prefer-includes'] !== 'error') {
87
- failures.push('.oxlintrc.json: у rules має бути "e18e/prefer-includes": "error"')
149
+ const o = /** @type {Record<string, unknown>} */ (cfg)
150
+ const c = /** @type {Record<string, unknown>} */ (canonical)
151
+
152
+ for (const key of Object.keys(c)) {
153
+ const expected = c[key]
154
+ const actual = o[key]
155
+
156
+ if (key === 'rules') {
157
+ compareOxlintRules(expected, actual, failures)
158
+ continue
159
+ }
160
+
161
+ if (!deepEqualOxlintCanonical(actual, expected)) {
162
+ failures.push(
163
+ `.oxlintrc.json: поле "${key}" має збігатися з каноном пакета @nitra/cursor (npm/scripts/utils/oxlint-canonical.json)`
164
+ )
88
165
  }
89
166
  }
167
+
90
168
  return { ok: failures.length === 0, failures }
91
169
  }
92
170
 
@@ -96,7 +174,7 @@ export function verifyOxlintRcE18e(cfg) {
96
174
  * @param {(msg: string) => void} failFn callback при помилці
97
175
  */
98
176
  async function checkEslintConfig(passFn, failFn) {
99
- let eslintPath = ''
177
+ let eslintPath
100
178
  if (existsSync('eslint.config.js')) {
101
179
  eslintPath = 'eslint.config.js'
102
180
  passFn('eslint.config.js існує')
@@ -157,10 +235,12 @@ function checkPackageJsonLintDeps(pkg, passFn, failFn) {
157
235
  if (nitraEslint) {
158
236
  passFn('@nitra/eslint-config є в devDependencies')
159
237
  if (nitraEslintConfigDeclaresE18eTransitive(nitraEslint)) {
160
- passFn('@nitra/eslint-config: мінімум 3.5.0 (транзитивний @e18e/eslint-plugin для oxlint jsPlugins, js-lint.mdc)')
238
+ passFn(
239
+ '@nitra/eslint-config: мінімум 3.6.12 (транзитивний @e18e/eslint-plugin для oxlint jsPlugins, js-lint.mdc)'
240
+ )
161
241
  } else {
162
242
  failFn(
163
- '@nitra/eslint-config: онови до мінімум "^3.5.0" — з цієї версії постачається @e18e/eslint-plugin для .oxlintrc.json (js-lint.mdc)'
243
+ '@nitra/eslint-config: онови до мінімум "^3.6.12" — з цієї версії постачається @e18e/eslint-plugin для .oxlintrc.json (js-lint.mdc)'
164
244
  )
165
245
  }
166
246
  } else {
@@ -269,9 +349,16 @@ async function checkOxlintRc(passFn, failFn) {
269
349
  return
270
350
  }
271
351
  passFn('.oxlintrc.json існує')
272
- const oxV = verifyOxlintRcE18e(oxCfg)
352
+ let canonical
353
+ try {
354
+ canonical = JSON.parse(await readFile(OXLINT_CANONICAL_JSON_PATH, 'utf8'))
355
+ } catch {
356
+ failFn('внутрішня помилка: не вдалося прочитати канон oxlint з пакета @nitra/cursor')
357
+ return
358
+ }
359
+ const oxV = verifyOxlintRcAgainstCanonical(oxCfg, canonical)
273
360
  if (oxV.ok) {
274
- passFn('.oxlintrc.json: jsPlugins з @e18e/eslint-plugin і e18e/prefer-includes: error')
361
+ passFn('.oxlintrc.json збігається з каноном oxlint (@nitra/cursor)')
275
362
  } else {
276
363
  for (const msg of oxV.failures) {
277
364
  failFn(msg)