@nitra/cursor 1.8.145 → 1.8.147

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
@@ -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,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
 
3
2
  /**
4
3
  * CLI для перейменування розширень YAML (k8s та `.github`). Бізнес-логіка — у **`scripts/rename-yaml-extensions.mjs`**.
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/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
 
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.147",
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)
@@ -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,24 @@
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(dirname(fileURLToPath(import.meta.url)), 'utils', 'oxlint-canonical.json')
22
+
17
23
  /** Очікуваний локальний скрипт. */
18
24
  export const CANONICAL_LINT_JS = 'bunx oxlint --fix && bunx eslint --fix . && bunx jscpd .'
19
25
 
@@ -43,9 +49,9 @@ export function isCanonicalLintJs(script) {
43
49
  }
44
50
 
45
51
  /**
46
- * Чи діапазон `@nitra/eslint-config` у `package.json` передбачає версію з транзитивним `@e18e/eslint-plugin` (>=3.5.0).
52
+ * Чи діапазон `@nitra/eslint-config` у `package.json` передбачає версію з транзитивним `@e18e/eslint-plugin` (>=3.6.12).
47
53
  * @param {unknown} versionSpec значення `devDependencies['@nitra/eslint-config']`
48
- * @returns {boolean} true для `workspace:*` або першої semver у рядку >= 3.5.0
54
+ * @returns {boolean} true для `workspace:*` або першої semver у рядку >= 3.6.12
49
55
  */
