@nitra/cursor 1.8.97 → 1.8.99

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/mdc/abie.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  description: Правила для проєктів AbInBev Efes
3
3
  alwaysApply: true
4
- version: '1.11'
4
+ version: '1.15'
5
5
  ---
6
6
 
7
- Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** — **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**, для спільних сервісів **`auth-run-hl`** / **`filelint-hl`** — **`namespace: dev`** у base та patch **`…/backendRefs/…/namespace`** у **ua** / **ru**), видалення **HealthCheckPolicy** у **ru**), гілки **dev**, **ua**, **ru** у **clean-merged-branch**, а також заборона артефактів **Firebase Hosting** у корені репозиторію.
7
+ Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** — **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**, для спільних сервісів **`auth-run-hl`** / **`filelint-hl`** — **`namespace: dev`** у base та patch **`…/backendRefs/…/namespace`** у **ua** / **ru**), у overlay **ru** — кожен **Service** (у т. ч. **headless** / **`-hl`**) → **`spec.type: NodePort`** через **JSON6902** у **`kustomization.yaml`**, видалення **HealthCheckPolicy** у **ru**), гілки **dev**, **ua**, **ru** у **clean-merged-branch**, а також заборона артефактів **Firebase Hosting** у корені репозиторію.
8
8
 
9
9
  **`npx @nitra/cursor check abie`** виконується лише якщо в **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень.
10
10
 
@@ -113,6 +113,25 @@ spec:
113
113
  gwin.yandex.cloud/rules.http.upgradeTypes: "websocket"
