@nitra/cursor 1.8.30 → 1.8.40

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/README.md CHANGED
@@ -85,10 +85,11 @@ CLI автоматично (команда завантаження правил
85
85
  ```
86
86
  npm/
87
87
  ├── AGENTS.template.md # шаблон AGENTS.md для цільових репозиторіїв (потрапляє в npm-архів)
88
- ├── mdc/ # cursor-правила
88
+ ├── mdc/ # cursor-правила (без префікса n-; після синку — .cursor/rules/n-<id>.mdc)
89
89
  │ ├── js-format.mdc
90
90
  │ ├── npm-module.mdc
91
91
  │ └── text.mdc
92
+ ├── skills/ # skills (каталоги <id>/; після синку — .cursor/skills/n-<id>/)
92
93
  └── bin/
93
94
  └── n-cursor.js # CLI-скрипт
94
95
  ```
package/bin/n-cursor.js CHANGED
@@ -24,8 +24,9 @@
24
24
  * `github-actions/` пакету при кожному успішному синку (workflows з правил ga / js-lint / text).
25
25
  *
26
26
  * Skills копіюються з npm/skills пакету лише для id з масиву «skills» у .n-cursor.json
27
- * (у JSON — без префікса, каталоги в проєкті n-<id>, як і для правил). Якщо ключа skills
28
- * немає, за замовчуванням підтягуються всі bundled skills з префіксом n- у пакеті.
27
+ * (у JSON — без префікса, як імена файлів у mdc/ без n-). У пакеті джерело — каталоги
28
+ * skills/<id>/ (без префікса); у проєкті .cursor/skills/n-<id>/ (префікс n-, як n-*.mdc).
29
+ * Якщо ключа skills немає, за замовчуванням підтягуються всі підкаталоги skills/ (лише імена без префікса n-).
29
30
  * Зайві каталоги n-* у .cursor/skills, яких немає у списку, видаляються.
30
31
  *
31
32
  * Якщо в корені є package.json і в ньому ще немає \@nitra/cursor у devDependencies (і не оголошено
@@ -87,7 +88,7 @@ async function discoverBundledRuleNames() {
87
88
  }
88
89
 
89
90
  /**
90
- * Імена skills (без префікса n-) з каталогу skills пакету — лише директорії n-*
91
+ * Імена skills (id без префікса n-) з каталогу skills пакету — лише підкаталоги `<id>/` без префікса n-
91
92
  * @returns {Promise<string[]>} відсортовані id
92
93
  */
93
94
  async function discoverBundledSkillNames() {
@@ -96,8 +97,8 @@ async function discoverBundledSkillNames() {
96
97
  }
97
98
  const entries = await readdir(BUNDLED_SKILLS_DIR, { withFileTypes: true })
98
99
  return entries
99
- .filter(e => e.isDirectory() && e.name.startsWith(RULE_PREFIX))
100
- .map(e => e.name.slice(RULE_PREFIX.length))
100
+ .filter(e => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith(RULE_PREFIX))
101
+ .map(e => e.name)
101
102
  .toSorted((a, b) => a.localeCompare(b))
102
103
  }
103
104
 
@@ -233,28 +234,13 @@ function normalizeSkillId(skillName) {
233
234
  return s
234
235
  }
235
236
 
236
- /** Legacy id у `.n-cursor.json` → поточний bundled id (каталог `n-<id>` у пакеті) */
237
- const LEGACY_SKILL_ID_MAP = {
238
- 'fix-cursor': 'fix'
239
- }
240
-
241
- /**
242
- * Поточний id skill для шляхів у пакеті та `.cursor/skills`
243
- * @param {string} skillName елемент масиву skills або ім'я каталогу
244
- * @returns {string} canonical id без префікса n-
245
- */
246
- function canonicalSkillId(skillName) {
247
- const id = normalizeSkillId(skillName)
248
- return LEGACY_SKILL_ID_MAP[id] ?? id
249
- }
250
-
251
237
  /**
252
238
  * Ім'я керованого каталогу skill у .cursor/skills (префікс n-)
253
- * @param {string} skillId id без префікса
239
+ * @param {string} skillId id без префікса (або з префіксом n- у конфігу — нормалізується)
254
240
  * @returns {string} наприклад n-fix
255
241
  */
256
242
  function managedSkillDirName(skillId) {
257
- return `${RULE_PREFIX}${canonicalSkillId(skillId)}`
243
+ return `${RULE_PREFIX}${normalizeSkillId(skillId)}`
258
244
  }
259
245
 
260
246
  /**
@@ -279,6 +265,16 @@ function extractSkillDescription(text) {
279
265
  .trim()
280
266
  }
281
267
 
268
+ /**
269
+ * Підготовка опису skill для вставки в звичайний markdown (заголовок H1, bullet без code fence).
270
+ * Послідовність `<id>` сприймається markdownlint (MD033) як inline HTML — замінюємо на `{id}`.
271
+ * @param {string} desc один рядок з YAML frontmatter SKILL.md
272
+ * @returns {string} той самий рядок після заміни літералу з кутовими дужками навколо id на плейсхолдер у фігурних дужках (MD033).
273
+ */
274
+ function skillDescriptionSafeForMarkdownInline(desc) {
275
+ return desc.replaceAll('<id>', '{id}')
276
+ }
277
+
282
278
  /**
283
279
  * Розгортає в шаблоні блок Mustache {{#section}} … {{/section}} для масиву елементів
284
280
  * @param {string} template вихідний текст шаблону
@@ -382,7 +378,7 @@ async function buildSkillBulletItems(skillIds) {
382
378
  const text = await readFile(skillMdPath, 'utf8')
383
379
  const parsed = extractSkillDescription(text)
384
380
  if (parsed) {
385
- desc = parsed
381
+ desc = skillDescriptionSafeForMarkdownInline(parsed)
386
382
  }
387
383
  }
388
384
  const pathLine = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
@@ -435,14 +431,14 @@ async function syncClaudeMd(configRules, configSkills) {
435
431
  lines.push('', '## Skills', '')
436
432
  const skillsRoot = join(cwd(), SKILLS_DIR)
437
433
  for (const skillId of configSkills) {
438
- const id = canonicalSkillId(skillId)
434
+ const id = normalizeSkillId(skillId)
439
435
  const dirName = managedSkillDirName(skillId)
440
436
  const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
441
437
  let desc = ''
442
438
  if (existsSync(skillMdPath)) {
443
439
  const text = await readFile(skillMdPath, 'utf8')
444
440
  const parsed = extractSkillDescription(text)
445
- if (parsed) desc = parsed
441
+ if (parsed) desc = skillDescriptionSafeForMarkdownInline(parsed)
446
442
  }
447
443
  const ref = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
448
444
  lines.push(desc ? `${ref} — ${desc}` : ref, ` Команда: \`/${RULE_PREFIX}${id}\``)
@@ -485,7 +481,7 @@ async function syncAgentsMd(configSkills) {
485
481
  }
486
482
 
487
483
  /**
488
- * Копіює лише skills зі списку configSkills (каталоги n-* у пакеті)
484
+ * Копіює лише skills зі списку configSkills (джерело: skills/<id>/ у пакеті)
489
485
  * @param {string[]} configSkills id без префікса n-
490
486
  * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих копіювань
491
487
  */
@@ -501,13 +497,13 @@ async function syncSkills(configSkills) {
501
497
  let fail = 0
502
498
 
503
499
  for (const skillId of configSkills) {
504
- const id = canonicalSkillId(skillId)
505
- const dirName = managedSkillDirName(skillId)
506
- const srcDir = join(BUNDLED_SKILLS_DIR, dirName)
507
- const destDir = join(skillsRoot, dirName)
500
+ const id = normalizeSkillId(skillId)
501
+ const srcDir = join(BUNDLED_SKILLS_DIR, id)
502
+ const destDirName = managedSkillDirName(skillId)
503
+ const destDir = join(skillsRoot, destDirName)
508
504
 
509
505
  if (existsSync(srcDir)) {
510
- process.stdout.write(` ⬇ ${id} → ${SKILLS_DIR}/${dirName} ... `)
506
+ process.stdout.write(` ⬇ ${id} → ${SKILLS_DIR}/${destDirName} ... `)
511
507
  try {
512
508
  await mkdir(destDir, { recursive: true })
513
509
  const files = await readdir(srcDir)
@@ -523,9 +519,9 @@ async function syncSkills(configSkills) {
523
519
  fail++
524
520
  }
525
521
  } else {
526
- process.stdout.write(` ⬇ ${id} → ${SKILLS_DIR}/${dirName} ... `)
522
+ process.stdout.write(` ⬇ ${id} → ${SKILLS_DIR}/${destDirName} ... `)
527
523
  console.log(`❌`)
528
- console.error(` Немає каталогу в пакеті: ${dirName}`)
524
+ console.error(` Немає каталогу в пакеті: skills/${id}`)
529
525
  fail++
530
526
  }
531
527
  }
@@ -550,18 +546,19 @@ async function syncCommands(configSkills) {
550
546
  let fail = 0
551
547
 
552
548
  for (const skillId of configSkills) {
553
- const id = canonicalSkillId(skillId)
554
- const dirName = managedSkillDirName(skillId)
555
- const srcSkillMd = join(BUNDLED_SKILLS_DIR, dirName, 'SKILL.md')
549
+ const id = normalizeSkillId(skillId)
550
+ const srcSkillMd = join(BUNDLED_SKILLS_DIR, id, 'SKILL.md')
551
+ const destDirName = managedSkillDirName(skillId)
556
552
  const destFile = join(commandsDir, `${RULE_PREFIX}${id}.md`)
557
553
 
558
554
  process.stdout.write(` ⬇ ${id} → ${COMMANDS_DIR}/${RULE_PREFIX}${id}.md ... `)
559
555
  if (existsSync(srcSkillMd)) {
560
556
  try {
561
557
  const raw = await readFile(srcSkillMd, 'utf8')
562
- const desc = extractSkillDescription(raw)
558
+ const descRaw = extractSkillDescription(raw)
559
+ const desc = descRaw ? skillDescriptionSafeForMarkdownInline(descRaw) : ''
563
560
  const header = desc ? `# ${RULE_PREFIX}${id} — ${desc}\n\n` : ''
564
- const body = `${header}Виконай інструкції зі скілу \`.cursor/skills/${dirName}/SKILL.md\`.\n`
561
+ const body = `${header}Виконай інструкції зі скілу \`.cursor/skills/${destDirName}/SKILL.md\`.\n`
565
562
  await writeFile(destFile, body, 'utf8')
566
563
  console.log(`✅`)
567
564
  success++
@@ -572,7 +569,7 @@ async function syncCommands(configSkills) {
572
569
  }
573
570
  } else {
574
571
  console.log(`❌`)
575
- console.error(` Немає SKILL.md у пакеті: ${dirName}`)
572
+ console.error(` Немає SKILL.md у пакеті: skills/${id}`)
576
573
  fail++
577
574
  }
578
575
  }
@@ -589,7 +586,7 @@ async function removeOrphanManagedCommandFiles(commandsDir, configSkills) {
589
586
  if (!existsSync(commandsDir)) {
590
587
  return []
591
588
  }
592
- const expected = new Set(configSkills.map(s => `${RULE_PREFIX}${canonicalSkillId(s)}.md`))
589
+ const expected = new Set(configSkills.map(s => `${RULE_PREFIX}${normalizeSkillId(s)}.md`))
593
590
  const names = await readdir(commandsDir)
594
591
  const removed = []
595
592
  for (const name of names) {
package/mdc/abie.mdc ADDED
@@ -0,0 +1,56 @@
1
+ ---
2
+ description: Правила для проєктів abinbevefes
3
+ alwaysApply: true
4
+ version: '1.0'
5
+ ---
6
+
7
+ ## k8s
8
+
9
+ Якщо в проекті є k8s deployment, рядом з ним повинен бути:
10
+
11
+ ```yaml title="hc.yaml"
12
+ # yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json
13
+ apiVersion: networking.gke.io/v1
14
+ kind: HealthCheckPolicy
15
+ metadata:
16
+ name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
17
+ namespace: dev # буде замінено через kustomize
18
+ spec:
19
+ default:
20
+ config:
21
+ type: HTTP
22
+ httpHealthCheck:
23
+ requestPath: /healthz
24
+ port: 8080
25
+ targetRef:
26
+ group: ''
27
+ kind: Service
28
+ name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
29
+ ```
30
+
31
+ і підключення до kustomize. Але у директорії ru повинен бути файл kustomization.yaml з patch, що видаляє ресурс **HealthCheckPolicy**.
32
+
33
+ ```yaml title="kustomization.yaml"
34
+ patches:
35
+ - target:
36
+ kind: HealthCheckPolicy
37
+ patch: |-
38
+ kind: HealthCheckPolicy
39
+ metadata:
40
+ name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
41
+ $patch: delete
42
+ ```
43
+
44
+ ## branch
45
+
46
+ В lean-merged-branch.yml список гілок, які повинні бути:
47
+
48
+ ```yaml title=".github/workflows/clean-merged-branch.yml"
49
+ ignore_branches: dev,ua,ru
50
+ ```
51
+
52
+ ## Перевірка
53
+
54
+ `npx @nitra/cursor check abie`
55
+
56
+ Програмна перевірка (**`check-abie.mjs`**) виконується лише якщо у **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень (щоб не вимагати **ua**/**ru** у репозиторіях без цього правила).
package/mdc/bun.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Bun як єдиний package manager у монорепо
3
3
  alwaysApply: true
4
- version: '1.5'
4
+ version: '1.6'
5
5
  ---
6
6
 
7
7
  Проект використовує тільки Bun для керування залежностями та запуску скриптів.
@@ -63,29 +63,16 @@ FROM oven/bun:alpine AS build-env
63
63
 
64
64
  замість образу node
65
65
 
66
- В Github actions bun повинен налаштований так:
66
+ У **GitHub Actions** не вставляй у workflow окремі кроки **`actions/setup-node`**, **`oven-sh/setup-bun`**, **`actions/cache`** та **`bun install`** — їх **заборонено** дублювати в кожному job; завжди використовуй **локальний composite** (деталі й заборона дублювання — **ga.mdc**). Під капотом composite уже містить Node **24**, Bun, кеш і **`bun install --frozen-lockfile`**.
67
+
68
+ Після **`actions/checkout@v6`** (`persist-credentials: false`):
67
69
 
68
70
  ```yaml
69
- - uses: actions/setup-node@v6
70
- with:
71
- node-version: '24'
72
-
73
- - uses: oven-sh/setup-bun@v2
74
-
75
- - name: Cache Bun dependencies
76
- uses: actions/cache@v5
77
- with:
78
- path: |
79
- ~/.bun/install/cache
80
- node_modules
81
- key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
82
- restore-keys: |
83
- ${{ runner.os }}-bun-
84
-
85
- - name: Install dependencies
86
- run: bun install --frozen-lockfile
71
+ - uses: ./.github/actions/setup-bun-deps
87
72
  ```
88
73
 
74
+ Якщо в репозиторії action збережено під **`./npm/github-actions/setup-bun-deps`**, у `uses:` вкажи цей шлях замість `.github/actions/…` (**ga.mdc**).
75
+
89
76
  ## Перевірка
90
77
 
91
78
  `npx @nitra/cursor check bun`
package/mdc/js-lint.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  description: Перевірка JavaScript коду
3
3
  alwaysApply: true
4
- version: '1.9'
4
+ version: '1.11'
5
5
  ---
6
6
 
7
- **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`:** `oxlint` (без `bunx`), **`bunx eslint`**, **`bunx jscpd`**; у CI `bunx oxlint` / `bunx eslint` / `bunx jscpd`. Без **prettier** і **@nitra/prettier-config**. Достатньо **`@nitra/eslint-config`** у devDependencies; пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
7
+ **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. Достатньо **`@nitra/eslint-config`** у devDependencies; пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
8
8
 
9
9
  ```json title=".vscode/extensions.json"
10
10
  {
@@ -38,7 +38,7 @@ version: '1.9'
38
38
  "exitCode": 1,
39
39
  "reporters": ["console"],
40
40
  "minLines": 25,
41
- "ignore": ["**/dist/**",]
41
+ "ignore": ["**/dist/**"]
42
42
  }
43
43
  ```
44
44
 
package/mdc/k8s.mdc CHANGED
@@ -206,7 +206,7 @@ resources: {}
206
206
  - Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
207
207
  - **`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
208
  - У кожному YAML-документі з **`kind: Deployment`** — парсинг через **`yaml`**: у кожного елемента **`spec.template.spec.containers[]`** має існувати ключ **`resources`** зі значенням-об'єктом (допускається порожній **`{}`**); у кожного контейнера — **`imagePullPolicy: Always`**.
209
- - У файлах, ім’я яких **не** `kustomization.yaml` / `kustomization.yml`, у кожному документі — заборона поля **`metadata.namespace`** (**namespace** задається в Kustomize).
209
+ - У файлах, ім’я яких **не** `kustomization.yaml`.
210
210
  - Документи з **`kind: Ingress`** — заборонені (потрібен перехід на Gateway API, див. розділ **Ingress → Gateway API**).
211
211
  - Якщо в будь-якому файлі під **`k8s`** є **`kind: HealthCheckPolicy`**, серед файлів має бути **`ru/kustomization.yaml`** (сегмент шляху **`ru`** перед іменем файлу), а його вміст — patch видалення **HealthCheckPolicy** з **`$patch: delete`** (див. той самий розділ).
212
212
  - Заборона шляхів **`…/k8s/dev/…`** (окремої директорії **`dev`** під **`k8s`** не має бути).
package/mdc/text.mdc CHANGED
@@ -28,9 +28,9 @@ version: '1.24'
28
28
  }
29
29
  ```
30
30
 
31
- **`package.json`:** скрипт **`lint-text`** і devDependencies **`@nitra/cspell-dict`**. Для української додай **`@cspell/dict-uk-ua`**. **`markdownlint-cli2`** викликай у `lint-text` лише через **`bunx markdownlint-cli2`**, не додавай пакет до devDependencies. **`v8r`** лише через **`bun x v8r`** (зазвичай **`bunx v8r`**), не в devDependencies. Окремий пакет **`markdownlint`** не потрібний.
31
+ **`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`** не потрібний.
32
32
 
33
- У v8r **немає** прапорця тихого режиму; рекомендовано скрипт **`run-v8r.mjs`** з репозиторію пакета `@nitra/cursor` (`npm/scripts/run-v8r.mjs`): один виклик у `lint-text` — під капотом послідовні **`bun x 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/…`.
33
+ У 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/…`.
34
34
 
35
35
  ```json title="package.json"
36
36
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.30",
3
+ "version": "1.8.40",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "skills": {
23
23
  "type": "array",
24
- "description": "Ідентифікатори skills без префікса n- (каталог .cursor/skills). Якщо відсутній, CLI доповнить списком skills з пакету.",
24
+ "description": "Ідентифікатори skills без префікса n- (у пакеті — підкаталог skills/<id>/, у проєкті після синку — .cursor/skills/n-<id>/). Якщо відсутній, CLI доповнить списком skills з пакету.",
25
25
  "items": {
26
26
  "type": "string",
27
27
  "minLength": 1
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Перевіряє відповідність проєкту правилу abie.mdc (проєкти abinbevefes).
3
+ *
4
+ * Застосовується лише якщо у **`.n-cursor.json`** у масиві **`rules`** є **`abie`** — інакше вихід **0**
5
+ * без перевірок (щоб не суперечити типовому **ga.mdc** з **`ignore_branches: main,dev`**).
6
+ *
7
+ * **Гілки:** у **`.github/workflows/clean-merged-branch.yml`** у кроці з
8
+ * **`phpdocker-io/github-actions-delete-abandoned-branches`** у **`with.ignore_branches`** мають бути
9
+ * **dev**, **ua** та **ru** (разом з іншими гілками, якщо потрібно).
10
+ *
11
+ * **k8s:** якщо під деревом із сегментом **`k8s`** є YAML з **`kind: Deployment`**, у тій самій директорії
12
+ * має існувати **`hc.yaml`** із **`HealthCheckPolicy`** (**`networking.gke.io/v1`**), modeline **`$schema`**
13
+ * як у abie.mdc, **`/healthz`**, порт **8080**, **`targetRef`** на **Service** з тим самим **`metadata.name`**.
14
+ * Якщо в дереві **k8s** є **HealthCheckPolicy**, перевіряється **`ru/kustomization.yaml`** з patch **`$patch: delete`**
15
+ * (узгоджено з **k8s.mdc** / **check-k8s.mjs**).
16
+ */
17
+ import { existsSync } from 'node:fs'
18
+ import { readFile } from 'node:fs/promises'
19
+ import { dirname, join, relative } from 'node:path'
20
+
21
+ import { parseAllDocuments } from 'yaml'
22
+
23
+ import { isRuKustomizationPath, pathHasK8sSegment, ruKustomizationHasHealthCheckDeletePatch } from './check-k8s.mjs'
24
+ import { pass } from './utils/pass.mjs'
25
+ import { flattenWorkflowSteps, getStepUses, parseWorkflowYaml } from './utils/gha-workflow.mjs'
26
+ import { walkDir } from './utils/walkDir.mjs'
27
+
28
+ const CONFIG_FILE = '.n-cursor.json'
29
+
30
+ /** Очікуваний URL **`$schema`** для **hc.yaml** (abie.mdc). */
31
+ export const ABIE_HC_SCHEMA_URL = 'https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json'
32
+
33
+ const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
34
+
35
+ /** Гілки, які мають бути в **`ignore_branches`** за abie.mdc. */
36
+ export const ABIE_REQUIRED_IGNORE_BRANCHES = ['dev', 'ua', 'ru']
37
+
38
+ /**
39
+ * Чи увімкнено правило **abie** у конфігу репозиторію.
40
+ * @param {string} root корінь репозиторію (cwd)
41
+ * @returns {Promise<boolean>} true, якщо **rules** містить **abie**
42
+ */
43
+ export async function isAbieRuleEnabled(root) {
44
+ const p = join(root, CONFIG_FILE)
45
+ if (!existsSync(p)) {
46
+ return false
47
+ }
48
+ let raw
49
+ try {
50
+ raw = await readFile(p, 'utf8')
51
+ } catch {
52
+ return false
53
+ }
54
+ let cfg
55
+ try {
56
+ cfg = JSON.parse(raw)
57
+ } catch {
58
+ return false
59
+ }
60
+ const rules = cfg?.rules
61
+ if (!Array.isArray(rules)) {
62
+ return false
63
+ }
64
+ return rules.some(r => String(r).trim().toLowerCase() === 'abie')
65
+ }
66
+
67
+ /**
68
+ * Розбирає **`ignore_branches`** з workflow **clean-merged-branch** (крок delete-abandoned-branches).
69
+ * @param {string} content вміст **.yml**
70
+ * @returns {string | null} рядок **ignore_branches** або **null**
71
+ */
72
+ export function parseCleanMergedIgnoreBranches(content) {
73
+ const root = parseWorkflowYaml(content)
74
+ if (!root) {
75
+ return null
76
+ }
77
+ for (const { step } of flattenWorkflowSteps(root)) {
78
+ const uses = getStepUses(step)
79
+ if (uses.includes('phpdocker-io/github-actions-delete-abandoned-branches')) {
80
+ const w = step.with
81
+ if (w && typeof w === 'object' && !Array.isArray(w)) {
82
+ const ib = /** @type {Record<string, unknown>} */ (w).ignore_branches
83
+ if (typeof ib === 'string') {
84
+ return ib
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return null
90
+ }
91
+
92
+ /**
93
+ * Чи рядок **ignore_branches** містить усі гілки з **required** (для abie — dev, ua, ru).
94
+ * @param {string} ignoreBranches значення **ignore_branches**
95
+ * @param {string[]} required імена гілок (нижній регістр для порівняння)
96
+ * @returns {boolean} true, якщо всі **required** присутні як окремі токени
97
+ */
98
+ export function ignoreBranchesIncludesRequired(ignoreBranches, required) {
99
+ const parts = new Set(
100
+ ignoreBranches
101
+ .split(',')
102
+ .map(s => s.trim().toLowerCase())
103
+ .filter(Boolean)
104
+ )
105
+ return required.every(r => parts.has(r.toLowerCase()))
106
+ }
107
+
108
+ /**
109
+ * Збирає абсолютні шляхи до **.yaml** / **.yml** під деревом, де є сегмент **k8s**.
110
+ * @param {string} root корінь репозиторію
111
+ * @returns {Promise<string[]>} відсортовані шляхи
112
+ */
113
+ async function findK8sYamlFiles(root) {
114
+ /** @type {string[]} */
115
+ const out = []
116
+ await walkDir(root, p => {
117
+ if (!pathHasK8sSegment(p)) {
118
+ return
119
+ }
120
+ if (!/\.ya?ml$/iu.test(p)) {
121
+ return
122
+ }
123
+ out.push(p)
124
+ })
125
+ return [...out].toSorted((a, b) => a.localeCompare(b))
126
+ }
127
+
128
+ /**
129
+ * Чи документ — **Deployment**.
130
+ * @param {unknown} obj корінь YAML-документа
131
+ * @returns {boolean} true, якщо **kind** документа — **Deployment**
132
+ */
133
+ function isDeploymentDoc(obj) {
134
+ return (
135
+ obj !== null &&
136
+ typeof obj === 'object' &&
137
+ !Array.isArray(obj) &&
138
+ /** @type {Record<string, unknown>} */ (obj).kind === 'Deployment'
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Директорії, де є хоча б один **Deployment** у файлах **k8s**.
144
+ * @param {string} root корінь cwd
145
+ * @param {string[]} yamlAbs абсолютні шляхи yaml під k8s
146
+ * @param {(msg: string) => void} fail реєстрація помилки парсингу
147
+ * @returns {Promise<Set<string>>} абсолютні шляхи директорій
148
+ */
149
+ async function collectDeploymentDirs(root, yamlAbs, fail) {
150
+ /** @type {Set<string>} */
151
+ const dirs = new Set()
152
+ for (const abs of yamlAbs) {
153
+ let raw
154
+ let readOk = false
155
+ try {
156
+ raw = await readFile(abs, 'utf8')
157
+ readOk = true
158
+ } catch (error) {
159
+ const msg = error instanceof Error ? error.message : String(error)
160
+ fail(`${relative(root, abs) || abs}: не вдалося прочитати (${msg})`)
161
+ }
162
+ if (readOk) {
163
+ const body = stripBom(raw)
164
+ const lines = body.split(/\r?\n/u)
165
+ const first = lines[0] ?? ''
166
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
167
+ /** @type {import('yaml').Document[]} */
168
+ let docs
169
+ let parseOk = false
170
+ try {
171
+ docs = parseAllDocuments(rest)
172
+ parseOk = true
173
+ } catch (error) {
174
+ const msg = error instanceof Error ? error.message : String(error)
175
+ fail(`${relative(root, abs) || abs}: YAML (${msg})`)
176
+ }
177
+ if (parseOk) {
178
+ for (const doc of docs) {
179
+ if (doc.errors.length === 0) {
180
+ const obj = doc.toJSON()
181
+ if (isDeploymentDoc(obj)) {
182
+ dirs.add(dirname(abs))
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+ return dirs
190
+ }
191
+
192
+ /**
193
+ * Прибирає BOM на початку файлу.
194
+ * @param {string} s вміст
195
+ * @returns {string} той самий рядок без BOM (U+FEFF) на початку
196
+ */
197
+ function stripBom(s) {
198
+ return s.startsWith('\uFEFF') ? s.slice(1) : s
199
+ }
200
+
201
+ /**
202
+ * Перевіряє **hc.yaml** на відповідність abie.mdc.
203
+ * @param {string} raw повний текст файлу
204
+ * @param {string} relPath відносний шлях для повідомлень
205
+ * @returns {string | null} текст помилки або **null**
206
+ */
207
+ export function validateAbieHcYaml(raw, relPath) {
208
+ const body = stripBom(raw)
209
+ const lines = body.split(/\r?\n/u)
210
+ if (lines.length === 0 || lines[0].trim() === '') {
211
+ return `${relPath}: перший рядок порожній — потрібен # yaml-language-server: $schema=… (abie.mdc)`
212
+ }
213
+ const m = lines[0].match(MODELINE_RE)
214
+ if (!m) {
215
+ return `${relPath}: перший рядок має бути modeline $schema (abie.mdc)`
216
+ }
217
+ if (m[1] !== ABIE_HC_SCHEMA_URL) {
218
+ return `${relPath}: $schema має бути\n ${ABIE_HC_SCHEMA_URL}\n (abie.mdc)`
219
+ }
220
+ const yamlBody = lines
221
+ .slice(1)
222
+ .join('\n')
223
+ .replace(/^\s*\n/u, '')
224
+ /** @type {import('yaml').Document[]} */
225
+ let docs
226
+ try {
227
+ docs = parseAllDocuments(yamlBody)
228
+ } catch (error) {
229
+ const msg = error instanceof Error ? error.message : String(error)
230
+ return `${relPath}: не вдалося розібрати YAML (${msg})`
231
+ }
232
+ /** @type {Record<string, unknown> | null} */
233
+ let policy = null
234
+ for (const doc of docs) {
235
+ if (doc.errors.length > 0) {
236
+ return `${relPath}: YAML: ${doc.errors.map(e => e.message).join('; ')}`
237
+ }
238
+ const obj = doc.toJSON()
239
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
240
+ const rec = /** @type {Record<string, unknown>} */ (obj)
241
+ if (rec.kind === 'HealthCheckPolicy') {
242
+ policy = rec
243
+ break
244
+ }
245
+ }
246
+ }
247
+ if (!policy) {
248
+ return `${relPath}: очікується документ kind: HealthCheckPolicy (abie.mdc)`
249
+ }
250
+ if (policy.apiVersion !== 'networking.gke.io/v1') {
251
+ return `${relPath}: apiVersion має бути networking.gke.io/v1 (abie.mdc)`
252
+ }
253
+ const meta = policy.metadata
254
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
255
+ return `${relPath}: відсутній metadata (abie.mdc)`
256
+ }
257
+ const name = /** @type {Record<string, unknown>} */ (meta).name
258
+ if (typeof name !== 'string' || name.trim() === '') {
259
+ return `${relPath}: metadata.name має бути непорожнім рядком (abie.mdc)`
260
+ }
261
+ const spec = policy.spec
262
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
263
+ return `${relPath}: відсутній spec (abie.mdc)`
264
+ }
265
+ const def = /** @type {Record<string, unknown>} */ (spec).default
266
+ if (def === null || typeof def !== 'object' || Array.isArray(def)) {
267
+ return `${relPath}: відсутній spec.default (abie.mdc)`
268
+ }
269
+ const config = /** @type {Record<string, unknown>} */ (def).config
270
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
271
+ return `${relPath}: відсутній spec.default.config (abie.mdc)`
272
+ }
273
+ if (config.type !== 'HTTP') {
274
+ return `${relPath}: spec.default.config.type має бути HTTP (abie.mdc)`
275
+ }
276
+ const httpHc = /** @type {Record<string, unknown>} */ (config).httpHealthCheck
277
+ if (httpHc === null || typeof httpHc !== 'object' || Array.isArray(httpHc)) {
278
+ return `${relPath}: відсутній httpHealthCheck (abie.mdc)`
279
+ }
280
+ if (httpHc.requestPath !== '/healthz') {
281
+ return `${relPath}: httpHealthCheck.requestPath має бути /healthz (abie.mdc)`
282
+ }
283
+ if (httpHc.port !== 8080) {
284
+ return `${relPath}: httpHealthCheck.port має бути 8080 (abie.mdc)`
285
+ }
286
+ const targetRef = /** @type {Record<string, unknown>} */ (spec).targetRef
287
+ if (targetRef === null || typeof targetRef !== 'object' || Array.isArray(targetRef)) {
288
+ return `${relPath}: відсутній targetRef (abie.mdc)`
289
+ }
290
+ if (targetRef.kind !== 'Service') {
291
+ return `${relPath}: targetRef.kind має бути Service (abie.mdc)`
292
+ }
293
+ const svcName = targetRef.name
294
+ if (typeof svcName !== 'string' || svcName !== name) {
295
+ return `${relPath}: targetRef.name має збігатися з metadata.name (${name}) (abie.mdc)`
296
+ }
297
+ return null
298
+ }
299
+
300
+ /**
301
+ * Збирає відносні шляхи файлів із **HealthCheckPolicy** у дереві k8s.
302
+ * @param {string} root корінь
303
+ * @param {string[]} yamlAbs абсолютні шляхи
304
+ * @returns {Promise<string[]>} унікальні відносні шляхи yaml із **HealthCheckPolicy**
305
+ */
306
+ async function collectHealthCheckPolicyRelPaths(root, yamlAbs) {
307
+ /** @type {string[]} */
308
+ const out = []
309
+ for (const abs of yamlAbs) {
310
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
311
+ let raw
312
+ try {
313
+ raw = await readFile(abs, 'utf8')
314
+ } catch {
315
+ raw = null
316
+ }
317
+ if (raw !== null) {
318
+ const body = stripBom(raw)
319
+ const lines = body.split(/\r?\n/u)
320
+ const first = lines[0] ?? ''
321
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
322
+ try {
323
+ const docs = parseAllDocuments(rest)
324
+ for (const doc of docs) {
325
+ if (doc.errors.length === 0) {
326
+ const obj = doc.toJSON()
327
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
328
+ const rec = /** @type {Record<string, unknown>} */ (obj)
329
+ if (rec.kind === 'HealthCheckPolicy' && !out.includes(rel)) {
330
+ out.push(rel)
331
+ }
332
+ }
333
+ }
334
+ }
335
+ } catch {
336
+ /* пропускаємо пошкоджені файли — їх ловить check-k8s */
337
+ }
338
+ }
339
+ }
340
+ return out
341
+ }
342
+
343
+ /**
344
+ * Якщо є **HealthCheckPolicy**, вимагає **ru/kustomization.yaml** з patch видалення (як **check-k8s**).
345
+ * @param {string} root корінь
346
+ * @param {string[]} yamlFilesAbs абсолютні шляхи yaml k8s
347
+ * @param {string[]} healthCheckPolicyRelativePaths відносні шляхи
348
+ * @param {(msg: string) => void} fail callback
349
+ * @returns {Promise<void>}
350
+ */
351
+ async function ensureRuKustomizationHealthCheckDelete(root, yamlFilesAbs, healthCheckPolicyRelativePaths, fail) {
352
+ if (healthCheckPolicyRelativePaths.length === 0) {
353
+ return
354
+ }
355
+ const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
356
+ if (ruAbsList.length === 0) {
357
+ fail(
358
+ `Знайдено HealthCheckPolicy у ${healthCheckPolicyRelativePaths.join(', ')} — додай ru/kustomization.yaml з patch видалення (abie.mdc)`
359
+ )
360
+ return
361
+ }
362
+ for (const abs of ruAbsList) {
363
+ let raw
364
+ try {
365
+ raw = await readFile(abs, 'utf8')
366
+ } catch (error) {
367
+ const msg = error instanceof Error ? error.message : String(error)
368
+ fail(`${relative(root, abs) || abs}: не вдалося прочитати (${msg})`)
369
+ return
370
+ }
371
+ if (ruKustomizationHasHealthCheckDeletePatch(raw)) {
372
+ return
373
+ }
374
+ }
375
+ fail(
376
+ 'Є HealthCheckPolicy, але жоден ru/kustomization.yaml не містить очікуваного patch видалення (kind: HealthCheckPolicy, metadata.name, $patch: delete) — abie.mdc'
377
+ )
378
+ }
379
+
380
+ /**
381
+ * Перевіряє відповідність проєкту правилам abie.mdc.
382
+ * @returns {Promise<number>} 0 — OK, 1 — є порушення
383
+ */
384
+ export async function check() {
385
+ let exitCode = 0
386
+ const fail = msg => {
387
+ console.log(` ❌ ${msg}`)
388
+ exitCode = 1
389
+ }
390
+
391
+ const root = process.cwd()
392
+ const enabled = await isAbieRuleEnabled(root)
393
+ if (!enabled) {
394
+ pass(`Правило abie не увімкнено в ${CONFIG_FILE} (rules) — перевірку пропущено`)
395
+ return 0
396
+ }
397
+
398
+ pass('Правило abie увімкнено — виконуємо перевірки')
399
+
400
+ const cleanMergedPath = join(root, '.github/workflows/clean-merged-branch.yml')
401
+ if (existsSync(cleanMergedPath)) {
402
+ /** @type {string | undefined} */
403
+ let wfRaw
404
+ try {
405
+ wfRaw = await readFile(cleanMergedPath, 'utf8')
406
+ } catch (error) {
407
+ const msg = error instanceof Error ? error.message : String(error)
408
+ fail(`Не вдалося прочитати clean-merged-branch.yml (${msg})`)
409
+ }
410
+ if (wfRaw !== undefined) {
411
+ const ib = parseCleanMergedIgnoreBranches(wfRaw)
412
+ if (ib === null || ib.trim() === '') {
413
+ fail(
414
+ 'clean-merged-branch.yml: не знайдено with.ignore_branches у кроці phpdocker-io/github-actions-delete-abandoned-branches (abie.mdc)'
415
+ )
416
+ } else if (ignoreBranchesIncludesRequired(ib, ABIE_REQUIRED_IGNORE_BRANCHES)) {
417
+ pass('clean-merged-branch.yml: ignore_branches містить dev, ua, ru')
418
+ } else {
419
+ fail(
420
+ `clean-merged-branch.yml: ignore_branches має містити dev, ua та ru (зараз: ${JSON.stringify(ib)}) — abie.mdc`
421
+ )
422
+ }
423
+ }
424
+ } else {
425
+ fail(`Відсутній ${cleanMergedPath} — потрібен для ignore_branches (abie.mdc)`)
426
+ }
427
+
428
+ const yamlFiles = await findK8sYamlFiles(root)
429
+ const deploymentDirs = await collectDeploymentDirs(root, yamlFiles, fail)
430
+
431
+ if (deploymentDirs.size > 0) {
432
+ pass(`Знайдено Deployment у ${deploymentDirs.size} директорія(ї/й) k8s — перевіряємо hc.yaml`)
433
+ for (const dir of [...deploymentDirs].toSorted((a, b) => a.localeCompare(b))) {
434
+ const hcAbs = join(dir, 'hc.yaml')
435
+ const relHc = relative(root, hcAbs).replaceAll('\\', '/') || 'hc.yaml'
436
+ if (existsSync(hcAbs)) {
437
+ let hcRaw
438
+ let hcReadOk = false
439
+ try {
440
+ hcRaw = await readFile(hcAbs, 'utf8')
441
+ hcReadOk = true
442
+ } catch (error) {
443
+ const msg = error instanceof Error ? error.message : String(error)
444
+ fail(`${relHc}: не вдалося прочитати (${msg})`)
445
+ }
446
+ if (hcReadOk) {
447
+ const v = validateAbieHcYaml(hcRaw, relHc)
448
+ if (v === null) {
449
+ pass(`${relHc}: відповідає abie.mdc`)
450
+ } else {
451
+ fail(v)
452
+ }
453
+ }
454
+ } else {
455
+ fail(
456
+ `${relative(root, dir) || dir}: є Deployment, але немає hc.yaml поруч — додай HealthCheckPolicy (abie.mdc)`
457
+ )
458
+ }
459
+ }
460
+ } else {
461
+ pass('Немає Deployment у дереві k8s — перевірку hc.yaml пропущено')
462
+ }
463
+
464
+ const healthCheckPolicyRelativePaths = await collectHealthCheckPolicyRelPaths(root, yamlFiles)
465
+ await ensureRuKustomizationHealthCheckDelete(root, yamlFiles, healthCheckPolicyRelativePaths, fail)
466
+
467
+ return exitCode
468
+ }
@@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises'
12
12
  import { parseWorkflowYaml, verifyLintJsWorkflowStructure } from './utils/gha-workflow.mjs'
13
13
  import { pass } from './utils/pass.mjs'
14
14
 
15
- /** Очікуваний локальний скрипт (oxlint без bunx; eslint/jscpd через bunx). */
15
+ /** Очікуваний локальний скрипт. */
16
16
  export const CANONICAL_LINT_JS = 'bunx oxlint --fix && bunx eslint --fix . && bunx jscpd .'
17
17
 
18
18
  /** Мінімальні рекомендації розширень редактора з js-lint.mdc (eslint, oxlint, GA). */
@@ -28,14 +28,12 @@ export function normalizeLintJsScript(s) {
28
28
  }
29
29
 
30
30
  /**
31
- * Чи рядок `lint-js` збігається з каноном і без `bunx oxlint`.
31
+ * Чи рядок `lint-js` збігається з каноном (`bunx oxlint`, `bunx eslint`, `bunx jscpd`).
32
32
  * @param {string} script значення `scripts.lint-js` з package.json
33
33
  * @returns {boolean} true, якщо рядок канонічний
34
34
  */
35
35
  export function isCanonicalLintJs(script) {
36
- const n = normalizeLintJsScript(script)
37
- if (n.includes('bunx oxlint')) return false
38
- return n === CANONICAL_LINT_JS
36
+ return normalizeLintJsScript(script) === CANONICAL_LINT_JS
39
37
  }
40
38
 
41
39
  /**
@@ -89,7 +87,7 @@ export async function check() {
89
87
  pass(`lint-js збігається з каноном: ${CANONICAL_LINT_JS}`)
90
88
  } else {
91
89
  fail(
92
- `lint-js має бути рівно: "${CANONICAL_LINT_JS}" (oxlint без bunx; див. js-lint.mdc / check-js-lint.mjs). Зараз: ${JSON.stringify(normalizeLintJsScript(lintJs))}`
90
+ `lint-js має бути рівно: "${CANONICAL_LINT_JS}" (див. js-lint.mdc / check-js-lint.mjs). Зараз: ${JSON.stringify(normalizeLintJsScript(lintJs))}`
93
91
  )
94
92
  }
95
93
  } else {
@@ -2,7 +2,7 @@
2
2
  * Тихий запуск v8r для усіх типів файлів, які підтримує v8r (json, json5, yaml, yml, toml).
3
3
  *
4
4
  * Один виклик цього скрипта з `lint-text` замість чотирьох окремих викликів v8r: під капотом для
5
- * кожного glob окремий `bun x v8r`, бо v8r у одному процесі падає з кодом 98, якщо хоч один із
5
+ * кожного glob окремий `bunx v8r`, бо v8r у одному процесі падає з кодом 98, якщо хоч один із
6
6
  * переданих глобів не знаходить файлів — тоді решта розширень не перевіряються.
7
7
  *
8
8
  * Каталог схем `@nitra/cursor` (`v8r-catalog.json` у каталозі `schemas` пакета) передається в v8r
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: abie-kustomize
3
+ description: >-
4
+ Трансформація дерев k8s у структуру Kustomize (base + overlays): dev → base, без окремої dev/
5
+ version: '1.0'
6
+ ---
7
+
8
+ Трансформуй директорії, щоб виділити спільне за допомогою kustomize. За основу беремо все, що в середовищі dev, і саме в такому вигляді з dev воно має стати **base**; якщо вже є base і немає dev — це нормально, рухайся далі.
9
+
10
+ У інших середовищах має бути лише `kustomization.yaml` і зміни через оверрайди.
11
+
12
+ У **base** у всіх ресурсів (окрім `base/kustomization.yaml`) має бути namespace **dev**.
13
+
14
+ Окремої директорії **dev** не має бути — за середовище dev відповідає **base**.
15
+
16
+ README має бути в директорії **k8s**.
17
+
18
+ Рядки в маніфестах у **base**, які змінюватимуться в інших середовищах, позначай коментарем на тому самому рядку: `# буде замінено через kustomize`.
19
+
20
+ Патчів лише на namespace не роби — namespace задається в `kustomization.yaml`.
21
+
22
+ Застарілі файли прибирай.
23
+
24
+ У всіх Deployment має бути `imagePullPolicy: Always`.
25
+
26
+ Для overlays **ru** та **ua** `namespace` задавай у `kustomization.yaml` (без окремих patch лише на зміну namespace). Деталі — **n-k8s** / **abie** у `.cursor/rules/`, якщо ці правила увімкнені в проєкті.
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: n-fix
2
+ name: fix
3
3
  description: >-
4
4
  Виправити проєкт відповідно до всіх правил в .cursor/rules/
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: n-publish-telegram
2
+ name: publish-telegram
3
3
  description: >-
4
4
  Підготовка матеріалу з поточного контексту для публікації в Telegram-каналі команди
5
5
  ---