50
56
  export function nitraEslintConfigDeclaresE18eTransitive(versionSpec) {
51
57
  const s = String(versionSpec).trim()
@@ -64,29 +70,97 @@ export function nitraEslintConfigDeclaresE18eTransitive(versionSpec) {
64
70
  }
65
71
 
66
72
  /**
67
- * Перевіряє потрібні поля `.oxlintrc.json` для розширення e18e (js-lint.mdc).
68
- * @param {unknown} cfg корінь JSON-конфігурації oxlint
69
- * @returns {{ ok: boolean, failures: string[] }} `ok` і перелік повідомлень для `fail`
73
+ * Рекурсивне порівняння фрагментів канону oxlint (масиви порядок як у каноні; об’єкти — той самий набір ключів і вкладеність).
74
+ * @param {unknown} actual значення з `.oxlintrc.json`
75
+ * @param {unknown} expected значення з канону
76
+ * @returns {boolean} true, якщо значення збігаються за правилами канону
77
+ */
78
+ function deepEqualOxlintCanonical(actual, expected) {
79
+ if (expected === null || typeof expected !== 'object') {
80
+ return actual === expected
81
+ }
82
+ if (Array.isArray(expected)) {
83
+ return Array.isArray(actual) && JSON.stringify(actual) === JSON.stringify(expected)
84
+ }
85
+ if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) {
86
+ return false
87
+ }
88
+ const exp = /** @type {Record<string, unknown>} */ (expected)
89
+ const act = /** @type {Record<string, unknown>} */ (actual)
90
+ const expKeys = Object.keys(exp)
91
+ const actKeys = Object.keys(act)
92
+ if (expKeys.length !== actKeys.length) {
93
+ return false
94
+ }
95
+ for (const k of expKeys) {
96
+ if (!(k in act) || !deepEqualOxlintCanonical(act[k], exp[k])) {
97
+ return false
98
+ }
99
+ }
100
+ return true
101
+ }
102
+
103
+ /**
104
+ * Безпечний доступ як до plain-object запису.
105
+ * @param {unknown} v будь-яке значення
106
+ * @returns {Record<string, unknown>} запис або пустий обʼєкт, якщо `v` не plain-object
107
+ */
108
+ function asRecordOrEmpty(v) {
109
+ return v && typeof v === 'object' && !Array.isArray(v) ? /** @type {Record<string, unknown>} */ (v) : {}
110
+ }
111
+
112
+ /**
113
+ * Звіряє блок `rules`: кожне правило з канону має точне збіжне значення в actual.
114
+ * @param {unknown} expected канонічне значення для `rules`
115
+ * @param {unknown} actual поточне значення для `rules`
116
+ * @param {string[]} failures буфер для помилок
117
+ */
118
+ function compareOxlintRules(expected, actual, failures) {
119
+ const er = asRecordOrEmpty(expected)
120
+ const ar = asRecordOrEmpty(actual)
121
+ for (const ruleKey of Object.keys(er)) {
122
+ if (ar[ruleKey] !== er[ruleKey]) {
123
+ failures.push(
124
+ `.oxlintrc.json: rules["${ruleKey}"] очікується ${JSON.stringify(er[ruleKey])}, зараз ${JSON.stringify(ar[ruleKey])}`
125
+ )
126
+ }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Перевіряє `.oxlintrc.json` проти канону пакета `@nitra/cursor` (усі правила з канону та інші поля з `oxlint-canonical.json`).
132
+ * Додаткові ключі лише в `rules` дозволені; інші поля мають збігатися з каноном.
133
+ * @param {unknown} cfg корінь JSON з `.oxlintrc.json`
134
+ * @param {unknown} canonical розпарений `oxlint-canonical.json`
135
+ * @returns {{ ok: boolean, failures: string[] }} статус і повідомлення для `fail`
70
136
  */
71
- export function verifyOxlintRcE18e(cfg) {
137
+ export function verifyOxlintRcAgainstCanonical(cfg, canonical) {
72
138
  const failures = []
73
139
  if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
74
140
  return { ok: false, failures: ['.oxlintrc.json: корінь має бути значенням типу object'] }
75
141
  }
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"')
142
+ if (!canonical || typeof canonical !== 'object' || Array.isArray(canonical)) {
143
+ return { ok: false, failures: ['внутрішня помилка: канон oxlint має бути object'] }
80
144
  }
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"')
145
+ const o = /** @type {Record<string, unknown>} */ (cfg)
146
+ const c = /** @type {Record<string, unknown>} */ (canonical)
147
+
148
+ for (const key of Object.keys(c)) {
149
+ const expected = c[key]
150
+ const actual = o[key]
151
+
152
+ if (key === 'rules') {
153
+ compareOxlintRules(expected, actual, failures)
154
+ continue
155
+ }
156
+
157
+ if (!deepEqualOxlintCanonical(actual, expected)) {
158
+ failures.push(
159
+ `.oxlintrc.json: поле "${key}" має збігатися з каноном пакета @nitra/cursor (npm/scripts/utils/oxlint-canonical.json)`
160
+ )
88
161
  }
89
162
  }
163
+
90
164
  return { ok: failures.length === 0, failures }
91
165
  }
92
166
 
@@ -96,7 +170,7 @@ export function verifyOxlintRcE18e(cfg) {
96
170
  * @param {(msg: string) => void} failFn callback при помилці
97
171
  */
98
172
  async function checkEslintConfig(passFn, failFn) {
99
- let eslintPath = ''
173
+ let eslintPath
100
174
  if (existsSync('eslint.config.js')) {
101
175
  eslintPath = 'eslint.config.js'
102
176
  passFn('eslint.config.js існує')
@@ -157,10 +231,12 @@ function checkPackageJsonLintDeps(pkg, passFn, failFn) {
157
231
  if (nitraEslint) {
158
232
  passFn('@nitra/eslint-config є в devDependencies')
159
233
  if (nitraEslintConfigDeclaresE18eTransitive(nitraEslint)) {
160
- passFn('@nitra/eslint-config: мінімум 3.5.0 (транзитивний @e18e/eslint-plugin для oxlint jsPlugins, js-lint.mdc)')
234
+ passFn(
235
+ '@nitra/eslint-config: мінімум 3.6.12 (транзитивний @e18e/eslint-plugin для oxlint jsPlugins, js-lint.mdc)'
236
+ )
161
237
  } else {
162
238
  failFn(
163
- '@nitra/eslint-config: онови до мінімум "^3.5.0" — з цієї версії постачається @e18e/eslint-plugin для .oxlintrc.json (js-lint.mdc)'
239
+ '@nitra/eslint-config: онови до мінімум "^3.6.12" — з цієї версії постачається @e18e/eslint-plugin для .oxlintrc.json (js-lint.mdc)'
164
240
  )
165
241
  }
166
242
  } else {
@@ -269,9 +345,16 @@ async function checkOxlintRc(passFn, failFn) {
269
345
  return
270
346
  }
271
347
  passFn('.oxlintrc.json існує')
272
- const oxV = verifyOxlintRcE18e(oxCfg)
348
+ let canonical
349
+ try {
350
+ canonical = JSON.parse(await readFile(OXLINT_CANONICAL_JSON_PATH, 'utf8'))
351
+ } catch {
352
+ failFn('внутрішня помилка: не вдалося прочитати канон oxlint з пакета @nitra/cursor')
353
+ return
354
+ }
355
+ const oxV = verifyOxlintRcAgainstCanonical(oxCfg, canonical)
273
356
  if (oxV.ok) {
274
- passFn('.oxlintrc.json: jsPlugins з @e18e/eslint-plugin і e18e/prefer-includes: error')
357
+ passFn('.oxlintrc.json збігається з каноном oxlint (@nitra/cursor)')
275
358
  } else {
276
359
  for (const msg of oxV.failures) {
277
360
  failFn(msg)