114
114
  ```
115
115
 
116
+ ## k8s: overlay **ru** і **Service** (у т. ч. headless → NodePort)
117
+
118
+ Для кожного **Service** в YAML під **`…/k8s/…`**, де шлях файлу **не** містить **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару; у т. ч. **headless** з **`spec.clusterIP: None`** і **`-hl`**), якщо ще не **`spec.type: NodePort`** / **`LoadBalancer`** / **`ExternalName`**, у **`k8s/ru/kustomization.yaml`** того ж пакета (overlay **ru**) додай **inline** **JSON6902** у **`patches`**: **`target.kind: Service`**, **`target.name`** як у маніфеста, **`path: /spec/type`**, **`value: NodePort`**. Якщо в base було **`spec.clusterIP: None`** або **`spec.clusterIPs`** з **`None`** (типово **headless**), у тому ж **patch** додай **`op: remove`** для **`/spec/clusterIP`** та **`/spec/clusterIPs`** — інакше API відхилить **NodePort** з помилкою на **`clusterIPs`**. Деталі — **`check-abie.mjs`**.
119
+
120
+ ```yaml title="…/ru/kustomization.yaml (фрагмент, headless → NodePort)"
121
+ patches:
122
+ - target:
123
+ kind: Service
124
+ name: user-site-hl
125
+ patch: |-
126
+ - op: replace
127
+ path: /spec/type
128
+ value: NodePort
129
+ - op: remove
130
+ path: /spec/clusterIP
131
+ - op: remove
132
+ path: /spec/clusterIPs
133
+ ```
134
+
116
135
  ## k8s: overlay **ru** і HealthCheckPolicy
117
136
 
118
137
  Якщо в дереві **k8s** є **HealthCheckPolicy**, у **`ru/kustomization.yaml`** має бути patch **`$patch: delete`** для політики (узгоджено з **k8s.mdc**; перевірка в **`check-k8s.mjs`**, **`ruKustomizationHasHealthCheckDeletePatch`**). Підстав реальне ім’я замість **`СЕРВІС`**:
package/mdc/bun.mdc CHANGED
@@ -50,7 +50,7 @@ Lockfile у репозиторії: `bun.lock`.
50
50
  - Якщо залежність потрібна лише одному пакету, додавати її в директорії цього пакета.
51
51
  - У CI та локально запускати скрипти через `bun run`.
52
52
 
53
- В кореневому в package.json не повинно бути `dependencies`, а в `devDependencies` будуть тільки `@cspell/*` та `@nitra/*` модулі.
53
+ В кореневому в package.json не повинно бути `dependencies`, а в `devDependencies` тільки модулі `@nitra/*`
54
54
 
55
55
  Якщо в package.json є поля `packageManager`, то прибрати їх, також прибрати всі директорії та файли для yarn
56
56
 
package/mdc/graphql.mdc CHANGED
@@ -4,7 +4,7 @@ alwaysApply: true
4
4
  version: '1.0'
5
5
  ---
6
6
 
7
- Якщо в **`.vue`** або в **JavaScript / TypeScript** джерелах (`.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.jsx` тощо) зустрічається **tagged template literal** з тегом **`gql`** (типово `gql\`query …\`` для GraphQL-запиту), у **корені репозиторію** має бути файл **`.graphqlrc.yml`** ([GraphQL Config](https://the-guild.dev/graphql/config/docs)), а в **`.vscode/extensions.json`** у масиві **`recommendations`** — запис **`graphql.vscode-graphql`**, щоб підсвітка, навігація до схеми й діагностика працювали в редакторі.
7
+ Якщо в **`.vue`** або в **JavaScript / TypeScript** джерелах (`.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.jsx` тощо) зустрічається **tagged template literal** з тегом **`gql`** (типово `gql\`query …\``для GraphQL-запиту), у **корені репозиторію** має бути файл **`.graphqlrc.yml`** ([GraphQL Config](https://the-guild.dev/graphql/config/docs)), а в **`.vscode/extensions.json`** у масиві **`recommendations`** — запис **`graphql.vscode-graphql`**, щоб підсвітка, навігація до схеми й діагностика працювали в редакторі.
8
8
 
9
9
  Деталі виявлення `gql` у скриптах (у т.ч. лише `<script>` у SFC) — **`npm/scripts/check-graphql.mjs`** / **`npm/scripts/utils/graphql-gql-scan.mjs`**.
10
10
 
package/mdc/js-lint.mdc CHANGED
@@ -30,7 +30,7 @@ version: '1.13'
30
30
  }
31
31
  ```
32
32
 
33
- У корені має бути **`.oxlintrc.json`** з підключенням **`@e18e/eslint-plugin`** через **`jsPlugins`** і правилом **`e18e/prefer-includes`** зі значенням **`error`**. Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.5.0**), oxlint резолвить його з **`node_modules`**.
33
+ У корені має бути **`.oxlintrc.json`** з підключенням **`@e18e/eslint-plugin`** через **`jsPlugins`** і правилом **`e18e/prefer-includes`** зі значенням **`error`**. Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.5.0**), oxlint підвантажує його з **`node_modules`**.
34
34
 
35
35
  ```json title=".oxlintrc.json (фрагмент)"
36
36
  {
package/mdc/text.mdc CHANGED
@@ -110,7 +110,7 @@ version: '1.25'
110
110
 
111
111
  Завжди пиши **JSDoc** до функцій та методів.
112
112
 
113
- **`package.json`:** скрипт **`lint-text`** і devDependencies **`@nitra/cspell-dict`**. Для української додай **`@cspell/dict-uk-ua`**. **`markdownlint-cli2`** викликай у `lint-text` лише через **`bunx markdownlint-cli2`**, не додавай пакет до devDependencies. **`v8r`** лише через **`bunx v8r`** (зазвичай **`bunx v8r`**), не в devDependencies. Окремий пакет **`markdownlint`** не потрібний.
113
+ **`package.json`:** скрипт **`lint-text`** і devDependencies **`@nitra/cspell-dict`** (**`^2.0.0`** або новіший у лінії 2.x) — з **2.0.0** у пакет транзитивно входять типові **`@cspell/dict-*`**, тому **не** додавай їх окремо в корінь. **`markdownlint-cli2`** викликай у `lint-text` лише через **`bunx markdownlint-cli2`**, не додавай пакет до devDependencies. **`v8r`** лише через **`bunx v8r`** (зазвичай **`bunx v8r`**), не в devDependencies. Окремий пакет **`markdownlint`** не потрібний.
114
114
 
115
115
  У v8r **немає** прапорця тихого режиму; рекомендовано скрипт **`run-v8r.mjs`** з репозиторію пакета `@nitra/cursor` (`npm/scripts/run-v8r.mjs`): один виклик у `lint-text` — під капотом послідовні **`bunx v8r`** для кожного типу (**json**, **json5**, **yml**, **yaml**, **toml**), бо один процес v8r з кількома глобами падає з **98**, якщо хоч один glob порожній, і тоді інші розширення не перевіряються. Вивід при кодах **0** і **98** не показується. Каталог схем **`schemas/v8r-catalog.json`** пакета `@nitra/cursor` скрипт підставляє в v8r сам. За бажання можна передати власні glob-и аргументами скрипта. Шлях до скрипта: `./npm/scripts/…`, `./scripts/…` після копіювання, або `node_modules/@nitra/cursor/scripts/…`.
116
116
 
@@ -120,7 +120,7 @@ version: '1.25'
120
120
  "lint-text": "npx cspell . && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
121
121
  },
122
122
  "devDependencies": {
123
- "@nitra/cspell-dict": "^1.0.188"
123
+ "@nitra/cspell-dict": "^2.0.0"
124
124
  }
125
125
  }
126
126
  ```
@@ -235,17 +235,16 @@ jobs:
235
235
  "lint-text": "npx cspell . && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
236
236
  },
237
237
  "devDependencies": {
238
- "@nitra/cspell-dict": "^1.0.188",
239
- "@cspell/dict-uk-ua": "^4.0.6"
238
+ "@nitra/cspell-dict": "^2.0.0"
240
239
  }
241
240
  }
242
241
  ```
243
242
 
244
- ## Проєкт з українською мовою
243
+ ## Проєкт з українською та російською (і суміжні мови)
245
244
 
246
- Якщо в репозиторії є українська документація, коментарі або рядки в кодіпотрібен **окремий словник** `@cspell/dict-ru_ru`, інакше cspell не перевірятиме російський правопис коректно.
245
+ У **`@nitra/cspell-dict`** від **2.0.0** уже зібрані залежності на **`@cspell/dict-uk-ua`**, **`@cspell/dict-ru_ru`** та інші типові словники **не** дублюй їх у кореневому `package.json` і **не** імпортуй **`@cspell/dict-*/cspell-ext.json`** у `.cspell.json`.
247
246
 
248
- **1. Залежності** — додай пакет словника поруч із `@nitra/cspell-dict`:
247
+ **1. Залежності** — лише корпоративний пакет:
249
248
 
250
249
  ```json title="package.json"
251
250
  {
@@ -253,31 +252,26 @@ jobs:
253
252
  "lint-text": "npx cspell . && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
254
253
  },
255
254
  "devDependencies": {
256
- "@nitra/cspell-dict": "^1.0.188",
257
- "@cspell/dict-uk-ua": "^4.0.6",
258
- "@cspell/dict-ru_ru": "^2.3.2"
255
+ "@nitra/cspell-dict": "^2.0.0"
259
256
  }
260
257
  }
261
258
  ```
262
259
 
263
- Встановлення: `bun add -d @cspell/dict-uk-ua` (або `npm i -D @cspell/dict-uk-ua`).
260
+ Встановлення: `bun add -d @nitra/cspell-dict@^2.0.0`.
264
261
 
265
- **2. `.cspell.json`** — у полі `language` має бути `uk` (разом з іншими мовами через кому), у `import` підключення розширення українського словника:
262
+ **2. `.cspell.json`** — у полі **`language`** додай потрібні коди (наприклад **`uk`**, **`ru-ru`** разом з **`en`** та **`nitra`**). У **`import`** залиш лише **`@nitra/cspell-dict/cspell-ext.json`**:
266
263
 
267
264
  ```json title=".cspell.json"
