@nitra/cursor 1.8.41 → 1.8.55

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
@@ -8,6 +8,7 @@
8
8
  * `npx \@nitra/cursor check` — перевірити правила, перелічені в AGENTS.md (якщо є check-*.mjs);
9
9
  * якщо в корені вже є `.n-cursor.json`, спочатку зчитується конфіг і за потреби дописується `$schema`
10
10
  * `npx \@nitra/cursor check bun` — перевірити лише вказані правила (ігнорує AGENTS.md)
11
+ * `npx \@nitra/cursor rename-yaml-extensions` — k8s `*.yml` → `*.yaml`, `.github` `*.yaml` → `*.yml` (опції: `--dry-run`, `--root=…`; див. bin/rename-yaml-extensions.mjs)
11
12
  *
12
13
  * Якщо у корені репозиторію немає .n-cursor.json, спочатку перейменовується за наявності nitra-cursor.json;
13
14
  * у `.cursor/rules` файли `nitra-*.mdc` перейменовуються на `n-*.mdc`; інакше конфіг створюється автоматично
@@ -43,6 +44,7 @@ import {
43
44
  ensureNitraCursorInRootDevDependencies,
44
45
  readBundledPackageVersion
45
46
  } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
47
+ import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
46
48
  import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
47
49
 
48
50
  const PACKAGE_NAME = '@nitra/cursor'
@@ -863,10 +865,31 @@ const [command, ...args] = process.argv.slice(2)
863
865
 