268
265
  {
269
266
  "version": "0.2",
270
- "language": "en,uk,nitra",
267
+ "language": "en,uk,ru-ru,nitra",
271
268
  "ignorePaths": ["**/node_modules/**", "**/vscode-extension/**", "**/.git/**", ".vscode", "report", "*.svg", "**/k8s/**/*.yaml"],
272
- "import": [
273
- "@nitra/cspell-dict/cspell-ext.json",
274
- "@cspell/dict-uk-ua/cspell-ext.json"
275
- ],
269
+ "import": ["@nitra/cspell-dict/cspell-ext.json"],
276
270
  "words": []
277
271
  }
278
272
  ```
279
273
 
280
- Підлаштуй `language` під проєкт (наприклад додай `ru-ru`, якщо потрібна перевірка російською). Порядок у `import` може впливати на пріоритет словників — тримай корпоративний `@nitra/cspell-dict` там, де зручно для ваших правил.
274
+ Підлаштуй **`language`** під проєкт. Порядок у **`import`** може впливати на пріоритет словників — тримай корпоративний **`@nitra/cspell-dict`** першим, якщо додаєш інші розширення (рідко).
281
275
 
282
276
  **Український апостроф:** у словах не використовуй прямий символ `'` (U+0027); потрібен типографський апостроф `’` (U+2019). Якщо після цього cspell досі підсвічує слово як невідоме — додай його до масиву `words` у `.cspell.json`.
283
277
 
@@ -293,7 +287,7 @@ jobs:
293
287
 
294
288
  ## Інші мови
295
289
 
296
- Для іншої мови встанови відповідний пакет `@cspell/dict-*`, додай його `cspell-ext.json` у `import` і код мови в `language`. Огляд словників: [streetsidesoftware/cspell-dicts](https://github.com/streetsidesoftware/cspell-dicts).
290
+ Якщо потрібна мова вже є в залежностях **`@nitra/cspell-dict`** додай лише код у **`language`**, без окремих **`@cspell/dict-*`** у споживачі. Якщо мови немає в корпоративному пакеті — розширюй **`@nitra/cspell-dict`**, а не підключай **`@cspell/dict-*`** у корені репозиторію-споживача. Огляд upstream-словників: [streetsidesoftware/cspell-dicts](https://github.com/streetsidesoftware/cspell-dicts).
297
291
 
298
292
  ## Перевірка
299
293
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.97",
3
+ "version": "1.8.99",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -32,6 +32,9 @@
32
32
  * **Спільні бекенди (`auth-run-hl`, `filelint-hl`):** у **HTTPRoute** під **`k8s`** поза overlay **ua** та **ru** (шлях не містить **`k8s/ua/`** чи **`k8s/ru/`**) кожен такий **`backendRefs`** має **`namespace: dev`** і порт **8080**;
33
33
  * у patch overlay **ua** та **ru** — по одному **JSON6902** на **`/spec/rules/…/backendRefs/…/namespace`** з **`value`**: **ua** або **ru** (кількість patch-ів = кількість таких **`backendRefs`** у пакеті).
34
34
  * Вибір **`op`** — **k8s.mdc**.
35
+ *
36
+ * **Service (overlay ru):** для кожного **Service**, оголошеного в YAML під **`…/k8s/…`**, де шлях **не** проходить через **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару, у т. ч. **headless** з **`clusterIP: None`** і **`-hl`**), якщо ще не **NodePort** / **LoadBalancer** / **ExternalName**,
37
+ * у файлі **`k8s/ru/kustomization.yaml`** того ж пакета (overlay середовища **ru**) — inline **JSON6902** на **`kind: Service`** з тим самим **`target.name`**: **`path: /spec/type`**, **`value: NodePort`**; якщо в base було **`spec.clusterIP: None`** або **`spec.clusterIPs`** з **`None`** — у тому ж patch додай **`op: remove`** для **`/spec/clusterIP`** та **`/spec/clusterIPs`** (інакше **NodePort** з **`None`** відхиляє API).
35
38
  */
36
39
  import { existsSync } from 'node:fs'
37
40
  import { readFile } from 'node:fs/promises'
@@ -295,6 +298,367 @@ function isDeploymentDoc(obj) {
295
298
  )
296
299
  }
297
300
 
301
+ /**
302
+ * Чи документ — **Service**.
303
+ * @param {unknown} obj корінь YAML-документа
304
+ * @returns {boolean} true, якщо **kind** — **Service**
305
+ */
306
+ function isServiceDoc(obj) {
307
+ return (
308
+ obj !== null &&
309
+ typeof obj === 'object' &&
310
+ !Array.isArray(obj) &&
311
+ /** @type {Record<string, unknown>} */ (obj).kind === 'Service'
312
+ )
313
+ }
314
+
315
+ /**
316
+ * Чи відносний шлях до YAML під **`…/k8s/…`** не в каталозі overlay **`k8s/ua/`** чи **`k8s/ru/`** у репозиторії (після **`k8s/`** одразу не йде **`ua/`** чи **`ru/`**).
317
+ * @param {string} relFromRoot шлях від кореня
318
+ * @returns {boolean} true для base / спільних маніфестів; **false** для файлів усередині **`k8s/ua/…`** або **`k8s/ru/…`**
319
+ */
320
+ function k8sYamlRelOutsideUaRuOverlays(relFromRoot) {
321
+ const norm = relFromRoot.replaceAll('\\', '/')
322
+ const idx = norm.indexOf('/k8s/')
323
+ if (idx === -1) {
324
+ return false
325
+ }
326
+ const after = norm.slice(idx + '/k8s/'.length)
327
+ return after.length > 0 && !after.startsWith('ua/') && !after.startsWith('ru/')
328
+ }
329
+
330
+ /**
331
+ * Каталог пакета з відносного шляху **`…/k8s/…`** (частина до **`/k8s/`**).
332
+ * @param {string} root корінь репозиторію
333
+ * @param {string} relFromRoot відносний шлях
334
+ * @returns {string | null} абсолютний шлях до каталогу пакета або **null**
335
+ */
336
+ function abiePackageDirFromK8sYamlRel(root, relFromRoot) {
337
+ const norm = relFromRoot.replaceAll('\\', '/')
338
+ const m = norm.match(/^(.+)\/k8s\//u)
339
+ return m ? join(root, m[1]) : null
340
+ }
341
+
342
+ /**
343
+ * Чи **Service** у base-шарі abie потребує в **ru** patch **`spec.type: NodePort`** (у т. ч. **headless**; не вже **NodePort** / **LoadBalancer** / **ExternalName**).
344
+ * @param {unknown} obj корінь YAML (**Service**)
345
+ * @returns {boolean} true, якщо для overlay **ru** очікується **NodePort**
346
+ */
347
+ export function serviceDocumentRequiresAbieRuNodePortOverlay(obj) {
348
+ if (!isServiceDoc(obj)) {
349
+ return false
350
+ }
351
+ const rec = /** @type {Record<string, unknown>} */ (obj)
352
+ const meta = rec.metadata
353
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
354
+ return false
355
+ }
356
+ const name = /** @type {Record<string, unknown>} */ (meta).name
357
+ if (typeof name !== 'string' || name.trim() === '') {
358
+ return false
359
+ }
360
+ const spec = rec.spec
361
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
362
+ return true
363
+ }
364
+ const sp = /** @type {Record<string, unknown>} */ (spec)
365
+ const t = sp.type
366
+ if (t === 'NodePort' || t === 'LoadBalancer' || t === 'ExternalName') {
367
+ return false
368
+ }
369
+ return true
370
+ }
371
+
372
+ /**
373
+ * Чи в base-**Service** є **headless** **`None`**, який треба прибрати в **ru** перед **NodePort** (**clusterIP** / **clusterIPs**).
374
+ * @param {unknown} obj корінь YAML (**Service**)
375
+ * @returns {boolean} **true**, якщо **`spec.clusterIP === 'None'`** або **`spec.clusterIPs`** містить **`'None'`**
376
+ */
377
+ export function serviceDocumentRequiresRuClusterIPNoneRemoval(obj) {
378
+ if (!isServiceDoc(obj)) {
379
+ return false
380
+ }
381
+ const rec = /** @type {Record<string, unknown>} */ (obj)
382
+ const spec = rec.spec
383
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
384
+ return false
385
+ }
386
+ const sp = /** @type {Record<string, unknown>} */ (spec)
387
+ if (sp.clusterIP === 'None') {
388
+ return true
389
+ }
390
+ const cips = sp.clusterIPs
391
+ if (Array.isArray(cips) && cips.includes('None')) {
392
+ return true
393
+ }
394
+ return false
395
+ }
396
+
397
+ /**
398
+ * Чи **JSON6902**-текст містить **`op: remove`** для заданого **`path`** (порядок ключів **op** / **path** неважливий).
399
+ * @param {string} patchText поле **patch** у kustomization
400
+ * @param {string} posixPath наприклад **`/spec/clusterIP`**
401
+ * @returns {boolean} true, якщо знайдено пару **remove** + **path**
402
+ */
403
+ export function jsonPatchRemovesPath(patchText, posixPath) {
404
+ if (typeof patchText !== 'string' || patchText.trim() === '') {
405
+ return false
406
+ }
407
+ if (posixPath !== '/spec/clusterIP' && posixPath !== '/spec/clusterIPs') {
408
+ return false
409
+ }
410
+ const pathRe =
411
+ posixPath === '/spec/clusterIP'
412
+ ? String.raw`path:\s*\/spec\/clusterIP\b`
413
+ : String.raw`path:\s*\/spec\/clusterIPs\b`
414
+ const opRe = String.raw`op:\s*remove\b`
415
+ return new RegExp(`${opRe}[\\s\\S]{0,200}?${pathRe}`, 'mu').test(patchText) || new RegExp(`${pathRe}[\\s\\S]{0,200}?${opRe}`, 'mu').test(patchText)
416
+ }
417
+
418
+ /**
419
+ * Чи patch прибирає **headless** поля **`clusterIP`** / **`clusterIPs`**, щоб **NodePort** пройшов валідацію API.
420
+ * @param {string} patchText поле **patch** у kustomization
421
+ * @returns {boolean} true, якщо є **remove** і для **`/spec/clusterIP`**, і для **`/spec/clusterIPs`**
422
+ */
423
+ export function jsonPatchTextClearsHeadlessServiceClusterIPNone(patchText) {
424
+ return jsonPatchRemovesPath(patchText, '/spec/clusterIP') && jsonPatchRemovesPath(patchText, '/spec/clusterIPs')
425
+ }
426
+
427
+ /**
428
+ * Чи фрагмент **JSON6902** у **`patch`** задає **`/spec/type`** зі значенням **NodePort** (abie overlay **ru**).
429
+ * @param {string} patchText поле **patch** у kustomization
430
+ * @returns {boolean} true, якщо знайдено **path** і **value**
431
+ */
432
+ export function jsonPatchTextSetsServiceTypeNodePort(patchText) {
433
+ if (typeof patchText !== 'string' || patchText.trim() === '') {
434
+ return false
435
+ }
436
+ if (!/path:\s*\/spec\/type\b/u.test(patchText)) {
437
+ return false
438
+ }
439
+ if (!/value:\s*['"]?NodePort['"]?(?:\s|$)/iu.test(patchText)) {
440
+ return false
441
+ }
442
+ return true
443
+ }
444
+
445
+ /**
446
+ * З одного документа **Kustomization** збирає пари **Service name → patch text** для **inline patches** з **target.kind: Service**.
447
+ * @param {import('yaml').Document} doc документ після **parseAllDocuments**
448
+ * @returns {Map<string, string>} ім’я сервісу → текст **patch**
449
+ */
450
+ function collectAbieServicePatchTextsByNameFromKustomizationDoc(doc) {
451
+ /** @type {Map<string, string>} */
452
+ const out = new Map()
453
+ if (doc.errors.length > 0) {
454
+ return out
455
+ }
456
+ const root = doc.toJSON()
457
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
458
+ return out
459
+ }
460
+ const rec = /** @type {Record<string, unknown>} */ (root)
461
+ if (rec.kind !== 'Kustomization') {
462
+ return out
463
+ }
464
+ const patches = rec.patches
465
+ if (!Array.isArray(patches)) {
466
+ return out
467
+ }
468
+ for (const p of patches) {
469
+ if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
470
+ const pr = /** @type {Record<string, unknown>} */ (p)
471
+ const target = pr.target
472
+ if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
473
+ const tg = /** @type {Record<string, unknown>} */ (target)
474
+ if (tg.kind === 'Service' && typeof tg.name === 'string' && tg.name.trim() !== '') {
475
+ const patchStr = pr.patch
476
+ if (typeof patchStr === 'string' && patchStr.trim() !== '') {
477
+ const prev = out.get(tg.name)
478
+ out.set(tg.name, prev === undefined ? patchStr : `${prev}\n${patchStr}`)
479
+ }
480
+ }
481
+ }
482
+ }
483
+ }
484
+ return out
485
+ }
486
+
487
+ /**
488
+ * Збирає тексти **patch** на **Service** з **kustomization.yaml** (усі документи).
489
+ * @param {string} raw повний текст **kustomization.yaml**
490
+ * @returns {Map<string, string>} **target.name** → об’єднаний текст **patch**
491
+ */
492
+ function collectAbieRuServicePatchTextByTargetNameFromRaw(raw) {
493
+ const body = stripBom(raw)
494
+ const lines = body.split(/\r?\n/u)
495
+ const first = lines[0] ?? ''
496
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
497
+ /** @type {Map<string, string>} */
498
+ const byName = new Map()
499
+ /** @type {import('yaml').Document[]} */
500
+ let docs
501
+ try {
502
+ docs = parseAllDocuments(rest)
503
+ } catch {
504
+ return byName
505
+ }
506
+ for (const doc of docs) {
507
+ const chunk = collectAbieServicePatchTextsByNameFromKustomizationDoc(doc)
508
+ for (const [k, v] of chunk) {
509
+ const prev = byName.get(k)
510
+ byName.set(k, prev === undefined ? v : `${prev}\n${v}`)
511
+ }
512
+ }
513
+ return byName
514
+ }
515
+
516
+ /**
517
+ * Повідомлення про порушення patch **Service** у **ru/kustomization.yaml** (abie.mdc).
518
+ * @param {string} raw повний текст **kustomization.yaml**
519
+ * @param {Map<string, { requiresClusterIPNoneClear: boolean }>} targetsByName ім’я **Service** → чи треба прибрати **None**
520
+ * @returns {string[]} порожньо, якщо все OK
521
+ */
522
+ export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
523
+ if (targetsByName.size === 0) {
524
+ return []
525
+ }
526
+ const byName = collectAbieRuServicePatchTextByTargetNameFromRaw(raw)
527
+ /** @type {string[]} */
528
+ const errors = []
529
+ for (const name of [...targetsByName.keys()].toSorted((a, b) => a.localeCompare(b))) {
530
+ const flags = targetsByName.get(name)
531
+ const requiresClear = flags?.requiresClusterIPNoneClear === true
532
+ const pt = byName.get(name)
533
+ if (pt === undefined || String(pt).trim() === '') {
534
+ errors.push(`${name}: немає inline patch для kind: Service`)
535
+ } else {
536
+ if (!jsonPatchTextSetsServiceTypeNodePort(pt)) {
537
+ errors.push(`${name}: потрібен JSON6902 path /spec/type та value NodePort`)
538
+ }
539
+ if (requiresClear && !jsonPatchTextClearsHeadlessServiceClusterIPNone(pt)) {
540
+ errors.push(
541
+ `${name}: для spec.clusterIP/spec.clusterIPs: None додай у той самий patch op: remove для path /spec/clusterIP та /spec/clusterIPs (abie.mdc)`
542
+ )
543
+ }
544
+ }
545
+ }
546
+ return errors
547
+ }
548
+
549
+ /**
550
+ * Для кожного пакета збирає **Service**, які в overlay **ru** мають стати **NodePort** (abie.mdc).
551
+ * @param {string} root корінь репозиторію
552
+ * @param {string[]} yamlAbs абсолютні шляхи yaml під **k8s**
553
+ * @param {(msg: string) => void} fail реєстрація помилки читання/парсингу
554
+ * @returns {Promise<Map<string, Map<string, { requiresClusterIPNoneClear: boolean }>>>} **pkgAbs** → (**ім’я** → прапорці)
555
+ */
556
+ async function collectAbieRuNodePortServiceTargetsByPackage(root, yamlAbs, fail) {
557
+ /** @type {Map<string, Map<string, { requiresClusterIPNoneClear: boolean }>>} */
558
+ const map = new Map()
559
+ for (const abs of yamlAbs) {
560
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
561
+ if (k8sYamlRelOutsideUaRuOverlays(rel)) {
562
+ const pkgAbs = abiePackageDirFromK8sYamlRel(root, rel)
563
+ if (pkgAbs) {
564
+ let raw
565
+ let readOk = false
566
+ try {
567
+ raw = await readFile(abs, 'utf8')
568
+ readOk = true
569
+ } catch (error) {
570
+ const msg = error instanceof Error ? error.message : String(error)
571
+ fail(`${rel}: не вдалося прочитати (${msg})`)
572
+ }
573
+ if (readOk) {
574
+ const body = stripBom(raw)
575
+ const lines = body.split(/\r?\n/u)
576
+ const first = lines[0] ?? ''
577
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
578
+ /** @type {import('yaml').Document[]} */
579
+ let docs
580
+ let parseOk = false
581
+ try {
582
+ docs = parseAllDocuments(rest)
583
+ parseOk = true
584
+ } catch (error) {
585
+ const msg = error instanceof Error ? error.message : String(error)
586
+ fail(`${rel}: YAML (${msg})`)
587
+ }
588
+ if (parseOk) {
589
+ for (const doc of docs) {
590
+ if (doc.errors.length === 0) {
591
+ const obj = doc.toJSON()
592
+ if (serviceDocumentRequiresAbieRuNodePortOverlay(obj)) {
593
+ const rec = /** @type {Record<string, unknown>} */ (obj)
594
+ const meta = /** @type {Record<string, unknown>} */ (rec.metadata)
595
+ const n = meta.name
596
+ if (typeof n === 'string' && n.trim() !== '') {
597
+ let inner = map.get(pkgAbs)
598
+ if (!inner) {
599
+ inner = new Map()
600
+ map.set(pkgAbs, inner)
601
+ }
602
+ const needClear = serviceDocumentRequiresRuClusterIPNoneRemoval(obj)
603
+ const prev = inner.get(n)
604
+ inner.set(n, {
605
+ requiresClusterIPNoneClear: (prev?.requiresClusterIPNoneClear === true) || needClear
606
+ })
607
+ }
608
+ }
609
+ }
610
+ }
611
+ }
612
+ }
613
+ }
614
+ }
615
+ }
616
+ return map
617
+ }
618
+
619
+ /**
620
+ * У **`k8s/ru/kustomization.yaml`** для кожного **Service** з YAML **`k8s`**, шлях якого без сегментів **`k8s/ua/`** та **`k8s/ru/`** (у т. ч. **headless** / **`-hl`**) — **JSON6902** **`/spec/type` → NodePort**; якщо в base було **`clusterIP: None`** / **`clusterIPs: None`** — також **`op: remove`** на **`/spec/clusterIP`** та **`/spec/clusterIPs`** (abie.mdc).
621
+ * @param {string} root корінь
622
+ * @param {string[]} yamlFilesAbs yaml під **k8s**
623
+ * @param {(msg: string) => void} fail callback
624
+ * @param {(msg: string) => void} passFn успішне повідомлення
625
+ * @returns {Promise<void>}
626
+ */
627
+ async function ensureRuAbieServiceNodePortPatches(root, yamlFilesAbs, fail, passFn) {
628
+ const byPkg = await collectAbieRuNodePortServiceTargetsByPackage(root, yamlFilesAbs, fail)
629
+ const entries = [...byPkg.entries()].filter(([, m]) => m.size > 0)
630
+ if (entries.length === 0) {
631
+ passFn('Немає Service у шарі k8s без k8s/ua/ та k8s/ru/ — patch NodePort у k8s/ru/ не вимагається (abie.mdc)')
632
+ return
633
+ }
634
+ for (const [pkgAbs, targetsByName] of entries.toSorted((a, b) => a[0].localeCompare(b[0]))) {
635
+ const relPkg = relative(root, pkgAbs).replaceAll('\\', '/') || pkgAbs
636
+ const ruAbs = join(pkgAbs, 'k8s', 'ru', 'kustomization.yaml')
637
+ const nameList = [...targetsByName.keys()].toSorted((a, b) => a.localeCompare(b))
638
+ if (!existsSync(ruAbs)) {
639
+ fail(
640
+ `${relPkg}/k8s: є Service, для overlay ru потрібен patch Service (NodePort; для headless — ще remove clusterIP/clusterIPs): ${nameList.join(', ')} — додай ru/kustomization.yaml (abie.mdc)`
641
+ )
642
+ return
643
+ }
644
+ const relRu = relative(root, ruAbs).replaceAll('\\', '/') || ruAbs
645
+ let raw
646
+ try {
647
+ raw = await readFile(ruAbs, 'utf8')
648
+ } catch (error) {
649
+ const msg = error instanceof Error ? error.message : String(error)
650
+ fail(`${relRu}: не вдалося прочитати (${msg})`)
651
+ return
652
+ }
653
+ const patchErrors = getAbieRuServiceNodePortPatchErrors(raw, targetsByName)
654
+ if (patchErrors.length > 0) {
655
+ fail(`${relRu}: ${patchErrors.join('; ')}`)
656
+ return
657
+ }
658
+ passFn(`${relRu}: patch Service → NodePort (ru) відповідає abie.mdc`)
659
+ }
660
+ }
661
+
298
662
  /**
299
663
  * Директорії, де є хоча б один **Deployment** у файлах **k8s**.
300
664
  * @param {string} root корінь cwd
@@ -1273,6 +1637,9 @@ export async function check() {
1273
1637
  const healthCheckPolicyRelativePaths = await collectHealthCheckPolicyRelPaths(root, yamlFiles)
1274
1638
  await ensureRuKustomizationHealthCheckDelete(root, yamlFiles, healthCheckPolicyRelativePaths, fail)
1275
1639
 
1640
+ pass('Перевіряємо Service → NodePort у ru/kustomization (abie.mdc)')
1641
+ await ensureRuAbieServiceNodePortPatches(root, yamlFiles, fail, pass)
1642
+
1276
1643
  if (deploymentDirs.size > 0) {
1277
1644
  pass('Є Deployment — перевіряємо nodeSelector у ua/ru kustomization (abie.mdc)')
1278
1645
  await ensureUaRuAbieNodeSelectorPatches(root, yamlFiles, deploymentDirs, fail, pass)
@@ -4,8 +4,8 @@
4
4
  * Очікує наявність `bun.lock`, забороняє lockfile та артефакти yarn/pnpm, директорію `.yarn`
5
5
  * і поле `packageManager` у кореневому `package.json`.
6
6
  *
7
- * У кореневому `package.json` не має бути поля **`dependencies`**; у **`devDependencies`** дозволені
8
- * лише пакети з префіксами **`@cspell/`** та **`@nitra/`** (інші залежності — у workspace-пакетах).
7
+ * У кореневому `package.json` не має бути поля **`dependencies`**; у **`devDependencies`** дозволені лише
8
+ * пакети **`@nitra/*`** (наприклад **`@nitra/cspell-dict`**, **`@nitra/eslint-config`**).
9
9
  *
10
10
  * Якщо в `.n-cursor.json` у `rules` є `docker` або `k8s`, вимагає у кореневому `package.json`
11
11
  * відповідно скриптів `lint-docker` / `lint-k8s` (див. docker.mdc, k8s.mdc).
@@ -20,12 +20,12 @@ import { readFile } from 'node:fs/promises'
20
20
  import { createCheckReporter } from './utils/check-reporter.mjs'
21
21
 
22
22
  /**
23
- * Чи ім'я пакета дозволене в кореневих `devDependencies` за bun.mdc (лише `@cspell/*` та `@nitra/*`).
23
+ * Чи ім'я пакета дозволене в кореневих `devDependencies` за bun.mdc (лише **`@nitra/*`**).
24
24
  * @param {string} name ключ з поля `devDependencies`
25
25
  * @returns {boolean} true, якщо префікс дозволений
26
26
  */