864
866
  try {
865
867
  await ensureNitraCursorInRootDevDependencies(cwd())
866
- if (command === 'check') {
867
- await runChecks(args)
868
- } else {
869
- await runSync()
868
+ switch (command) {
869
+ case 'check': {
870
+ await runChecks(args)
871
+
872
+ break
873
+ }
874
+ case 'rename-yaml-extensions': {
875
+ const code = await runRenameYamlExtensionsCli(args)
876
+ if (code !== 0) {
877
+ process.exitCode = 1
878
+ }
879
+
880
+ break
881
+ }
882
+ case undefined:
883
+ case '': {
884
+ await runSync()
885
+
886
+ break
887
+ }
888
+ default: {
889
+ console.error(`❌ Невідома команда: ${command}`)
890
+ console.error(` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions`)
891
+ process.exitCode = 1
892
+ }
870
893
  }
871
894
  } catch {
872
895
  process.exitCode = 1
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI для перейменування розширень YAML (k8s та `.github`). Бізнес-логіка — у **`scripts/rename-yaml-extensions.mjs`**.
5
+ *
6
+ * Точки входу:
7
+ * - **`npx \@nitra/cursor rename-yaml-extensions`** [опції]
8
+ * - **`npx n-rename-yaml-extensions`** [опції] (глобальний shim з `package.json` bin)
9
+ * - **`bun ./node_modules/\@nitra/cursor/bin/rename-yaml-extensions.mjs`**
10
+ *
11
+ * Опції: **`--dry-run`**, **`--root=<шлях>`** (корінь обходу; за замовчуванням **`process.cwd()`**).
12
+ */
13
+ import { isRunAsCli } from '../scripts/cli-entry.mjs'
14
+ import { parseRenameYamlArgs, renameYamlExtensions } from '../scripts/rename-yaml-extensions.mjs'
15
+
16
+ /**
17
+ * Запускає перейменування з виводом у консоль (для підкоманди **`n-cursor`** або прямого bin).
18
+ * @param {string[]} argv аргументи без імені команди (усі після `rename-yaml-extensions` у **`n-cursor`**)
19
+ * @returns {Promise<number>} **0** — успіх; **1** — були помилки (існуючий цільовий файл тощо)
20
+ */
21
+ export async function runRenameYamlExtensionsCli(argv) {
22
+ const { dryRun, root } = parseRenameYamlArgs(argv)
23
+ const label = dryRun ? '[dry-run] ' : ''
24
+ const { renamed, errors } = await renameYamlExtensions(root, { dryRun })
25
+
26
+ for (const { relFrom, relTo } of renamed) {
27
+ console.log(`${label}${relFrom} → ${relTo}`)
28
+ }
29
+ if (renamed.length === 0 && errors.length === 0) {
30
+ console.log(`${label}Немає файлів для перейменування (k8s + .yml → .yaml; .github + .yaml → .yml).`)
31
+ }
32
+ for (const err of errors) {
33
+ console.error(` ❌ ${err}`)
34
+ }
35
+
36
+ return errors.length > 0 ? 1 : 0
37
+ }
38
+
39
+ if (isRunAsCli()) {
40
+ const code = await runRenameYamlExtensionsCli(process.argv.slice(2))
41
+ if (code !== 0) {
42
+ process.exitCode = 1
43
+ }
44
+ }
package/mdc/abie.mdc CHANGED
@@ -28,7 +28,7 @@ spec:
28
28
  name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
29
29
  ```
30
30
 
31
- і підключення до kustomize. Але у директорії ru повинен бути файл kustomization.yaml з patch, що видаляє ресурс **HealthCheckPolicy**.
31
+ **Overlay `ru`:** у **`…/ru/kustomization.yaml`** під **`k8s`** додай **видалення** ресурсу HealthCheckPolicy для середовища, де політика не потрібна (підстав **реальне ім’я** замість `НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ`):
32
32
 
33
33
  ```yaml title="kustomization.yaml"
34
34
  patches:
@@ -53,4 +53,6 @@ ignore_branches: dev,ua,ru
53
53
 
54
54
  `npx @nitra/cursor check abie`
55
55
 
56
+ Повна матриця полів **`hc.yaml`**, **`ignore_branches`** і умов для **`ru/kustomization.yaml`** — у **`npm/scripts/check-abie.mjs`**.
57
+
56
58
  Програмна перевірка (**`check-abie.mjs`**) виконується лише якщо у **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень (щоб не вимагати **ua**/**ru** у репозиторіях без цього правила).
package/mdc/k8s.mdc CHANGED
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  description: K8s YAML — $schema (yaml-language-server); lint-k8s (kubeconform, kubescape); check-k8s
3
- version: '1.18'
4
- globs: "**/k8s/**/*.{yaml,yml}"
3
+ version: '1.23'
4
+ globs: "**/k8s/**/*.yaml"
5
5
  alwaysApply: false
6
6
  ---
7
7
 
8
8
  # Kubernetes YAML у шляхах з `k8s`
9
9
 
10
- Для кожного файлу `*.yaml` або `*.yml`, у шляху якого є сегмент директорії **`k8s`** (наприклад `site/k8s/base/deployment.yaml`), **перший рядок** — коментар-директива для [YAML Language Server](https://github.com/redhat-developer/yaml-language-server):
10
+ Для кожного файлу `*.yaml`, у шляху якого є сегмент директорії **`k8s`** (наприклад `site/k8s/base/deployment.yaml`), **перший рядок** — коментар-директива для [YAML Language Server](https://github.com/redhat-developer/yaml-language-server):
11
11
 
12
12
  ```yaml
13
13
  # yaml-language-server: $schema=https://...
@@ -15,7 +15,7 @@ alwaysApply: false
15
15
 
16
16
  Далі — вміст маніфесту. Зайвий порожній рядок між коментарем і YAML не додавай, якщо в проєкті не прийнято інше.
17
17
 
18
- **Розширення:** усі маніфести **`.yaml`**. Виняток: **`kustomization.yml`** (і `kustomization.yaml`) дозволені за іменем.
18
+ **Розширення:** усі маніфести під **`k8s`**, включно з **`kustomization.yaml`**, — лише **`.yaml`** (розширення **`.yml`** не використовуй).
19
19
 
20
20
  **Dockerfile / hadolint** — окреме правило **`docker.mdc`** і **`npx @nitra/cursor check docker`**.
21
21
 
@@ -46,7 +46,7 @@ alwaysApply: false
46
46
 
47
47
  Шлях до скрипта підстав свій (`./scripts/…` після копіювання, `node_modules/@nitra/cursor/scripts/…` якщо пакет у залежностях).
48
48
 
49
- Додай workflow **`.github/workflows/lint-k8s.yml`** (гілки **`dev`** і **`main`**, лише **`.yml`**, узгоджено з **`ga.mdc`**):
49
+ Додай workflow **`.github/workflows/lint-k8s.yml`** (гілки **`dev`** і **`main`**, лише **`.yml`**, узгоджено з **`ga.mdc`**). **Не** дублюй **`setup-node`**, **`oven-sh/setup-bun`**, **`actions/cache`** і **`bun install`** у job — після **`checkout`** використовуй локальний composite **`setup-bun-deps`** (шлях **`./.github/actions/setup-bun-deps`** або **`./npm/github-actions/setup-bun-deps`**, як у репозиторії). Встановлення **kubeconform** / **kubescape** лишаються окремими кроками.
50
50
 
51
51
  ```yaml title=".github/workflows/lint-k8s.yml"
52
52
  name: Lint K8s
@@ -57,7 +57,7 @@ on:
57
57
  - dev
58
58
  - main
59
59
  paths:
60
- - '**/k8s/**/*.yml'
60
+ - '**/k8s/**/*.yaml'
61
61
 
62
62
  pull_request:
63
63
  branches:
@@ -78,6 +78,8 @@ jobs:
78
78
  with:
79
79
  persist-credentials: false
80
80
 
81
+ - uses: ./.github/actions/setup-bun-deps
82
+
81
83
  - name: Install kubeconform
82
84
  run: |
83
85
  curl -sSL "https://github.com/yannh/kubeconform/releases/download/v0.7.0/kubeconform-linux-amd64.tar.gz" | tar xz
@@ -88,33 +90,10 @@ jobs:
88
90
  curl -s https://raw.githubusercontent.com/kubescape/kubescape/master/install.sh | /bin/bash
89
91
  echo "$HOME/.kubescape/bin" >> $GITHUB_PATH
90
92
 
91
- - uses: actions/setup-node@v6
92
- with:
93
- node-version: '24'
94
-
95
- - uses: oven-sh/setup-bun@v2
96
-
97
- - name: Cache Bun dependencies
98
- uses: actions/cache@v5
99
- with:
100
- path: |
101
- ~/.bun/install/cache
102
- node_modules
103
- key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
104
- restore-keys: |
105
- ${{ runner.os }}-bun-
106
-
107
- - name: Install dependencies
108
- run: bun install --frozen-lockfile
109
-
110
93
  - name: Lint K8s
111
94
  run: bun run lint-k8s
112
95
  ```
113
96
 
114
- Після **`install.sh`** kubescape може опинитися поза стандартним PATH; за потреби додай крок **`echo "$HOME/.kubescape/bin" >> $GITHUB_PATH`** (див. документацію [kubescape](https://github.com/kubescape/kubescape#readme)).
115
-
116
- Кореневий скрипт **`lint`** (див. **`n-bun.mdc`**) **обов'язково** містить **`bun run lint-k8s`**, коли в проєкті підключено правило **`k8s`**.
117
-
118
97
  ## Deployment: `resources`
119
98
 
120
99
  Для **`kind: Deployment`** у кожному контейнері **`spec.template.spec.containers[]`** має бути явне поле **`resources`**. Якщо ліміти та requests ще не задані, додай порожній об'єкт:
@@ -144,7 +123,11 @@ resources: {}
144
123
 
145
124
  ### Namespace
146
125
 
147
- - У **`base/kustomization.yaml`** задай **`namespace: dev`** (або узгоджене ім’я для dev **namespace**), щоб **усі ресурси base** потрапляли в цей namespace через Kustomize.
126
+ - **`base/kustomization.yaml`:** у цьому файлі поле **`namespace:`** **завжди** має бути присутнє й **непорожнє**, щоб Kustomize застосував один цільовий namespace до ресурсів **base**. **`check k8s`** перевіряє це, коли файл є в репозиторії.
127
+
128
+ - **Де не дублювати `metadata.namespace`:** у YAML під **`k8s`**, чиї шляхи **досяжні** з будь-якого **`kustomization.yaml`** через **`resources`**, **`bases`**, **`components`**, **`crds`**, елементи **`patches`** з полем **`path`**, **`patchesStrategicMerge`** (транзитивно, зокрема через каталог із дочірнім **`kustomization.yaml`**). У таких файлах **не** вказуй **`metadata.namespace`** — його виставляє Kustomize за полем **`namespace:`** у kustomization.
129
+
130
+ - **Коли `metadata.namespace` обов’язковий:** YAML під **`k8s`**, який **ніде** не перелічений у згаданих полях жодного kustomization-файлу, має містити **непорожній** **`metadata.namespace`** у кожному документі з **`apiVersion`** та **`kind`**, **окрім** кластерних ресурсів (наприклад **`Namespace`**, **`ClusterRole`**, **`PersistentVolume`** — повний перелік у **`check-k8s.mjs`**, **`CLUSTER_SCOPED_KINDS`**). Якщо файл має бути без namespace у маніфесті — підключи його до kustomization через **`resources`** / **`patches`** тощо.
148
131
 
149
132
  - **Не додавай** окремі **patches** Kustomize, які лише змінюють **namespace**: **namespace** визначає Kustomize; у overlays додаткові зміни — без дублювання логіки **namespace**.
150
133
 
@@ -176,28 +159,15 @@ resources: {}
176
159
 
177
160
  Для `$schema` у першому рядку див. приклад **HealthCheckPolicy** у тому ж розділі (datree CRDs-catalog).
178
161
 
179
- 3. **Overlay `ru`:** у **`ru/kustomization.yaml`** (шлях з сегментами **`…/ru/kustomization.yaml`** під **`k8s`**) додай **видалення** ресурсу HealthCheckPolicy для середовища, де політика не потрібна (підстав **реальне ім’я** замість `SERVICE_NAME`):
180
-
181
- ```yaml
182
- patches:
183
- - target:
184
- kind: HealthCheckPolicy
185
- patch: |-
186
- kind: HealthCheckPolicy
187
- metadata:
188
- name: SERVICE_NAME
189
- $patch: delete
190
- ```
191
-
192
162
  За потреби розшир **`target`** (`name`, `namespace`), щоб однозначно вказати об’єкт.
193
163
 
194
- **`check k8s`:** заборонено **`kind: Ingress`**; якщо в проєкті є **`kind: HealthCheckPolicy`**, має існувати хоча б один **`ru/kustomization.yaml`** під **`k8s`** із блоком видалення (**`$patch: delete`** для **HealthCheckPolicy**).
164
+ **`check k8s`:** заборонено **`kind: Ingress`**.
195
165
 
196
166
  ## Перевірка
197
167
 
198
- **`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче; для кожного документа **`Deployment`** — **`containers[].resources`**, **`imagePullPolicy: Always`**; заборона **`kind: Ingress`**; наявність **`HealthCheckPolicy`** — вимога до **`ru/kustomization.yaml`** з **`$patch: delete`** (див. **Ingress → Gateway API**); заборона шляхів **`…/k8s/dev/…`**; якщо існує **`k8s/base/kustomization.yaml`** (або **`.yml`**) — непорожній **`namespace`** у першому документі. Якщо під `k8s` немає yaml/yml — перевірку пропущено. Інший зміст маніфесту — вручну / **`lint-k8s`**.
168
+ **`npx @nitra/cursor check k8s`** — критерії збігаються з розділом **«Що закодовано в `check-k8s.mjs`»** і з **«Визначення схеми YAML»** вище. Якщо під `k8s` немає `*.yaml` — перевірку пропущено. Інший зміст маніфесту — вручну / **`lint-k8s`**.
199
169
 
200
- Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape; обов'язково для проєктів з правилом **`k8s`** у **`.n-cursor.json`**).
170
+ Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape).
201
171
 
202
172
  ## Що закодовано в `check-k8s.mjs`
203
173
 
@@ -206,25 +176,24 @@ resources: {}
206
176
  - Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
207
177
  - **`file:`** у `$schema` — URL до apiVersion/kind не звіряється; **`https:`** — kustomization за іменем файлу → Schema Store; далі перевіряється **`EXPLICIT_K8S_SCHEMAS`** (`Map`: `apiVersion` + `kind` + `type`, для записів без `type` у маніфесті — третій компонент **`*`**); потім `v1` → yannh; група з `YANNH_GROUPS` → yannh; інакше → datree (GitHub Pages), крім рядків явної таблиці (наприклад **InfisicalSecret** — raw на `main`).
208
178
  - У кожному YAML-документі з **`kind: Deployment`** — парсинг через **`yaml`**: у кожного елемента **`spec.template.spec.containers[]`** має існувати ключ **`resources`** зі значенням-об'єктом (допускається порожній **`{}`**); у кожного контейнера — **`imagePullPolicy: Always`**.
209
- - У файлах, ім’я яких **не** `kustomization.yaml`.
179
+ - **Namespace у маніфестах (не ім’я `kustomization`):** ресурсні YAML у **`…/k8s/base/`** — **завжди** непорожній **`metadata.namespace`**. Інакше будується множина шляхів, досяжних з **`kustomization.yaml`** через **`resources`**, **`bases`**, **`components`**, **`crds`**, **`patches[].path`**, **`patchesStrategicMerge`**, з рекурсією в каталоги з дочірнім **`kustomization.yaml`**: для таких файлів **поза** **`k8s/base`** — **заборона** **`metadata.namespace`**; для файлів поза **`k8s/base`** і поза множиною — **вимога** непорожнього **`metadata.namespace`** (крім **`CLUSTER_SCOPED_KINDS`**).
210
180
  - Документи з **`kind: Ingress`** — заборонені (потрібен перехід на Gateway API, див. розділ **Ingress → Gateway API**).
211
- - Якщо в будь-якому файлі під **`k8s`** є **`kind: HealthCheckPolicy`**, серед файлів має бути **`ru/kustomization.yaml`** (сегмент шляху **`ru`** перед іменем файлу), а його вміст — patch видалення **HealthCheckPolicy** з **`$patch: delete`** (див. той самий розділ).
212
181
  - Заборона шляхів **`…/k8s/dev/…`** (окремої директорії **`dev`** під **`k8s`** не має бути).
213
- - Якщо існує **`k8s/base/kustomization.yaml`** (або **`.yml`**), у першому документі має бути непорожнє поле **`namespace`** (типово **`dev`**; див. розділ **Namespace**).
182
+ - Якщо в дереві є **`k8s/base/kustomization.yaml`**, у першому документі **завжди** має бути непорожнє поле **`namespace`** (типово **`dev`**; див. розділ **Namespace**).
214
183
 
215
184
  ## Коли застосовувати (агентам)
216
185
 
217
186
  - Зміни в k8s YAML — після правок **`check k8s`**.
218
187
  - Якщо перший рядок уже коректний і URL відповідає `apiVersion` / `kind` — не дублюй; змінився ресурс — онови лише `$schema`.
219
188
  - У **`Deployment`** без поля **`resources`** у контейнері — додай **`resources: {}`** (див. розділ **Deployment: `resources`**); додай **`imagePullPolicy: Always`** для кожного контейнера.
220
- - Дотримуйся структури **Kustomize** (`base` = dev, overlays без дублювання `base`, коментарі для рядків, що змінюються в overlay).
189
+ - Дотримуйся структури **Kustomize** (`base` = dev, overlays без дублювання `base`, коментарі для рядків, що змінюються в overlay). У **`base/kustomization.yaml`** завжди задавай непорожній **`namespace:`**. У **`k8s/base`** у кожному ресурсному YAML має бути явний **`metadata.namespace`**. Поза **base**, якщо не хочеш **`metadata.namespace`** у файлі — підключи його до kustomization (**`resources`** / **`patches`** тощо); інакше додай явний **`metadata.namespace`**.
221
190
  - Після міграції на нову структуру **видали** застарілі файли та каталоги, які вже замінені (див. **Міграція зі старої структури**).
222
191
 
223
192
  ## Визначення схеми YAML (канон)
224
193
 
225
194
  Орієнтир — **перший документ** (до наступного `---`).
226
195
 
227
- 1. **Ім’я** `kustomization.yaml` або `kustomization.yml` → `https://json.schemastore.org/kustomization.json`.
196
+ 1. **Ім’я** `kustomization.yaml` → `https://json.schemastore.org/kustomization.json`.
228
197
  2. **`apiVersion: v1`** → yannh, PIN набору схем **`v1.33.9-standalone-strict`**, ref репозиторію для raw URL — **`master`** (каталог версії не є коренем репо на GitHub):
229
198
  `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/<PIN>/<kind>-v1.json`
230
199
  `<kind>`: літери в нижньому регістрі без роздільників між CamelCase (наприклад `Service` → `service`).
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  description: Правила стилів CSS та SCSS
3
3
  alwaysApply: true
4
- version: '1.1'
4
+ version: '1.2'
5
5
  ---
6
6
 
7
7
  ## Генерація та редагування стилів (Cursor і інші агенти)
8
8
 
9
9
  - **Джерело правил:** перед тим як писати або суттєво змінювати **`.css`**, **`.scss`** або стилі в **`.vue`**, переглянь у корені проєкту (і в релевантних пакетах монорепо, якщо є) поле **`stylelint`** у **`package.json`** (зокрема `extends`), наявні **`.stylelintrc.*`**, **`stylelint.config.*`** та **`.stylelintignore`**. Не покладайся на «типові» правила stylelint з пам’яті — дотримуйся **проєктного** **`@nitra/stylelint-config`** і будь-яких локальних доповнень у репозиторії.
10
10
  - **Форматування** узгоджуй з **`n-js-format.mdc`** (oxfmt / `.oxfmtrc.json` для css, scss тощо), щоб форматер і stylelint не суперечили один одному.
11
- - **Після змін:** запускай **`bun run lint-style`** (або `bunx stylelint` з тими ж glob-ами та прапорцями, що в скрипті та CI) і виправляй усе, що лишилось після auto-fix. За потреби пройдись повним набором `lint-*` з `package.json` (див. навичку **`n-fix`**).
12
- - **Не розширюй винятки:** не додавай зайві **`stylelint-disable`** / вузькі придушення правил без потреби; краще змінити стилі під правила проєкту.
11
+ - **Запуск stylelint:** лише **`npx stylelint`** (у **`lint-style`**, у CI, вручну). Не використовуй **`bunx stylelint`** і не запускай **stylelint** напряму без **`npx`**. Після змін запускай **`bun run lint-style`** і виправляй усе, що лишилось після auto-fix; за потреби повний набір `lint-*` (навичка **`n-fix`**).
12
+ - **Не розширюй винятки:** не додавай зайві **`stylelint-disable`** без потреби; краще підлаштувати стилі під правила проєкту.
13
13
 
14
- В файлі .vscode/extensions.json є налаштування для офіційного плагіну:
14
+ **VSCode:** у **`.vscode/extensions.json`** рекомендуй **`stylelint.vscode-stylelint`**. У **`.vscode/settings.json`** вимкни вбудовану валідацію CSS/SCSS/Less і увімкни явні code actions:
15
15
 
16
16
  ```json title=".vscode/extensions.json"
17
17
  {
@@ -19,8 +19,6 @@ version: '1.1'
19
19
  }
20
20
  ```
21
21
 
22
- в файлі .vscode/settings.json є налаштування для stylelint:
23
-
24
22
  ```json title=".vscode/settings.json"
25
23
  {
26
24
  "css.validate": false,
@@ -32,11 +30,11 @@ version: '1.1'
32
30
  }
33
31
  ```
34
32
 
35
- в файлі package.json є налаштування для stylelint:
33
+ **`package.json`:**
36
34
 
37
35
  ```json title="package.json"
38
36
  "scripts": {
39
- "lint-style": "stylelint '**/*.{css,scss,vue}' --fix",
37
+ "lint-style": "npx stylelint '**/*.{css,scss,vue}' --fix",
40
38
  },
41
39
  "devDependencies": {
42
40
  "@nitra/stylelint-config": "^1.4.0"
@@ -46,9 +44,9 @@ version: '1.1'
46
44
  },
47
45
  ```
48
46
 
49
- Є гітхаб actions .github/workflows/lint-style.yml:
47
+ Додай **`.github/workflows/lint-style.yml`** (лише **`.yml`**, **`ga.mdc`**): після **`checkout`** — локальний composite **`setup-bun-deps`**, далі **`bun run lint-style`** (у скрипті вже **`npx stylelint`**). **Не** дублюй окремі кроки **`setup-node`** / **`oven-sh/setup-bun`** / кеш / **`npm install`**.
50
48
 
51
- ```yaml
49
+ ```yaml title=".github/workflows/lint-style.yml"
52
50
  name: StyleLint
53
51
 
54
52
  on:
@@ -77,23 +75,20 @@ concurrency:
77
75
  jobs:
78
76
  stylelint:
79
77
  runs-on: ubuntu-latest
78
+ permissions:
79
+ contents: read
80
80
  steps:
81
81
  - uses: actions/checkout@v6
82
+ with:
83
+ persist-credentials: false
84
+
85
+ - uses: ./.github/actions/setup-bun-deps
82
86
 
83
87
  - name: StyleLint
84
- run: |
85
- node - <<'NODE'
86
- const fs = require('fs')
87
- const data = JSON.parse(fs.readFileSync('package.json', 'utf8'))
88
- delete data.workspaces
89
- data.devDependencies = {}
90
- fs.writeFileSync('package.json', JSON.stringify(data, null, 2) + '\n')
91
- NODE
92
- npm install @nitra/stylelint-config
93
- npx stylelint '**/*.{css,scss,vue}'
88
+ run: bun run lint-style
94
89
  ```
95
90
 
96
- В корені проекту має бути файл .stylelintignore з виключенням:
91
+ У корені проєкту має бути **`.stylelintignore`**:
97
92
 
98
93
  ```text title=".stylelintignore"
99
94
  dist/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.41",
3
+ "version": "1.8.55",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -20,7 +20,8 @@
20
20
  "url": "git+https://github.com/n/cursor.git"
21
21
  },
22
22
  "bin": {
23
- "n-cursor": "./bin/n-cursor.js"
23
+ "n-cursor": "./bin/n-cursor.js",
24
+ "n-rename-yaml-extensions": "./bin/rename-yaml-extensions.mjs"
24
25
  },
25
26
  "files": [
26
27
  "mdc",
@@ -33,7 +34,8 @@
33
34
  ],
34
35
  "type": "module",
35
36
  "scripts": {
36
- "test": "bun test tests"
37
+ "test": "bun test tests",
38
+ "rename-yaml-extensions": "bun ./bin/rename-yaml-extensions.mjs"
37
39
  },
38
40
  "dependencies": {
39
41
  "oxc-parser": "^0.124.0",
@@ -2,29 +2,33 @@
2
2
  * Перевіряє Kubernetes YAML у шляхах з сегментом `k8s` (див. k8s.mdc).
3
3
  *
4
4
  * Перший рядок `# yaml-language-server: $schema=…`, без дублікатів, розширення `.yaml`
5
- * (окрім `kustomization.yml`); URL схеми за першим документом — kustomization / yannh / datree
5
+ * (окрім `kustomization.yaml`); URL схеми за першим документом — kustomization / yannh / datree
6
6
  * (datree за замовчуванням: GitHub Pages `https://datreeio.github.io/CRDs-catalog/…`).
7
7
  *
8
8
  * Додатково: у кожному YAML-документі з **`kind: Deployment`** у кожного контейнера
9
9
  * **`spec.template.spec.containers[]`** має бути ключ **`resources`** (значення — об'єкт, допускається
10
10
  * порожній **`{}`**) та **`imagePullPolicy: Always`**.
11
11
  *
12
- * У файлах **не** `kustomization.yaml` / `kustomization.yml` у документах не має бути **`metadata.namespace`**
13
- * (namespace лише в Kustomize).
12
+ * **Namespace і Kustomize:** YAML у **`…/k8s/base/`** (окрім імені **`kustomization.yaml`**)
13
+ * завжди має **непорожній** **`metadata.namespace`** у відповідних документах (узгоджено з dev у репозиторії),
14
+ * навіть якщо **`namespace:`** заданий у **`base/kustomization.yaml`**.
15
+ * Поза **`k8s/base`**: для файлів, досяжних з kustomization через **`resources`**, **`bases`**, **`components`**,
16
+ * **`crds`**, **`patches[].path`**, **`patchesStrategicMerge`**, **`metadata.namespace`** у маніфесті **не** додають;
17
+ * файли **поза** цим графом — **непорожній** **`metadata.namespace`** (крім **кластерних** kind; див. k8s.mdc).
14
18
  *
15
- * **`kind: Ingress`** заборонено (потрібен перехід на Gateway API). Якщо є **`HealthCheckPolicy`**,
16
- * має існувати **`ru/kustomization.yaml`** з patch видалення цього kind (`$patch: delete`).
19
+ * **`kind: Ingress`** заборонено (потрібен перехід на Gateway API).
17
20
  *
18
- * Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; якщо є **`…/k8s/base/kustomization.yaml`**
19
- * (або **`.yml`**), у першому документі має бути непорожнє поле **`namespace`**.
21
+ * Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; у **`k8s/base/kustomization.yaml`**
22
+ * завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує).
20
23
  *
21
24
  * Явні винятки до загальної логіки yannh/datree — таблиця **`EXPLICIT_K8S_SCHEMAS`** (`Map`): ключ
22
25
  * **`apiVersion`, `kind`, `type`** (для CRD без поля `type` у маніфесті — зірочка **`*`** як третій
23
26
  * компонент). Спочатку шукається збіг за фактичним `type`, потім за **`*`**.
24
27
  * Dockerfile — правило docker.mdc, скрипт check-docker.mjs.
25
28
  */
26
- import { readFile } from 'node:fs/promises'
27
- import { basename, relative } from 'node:path'
29
+ import { existsSync } from 'node:fs'
30
+ import { readFile, stat } from 'node:fs/promises'
31
+ import { basename, dirname, join, relative, resolve } from 'node:path'
28
32
 
29
33
  import { parseAllDocuments } from 'yaml'
30
34
 
@@ -164,34 +168,218 @@ export function isForbiddenK8sDevPath(rel) {
164
168
  }
165
169
 
166
170
  /**
167
- * Чи це **`k8s/base/kustomization.yaml`** або **`kustomization.yml`** (перевірка поля **`namespace`**).
171
+ * Відносний шлях від кореня репозиторію у вигляді з `/` (для множини kustomize).
172
+ * @param {string} root корінь cwd
173
+ * @param {string} abs абсолютний шлях
174
+ * @returns {string | null} posix-відносний шлях або null, якщо поза root
175
+ */
176
+ function posixRelFromAbs(root, abs) {
177
+ const r = (relative(root, abs) || abs).replaceAll('\\', '/')
178
+ if (r.startsWith('..')) return null
179
+ return r
180
+ }
181
+
182
+ /**
183
+ * Вбудовані та поширені **кластерні** `kind`, для яких **`metadata.namespace`** не застосовується.
184
+ * CRD з невідомим kind лишаються з вимогою namespace, якщо файл не в kustomization — за потреби додай path у `resources`.
185
+ * @type {Set<string>}
186
+ */
187
+ const CLUSTER_SCOPED_KINDS = new Set([
188
+ 'APIService',
189
+ 'CertificateSigningRequest',
190
+ 'ClusterCIDR',
191
+ 'ClusterRole',
192
+ 'ClusterRoleBinding',
193
+ 'ComponentStatus',
194
+ 'CSIDriver',
195
+ 'CSINode',
196
+ 'CustomResourceDefinition',
197
+ 'FlowSchema',
198
+ 'IPAddress',
199
+ 'IngressClass',
200
+ 'MutatingWebhookConfiguration',
201
+ 'Namespace',
202
+ 'Node',
203
+ 'PersistentVolume',
204
+ 'PriorityClass',
205
+ 'PriorityLevelConfiguration',
206
+ 'RuntimeClass',
207
+ 'ServiceCIDR',
208
+ 'StorageClass',
209
+ 'StorageVersionMigration',
210
+ 'ValidatingAdmissionPolicy',
211
+ 'ValidatingAdmissionPolicyBinding',
212
+ 'ValidatingWebhookConfiguration',
213
+ 'VolumeAttachment'
214
+ ])
215
+
216
+ /**
217
+ * Чи `kind` за замовчуванням **кластерний** (без namespace у маніфесті).
218
+ * @param {string} kind значення `kind`
219
+ * @returns {boolean} true для кластерних built-in / поширених API
220
+ */
221
+ export function isClusterScopedKubernetesKind(kind) {
222
+ return typeof kind === 'string' && kind !== '' && CLUSTER_SCOPED_KINDS.has(kind)
223
+ }
224
+
225
+ /**
226
+ * Додає рядки шляхів з поля-масиву kustomization.
227
+ * @param {unknown} arr значення з YAML
228
+ * @param {string[]} acc накопичувач
229
+ */
230
+ function pushStringPaths(arr, acc) {
231
+ if (!Array.isArray(arr)) return
232
+ for (const item of arr) {
233
+ if (typeof item === 'string' && item.trim() !== '') acc.push(item.trim())
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Шляхи з полів Kustomization для resolve відносно каталогу **`kustomization.yaml`**.
239
+ * @param {unknown} obj корінь першого документа Kustomization
240
+ * @returns {string[]} відносні або абсолютні посилання з маніфесту
241
+ */
242
+ function pathsFromKustomizationObject(obj) {
243
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return []
244
+ const rec = /** @type {Record<string, unknown>} */ (obj)
245
+ /** @type {string[]} */
246
+ const out = []
247
+ pushStringPaths(rec.resources, out)
248
+ pushStringPaths(rec.bases, out)
249
+ pushStringPaths(rec.components, out)
250
+ pushStringPaths(rec.crds, out)
251
+ pushStringPaths(rec.patchesStrategicMerge, out)
252
+ const patches = rec.patches
253
+ if (Array.isArray(patches)) {
254
+ for (const p of patches) {
255
+ if (
256
+ p !== null &&
257
+ typeof p === 'object' &&
258
+ !Array.isArray(p) &&
259
+ typeof p.path === 'string' &&
260
+ p.path.trim() !== ''
261
+ ) {
262
+ out.push(p.path.trim())
263
+ }
264
+ }
265
+ }
266
+ return out
267
+ }
268
+
269
+ /**
270
+ * Збирає відносні шляхи (posix) до YAML, підключених до Kustomize з будь-якого **`kustomization.yaml`** під `k8s`.
271
+ * Обходить **`resources`**, **`bases`**, **`components`**, **`crds`**, **`patches[].path`**, **`patchesStrategicMerge`**;
272
+ * для каталогу з **`kustomization.yaml`** виконує рекурсивний обхід.
273
+ * @param {string} root корінь репозиторію
274
+ * @param {string[]} yamlFilesAbs відсортовані абсолютні шляхи до `*.yaml` / `*.yml` під k8s (для `.yml` check-k8s вимагає перейменувати на `.yaml`)
275
+ * @returns {Promise<Set<string>>} множина відносних шляхів до керованих файлів
276
+ */
277
+ export async function collectKustomizeManagedRelPaths(root, yamlFilesAbs) {
278
+ /** @type {Set<string>} */
279
+ const managed = new Set()
280
+ const kustomizationAbsList = yamlFilesAbs.filter(abs => {
281
+ const b = basename(abs).toLowerCase()
282
+ return b === 'kustomization.yaml'
283
+ })
284
+
285
+ /** @type {Set<string>} */
286
+ const visitedKustomization = new Set()
287
+
288
+ /**
289
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
290
+ * @returns {Promise<void>}
291
+ */
292
+ async function walkKustomization(kustAbs) {
293
+ const normKust = resolve(kustAbs)
294
+ if (visitedKustomization.has(normKust)) return
295
+ visitedKustomization.add(normKust)
296
+
297
+ let raw
298
+ try {
299
+ raw = await readFile(normKust, 'utf8')
300
+ } catch {
301
+ return
302
+ }
303
+ const lines = toLines(raw)
304
+ const body = yamlBodyAfterModeline(lines)
305
+
306
+ /** @type {import('yaml').Document[] | undefined} */
307
+ let docs
308
+ try {
309
+ docs = parseAllDocuments(body)
310
+ } catch {
311
+ return
312
+ }
313
+ const first = docs[0]?.toJSON()
314
+ if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) return
315
+
316
+ const kustDir = dirname(normKust)
317
+ const pathRefs = pathsFromKustomizationObject(first)
318
+
319
+ for (const ref of pathRefs) {
320
+ if (!ref.includes('://')) {
321
+ const resolved = resolve(kustDir, ref)
322
+ let st
323
+ try {
324
+ st = await stat(resolved)
325
+ } catch {
326
+ st = undefined
327
+ }
328
+ if (st) {
329
+ if (st.isFile()) {
330
+ if (/\.ya?ml$/iu.test(resolved)) {
331
+ const pr = posixRelFromAbs(root, resolved)
332
+ if (pr !== null) managed.add(pr)
333
+ }
334
+ } else if (st.isDirectory()) {
335
+ const childK = existsSync(join(resolved, 'kustomization.yaml'))
336
+ ? join(resolved, 'kustomization.yaml')
337
+ : null
338
+ if (childK !== null) {
339
+ await walkKustomization(childK)
340
+ }
341
+ }
342
+ }
343
+ }
344
+ }
345
+ }
346
+
347
+ for (const k of kustomizationAbsList) {
348
+ await walkKustomization(k)
349
+ }
350
+
351
+ return managed
352
+ }
353
+
354
+ /**
355
+ * Чи це **`k8s/base/kustomization.yaml`** (перевірка обов’язкового непорожнього **`namespace:`**).
168
356
  * @param {string} rel шлях від кореня репозиторію
169
- * @returns {boolean} true, якщо це `…/k8s/base/kustomization.yaml` або `…/k8s/base/kustomization.yml`
357
+ * @returns {boolean} true для шляху виду `…/k8s/base/kustomization.yaml`
170
358
  */
171
359
  export function isBaseKustomizationPath(rel) {
172
360
  const n = rel.replaceAll('\\', '/')
173
- return /(^|\/)k8s\/base\/kustomization\.yaml$/u.test(n) || /(^|\/)k8s\/base\/kustomization\.yml$/u.test(n)
361
+ return /(^|\/)k8s\/base\/kustomization\.yaml$/u.test(n)
174
362
  }
175
363
 
176
364
  /**
177
- * Чи коректне поле **`namespace`** у розібраному Kustomization для **`base`**.
365
+ * Чи є в Kustomization для **`base`** завжди обов’язкове непорожнє поле **`namespace:`** (k8s.mdc).
178
366
  * @param {unknown} obj перший документ YAML
179
367
  * @returns {string | null} текст порушення або null, якщо ок
180
368
  */
181
369
  export function baseKustomizationNamespaceViolation(obj) {
182
370
  if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
183
- return 'у base/kustomization.yaml має бути непорожній namespace (див. k8s.mdc)'
371
+ return 'у base/kustomization.yaml завжди має бути непорожній namespace: (див. k8s.mdc)'
184
372
  }
185
373
  const rec = /** @type {Record<string, unknown>} */ (obj)
186
374
  const ns = rec.namespace
187
375
  if (typeof ns === 'string' && ns.trim() !== '') {
188
376
  return null
189
377
  }
190
- return 'у base/kustomization.yaml має бути непорожній namespace (наприклад namespace: dev; див. k8s.mdc)'
378
+ return 'у base/kustomization.yaml завжди додай непорожній namespace: (наприклад namespace: dev; див. k8s.mdc)'
191
379
  }
192
380
 
193
381
  /**
194
- * Збирає всі yaml/yml під деревом від кореня cwd, якщо шлях містить сегмент `k8s`.
382
+ * Збирає всі `*.yaml` та `*.yml` під деревом від кореня cwd, якщо шлях містить сегмент `k8s` (для `.yml` далі — помилка перейменування).
195
383
  * @param {string} root корінь репозиторію (cwd)
196
384
  * @returns {Promise<string[]>} відсортовані абсолютні шляхи до файлів
197
385
  */
@@ -276,14 +464,13 @@ export function ruKustomizationHasHealthCheckDeletePatch(raw) {
276
464
  }
277
465
 
278
466
  /**
279
- * Шукає **Ingress** / **HealthCheckPolicy** у розібраних документах; реєструє порушення для Ingress.
467
+ * Шукає **Ingress** у розібраних документах; реєструє порушення.
280
468
  * @param {string} rel відносний шлях до файлу
281
469
  * @param {string} body YAML після modeline
282
470
  * @param {(msg: string) => void} fail callback для помилки (Ingress)
283
- * @param {string[]} healthCheckPolicyFiles накопичувач шляхів, де зустріли HealthCheckPolicy
284
471
  * @returns {void}
285
472
  */
286
- function scanIngressAndHealthCheckPolicy(rel, body, fail, healthCheckPolicyFiles) {
473
+ function scanIngressInYamlDocuments(rel, body, fail) {
287
474
  /** @type {import('yaml').Document[]} */
288
475
  let docs
289
476
  try {
@@ -299,56 +486,14 @@ function scanIngressAndHealthCheckPolicy(rel, body, fail, healthCheckPolicyFiles
299
486
  const rec = /** @type {Record<string, unknown>} */ (obj)
300
487
  if (rec.kind === 'Ingress') {
301
488
  fail(
302
- `${rel}: знайдено kind: Ingress (документ ${di + 1}) — заміни на Gateway API: HTTPRoute (hr.yaml), HealthCheckPolicy (hc.yaml), patch у ru/kustomization.yaml (див. k8s.mdc)`
489
+ `${rel}: знайдено kind: Ingress (документ ${di + 1}) — заміни на Gateway API: HTTPRoute (hr.yaml), HealthCheckPolicy (hc.yaml) (див. k8s.mdc)`
303
490
  )
304
- } else if (rec.kind === 'HealthCheckPolicy' && !healthCheckPolicyFiles.includes(rel)) {
305
- healthCheckPolicyFiles.push(rel)
306
491
  }
307
492
  }
308
493
  }
309
494
  }
310
495
  }
311
496
 
312
- /**
313
- * Якщо у дереві k8s є HealthCheckPolicy, вимагає **ru/kustomization.yaml** з patch видалення.
314
- * @param {string} root корінь cwd
315
- * @param {string[]} yamlFiles абсолютні шляхи до yaml під k8s
316
- * @param {string[]} healthCheckPolicyFiles відносні шляхи з HealthCheckPolicy
317
- * @param {(msg: string) => void} fail callback для помилки (немає ru або немає patch)
318
- * @returns {Promise<void>} завершення після перевірки overlay ru
319
- */
320
- async function ensureRuKustomizationHealthCheckDelete(root, yamlFiles, healthCheckPolicyFiles, fail) {
321
- if (healthCheckPolicyFiles.length === 0) {
322
- return
323
- }
324
-
325
- const ruAbsList = yamlFiles.filter(abs => isRuKustomizationPath(relative(root, abs) || abs))
326
- if (ruAbsList.length === 0) {
327
- fail(
328
- `Знайдено HealthCheckPolicy у ${healthCheckPolicyFiles.join(', ')} — додай ru/kustomization.yaml з patch видалення (див. k8s.mdc)`
329
- )
330
- return
331
- }
332
-
333
- for (const abs of ruAbsList) {
334
- let raw
335
- try {
336
- raw = await readFile(abs, 'utf8')
337
- } catch (error) {
338
- const msg = error instanceof Error ? error.message : String(error)
339
- fail(`${relative(root, abs) || abs}: не вдалося прочитати (${msg})`)
340
- return
341
- }
342
- if (ruKustomizationHasHealthCheckDeletePatch(raw)) {
343
- return
344
- }
345
- }
346
-
347
- fail(
348
- 'Є HealthCheckPolicy, але жоден ru/kustomization.yaml не містить очікуваного patch видалення (kind: HealthCheckPolicy, metadata.name, $patch: delete) — див. k8s.mdc'
349
- )
350
- }
351
-
352
497
  /**
353
498
  * Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
354
499
  * @param {unknown} manifest корінь YAML-документа як об'єкт JavaScript
@@ -426,7 +571,7 @@ export function deploymentImagePullPolicyViolation(manifest) {
426
571
  }
427
572
 
428
573
  /**
429
- * У маніфестах ресурсів не має бути **metadata.namespace** лише у **kustomization** (k8s.mdc).
574
+ * Для маніфестів, **підключених** до Kustomize (шлях у `resources` / `patches` / …), **metadata.namespace** не додають.
430
575
  * @param {unknown} manifest корінь YAML-документа
431
576
  * @returns {string | null} текст порушення або null, якщо поля немає
432
577
  */
@@ -436,7 +581,37 @@ export function metadataNamespaceForbiddenViolation(manifest) {
436
581
  const rec = /** @type {Record<string, unknown>} */ (manifest)
437
582
  const meta = rec.metadata
438
583
  if (meta !== null && typeof meta === 'object' && !Array.isArray(meta) && 'namespace' in meta) {
439
- return 'metadata.namespace заборонено — задай namespace у kustomization.yaml (поле namespace) (див. k8s.mdc)'
584
+ return 'metadata.namespace заборонено — namespace задає kustomization.yaml (поле namespace); файл підключено через resources / patches / … (див. k8s.mdc)'
585
+ }
586
+ return null
587
+ }
588
+
589
+ /**
590
+ * Вимагає непорожній **metadata.namespace** для namespaced-документів (крім кластерних kind).
591
+ * @param {unknown} manifest корінь YAML-документа
592
+ * @param {boolean} [inBaseDir] true — файл у **`k8s/base/`** (текст повідомлення для base)
593
+ * @returns {string | null} текст порушення або null, якщо перевірка не застосовується / ок
594
+ */
595
+ export function metadataNamespaceRequiredViolation(manifest, inBaseDir = false) {
596
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
597
+ return null
598
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
599
+ if (rec.kind === 'List' || rec.kind === 'Kustomization') return null
600
+ if (typeof rec.kind !== 'string' || rec.kind === '') return null
601
+ if (typeof rec.apiVersion !== 'string' || rec.apiVersion === '') return null
602
+ if (isClusterScopedKubernetesKind(rec.kind)) return null
603
+ const meta = rec.metadata
604
+ if (meta === null || meta === undefined || typeof meta !== 'object' || Array.isArray(meta)) {
605
+ return inBaseDir
606
+ ? 'додай metadata з непорожнім metadata.namespace — у k8s/base у кожному ресурсному YAML має бути явний namespace (див. k8s.mdc)'
607
+ : 'додай metadata з непорожнім metadata.namespace — файл не підключено до жодного kustomization.yaml (resources, patches, …) під k8s (див. k8s.mdc)'
608
+ }
609
+ const m = /** @type {Record<string, unknown>} */ (meta)
610
+ const ns = m.namespace
611
+ if (typeof ns !== 'string' || ns.trim() === '') {
612
+ return inBaseDir
613
+ ? 'metadata.namespace обов’язковий у k8s/base — додай явний namespace у маніфесті (див. k8s.mdc)'
614
+ : 'metadata.namespace обов’язковий — файл не перелічений у kustomization.yaml під k8s; додай path у kustomization або явний namespace (див. k8s.mdc)'
440
615
  }
441
616
  return null
442
617
  }
@@ -444,10 +619,22 @@ export function metadataNamespaceForbiddenViolation(manifest) {
444
619
  /**
445
620
  * Чи ім’я файлу — kustomization (дозволяє не застосовувати перевірку metadata.namespace до вмісту).
446
621
  * @param {string} baseLower basename у нижньому регістрі
447
- * @returns {boolean} true для `kustomization.yaml` / `kustomization.yml`
622
+ * @returns {boolean} true для `kustomization.yaml`
448
623
  */
449
624
  function isKustomizationFileName(baseLower) {
450
- return baseLower === 'kustomization.yaml' || baseLower === 'kustomization.yml'
625
+ return baseLower === 'kustomization.yaml'
626
+ }
627
+
628
+ /**
629
+ * Чи це **ресурсний** YAML у каталозі **`k8s/base`** (не `kustomization.yaml`).
630
+ * @param {string} rel відносний шлях від кореня репозиторію
631
+ * @param {string} baseLower basename у нижньому регістрі
632
+ * @returns {boolean} true для `…/k8s/base/*.yaml` окрім kustomization
633
+ */
634
+ export function isK8sBaseManifestYamlPath(rel, baseLower) {
635
+ if (isKustomizationFileName(baseLower)) return false
636
+ const n = rel.replaceAll('\\', '/')
637
+ return /(^|\/)k8s\/base\//u.test(n)
451
638
  }
452
639
 
453
640
  /**
@@ -456,8 +643,9 @@ function isKustomizationFileName(baseLower) {
456
643
  * @param {string} baseLower basename файлу (нижній регістр)
457
644
  * @param {string} body вміст після modeline
458
645
  * @param {(msg: string) => void} fail реєстрація помилки
646
+ * @param {boolean} kustomizeManaged чи файл досяжний з kustomization.yaml (resources / patches / …)
459
647
  */
460
- function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail) {
648
+ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged) {
461
649
  /** @type {import('yaml').Document[]} */
462
650
  let docs
463
651
  try {
@@ -469,6 +657,7 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail) {
469
657
  }
470
658
 
471
659
  const skipMetaNs = isKustomizationFileName(baseLower)
660
+ const inBaseManifest = isK8sBaseManifestYamlPath(rel, baseLower)
472
661
 
473
662
  for (const [di, doc] of docs.entries()) {
474
663
  if (doc.errors.length > 0) {
@@ -476,9 +665,21 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail) {
476
665
  } else {
477
666
  const obj = doc.toJSON()
478
667
  if (!skipMetaNs) {
479
- const ns = metadataNamespaceForbiddenViolation(obj)
480
- if (ns !== null) {
481
- fail(`${rel}: документ ${di + 1}: ${ns}`)
668
+ if (inBaseManifest) {
669
+ const req = metadataNamespaceRequiredViolation(obj, true)
670
+ if (req !== null) {
671
+ fail(`${rel}: документ ${di + 1}: ${req}`)
672
+ }
673
+ } else if (kustomizeManaged) {
674
+ const ns = metadataNamespaceForbiddenViolation(obj)
675
+ if (ns !== null) {
676
+ fail(`${rel}: документ ${di + 1}: ${ns}`)
677
+ }
678
+ } else {
679
+ const req = metadataNamespaceRequiredViolation(obj, false)
680
+ if (req !== null) {
681
+ fail(`${rel}: документ ${di + 1}: ${req}`)
682
+ }
482
683
  }
483
684
  }
484
685
  const resV = deploymentResourcesViolation(obj)
@@ -512,7 +713,7 @@ export function expectedSchemaUrl(filePath, doc) {
512
713
  const base = basename(filePath)
513
714
  const baseLower = base.toLowerCase()
514
715
 
515
- if (baseLower === 'kustomization.yaml' || baseLower === 'kustomization.yml') {
716
+ if (baseLower === 'kustomization.yaml') {
516
717
  return { expected: KUSTOMIZATION_SCHEMA, reason: 'kustomization (ім’я файлу)' }
517
718
  }
518
719
 
@@ -574,15 +775,15 @@ function countSchemaModelines(lines) {
574
775
  * @param {string} root корінь репозиторію
575
776
  * @param {(msg: string) => void} fail реєстрація помилки
576
777
  * @param {(msg: string) => void} pass реєстрація успіху
577
- * @param {string[]} healthCheckPolicyFiles накопичувач файлів із kind: HealthCheckPolicy
778
+ * @param {Set<string>} kustomizeManagedRel відносні posix-шляхи з collectKustomizeManagedRelPaths
578
779
  * @returns {Promise<void>}
579
780
  */
580
- async function checkK8sYamlFile(abs, root, fail, pass, healthCheckPolicyFiles) {
581
- const rel = relative(root, abs) || abs
781
+ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
782
+ const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
582
783
  const base = basename(abs)
583
784
  const baseLower = base.toLowerCase()
584
785
 
585
- if (baseLower.endsWith('.yml') && baseLower !== 'kustomization.yml') {
786
+ if (baseLower.endsWith('.yml')) {
586
787
  fail(`${rel}: розширення .yml — перейменуй на .yaml (див. k8s.mdc)`)
587
788
  return
588
789
  }
@@ -615,7 +816,7 @@ async function checkK8sYamlFile(abs, root, fail, pass, healthCheckPolicyFiles) {
615
816
 
616
817
  const body = yamlBodyAfterModeline(lines)
617
818
 
618
- scanIngressAndHealthCheckPolicy(rel, body, fail, healthCheckPolicyFiles)
819
+ scanIngressInYamlDocuments(rel, body, fail)
619
820
 
620
821
  if (schemaUrl.startsWith('file:')) {
621
822
  pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
@@ -639,7 +840,8 @@ async function checkK8sYamlFile(abs, root, fail, pass, healthCheckPolicyFiles) {
639
840
  return
640
841
  }
641
842
 
642
- validateK8sYamlPolicyDocuments(rel, baseLower, body, fail)
843
+ const kustomizeManaged = kustomizeManagedRel.has(rel)
844
+ validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
643
845
  }
644
846
 
645
847
  /**
@@ -659,7 +861,7 @@ function assertNoForbiddenK8sDevPaths(yamlFiles, root, fail) {
659
861
  }
660
862
 
661
863
  /**
662
- * Якщо є **`k8s/base/kustomization.yaml`**, у ньому має бути непорожній **`namespace`**.
864
+ * Якщо є **`k8s/base/kustomization.yaml`**, у ньому **завжди** має бути непорожній **`namespace:`**.
663
865
  * @param {string} root корінь репозиторію
664
866
  * @param {string[]} yamlFiles абсолютні шляхи
665
867
  * @param {(msg: string) => void} fail callback для реєстрації порушення
@@ -710,7 +912,7 @@ export async function check() {
710
912
  const yamlFiles = await findK8sYamlFiles(root)
711
913
 
712
914
  if (yamlFiles.length === 0) {
713
- pass('Немає yaml/yml під k8s — перевірку $schema пропущено')
915
+ pass('Немає *.yaml під k8s — перевірку $schema пропущено')
714
916
  return 0
715
917
  }
716
918
 
@@ -718,15 +920,12 @@ export async function check() {
718
920
 
719
921
  assertNoForbiddenK8sDevPaths(yamlFiles, root, fail)
720
922
 
721
- /** @type {string[]} */
722
- const healthCheckPolicyFiles = []
923
+ const kustomizeManagedRel = await collectKustomizeManagedRelPaths(root, yamlFiles)
723
924
 
724
925
  for (const abs of yamlFiles) {
725
- await checkK8sYamlFile(abs, root, fail, pass, healthCheckPolicyFiles)
926
+ await checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel)
726
927
  }
727
928
 
728
- await ensureRuKustomizationHealthCheckDelete(root, yamlFiles, healthCheckPolicyFiles, fail)
729
-
730
929
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
731
930
 
732
931
  return exitCode
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Перевіряє CSS/SCSS лінт за правилом style-lint.mdc.
3
3
  *
4
- * `@nitra/stylelint-config`, скрипт `lint-style`, `.stylelintignore`, workflow `lint-style.yml`,
5
- * VSCode stylelint і вимкнена вбудована CSS-валідація.
4
+ * Очікування: `@nitra/stylelint-config`, `lint-style` через `npx stylelint`, `.stylelintignore`,
5
+ * workflow `lint-style.yml` (у `run` — `npx stylelint` або `bun run lint-style`), VSCode stylelint,
6
+ * `css.validate` / `scss.validate` / `less.validate`: false.
6
7
  */
7
8
  import { existsSync } from 'node:fs'
8
9
  import { readFile } from 'node:fs/promises'
@@ -24,8 +25,14 @@ export async function check() {
24
25
  if (existsSync('package.json')) {
25
26
  const pkg = JSON.parse(await readFile('package.json', 'utf8'))
26
27
 
27
- if (pkg.scripts?.['lint-style']) {
28
+ const lintStyle = pkg.scripts?.['lint-style']
29
+ if (lintStyle) {
28
30
  pass('package.json містить скрипт lint-style')
31
+ if (String(lintStyle).includes('npx stylelint')) {
32
+ pass('lint-style викликає stylelint через npx')
33
+ } else {
34
+ fail("lint-style має викликати stylelint через npx — наприклад: npx stylelint '**/*.{css,scss,vue}' --fix")
35
+ }
29
36
  } else {
30
37
  fail('package.json не містить скрипт "lint-style"')
31
38
  }
@@ -56,11 +63,13 @@ export async function check() {
56
63
  const content = await readFile('.github/workflows/lint-style.yml', 'utf8')
57
64
  pass('lint-style.yml існує')
58
65
  const root = parseWorkflowYaml(content)
59
- const ok = root ? anyRunStepIncludesStylelint(root) : content.includes('stylelint')
66
+ const ok = root
67
+ ? anyRunStepIncludesStylelint(root)
68
+ : content.includes('npx stylelint') || content.includes('bun run lint-style')
60
69
  if (ok) {
61
- pass('lint-style.yml містить stylelint у кроці run')
70
+ pass('lint-style.yml містить npx stylelint або bun run lint-style у кроці run')
62
71
  } else {
63
- fail('lint-style.yml не містить виклик stylelint')
72
+ fail('lint-style.yml має містити npx stylelint або bun run lint-style у кроці run')
64
73
  }
65
74
  } else {
66
75
  fail('.github/workflows/lint-style.yml не існує — створи його')
@@ -89,6 +98,11 @@ export async function check() {
89
98
  } else {
90
99
  fail('settings.json: scss.validate має бути false')
91
100
  }
101
+ if (s['less.validate'] === false) {
102
+ pass('less.validate вимкнено')
103
+ } else {
104
+ fail('settings.json: less.validate має бути false')
105
+ }
92
106
  }
93
107
 
94
108
  return exitCode
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Перейменовує розширення YAML за домовленістю репозиторію (k8s.mdc, ga.mdc). Лише логіка; **CLI** — **`bin/rename-yaml-extensions.mjs`**
3
+ * та підкоманда **`npx \@nitra/cursor rename-yaml-extensions`**.
4
+ *
5
+ * - Файли з сегментом шляху `k8s` та суфіксом `.yml` → `.yaml` (маніфести під k8s).
6
+ * - Файли з сегментом `.github` та суфіксом `.yaml` → `.yml` (workflows тощо; у workflows лише `.yml`).
7
+ *
8
+ * Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next` — як у **`walkDir`**.
9
+ *
10
+ * Розбір аргументів для CLI: **`parseRenameYamlArgs`** (`--dry-run`, `--root=…`).
11
+ */
12
+ import { existsSync } from 'node:fs'
13
+ import { rename } from 'node:fs/promises'
14
+ import { cwd } from 'node:process'
15
+ import { relative, resolve } from 'node:path'
16
+
17
+ import { walkDir } from './utils/walkDir.mjs'
18
+
19
+ /**
20
+ * Відносний шлях від кореня з `/`; `null`, якщо поза root.
21
+ * @param {string} rootAbs абсолютний корінь
22
+ * @param {string} fileAbs абсолютний шлях до файлу
23
+ * @returns {string | null} відносний шлях з `/` або null, якщо fileAbs поза rootAbs
24
+ */
25
+ export function posixRelFromRoot(rootAbs, fileAbs) {
26
+ const r = (relative(rootAbs, resolve(fileAbs)) || fileAbs).replaceAll('\\', '/')
27
+ if (r.startsWith('..')) return null
28
+ return r
29
+ }
30
+
31
+ /**
32
+ * Чи шлях підходить під k8s-маніфести: є сегмент `k8s`, суфікс `.yml` (регістр розширення ігнорується).
33
+ * @param {string} relPosix відносний шлях
34
+ * @returns {boolean} true, якщо є сегмент k8s і суфікс .yml
35
+ */
36
+ export function pathMatchesK8sYml(relPosix) {
37
+ if (!/\.yml$/iu.test(relPosix)) return false
38
+ return relPosix.split('/').includes('k8s')
39
+ }
40
+
41
+ /**
42
+ * Чи шлях підходить під `.github`: є сегмент `.github`, суфікс `.yaml` (регістр розширення ігнорується).
43
+ * @param {string} relPosix відносний шлях
44
+ * @returns {boolean} true, якщо є сегмент .github і суфікс .yaml
45
+ */
46
+ export function pathMatchesGithubYaml(relPosix) {
47
+ if (!/\.yaml$/iu.test(relPosix)) return false
48
+ return relPosix.split('/').includes('.github')
49
+ }
50
+
51
+ /**
52
+ * Замінює останнє розширення файлу на **newExt** (з крапкою, наприклад **`.yaml`**).
53
+ * @param {string} relPosix відносний шлях
54
+ * @param {string} newExt нове розширення
55
+ * @returns {string} шлях з останнім розширенням, заміненим на newExt
56
+ */
57
+ export function replaceExtension(relPosix, newExt) {
58
+ const m = relPosix.match(/^(.+)(\.[^./\\]+)$/u)
59
+ if (!m) return relPosix + newExt
60
+ return m[1] + newExt
61
+ }
62
+
63
+ /**
64
+ * Збирає операції перейменування (без виконання).
65
+ * @param {string} rootAbs абсолютний корінь репозиторію
66
+ * @returns {Promise<Array<{ kind: 'k8s' | 'github', fromAbs: string, toAbs: string, relFrom: string, relTo: string }>>} відсортовані операції перейменування без запису на диск
67
+ */
68
+ async function collectRenameOps(rootAbs) {
69
+ /** @type {Array<{ kind: 'k8s' | 'github', fromAbs: string, toAbs: string, relFrom: string, relTo: string }>} */
70
+ const ops = []
71
+
72
+ await walkDir(rootAbs, fileAbs => {
73
+ const rel = posixRelFromRoot(rootAbs, fileAbs)
74
+ if (rel === null) return
75
+ if (pathMatchesK8sYml(rel)) {
76
+ const relTo = replaceExtension(rel, '.yaml')
77
+ if (relTo === rel) return
78
+ ops.push({
79
+ kind: 'k8s',
80
+ fromAbs: resolve(rootAbs, rel),
81
+ toAbs: resolve(rootAbs, relTo),
82
+ relFrom: rel,
83
+ relTo
84
+ })
85
+ return
86
+ }
87
+ if (pathMatchesGithubYaml(rel)) {
88
+ const relTo = replaceExtension(rel, '.yml')
89
+ if (relTo === rel) return
90
+ ops.push({
91
+ kind: 'github',
92
+ fromAbs: resolve(rootAbs, rel),
93
+ toAbs: resolve(rootAbs, relTo),
94
+ relFrom: rel,
95
+ relTo
96
+ })
97
+ }
98
+ })
99
+
100
+ ops.sort((a, b) => {
101
+ const ko = (a.kind === 'k8s' ? 0 : 1) - (b.kind === 'k8s' ? 0 : 1)
102
+ if (ko !== 0) return ko
103
+ return a.relFrom.localeCompare(b.relFrom)
104
+ })
105
+
106
+ return ops
107
+ }
108
+
109
+ /**
110
+ * Виконує перейменування за правилами k8s / .github.
111
+ * @param {string} root корінь обходу (відносний або абсолютний)
112
+ * @param {{ dryRun?: boolean }} [options] опції: лише симуляція без `rename`, якщо dryRun true
113
+ * @returns {Promise<{ renamed: { relFrom: string, relTo: string }[], errors: string[] }>} списки успішних перейменувань і текстів помилок
114
+ */
115
+ export async function renameYamlExtensions(root, options = {}) {
116
+ const dryRun = options.dryRun === true
117
+ const rootAbs = resolve(root)
118
+ const ops = await collectRenameOps(rootAbs)
119
+
120
+ /** @type { { relFrom: string, relTo: string }[]} */
121
+ const renamed = []
122
+ /** @type {string[]} */
123
+ const errors = []
124
+
125
+ for (const op of ops) {
126
+ if (!existsSync(op.fromAbs)) {
127
+ errors.push(`${op.relFrom}: файл зник перед перейменуванням`)
128
+ } else if (existsSync(op.toAbs)) {
129
+ errors.push(`${op.relFrom} → ${op.relTo}: цільовий файл уже існує, пропущено`)
130
+ } else if (dryRun) {
131
+ renamed.push({ relFrom: op.relFrom, relTo: op.relTo })
132
+ } else {
133
+ try {
134
+ await rename(op.fromAbs, op.toAbs)
135
+ renamed.push({ relFrom: op.relFrom, relTo: op.relTo })
136
+ } catch (error) {
137
+ const msg = error instanceof Error ? error.message : String(error)
138
+ errors.push(`${op.relFrom}: ${msg}`)
139
+ }
140
+ }
141
+ }
142
+
143
+ return { renamed, errors }
144
+ }
145
+
146
+ /**
147
+ * Розбір CLI: **`--dry-run`**, **`--root=...`**.
148
+ * @param {string[]} argv зазвичай **`process.argv.slice(2)`**
149
+ * @returns {{ dryRun: boolean, root: string }} прапор симуляції та абсолютний корінь обходу
150
+ */
151
+ export function parseRenameYamlArgs(argv) {
152
+ let dryRun = false
153
+ let root = cwd()
154
+ for (const a of argv) {
155
+ if (a === '--dry-run') dryRun = true
156
+ else if (a.startsWith('--root=')) root = resolve(a.slice('--root='.length))
157
+ }
158
+ return { dryRun, root }
159
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Запуск kubeconform та kubescape для каталогів `…/k8s`, де є YAML-маніфести (див. k8s.mdc).
3
3
  *
4
- * Знаходить унікальні корені каталогів із іменем `k8s` за шляхами файлів `*.yaml` / `*.yml`
5
- * (той самий принцип сегмента `k8s`, що й у check-k8s.mjs). Якщо таких файлів немає — вихід 0
4
+ * Знаходить унікальні корені каталогів із іменем `k8s` за шляхами файлів **`*.yaml`**
5
+ * (той самий принцип сегмента `k8s`, що й у check-k8s.mjs; розширення **`.yml`** під `k8s` не використовується). Якщо таких файлів немає — вихід 0
6
6
  * без виклику зовнішніх CLI.
7
7
  *
8
8
  * kubeconform перевіряє маніфести проти OpenAPI-схем Kubernetes; kubescape — сканування на
@@ -52,7 +52,7 @@ export function k8sRootFromFile(absFile) {
52
52
  }
53
53
 
54
54
  /**
55
- * Унікальні корені `k8s` з yaml/yml під деревом cwd.
55
+ * Унікальні корені `k8s` за наявності `*.yaml` під деревом cwd.
56
56
  * @param {string} root корінь репозиторію
57
57
  * @returns {Promise<string[]>} відсортовані абсолютні шляхи до каталогів `k8s`
58
58
  */
@@ -61,7 +61,7 @@ export async function findK8sRoots(root) {
61
61
  const roots = new Set()
62
62
  await walkDir(root, p => {
63
63
  if (!pathHasK8sSegment(p)) return
64
- if (!/\.ya?ml$/iu.test(p)) return
64
+ if (!/\.yaml$/iu.test(p)) return
65
65
  const k8sRoot = k8sRootFromFile(p)
66
66
  if (k8sRoot) roots.add(k8sRoot)
67
67
  })
@@ -123,7 +123,7 @@ async function main() {
123
123
  const dirs = await findK8sRoots(root)
124
124
 
125
125
  if (dirs.length === 0) {
126
- console.log('run-k8s: немає yaml/yml під k8s — kubeconform і kubescape пропущено')
126
+ console.log('run-k8s: немає *.yaml під k8s — kubeconform і kubescape пропущено')
127
127
  return 0
128
128
  }
129
129
 
@@ -341,10 +341,11 @@ export function anyRunStepIncludes(root, needle) {
341
341
  }
342
342
 
343
343
  /**
344
- * Чи є в будь-якому `run` підрядок `stylelint`.
344
+ * Чи викликається stylelint коректно: `npx stylelint` у run або `bun run lint-style`
345
+ * (скрипт `lint-style` у package.json має містити `npx stylelint`).
345
346
  * @param {Record<string, unknown>} root корінь workflow
346
- * @returns {boolean} `true`, якщо stylelint згадано в команді
347
+ * @returns {boolean} `true`, якщо умова виконана
347
348
  */
348
349
  export function anyRunStepIncludesStylelint(root) {
349
- return anyRunStepIncludes(root, 'stylelint')
350
+ return anyRunStepIncludes(root, 'npx stylelint') || anyRunStepIncludes(root, 'bun run lint-style')
350
351
  }