27
27
  export function isAllowedRootDevDependency(name) {
28
- return name.startsWith('@cspell/') || name.startsWith('@nitra/')
28
+ return name.startsWith('@nitra/')
29
29
  }
30
30
 
31
31
  /**
@@ -105,14 +105,14 @@ export async function check() {
105
105
  const bad = Object.keys(dev).filter(n => !isAllowedRootDevDependency(n))
106
106
  if (bad.length > 0) {
107
107
  fail(
108
- `Кореневі devDependencies дозволені лише @cspell/* та @nitra/* — прибери або перенеси: ${bad.join(', ')} (bun.mdc)`
108
+ `Кореневі devDependencies: дозволені лише @nitra/* — прибери або перенеси: ${bad.join(', ')} (bun.mdc)`
109
109
  )
110
110
  } else {
111
111
  const n = Object.keys(dev).length
112
112
  pass(
113
113
  n === 0
114
- ? 'Кореневі devDependencies порожні (дозволені лише @cspell/* та @nitra/*)'
115
- : `Кореневі devDependencies лише @cspell/* та @nitra/* (${n} пак.)`
114
+ ? 'Кореневі devDependencies порожні або відсутні (лише @nitra/*)'
115
+ : `Кореневі devDependencies: лише @nitra/* (${n} пак.)`
116
116
  )
117
117
  }
118
118
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Перевіряє правило graphql.mdc: наявність **`.graphqlrc.yml`** і рекомендації **`graphql.vscode-graphql`**, якщо у дереві є **`gql\`…\``**.
3
3
  *
4
- * Обхід репозиторію — **`walkDir`** від **`process.cwd()`** (пропуски як у інших check). Кандидати — **`.vue`** та **`.js`/`.ts`/`.jsx`/`.tsx`** тощо; ігнор **`.d.ts`**, **auto-imports.d.ts** тощо — **`shouldSkipFileForGqlScan`**.
4
+ * Обхід репозиторію — **`walkDir`** від **`process.cwd()`** (пропуски як у інших check). Кандидати — **`.vue`** та **`.js`/`.ts`/`.jsx`/`.tsx`** тощо; пропуск **`.d.ts`**, **auto-imports.d.ts** тощо — **`shouldSkipFileForGqlScan`**.
5
5
  *
6
6
  * Виявлення **`gql`** — **oxc-parser** після витягування `<script>` з SFC (**`graphql-gql-scan.mjs`**). Якщо збігів немає — перевірка завершується успішно без вимог до конфігів.
7
7
  */
@@ -59,7 +59,7 @@ export function nitraEslintConfigDeclaresE18eTransitive(versionSpec) {
59
59
  }
60
60
 
61
61
  /**
62
- * Перевіряє обов’язкові поля `.oxlintrc.json` для плагіна e18e (js-lint.mdc).
62
+ * Перевіряє потрібні поля `.oxlintrc.json` для розширення e18e (js-lint.mdc).
63
63
  *
64
64
  * @param {unknown} cfg корінь JSON-конфігурації oxlint
65
65
  * @returns {{ ok: boolean, failures: string[] }} `ok` і перелік повідомлень для `fail`
@@ -67,7 +67,7 @@ export function nitraEslintConfigDeclaresE18eTransitive(versionSpec) {
67
67
  export function verifyOxlintRcE18e(cfg) {
68
68
  const failures = []
69
69
  if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
70
- return { ok: false, failures: ['.oxlintrc.json: корінь має бути об’єктом'] }
70
+ return { ok: false, failures: ['.oxlintrc.json: корінь має бути значенням типу object'] }
71
71
  }
72
72
  const o = /** @type {Record<string, unknown>} */ (cfg)
73
73
  const jsPlugins = o.jsPlugins
@@ -76,7 +76,7 @@ export function verifyOxlintRcE18e(cfg) {
76
76
  }
77
77
  const rules = o.rules
78
78
  if (!rules || typeof rules !== 'object' || Array.isArray(rules)) {
79
- failures.push('.oxlintrc.json: поле rules має бути об’єктом')
79
+ failures.push('.oxlintrc.json: поле rules має бути значенням типу object')
80
80
  } else {
81
81
  const r = /** @type {Record<string, unknown>} */ (rules)
82
82
  if (r['e18e/prefer-includes'] !== 'error') {
@@ -4,7 +4,8 @@
4
4
  * oxfmt: `.oxfmtrc.json` з обовʼязковими ключами, VSCode (formatOnSave, defaultFormatter для js/ts/json/vue/css/html),
5
5
  * відсутність Prettier у конфігах і залежностях.
6
6
  *
7
- * cspell, markdownlint через `bunx markdownlint-cli2` у `lint-text` (без оголошення пакета в package.json), заборона
7
+ * cspell, markdownlint через `bunx markdownlint-cli2` у `lint-text` (без оголошення пакета в package.json); у кореневих **`devDependencies`**
8
+ * дозволені лише **`@nitra/*`** (як у bun.mdc), зокрема **`@nitra/cspell-dict` ^2.0.0+**; без імпорту **`@cspell/dict-*`** у `.cspell.json`, заборона
8
9
  * `markdownlint-cli2` у dependencies/devDependencies, v8r (`run-v8r.mjs` або чотири `bunx v8r`),
9
10
  * `.v8rignore` (vscode JSON),
10
11
  * workflow `lint-text.yml`, розширення VSCode (markdownlint, oxc).
@@ -15,12 +16,27 @@
15
16
  import { existsSync } from 'node:fs'
16
17
  import { readFile } from 'node:fs/promises'
17
18
 
19
+ import { isAllowedRootDevDependency } from './check-bun.mjs'
18
20
  import { createCheckReporter } from './utils/check-reporter.mjs'
19
21
  import { anyRunStepIncludes, parseWorkflowYaml } from './utils/gha-workflow.mjs'
20
22
 
21
23
  /** Заголовок абзацу про апостроф у text.mdc / n-text.mdc. */
22
24
  const UK_APOSTROPHE_HEADING = '**Український апостроф:**'
23
25
 
26
+ /**
27
+ * Чи діапазон версії @nitra/cspell-dict у package.json означає лінію 2.0.0+ (з цієї версії словники входять у пакет).
28
+ * @param {string|undefined} range наприклад "^2.0.0"
29
+ * @returns {boolean}
30
+ */
31
+ function cspellDictVersionAtLeast200(range) {
32
+ if (typeof range !== 'string' || !range.trim()) return false
33
+ const cleaned = range.trim().replace(/^workspace:\*/, '').replace(/^[\^~>=<]+\s*/, '')
34
+ const m = cleaned.match(/^(\d+)\.(\d+)\.(\d+)/)
35
+ if (!m) return false
36
+ const major = Number(m[1])
37
+ return major >= 2
38
+ }
39
+
24
40
  /**
25
41
  * Перевіряє абзац про український апостроф у вмісті правила text.
26
42
  * @param {string} filePath шлях до файлу (для повідомлень)
@@ -217,11 +233,22 @@ export async function check() {
217
233
  if (pkg.prettier) fail('package.json містить поле "prettier" — видали його')
218
234
 
219
235
  const devDeps = pkg.devDependencies || {}
236
+ const nonNitraDev = Object.keys(devDeps).filter(n => !isAllowedRootDevDependency(n))
237
+ if (nonNitraDev.length > 0) {
238
+ fail(
239
+ `Кореневі devDependencies: дозволені лише @nitra/* — прибери або перенеси: ${nonNitraDev.join(', ')} (bun.mdc)`
240
+ )
241
+ } else {
242
+ pass('Кореневі devDependencies лише @nitra/*')
243
+ }
220
244
 
221
- if (devDeps['@nitra/cspell-dict']) {
222
- pass('@nitra/cspell-dict є в devDependencies')
245
+ const cspellRange = devDeps['@nitra/cspell-dict']
246
+ if (!cspellRange) {
247
+ fail('@nitra/cspell-dict у devDependencies обовʼязковий для cspell — bun add -d @nitra/cspell-dict@^2.0.0')
248
+ } else if (!cspellDictVersionAtLeast200(cspellRange)) {
249
+ fail('@nitra/cspell-dict має бути ^2.0.0 або новіший (словники зібрані в пакеті з 2.x)')
223
250
  } else {
224
- fail('@nitra/cspell-dict відсутній — bun add -d @nitra/cspell-dict')
251
+ pass('@nitra/cspell-dict ^2.0.0+')
225
252
  }
226
253
 
227
254
  const rootDeps = pkg.dependencies || {}
@@ -276,9 +303,13 @@ export async function check() {
276
303
 
277
304
  if (existsSync('.cspell.json')) {
278
305
  const cfg = JSON.parse(await readFile('.cspell.json', 'utf8'))
279
- const hasUkImport = (cfg.import || []).some(i => i.includes('@cspell/dict-uk-ua'))
280
- if (hasUkImport && !devDeps['@cspell/dict-uk-ua']) {
281
- fail('.cspell.json імпортує @cspell/dict-uk-ua, але пакет відсутній в devDependencies')
306
+ const dictImports = (cfg.import || []).filter(i => typeof i === 'string' && i.includes('@cspell/dict-'))
307
+ if (dictImports.length > 0) {
308
+ fail(
309
+ `.cspell.json не має імпортувати @cspell/dict-* (${dictImports.join(', ')}) — використовуй лише @nitra/cspell-dict/cspell-ext.json`
310
+ )
311
+ } else {
312
+ pass('.cspell.json без прямих імпортів @cspell/dict-*')
282
313
  }
283
314
  }
284
315
  }