@nitra/cursor 1.8.217 → 1.8.220

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/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.8.220] - 2026-05-09
8
+
9
+ ### Fixed
10
+
11
+ - **k8s / `prodOverlayHpaPdbOverrideNeeds`:** виключено Kustomize Component (`kind: Component`) з prod-overlay-перевірки. Раніше `<pkg>/k8s/components/kustomization.yaml` помилково тригерив `прод-оверлей має перевизначати spec.minReplicas/maxReplicas/minAvailable` — але Component є **джерелом** ресурсів для overlays, не overlay сам по собі. Прод-перезаписи живуть у `ru/` / `ua/` / `prod/` тощо, що підключають Component через `components:`. Додано ранній return за `kind: Component` у `npm/scripts/check-k8s.mjs`; уточнення додано до `npm/mdc/k8s.mdc` і регресійний тест у `npm/tests/check-k8s-schema.test.mjs`.
12
+
13
+ ## [1.8.219] - 2026-05-09
14
+
15
+ ### Added
16
+
17
+ - **skill `n-llm-patch`:** наповнено `npm/skills/llm-patch/SKILL.md` (та дзеркальну копію `.cursor/skills/n-llm-patch/SKILL.md`). Скіл готує самодостатній текстовий промпт для іншого Claude/Cursor-агента у цільовому проєкті: read-only аналіз CWD (`package.json`, `tree -L 2`, `README`, релевантні конфіги), формування єдиного markdown-блоку за шаблоном `Завдання → Контекст → Релевантні файли → Що треба зробити → Обмеження → Як перевірити`. Цільова LLM — Claude / Cursor agent; жодних змін у поточному репо, тимчасові артефакти — лише у `/tmp`.
18
+
19
+ ### Changed
20
+
21
+ - **k8s / check-k8s:** канонічна структура HPA/PDB — через **Kustomize Component** з фіксованою назвою каталогу `components/` (sibling до `base/`). У `base/` HPA і PDB не існує: ні локальних `hpa.yaml` / `pdb.yaml`, ні через `resources` / `components`. Overlays підключають `components: [- ../components]` і додають JSON6902-патчі для прод-значень `/spec/minReplicas`, `/spec/maxReplicas`, `/spec/minAvailable`. Для кожного `Deployment` у `…/k8s/…/base/` тепер вимагається sibling каталог `…/k8s/…/components/` з валідним `kustomization.yaml` (`kind: Component`), `hpa.yaml` (dev-like `min=max=1`) і `pdb.yaml` (dev-like `minAvailable=0`).
22
+ - **k8s / check-k8s:** заборона локальних `base/hpa.yaml` і `base/pdb.yaml` (file-existence для обох). Якщо в дереві base-kustomize лишилися HPA або PDB через `resources` / `components` — fail (`HPA/PDB заборонені у base — переведіть у components/`).
23
+ - **k8s / check-k8s:** прод-overlay тригерить вимоги патчів `/spec/minReplicas`, `/spec/maxReplicas` (HPA), `/spec/minAvailable` (PDB), коли overlay-tree містить HPA/PDB (тобто overlay підключив `components/`).
24
+ - **k8s.mdc:** оновлено опис, приклади `components/kustomization.yaml`, `components/hpa.yaml`, `components/pdb.yaml` і прикладу прод-overlay із `components: [- ../components]` + JSON6902-патчами.
25
+
26
+ ### Removed
27
+
28
+ - **k8s / check-k8s:** прибрано застарілий механізм `$patch: delete` для HorizontalPodAutoscaler у `base/kustomization.yaml`. Видалено функції `verifyK8sBaseKustomizeHpaDeletedWhenInherited`, `kustomizationDeclaresHpaStrategicDelete`, `patchTextDeclaresHpaStrategicDelete` і константи-регекспи `HPA_STRATEGIC_DELETE_RE`, `HPA_KIND_LINE_RE` як мертві. Відповідні тести оновлено / видалено.
29
+
30
+ ## [1.8.218] - 2026-05-09
31
+
32
+ ### Added
33
+
34
+ - **auto-skills:** `llm-patch` додано як always-on скіл (без секції `[rules]` в `npm/bin/auto-skills.md`). Оновлено `AUTO_SKILL_ORDER` і `ALWAYS_ON_SKILLS` у `npm/scripts/auto-skills.mjs` та відповідні очікування у `npm/tests/auto-skills.test.mjs`. Сам вміст `npm/skills/llm-patch/SKILL.md` поки лишається стартовим — наповнюється окремо.
35
+
7
36
  ## [1.8.217] - 2026-05-09
8
37
 
9
38
  ### Added
@@ -28,7 +57,7 @@
28
57
 
29
58
  ### Fixed
30
59
 
31
- - **k8s / check-k8s:** конвертація image-replace patches → `images:` падала з `byPatch.keys(...).toSorted is not a function`, бо `Map.keys()` повертає ітератор без `toSorted`. Тепер ключі спершу матеріалізуються у масив (`[...byPatch.keys()].toSorted(...)`).
60
+ - **k8s / check-k8s:** конвертація image-replace patches → `images:` падала з `byPatch.keys(...).toSorted is not a function` (а в `rewriteInlinePatchWithoutOps` — з `(intermediate value).toSorted is not a function`), бо `Map.keys()` повертає ітератор, а `Set` — не масив, і `toSorted` на них немає. Тепер ключі/елементи матеріалізуються у масив через spread (`[...byPatch.keys()].toSorted(...)`, `[...new Set(opIndices)].toSorted(...)`).
32
61
 
33
62
  ### Changed
34
63
 
@@ -10,6 +10,8 @@ abie-kustomize - [abie]
10
10
 
11
11
  fix - завжди
12
12
 
13
+ llm-patch - завжди
14
+
13
15
  lint - завжди
14
16
 
15
17
  publish-telegram - завжди
package/mdc/k8s.mdc CHANGED
@@ -111,7 +111,7 @@ resources:
111
111
  memory: '128Mi'
112
112
  ```
113
113
 
114
- **HPA у base не тримаємо** у локальному **`hpa.yaml`** поруч із `Deployment`. Якщо **HorizontalPodAutoscaler** потрапляє в дерево Kustomize з **`resources`** / **`components`** / **`bases`**, у **`…/base/kustomization.yaml`** додай strategic-merge patch з **`$patch: delete`** і **`kind: HorizontalPodAutoscaler`** (і **`metadata.name`**, що збігається з ресурсом, який треба прибрати). **`check k8s`** перевіряє наявність такого patch, коли в дереві base одночасно є **Deployment** і **HPA**.
114
+ **HPA і PDB у base не тримаємо**: ні локальних `hpa.yaml` / `pdb.yaml` поруч із `Deployment`, ні через `resources` / `components` / `bases`. Канон sibling каталог **`components/`** (Kustomize Component) поруч з `base/` (див. розділ нижче).
115
115
 
116
116
  ### Поза base (оверлеї, окремі каталоги)
117
117
 
@@ -373,17 +373,29 @@ images:
373
373
 
374
374
  **`check k8s`:** заборонено **`kind: Ingress`**.
375
375
 
376
- ## Deployment: `pdb.yaml`, `topologySpreadConstraints`, HPA поза dev-base
376
+ ## Deployment: `topologySpreadConstraints`, HPA / PDB через `components/`
377
377
 
378
- Для **кожного** `kind: Deployment` у каталозі **`…/k8s/…/base/`** (у будь-якому файлі `.yaml`, наприклад **`deploy.yaml`**, **`deployment.yaml`**) у **тому ж каталозі** має бути **`pdb.yaml`** (PDB), а сам Deployment канонічні **`spec.template.spec.topologySpreadConstraints`**. Локальний **`hpa.yaml`** у каталозі **`…/base/`** **заборонено**для dev HPA з дерева Kustomize прибирається через **`$patch: delete`** у **`base/kustomization.yaml`** (див. розділ про **`resources.requests`** вище).
378
+ Для **кожного** `kind: Deployment` у каталозі **`…/k8s/…/base/`** (у будь-якому файлі `.yaml`, наприклад **`deploy.yaml`**, **`deployment.yaml`**) сам Deployment має канонічні **`spec.template.spec.topologySpreadConstraints`**, а **HPA і PDB** живуть у **sibling каталозі** **`…/k8s/…/components/`** (Kustomize Component, фіксована назва каталогу `components`). У `base/` локальні `hpa.yaml` і `pdb.yaml` **заборонені** (file-existence error). У дереві base-kustomize HPA / PDB також **не дозволені** через `resources` / `components` / `bases`.
379
379
 
380
- У **не-base** оверлеях поруч із `Deployment` лишається звична схема: окремий **`hpa.yaml`**, якщо потрібен HPA для цього середовища. **`check k8s`** звіряє прив’язку за іменами:
380
+ **Канонічна структура `<pkg>/k8s/components/`** (sibling до `base/`):
381
+
382
+ - **`kustomization.yaml`** — `apiVersion: kustomize.config.k8s.io/v1alpha1`, `kind: Component`, `resources: [hpa.yaml, pdb.yaml]`.
383
+ - **`hpa.yaml`** — `autoscaling/v2`, `HorizontalPodAutoscaler`, **без** `metadata.namespace` (namespace задає kustomization-споживач), `spec.scaleTargetRef.name` **= `metadata.name`** Deployment з base, dev-like значення `minReplicas: 1`, `maxReplicas: 1`.
384
+ - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, **без** `metadata.namespace`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment, dev-like `minAvailable: 0`.
385
+
386
+ Інші назви каталогу (`scale/`, `hpa-component/`, `pdb-component/`) — fail.
387
+
388
+ **Overlays** (`ua/`, `ru/`, прод-overlays) підключають `components: [- ../components]` і додають JSON6902-патчі для прод-значень: `/spec/minReplicas`, `/spec/maxReplicas` (HPA), `/spec/minAvailable` (PDB). Dev-середовище (`base`) HPA/PDB не отримує — як і потрібно.
389
+
390
+ **`<pkg>/k8s/components/kustomization.yaml`** має `kind: Component` (не `kind: Kustomization`) — це **джерело** канонічних HPA/PDB для всіх overlays, а не overlay сам по собі. Прод-перезаписи (`/spec/minReplicas`, `/spec/maxReplicas`, `/spec/minAvailable`) живуть у `<env>/kustomization.yaml`, що підключає Component через `components:`. У самому Component patches не потрібні — він env-неутральний; **`check k8s`** не вимагає прод-патчів від `components/kustomization.yaml`.
391
+
392
+ У **не-base** оверлеях (без `components/`) поруч із `Deployment` лишається звична схема: окремий **`hpa.yaml`** / **`pdb.yaml`**, якщо такі потрібні для цього середовища. **`check k8s`** звіряє прив'язку за іменами:
381
393
 
382
394
  - **`hpa.yaml`** (поза **`…/base/`**) — `autoscaling/v2`, `HorizontalPodAutoscaler`, `spec.scaleTargetRef.name` **= `metadata.name`** Deployment.
383
395
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
384
396
  - **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
385
397
 
386
- **Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є хоча б один **`Deployment`** у YAML під **`…/k8s/…/base/`**. Якщо після збору в дереві є і **Deployment**, і **HPA**, **base** `kustomization.yaml` має містити strategic-merge видалення **HorizontalPodAutoscaler** (`$patch: delete`). У `kustomization.yaml` overlay, який підключає цей `base`, не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться такий Deployment. Перевіряє **`check-k8s.mjs`**.
398
+ **Перевірка структури `components/`** (для кожного Deployment у `base/`): наявність каталогу, валідний `kustomization.yaml` як Component, `hpa.yaml` і `pdb.yaml` з відповідністю до Deployment-name / app-label. Алгоритм функція `validateComponentsForBaseDeployment` у **`check-k8s.mjs`**.
387
399
 
388
400
  **Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
389
401
 
@@ -403,14 +415,21 @@ images:
403
415
 
404
416
  ### Прод-оверрайди у `kustomization.yaml`
405
417
 
406
- У прод-накладенні `kustomization.yaml` у `patches[]` **обов’язкові** перевизначення залежно від того, що дає успадковане **base**-дерево:
418
+ У прод-накладенні `kustomization.yaml`, що підключає `components: [- ../components]` (тобто overlay-tree містить **HorizontalPodAutoscaler** і **PodDisruptionBudget**), у `patches[]` **обов'язкові** JSON6902-перевизначення прод-значень:
407
419
 
408
- - **`PodDisruptionBudget`**: `spec.minAvailable` — якщо в base-дереві є **Deployment** і **PDB** (типово dev-like `0` → прод ≥1).
409
- - **`HorizontalPodAutoscaler`**: `spec.minReplicas` **і** `spec.maxReplicas` — **лише якщо** в base-дереві є **HPA**, який **не** прибирається в **base** через **`$patch: delete`**. Якщо base видаляє HPA для dev, у прод-оверлеї додай **HPA** окремим YAML у **`resources`** (або patches на ресурс, що з’являється з іншого шару) з прод-мінімумами.
420
+ - **`HorizontalPodAutoscaler`**: `/spec/minReplicas` і `/spec/maxReplicas` (мінімум 2).
421
+ - **`PodDisruptionBudget`**: `/spec/minAvailable` (мінімум 1).
410
422
 
411
423
  Формат patch — JSON6902 або Strategic Merge; `check k8s` перевіряє **наявність** відповідних JSON Pointer-ів у **`patches[]`**.
412
424
 
413
425
  ```yaml title="k8s/prod/kustomization.yaml (фрагмент)"
426
+ apiVersion: kustomize.config.k8s.io/v1beta1
427
+ kind: Kustomization
428
+ namespace: prod
429
+ resources:
430
+ - ../base
431
+ components:
432
+ - ../components
414
433
  patches:
415
434
  - target:
416
435
  kind: HorizontalPodAutoscaler
@@ -431,30 +450,17 @@ patches:
431
450
  value: 1
432
451
  ```
433
452
 
434
- ### Приклад: прибрати HPA у `base/kustomization.yaml`
435
-
436
- Якщо **HPA** підключений з **`../component`** (або іншого шляху в **`resources`**), а у dev він не потрібен:
453
+ ### Приклади `components/`
437
454
 
438
- ```yaml title="k8s/base/kustomization.yaml (фрагмент)"
455
+ ```yaml title="k8s/components/kustomization.yaml"
456
+ apiVersion: kustomize.config.k8s.io/v1alpha1
457
+ kind: Component
439
458
  resources:
440
- - ../component
441
- - deploy.yaml
459
+ - hpa.yaml
442
460
  - pdb.yaml
443
- patches:
444
- - target:
445
- kind: HorizontalPodAutoscaler
446
- name: backend-api
447
- patch: |-
448
- $patch: delete
449
- apiVersion: autoscaling/v2
450
- kind: HorizontalPodAutoscaler
451
- metadata:
452
- name: backend-api
453
461
  ```
454
462
 
455
- ### Приклади
456
-
457
- ```yaml title="k8s/prod/hpa.yaml (або компонент з HPA — не в …/base/ локальному hpa.yaml)"
463
+ ```yaml title="k8s/components/hpa.yaml"
458
464
  # yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/horizontalpodautoscaler-autoscaling-v2.json
459
465
  apiVersion: autoscaling/v2
460
466
  kind: HorizontalPodAutoscaler
@@ -465,8 +471,8 @@ spec:
465
471
  apiVersion: apps/v1
466
472
  kind: Deployment
467
473
  name: backend-api
468
- minReplicas: 2
469
- maxReplicas: 10
474
+ minReplicas: 1 # прод overlay підіймає до >= 2
475
+ maxReplicas: 1 # прод overlay підіймає до >= 2
470
476
  metrics:
471
477
  - type: Resource
472
478
  resource:
@@ -494,14 +500,14 @@ spec:
494
500
  selectPolicy: Min
495
501
  ```
496
502
 
497
- ```yaml title="k8s/base/pdb.yaml"
503
+ ```yaml title="k8s/components/pdb.yaml"
498
504
  # yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/poddisruptionbudget-policy-v1.json
499
505
  apiVersion: policy/v1
500
506
  kind: PodDisruptionBudget
501
507
  metadata:
502
508
  name: backend-api
503
509
  spec:
504
- minAvailable: 0 # прод: >= 1
510
+ minAvailable: 0 # прод overlay підіймає до >= 1
505
511
  selector:
506
512
  matchLabels:
507
513
  app: backend-api
@@ -527,7 +533,7 @@ spec:
527
533
  app: backend-api
528
534
  ```
529
535
 
530
- В overlays для прод-середовища `minReplicas`, `maxReplicas`, `minAvailable` підіймаєш до прод-мінімумів через **Kustomize patches** або окремі `hpa.yaml` / `pdb.yaml` у каталозі overlay (тоді перевірка спрацьовує за їхнім середовищем).
536
+ Точні умови та повідомлення `fail` JSDoc на початку `npm/scripts/check-k8s.mjs` і функції `validateComponentsForBaseDeployment` / `prodOverlayHpaPdbOverrideNeeds`.
531
537
 
532
538
  ## HorizontalPodAutoscaler: `autoscaling/v2`
533
539
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.217",
3
+ "version": "1.8.220",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -7,12 +7,12 @@
7
7
  * - `abie-kustomize - [abie]` — додається разом з правилом `abie`
8
8
  * - `taze - [bun]` — додається разом з правилом `bun`
9
9
  *
10
- * Скіли без секції `[rules]` у `auto-skills.md` (`fix`, `lint`, `publish-telegram`)
10
+ * Скіли без секції `[rules]` у `auto-skills.md` (`fix`, `lint`, `llm-patch`, `publish-telegram`)
11
11
  * додаються завжди, якщо доступні в пакеті й не у `disable-skills`.
12
12
  */
13
13
 
14
14
  /** Порядок автододавання skills відповідно до `auto-skills.md`. */
15
- export const AUTO_SKILL_ORDER = Object.freeze(['abie-kustomize', 'fix', 'lint', 'publish-telegram', 'taze'])
15
+ export const AUTO_SKILL_ORDER = Object.freeze(['abie-kustomize', 'fix', 'lint', 'llm-patch', 'publish-telegram', 'taze'])
16
16
 
17
17
  /**
18
18
  * Залежність скілів від правил (`auto-skills.md` синтаксис `skill - [rules]`).
@@ -26,7 +26,7 @@ export const AUTO_SKILL_RULE_DEPENDENCIES = Object.freeze(
26
26
  )
27
27
 
28
28
  /** Скіли без залежностей — додаються завжди (рядок «завжди» в `auto-skills.md`). */
29
- const ALWAYS_ON_SKILLS = Object.freeze(['fix', 'lint', 'publish-telegram'])
29
+ const ALWAYS_ON_SKILLS = Object.freeze(['fix', 'lint', 'llm-patch', 'publish-telegram'])
30
30
 
31
31
  const DEFAULT_DISABLED_LIST = Object.freeze([])
32
32
 
@@ -84,19 +84,25 @@
84
84
  * або рядок `"true"`, без регістрової залежності).
85
85
  *
86
86
  * **HPA / PDB / topologySpreadConstraints:** для кожного **`Deployment`** у шарі **`…/k8s/…/base/`** (будь-який
87
- * `.yaml` у цьому каталозі) поруч обов’язкові **`pdb.yaml`** і канонічні **topologySpreadConstraints**. У каталозі
88
- * **`…/base/`** не тримай **`hpa.yaml`** для dev HPA прибирається з дерева Kustomize через strategic-merge patch
89
- * з **`$patch: delete`** і **`kind: HorizontalPodAutoscaler`** у **`base/kustomization.yaml`** (якщо HPA тягнеться з
90
- * `resources` / `components` / `bases`). Якщо в дереві base є і **Deployment**, і **HorizontalPodAutoscaler**, такий
91
- * patch обов’язковий. **HPA** поруч із Deployment у **не-base** оверлеях — як раніше (див. k8s.mdc).
87
+ * `.yaml` у цьому каталозі) обов'язкові канонічні **topologySpreadConstraints**, а HPA і PDB живуть у sibling
88
+ * каталозі **`…/k8s/…/components/`** (Kustomize Component, фіксована назва каталогу `components`). У `base/`
89
+ * заборонено тримати локальні `hpa.yaml` і `pdb.yaml` (file-existence error) і також у дереві base-kustomize
90
+ * не повинно бути HPA/PDB через `resources` / `components` / `bases`. Структура `components/`:
91
+ * `kustomization.yaml` з `apiVersion: kustomize.config.k8s.io/v1alpha1`, `kind: Component`, `resources` що містять
92
+ * `hpa.yaml` і `pdb.yaml` (як єдині або принаймні обов'язкові), `hpa.yaml` (валідний `autoscaling/v2`
93
+ * HorizontalPodAutoscaler з `scaleTargetRef.name` = ім'я Deployment, dev-like `min=max=1`), `pdb.yaml` (валідний
94
+ * `policy/v1` PodDisruptionBudget з `selector.matchLabels.app` = мітка `app` Deployment, dev-like `minAvailable=0`).
95
+ * Overlays (`ua/`, `ru/`, прод-overlays) підключають `components: [- ../components]` і додають JSON6902-патчі для
96
+ * прод-значень: `/spec/minReplicas`, `/spec/maxReplicas` (HPA), `/spec/minAvailable` (PDB). HPA поруч із Deployment
97
+ * у не-base оверлеях — як раніше (див. k8s.mdc).
92
98
  * Env-залежні межі за сегментом після `/k8s/`: **dev-like** (`base`, `dev`, `*-qa`) — для HPA, що лишився після
93
99
  * збірки, `minReplicas === 1`, `maxReplicas === 1`, PDB `minAvailable === 0`; **прод** — `minReplicas >= 2`,
94
100
  * `maxReplicas >= 2`, `minAvailable >= 1`.
95
101
  *
96
102
  * **Прод-оверрайди в kustomization.yaml:** для прод overlays (не dev-like) у `patches[]` потрібні перевизначення
97
- * **`/spec/minReplicas`** і **`/spec/maxReplicas`** для **HorizontalPodAutoscaler** лише якщо успадковане base-дерево
98
- * містить HPA **і** base **не** видаляє його через **`$patch: delete`**. **`/spec/minAvailable`** для **PDB**
99
- * якщо в base-дереві є Deployment і PDB. Формат patch — JSON6902 або Strategic Merge; наявність шляхів —
103
+ * **`/spec/minReplicas`** і **`/spec/maxReplicas`** для **HorizontalPodAutoscaler** і **`/spec/minAvailable`** для
104
+ * **PDB** якщо overlay-tree (через `resources` / `components`) містить HPA / PDB (тобто overlay підключив
105
+ * `…/k8s/…/components/`). Формат patch — JSON6902 або Strategic Merge; наявність шляхів —
100
106
  * `kustomizationPatchPathsByTargetKind`.
101
107
  *
102
108
  * **Існування шляхів у `kustomization.yaml`:** кожне локальне посилання (без `://`) з `resources` / `bases` /
@@ -111,11 +117,11 @@
111
117
  * прибирається; (б) чистить існуючий блок **`images:`** — зрізає `:tag` з `name` (digest `@…` не чіпає) і видаляє
112
118
  * `newTag`, який збігається з відрізаним тегом.
113
119
  *
114
- * **HPA / PDB тільки з Deployment у шарі base:** у дереві Kustomize з `…/k8s/…/base/kustomization.yaml` не
115
- * дозволяти `HorizontalPodAutoscaler` / `PodDisruptionBudget` у `resources` / `bases` / `components` / `crds`
116
- * (рекурсивно), якщо в цьому ж дереві немає документа **`Deployment`** у жодному YAML під **`…/k8s/…/base/`**. У
117
- * `kustomization.yaml` overlay, який підключає каталог `…/k8s/…/base`, не додавай окремі YAML-файли з HPA / PDB,
118
- * поки в наслідуваному `base` у дереві не з’явиться такий Deployment (k8s.mdc).
120
+ * **HPA / PDB заборонені у base-дереві Kustomize:** у дереві з `…/k8s/…/base/kustomization.yaml` не дозволяти
121
+ * `HorizontalPodAutoscaler` / `PodDisruptionBudget` у `resources` / `bases` / `components` / `crds` (рекурсивно)
122
+ * взагалі. Канон HPA/PDB у sibling `…/k8s/…/components/` (Kustomize Component) і підключаються лише з overlay.
123
+ * У `kustomization.yaml` overlay, який підключає каталог `…/k8s/…/base`, не додавай окремі YAML-файли з HPA / PDB,
124
+ * поки в наслідуваному `base` у дереві не з'явиться такий Deployment (k8s.mdc).
119
125
  */
120
126
  import { existsSync } from 'node:fs'
121
127
  import { readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
@@ -311,10 +317,6 @@ const KIND_FIELD_RE = /^\s*kind:\s*(\S+)\s*$/
311
317
  const TYPE_FIELD_RE = /^\s*type:\s*(\S+)\s*$/
312
318
  const YAML_DOC_SEPARATOR_LINE_RE = /^---\s*$/
313
319
  const HEALTHCHECK_DELETE_RE = /\$patch:\s*delete/u
314
- /** Strategic-merge patch видалення **HorizontalPodAutoscaler** у kustomization (k8s.mdc). */
315
- const HPA_STRATEGIC_DELETE_RE = /\$patch:\s*delete/u
316
- /** Рядок **kind:** для HPA у strategic-merge patch. */
317
- const HPA_KIND_LINE_RE = /^kind:\s*HorizontalPodAutoscaler\b/m
318
320
  const HEALTHCHECK_KIND_RE = /kind:\s*HealthCheckPolicy/u
319
321
  const METADATA_LINE_RE = /metadata:/u
320
322
  const NAME_NON_EMPTY_RE = /name:\s*\S+/u
@@ -2149,60 +2151,6 @@ export function ruKustomizationHasHealthCheckDeletePatch(raw) {
2149
2151
  return true
2150
2152
  }
2151
2153
 
2152
- /**
2153
- * Чи вміст strategic-merge patch оголошує видалення **HorizontalPodAutoscaler** (`$patch: delete`, `kind:`).
2154
- * @param {string} text вміст поля **patch** або файлу merge
2155
- * @returns {boolean} true, якщо присутні **`$patch: delete`** і **`kind: HorizontalPodAutoscaler`**
2156
- */
2157
- export function patchTextDeclaresHpaStrategicDelete(text) {
2158
- const t = typeof text === 'string' ? text : ''
2159
- if (!HPA_STRATEGIC_DELETE_RE.test(t)) return false
2160
- return HPA_KIND_LINE_RE.test(t)
2161
- }
2162
-
2163
- /**
2164
- * Чи **kustomization.yaml** (inline **patches** / **patchesStrategicMerge**) містить strategic-merge видалення HPA.
2165
- * @param {string} kustAbs абсолютний шлях до **kustomization.yaml**
2166
- * @param {string} rootNorm нормалізований корінь репо
2167
- * @returns {Promise<boolean>} true, якщо знайдено patch видалення HPA
2168
- */
2169
- async function kustomizationDeclaresHpaStrategicDelete(kustAbs, rootNorm) {
2170
- const kust = await readFirstYamlObject(kustAbs)
2171
- if (kust === null) return false
2172
- const kustDir = dirname(kustAbs)
2173
- const patches = kust.patches
2174
- if (Array.isArray(patches)) {
2175
- for (const p of patches) {
2176
- if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
2177
- const rec = /** @type {Record<string, unknown>} */ (p)
2178
- const inline = rec.patch
2179
- if (typeof inline === 'string' && patchTextDeclaresHpaStrategicDelete(inline)) return true
2180
- const pathRef = rec.path
2181
- if (typeof pathRef === 'string' && pathRef.trim() !== '') {
2182
- const abs = resolve(kustDir, pathRef.trim())
2183
- if (resolvedFilePathIsUnderRoot(rootNorm, abs)) {
2184
- const body = await tryReadFileUtf8(abs)
2185
- if (body !== undefined && patchTextDeclaresHpaStrategicDelete(body)) return true
2186
- }
2187
- }
2188
- }
2189
- }
2190
- }
2191
- const sm = kust.patchesStrategicMerge
2192
- if (Array.isArray(sm)) {
2193
- for (const ref of sm) {
2194
- if (typeof ref === 'string' && ref.trim() !== '') {
2195
- const abs = resolve(kustDir, ref.trim())
2196
- if (resolvedFilePathIsUnderRoot(rootNorm, abs)) {
2197
- const body = await tryReadFileUtf8(abs)
2198
- if (body !== undefined && patchTextDeclaresHpaStrategicDelete(body)) return true
2199
- }
2200
- }
2201
- }
2202
- }
2203
- return false
2204
- }
2205
-
2206
2154
  /**
2207
2155
  * Чи абсолютний шлях лежить усередині кореня репозиторію (без виходу через `..`).
2208
2156
  * @param {string} rootAbs абсолютний корінь
@@ -4457,6 +4405,18 @@ export const HPA_FILENAME = 'hpa.yaml'
4457
4405
  */
4458
4406
  export const PDB_FILENAME = 'pdb.yaml'
4459
4407
 
4408
+ /**
4409
+ * Фіксована назва каталогу Kustomize Component, sibling до `base/`, де живуть HPA і PDB
4410
+ * (за каноном — `hpa.yaml` і `pdb.yaml` з `kind: Component` у `kustomization.yaml`). Інші назви
4411
+ * (`scale/`, `hpa-component/`) у правилі **k8s** не дозволені (k8s.mdc).
4412
+ */
4413
+ export const COMPONENTS_DIR = 'components'
4414
+
4415
+ /**
4416
+ * `apiVersion` маніфесту Kustomize **Component** (sibling до `base/`).
4417
+ */
4418
+ const KUSTOMIZE_COMPONENT_API_VERSION = 'kustomize.config.k8s.io/v1alpha1'
4419
+
4460
4420
  /**
4461
4421
  * Канонічний topologyKey для **topologySpreadConstraints** у Deployment (див. k8s.mdc).
4462
4422
  */
@@ -5086,56 +5046,24 @@ async function yamlFileContainsHpaOrPdbDocument(fileAbs) {
5086
5046
  }
5087
5047
 
5088
5048
  /**
5089
- * Для `…/k8s/…/base/kustomization.yaml`: HPA / PDB дозволені в дереві kustomize лише разом із Deployment.
5049
+ * Для `…/k8s/…/base/kustomization.yaml`: HPA / PDB заборонені у base-дереві Kustomize взагалі.
5050
+ * Канон — HPA/PDB живуть у sibling каталозі **`…/k8s/…/components/`** (Kustomize Component) і підключаються
5051
+ * лише з overlay (`components: [- ../components]`). Dev-середовище — `base` без HPA/PDB.
5090
5052
  * @param {string} kustAbs kustomization.yaml
5091
5053
  * @param {string} rel для повідомлень
5092
- * @param {(msg: string) => void} fail callback
5093
- * @param {(msg: string) => void} passFn success
5054
+ * @param {(msg: string) => void} fail callback при помилці
5055
+ * @param {(msg: string) => void} passFn callback при успіху
5094
5056
  * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags мемоізований аналіз дерева
5095
5057
  * @returns {Promise<void>}
5096
5058
  */
5097
- async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags) {
5098
- const { hasDeployment, hasHpa, hasPdb } = await getTreeFlags(kustAbs)
5059
+ async function verifyK8sBaseKustomizeHasNoHpaPdb(kustAbs, rel, fail, passFn, getTreeFlags) {
5060
+ const { hasHpa, hasPdb } = await getTreeFlags(kustAbs)
5099
5061
  if (hasHpa || hasPdb) {
5100
- if (hasDeployment) {
5101
- passFn(`${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`)
5102
- } else {
5103
- fail(
5104
- `${rel}: у base є HorizontalPodAutoscaler і/або PodDisruptionBudget у resources/bases/…, але дерева kustomize не містить Deployment — HPA і PDB дозволені тільки разом із Deployment (k8s.mdc)`
5105
- )
5106
- }
5107
- }
5108
- }
5109
-
5110
- /**
5111
- * У **`…/k8s/…/base/kustomization.yaml`**: якщо з **resources** тягнеться HPA разом із Deployment,
5112
- * обов’язковий strategic-merge patch з **`$patch: delete`** для **HorizontalPodAutoscaler** (k8s.mdc).
5113
- * @param {string} kustAbs абсолютний шлях до **kustomization.yaml**
5114
- * @param {string} rel відносний шлях для повідомлень
5115
- * @param {string} rootNorm корінь репо
5116
- * @param {(msg: string) => void} fail callback
5117
- * @param {(msg: string) => void} passFn success
5118
- * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags
5119
- * @returns {Promise<void>}
5120
- */
5121
- async function verifyK8sBaseKustomizeHpaDeletedWhenInherited(
5122
- kustAbs,
5123
- rel,
5124
- rootNorm,
5125
- fail,
5126
- passFn,
5127
- getTreeFlags
5128
- ) {
5129
- const { hasDeployment, hasHpa } = await getTreeFlags(kustAbs)
5130
- if (!hasDeployment || !hasHpa) {
5131
- return
5132
- }
5133
- if (await kustomizationDeclaresHpaStrategicDelete(kustAbs, rootNorm)) {
5134
- passFn(`${rel}: base kustomization містить $patch: delete для HorizontalPodAutoscaler (k8s.mdc)`)
5135
- } else {
5136
5062
  fail(
5137
- `${rel}: у дереві base є HorizontalPodAutoscaler — додай у цей kustomization.yaml strategic-merge patch з $patch: delete та kind: HorizontalPodAutoscaler (k8s.mdc)`
5063
+ `${rel}: у base-дереві kustomize є HorizontalPodAutoscaler і/або PodDisruptionBudget HPA/PDB заборонені у base, переведіть у sibling каталог components/ і підключайте з overlay (k8s.mdc)`
5138
5064
  )
5065
+ } else {
5066
+ passFn(`${rel}: base-дерево kustomize без HPA/PDB (k8s.mdc)`)
5139
5067
  }
5140
5068
  }
5141
5069
 
@@ -5245,8 +5173,7 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
5245
5173
  const kust = await readFirstYamlObject(kustAbs)
5246
5174
  if (kust !== null) {
5247
5175
  if (isK8sBaseKustomizationRelPath(rel)) {
5248
- await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
5249
- await verifyK8sBaseKustomizeHpaDeletedWhenInherited(kustAbs, rel, rootNorm, fail, passFn, getTreeFlags)
5176
+ await verifyK8sBaseKustomizeHasNoHpaPdb(kustAbs, rel, fail, passFn, getTreeFlags)
5250
5177
  } else {
5251
5178
  await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(rootNorm, kustAbs, rel, kust, fail, passFn, getTreeFlags)
5252
5179
  }
@@ -5299,12 +5226,17 @@ function checkProdOverridesInKustomization(kust, rel, fail, passFn, needs) {
5299
5226
  }
5300
5227
 
5301
5228
  /**
5302
- * Які прод-оверрайди потрібні для **kustomization.yaml** (не dev-like), що посилається на **…/k8s/…/base**.
5229
+ * Які прод-оверрайди потрібні для **kustomization.yaml** (не dev-like).
5303
5230
  *
5304
- * **HPA:** patches на **`minReplicas`/`maxReplicas`** лише якщо в base-дереві є HPA **і** base **не** містить
5305
- * strategic-merge **`$patch: delete`** для **HorizontalPodAutoscaler** (k8s.mdc).
5231
+ * Тригер overlay-tree (через `resources` / `components`) містить HPA / PDB. На практиці це означає,
5232
+ * що overlay підключив sibling каталог `…/k8s/…/components/` (Kustomize Component) з канонічними
5233
+ * `hpa.yaml` / `pdb.yaml`. Тоді у `patches[]` обов'язкові JSON6902-патчі прод-значень: для **HPA** —
5234
+ * `/spec/minReplicas` і `/spec/maxReplicas` (мінімум 2), для **PDB** — `/spec/minAvailable` (мінімум 1).
5235
+ * Для dev-like (`base` / `dev` / `*-qa`) overrides не потрібні (k8s.mdc).
5306
5236
  *
5307
- * **PDB:** **`minAvailable`** якщо в base-дереві є Deployment і PDB.
5237
+ * **ВинятокKustomize Component (`kind: Component`):** сам `…/k8s/…/components/kustomization.yaml`
5238
+ * не overlay, а **джерело** ресурсів для overlays. Прод-перезаписи живуть у `<env>/kustomization.yaml`,
5239
+ * що підключає Component через `components:`; у самому Component patches не потрібні (env-неутральний).
5308
5240
  * @param {string} rootNorm нормалізований корінь репозиторію
5309
5241
  * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5310
5242
  * @returns {Promise<ProdOverlayHpaPdbOverrideNeeds>} прапорці потрібних перевизначень
@@ -5316,28 +5248,20 @@ export async function prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs) {
5316
5248
  return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
5317
5249
  }
5318
5250
 
5319
- const kust = await readFirstYamlObject(kustAbs)
5320
- if (kust === null) return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
5321
-
5322
- const kustDir = dirname(kustAbs)
5323
- const pathRefs = resourcePathRefsFromKustomizationObject(kust)
5324
- const baseDirs = await k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootNorm)
5325
- if (baseDirs.length === 0) return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
5251
+ // Kustomize Component (kind: Component) — джерело канонічних HPA/PDB для overlays,
5252
+ // а не overlay сам по собі. Прод-перезаписи (/spec/minReplicas, /spec/maxReplicas,
5253
+ // /spec/minAvailable) живуть у <env>/kustomization.yaml, що підключає Component
5254
+ // через `components:`; у самому Component patches не потрібні (env-неутральний).
5255
+ const kustDoc = await readFirstYamlObject(kustAbs)
5256
+ if (kustDoc !== null && kustDoc.kind === 'Component') {
5257
+ return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
5258
+ }
5326
5259
 
5327
- let needsHpaReplicaPatches = false
5328
- let needsPdbMinAvailablePatch = false
5329
- for (const bd of baseDirs) {
5330
- const bk = join(bd, 'kustomization.yaml')
5331
- const f = await kustomizeResourceTreeHpaPdbDeploymentFlags(bk, rootNorm)
5332
- const baseDeletesHpa = await kustomizationDeclaresHpaStrategicDelete(bk, rootNorm)
5333
- if (f.hasDeployment && f.hasPdb) {
5334
- needsPdbMinAvailablePatch = true
5335
- }
5336
- if (f.hasDeployment && f.hasHpa && !baseDeletesHpa) {
5337
- needsHpaReplicaPatches = true
5338
- }
5260
+ const flags = await kustomizeResourceTreeHpaPdbDeploymentFlags(kustAbs, rootNorm)
5261
+ return {
5262
+ needsHpaReplicaPatches: flags.hasHpa,
5263
+ needsPdbMinAvailablePatch: flags.hasPdb
5339
5264
  }
5340
- return { needsHpaReplicaPatches, needsPdbMinAvailablePatch }
5341
5265
  }
5342
5266
 
5343
5267
  /**
@@ -5459,12 +5383,143 @@ function validatePdbForDeployment(pdbDocs, deployName, appLabel, isDevLike, pdbR
5459
5383
  }
5460
5384
  }
5461
5385
 
5386
+ /**
5387
+ * Перевіряє sibling каталог `…/k8s/…/components/` для одного **Deployment** з шару `…/k8s/…/base/`.
5388
+ *
5389
+ * Канон (k8s.mdc):
5390
+ * - Існує каталог `<baseDir>/../components/`.
5391
+ * - У ньому `kustomization.yaml` з `apiVersion: kustomize.config.k8s.io/v1alpha1`, `kind: Component` і
5392
+ * `resources` що містять `hpa.yaml` і `pdb.yaml`.
5393
+ * - `components/hpa.yaml` — валідний `autoscaling/v2` `HorizontalPodAutoscaler` зі `scaleTargetRef.name`,
5394
+ * що дорівнює `metadata.name` цього Deployment, з dev-like значеннями `min=max=1`.
5395
+ * - `components/pdb.yaml` — валідний `policy/v1` `PodDisruptionBudget` зі `selector.matchLabels.app`,
5396
+ * що дорівнює мітці `app` Deployment, з dev-like `minAvailable=0`.
5397
+ * @param {string} baseDir абсолютний шлях до `…/k8s/…/base/`
5398
+ * @param {string} deployName ім'я Deployment з base
5399
+ * @param {string} appLabel мітка `app` з `spec.selector.matchLabels.app`
5400
+ * @param {string} root корінь репозиторію
5401
+ * @param {(msg: string) => void} fail callback при помилці
5402
+ * @param {(msg: string) => void} passFn callback при успіху
5403
+ * @returns {Promise<void>}
5404
+ */
5405
+ export async function validateComponentsForBaseDeployment(baseDir, deployName, appLabel, root, fail, passFn) {
5406
+ const componentsDir = resolve(baseDir, '..', COMPONENTS_DIR)
5407
+ const componentsRel = (relative(root, componentsDir) || componentsDir).replaceAll('\\', '/')
5408
+ if (!existsSync(componentsDir)) {
5409
+ fail(
5410
+ `${componentsRel}: для Deployment '${deployName}' з sibling base/ обов'язковий каталог components/ з hpa.yaml і pdb.yaml (Kustomize Component) (k8s.mdc)`
5411
+ )
5412
+ return
5413
+ }
5414
+ let stat0
5415
+ try {
5416
+ stat0 = await stat(componentsDir)
5417
+ } catch {
5418
+ stat0 = null
5419
+ }
5420
+ if (stat0 === null || !stat0.isDirectory()) {
5421
+ fail(`${componentsRel}: очікується каталог Kustomize Component (k8s.mdc)`)
5422
+ return
5423
+ }
5424
+ await validateComponentsKustomizationManifest(componentsDir, componentsRel, fail, passFn)
5425
+ await validateComponentsHpaFile(componentsDir, componentsRel, deployName, fail, passFn)
5426
+ await validateComponentsPdbFile(componentsDir, componentsRel, deployName, appLabel, fail, passFn)
5427
+ }
5428
+
5429
+ /**
5430
+ * Перевіряє `components/kustomization.yaml`: `apiVersion: kustomize.config.k8s.io/v1alpha1`, `kind: Component`,
5431
+ * `resources` містить `hpa.yaml` і `pdb.yaml` (як мінімум).
5432
+ * @param {string} componentsDir абсолютний шлях до каталогу `components/`
5433
+ * @param {string} componentsRel відносний шлях для повідомлень
5434
+ * @param {(msg: string) => void} fail callback при помилці
5435
+ * @param {(msg: string) => void} passFn callback при успіху
5436
+ * @returns {Promise<void>}
5437
+ */
5438
+ async function validateComponentsKustomizationManifest(componentsDir, componentsRel, fail, passFn) {
5439
+ const kustAbs = join(componentsDir, 'kustomization.yaml')
5440
+ if (!existsSync(kustAbs)) {
5441
+ fail(`${componentsRel}/kustomization.yaml: відсутній — додай Kustomize Component-маніфест (k8s.mdc)`)
5442
+ return
5443
+ }
5444
+ const obj = await readFirstYamlObject(kustAbs)
5445
+ if (obj === null) {
5446
+ fail(`${componentsRel}/kustomization.yaml: не вдалося розпарсити перший YAML-документ (k8s.mdc)`)
5447
+ return
5448
+ }
5449
+ if (obj.apiVersion !== KUSTOMIZE_COMPONENT_API_VERSION) {
5450
+ fail(
5451
+ `${componentsRel}/kustomization.yaml: apiVersion має бути '${KUSTOMIZE_COMPONENT_API_VERSION}' (зараз: ${JSON.stringify(obj.apiVersion)}) (k8s.mdc)`
5452
+ )
5453
+ }
5454
+ if (obj.kind !== 'Component') {
5455
+ fail(
5456
+ `${componentsRel}/kustomization.yaml: kind має бути 'Component' (зараз: ${JSON.stringify(obj.kind)}) (k8s.mdc)`
5457
+ )
5458
+ }
5459
+ const resources = Array.isArray(obj.resources) ? obj.resources.filter(x => typeof x === 'string') : []
5460
+ const hasHpa = resources.includes(HPA_FILENAME)
5461
+ const hasPdb = resources.includes(PDB_FILENAME)
5462
+ if (!hasHpa) {
5463
+ fail(`${componentsRel}/kustomization.yaml: у resources має бути '${HPA_FILENAME}' (k8s.mdc)`)
5464
+ }
5465
+ if (!hasPdb) {
5466
+ fail(`${componentsRel}/kustomization.yaml: у resources має бути '${PDB_FILENAME}' (k8s.mdc)`)
5467
+ }
5468
+ if (obj.apiVersion === KUSTOMIZE_COMPONENT_API_VERSION && obj.kind === 'Component' && hasHpa && hasPdb) {
5469
+ passFn(`${componentsRel}/kustomization.yaml: канонічний Kustomize Component з hpa.yaml і pdb.yaml (k8s.mdc)`)
5470
+ }
5471
+ }
5472
+
5473
+ /**
5474
+ * Перевіряє `components/hpa.yaml`: HPA для Deployment, dev-like `min=max=1`.
5475
+ * @param {string} componentsDir абсолютний шлях до каталогу `components/`
5476
+ * @param {string} componentsRel відносний шлях для повідомлень
5477
+ * @param {string} deployName ім'я Deployment з base
5478
+ * @param {(msg: string) => void} fail callback при помилці
5479
+ * @param {(msg: string) => void} passFn callback при успіху
5480
+ * @returns {Promise<void>}
5481
+ */
5482
+ async function validateComponentsHpaFile(componentsDir, componentsRel, deployName, fail, passFn) {
5483
+ const hpaAbs = join(componentsDir, HPA_FILENAME)
5484
+ const hpaRel = `${componentsRel}/${HPA_FILENAME}`
5485
+ if (!existsSync(hpaAbs)) {
5486
+ fail(`${hpaRel}: відсутній — додай HorizontalPodAutoscaler для Deployment '${deployName}' (k8s.mdc)`)
5487
+ return
5488
+ }
5489
+ const hpaDocs = await readAllDocsByKindFromFile(hpaAbs, 'HorizontalPodAutoscaler')
5490
+ validateHpaForDeployment(hpaDocs, deployName, true, hpaRel, fail, passFn)
5491
+ }
5492
+
5493
+ /**
5494
+ * Перевіряє `components/pdb.yaml`: PDB для Deployment, dev-like `minAvailable=0`.
5495
+ * @param {string} componentsDir абсолютний шлях до каталогу `components/`
5496
+ * @param {string} componentsRel відносний шлях для повідомлень
5497
+ * @param {string} deployName ім'я Deployment з base
5498
+ * @param {string} appLabel мітка `app` Deployment
5499
+ * @param {(msg: string) => void} fail callback при помилці
5500
+ * @param {(msg: string) => void} passFn callback при успіху
5501
+ * @returns {Promise<void>}
5502
+ */
5503
+ async function validateComponentsPdbFile(componentsDir, componentsRel, deployName, appLabel, fail, passFn) {
5504
+ const pdbAbs = join(componentsDir, PDB_FILENAME)
5505
+ const pdbRel = `${componentsRel}/${PDB_FILENAME}`
5506
+ if (!existsSync(pdbAbs)) {
5507
+ fail(`${pdbRel}: відсутній — додай PodDisruptionBudget для Deployment '${deployName}' (k8s.mdc)`)
5508
+ return
5509
+ }
5510
+ const pdbDocs = await readAllDocsByKindFromFile(pdbAbs, 'PodDisruptionBudget')
5511
+ validatePdbForDeployment(pdbDocs, deployName, appLabel, true, pdbRel, fail, passFn)
5512
+ }
5513
+
5462
5514
  /**
5463
5515
  * Перевіряє один Deployment: topologySpreadConstraints, HPA та PDB.
5516
+ *
5517
+ * Для **base-шару** HPA/PDB не вимагаються поруч із Deployment — натомість викликач має звіряти sibling
5518
+ * каталог `components/` через `validateComponentsForBaseDeployment`.
5464
5519
  * @param {Record<string, unknown>} deployment об'єкт Deployment
5465
5520
  * @param {string} deployRel відносний шлях каталогу для повідомлень
5466
5521
  * @param {boolean} isDevLike чи середовище dev-like
5467
- * @param {boolean} isK8sBaseLayer чи каталог під **`…/k8s/…/base/`** (HPA поруч не вимагаємо — прибирається в kustomization)
5522
+ * @param {boolean} isK8sBaseLayer чи каталог під **`…/k8s/…/base/`** (HPA/PDB поруч не вимагаємо — живуть у `components/`)
5468
5523
  * @param {Record<string, unknown>[]} hpaDocs HPA-документи каталогу
5469
5524
  * @param {Record<string, unknown>[]} pdbDocs PDB-документи каталогу
5470
5525
  * @param {(msg: string) => void} fail callback при помилці
@@ -5496,9 +5551,10 @@ function validateSingleDeploymentHpaPdbTopology(
5496
5551
  } else {
5497
5552
  fail(`${deployRel}: Deployment '${deployName}': ${tscViolation}`)
5498
5553
  }
5499
- if (!isK8sBaseLayer) {
5500
- validateHpaForDeployment(hpaDocs, deployName, isDevLike, `${deployRel}/${HPA_FILENAME}`, fail, passFn)
5554
+ if (isK8sBaseLayer) {
5555
+ return
5501
5556
  }
5557
+ validateHpaForDeployment(hpaDocs, deployName, isDevLike, `${deployRel}/${HPA_FILENAME}`, fail, passFn)
5502
5558
  validatePdbForDeployment(pdbDocs, deployName, appLabel, isDevLike, `${deployRel}/${PDB_FILENAME}`, fail, passFn)
5503
5559
  }
5504
5560
 
@@ -5515,18 +5571,23 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
5515
5571
  const segment = k8sEnvSegmentFromRelPath(relDir + '/')
5516
5572
  const isDevLike = isDevLikeK8sEnvSegment(segment)
5517
5573
  const isK8sBaseLayer = isK8sYamlUnderBaseDirectory(`${relDir}/probe.yaml`)
5574
+ const deployRel = relDir === '' ? '.' : relDir
5518
5575
  if (isK8sBaseLayer && deployments.length > 0) {
5519
5576
  const hpaAbs = join(dir, HPA_FILENAME)
5520
5577
  if (existsSync(hpaAbs)) {
5521
- const prefix = relDir === '' ? '.' : relDir
5522
5578
  fail(
5523
- `${prefix}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — прибери HorizontalPodAutoscaler через $patch: delete у base/kustomization.yaml (k8s.mdc)`
5579
+ `${deployRel}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — HPA живе у sibling components/ (k8s.mdc)`
5580
+ )
5581
+ }
5582
+ const pdbAbs = join(dir, PDB_FILENAME)
5583
+ if (existsSync(pdbAbs)) {
5584
+ fail(
5585
+ `${deployRel}/${PDB_FILENAME}: у шарі k8s/.../base не тримай локальний pdb.yaml — PDB живе у sibling components/ (k8s.mdc)`
5524
5586
  )
5525
5587
  }
5526
5588
  }
5527
- const hpaDocs = await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
5528
- const pdbDocs = await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
5529
- const deployRel = relDir === '' ? '.' : relDir
5589
+ const hpaDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
5590
+ const pdbDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
5530
5591
  for (const deployment of deployments) {
5531
5592
  validateSingleDeploymentHpaPdbTopology(
5532
5593
  deployment,
@@ -5538,6 +5599,13 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
5538
5599
  fail,
5539
5600
  passFn
5540
5601
  )
5602
+ if (isK8sBaseLayer) {
5603
+ const deployName = manifestMetadataName(deployment)
5604
+ const appLabel = deploymentAppLabel(deployment)
5605
+ if (deployName !== null && appLabel !== null) {
5606
+ await validateComponentsForBaseDeployment(dir, deployName, appLabel, root, fail, passFn)
5607
+ }
5608
+ }
5541
5609
  }
5542
5610
  }
5543
5611
 
@@ -5556,8 +5624,9 @@ async function extractDeploymentsFromFile(filePath) {
5556
5624
 
5557
5625
  /**
5558
5626
  * Для кожного **Deployment** у шарі **`…/k8s/…/base/`** (будь-який YAML у відповідному каталозі) перевіряє:
5559
- * заборона локального **`hpa.yaml`**; **`pdb.yaml`** (валідний `policy/v1`); канонічні **topologySpreadConstraints**.
5560
- * HPA для dev прибирається в **`base/kustomization.yaml`** через **`$patch: delete`** (див. `verifyK8sBaseKustomizeHpaDeletedWhenInherited`).
5627
+ * заборона локальних **`hpa.yaml`** і **`pdb.yaml`** (file-existence); канонічні **topologySpreadConstraints**;
5628
+ * наявність і канон sibling каталогу **`components/`** (Kustomize Component) з `hpa.yaml` і `pdb.yaml` через
5629
+ * `validateComponentsForBaseDeployment`. У не-base шарах — звична схема (`hpa.yaml` / `pdb.yaml` поруч).
5561
5630
  * Env-залежні межі — за сегментом після `/k8s/`: dev-like vs прод.
5562
5631
  * @param {string} root корінь репозиторію
5563
5632
  * @param {string[]} yamlFilesAbs yaml під k8s
@@ -6294,7 +6363,7 @@ function rewriteInlinePatchWithoutOps(patchText, opIndices) {
6294
6363
  const seq = inner.contents
6295
6364
  if (!isSeq(seq)) return null
6296
6365
 
6297
- const toRemove = new Set(opIndices).toSorted((a, b) => b - a)
6366
+ const toRemove = [...new Set(opIndices)].toSorted((a, b) => b - a)
6298
6367
  for (const i of toRemove) {
6299
6368
  if (i < 0 || i >= seq.items.length) return null
6300
6369
  seq.delete(i)
@@ -0,0 +1,198 @@
1
+ ---
2
+ name: n-llm-patch
3
+ description: >-
4
+ Підготовка самодостатнього текстового промпта для іншого Claude/Cursor-агента —
5
+ read-only аналіз CWD без жодних змін у поточному репо
6
+ ---
7
+
8
+ # Підготовка LLM-патчу (текстова комунікація між агентами)
9
+
10
+ Скіл готує **самодостатній текстовий промпт** ("патч") для іншої LLM-сесії
11
+ (Claude / Cursor agent у цільовому проєкті). Користувач копіює готовий блок
12
+ з відповіді чату й вставляє його в розмову з іншим агентом — той виконує
13
+ запитані зміни вже у своєму середовищі.
14
+
15
+ ## Принципи
16
+
17
+ - **Read-only:** скіл нічого не пише в поточне репо. Лише читає CWD.
18
+ - **Тимчасові артефакти — лише в `/tmp`** (наприклад, `tree`-вивід чи
19
+ проміжні чернетки промпту); ніколи не у CWD.
20
+ - **Цільова LLM:** Claude / Cursor agent — припускаємо, що читач знається
21
+ на XML-тегах, file refs (`path/to/file.ts:42`), markdown.
22
+ - **Один блок виводу:** результат — це **один** markdown-блок у відповіді
23
+ чату, готовий до копіювання.
24
+
25
+ ## Виклик
26
+
27
+ ```
28
+ /n-llm-patch <вільний опис завдання>
29
+ ```
30
+
31
+ Аргумент має містити: **що треба зробити**, опційно — **назву пакета /
32
+ підмодуля** для контексту. Якщо аргумент порожній — попроси користувача
33
+ сформулювати завдання, не вгадуй.
34
+
35
+ Приклад:
36
+
37
+ ```
38
+ /n-llm-patch підготуй для проекту @nitra/eslint-config щоб він врахував останню версію node
39
+ ```
40
+
41
+ ## Workflow
42
+
43
+ 1. **Розпарсити аргумент** — виокремити суть завдання та (якщо є) назву
44
+ пакета / шлях. Аргумент = намір користувача, передається у промпт
45
+ як розділ "Завдання".
46
+
47
+ 2. **Зібрати read-only контекст з CWD:**
48
+ - `package.json` — поля `name`, `version`, `engines`, `peerDependencies`,
49
+ `dependencies`, `devDependencies`, `scripts`, `type`, `exports`.
50
+ - Структура репо: `tree -L 2 -I 'node_modules|.git|dist|build|.next|.nuxt'`
51
+ (або `ls -la` коли `tree` недоступний).
52
+ - `README.md` — перші 40-60 рядків (head).
53
+ - `CLAUDE.md` / `AGENTS.md` / `.cursor/rules/*.mdc` — якщо є, відмітити їх
54
+ існування й перелік.
55
+ - **Релевантні до завдання config-файли** — підбирати за ключовими
56
+ словами з аргументу (наприклад, "node" → `engines`, `.nvmrc`,
57
+ `.node-version`; "eslint" → `eslint.config.*`, `.eslintrc*`;
58
+ "vite" → `vite.config.*`; "ts" → `tsconfig.json`).
59
+
60
+ 3. **Визначити "точку патчу"** — короткий список файлів, які найімовірніше
61
+ доведеться правити цільовому агенту, з обґрунтуванням.
62
+
63
+ 4. **Сформувати промпт за шаблоном нижче.** Дрібні релевантні файли
64
+ (≤ ~80 рядків) вбудовуй **повністю**; великі — цитуй фрагмент з
65
+ посиланням на шлях:рядки.
66
+
67
+ 5. **Вивести один markdown-блок у чат** — без додаткових коментарів
68
+ поза блоком, окрім однорядкового підпису "готово до копіювання".
69
+
70
+ ## Шаблон вихідного промпта
71
+
72
+ ````markdown
73
+ ```markdown
74
+ # Завдання
75
+
76
+ <нормалізований опис з аргументу — 1-3 речення без води>
77
+
78
+ # Контекст проєкту
79
+
80
+ - Назва / версія: `<name>@<version>`
81
+ - Тип: <library | app | eslint-config | monorepo | …>
82
+ - Стек: Node `<engines.node>`, <TS/JS>, <ключові залежності>
83
+ - Документи правил: <CLAUDE.md / .cursor/rules — або "немає">
84
+
85
+ ## Структура (skim)
86
+
87
+ ```
88
+ <вивід tree -L 2>
89
+ ```
90
+
91
+ # Релевантні файли
92
+
93
+ ## `package.json`
94
+
95
+ ```json
96
+ <повний вміст або ключові поля>
97
+ ```
98
+
99
+ ## `<інший релевантний файл>`
100
+
101
+ ```<lang>
102
+ <вміст або фрагмент з посиланням на шлях:рядки>
103
+ ```
104
+
105
+ # Що треба зробити
106
+
107
+ - крок 1 — у файлі `X` (`<коротке пояснення>`)
108
+ - крок 2 — у файлі `Y`
109
+ - крок 3 — оновити `CHANGELOG.md` / bump `version`, якщо це npm-пакет
110
+
111
+ # Обмеження
112
+
113
+ - Не ламати публічний API
114
+ - <constraints, виявлені з package.json / конфігів — наприклад "engines.node вже >=22, треба >=25">
115
+ - Дотриматись правил репо (CLAUDE.md / .cursor/rules — посилання вище)
116
+
117
+ # Як перевірити
118
+
119
+ - `<команда з scripts — npm test / bun test / lint>`
120
+ - <конкретні acceptance-checks: "у `engines.node` має бути `>=25`",
121
+ "CI зелений" тощо>
122
+ ```
123
+ ````
124
+
125
+ ## Правила
126
+
127
+ - **Мова промпту:** українська за замовчуванням; якщо аргумент англійською —
128
+ можна англійською. Технічні терміни — англійською.
129
+ - **Обсяг:** прагни вмістити промпт у ~200-500 рядків. Якщо релевантних
130
+ файлів багато — обери 3-5 найважливіших, решту згадай посиланнями.
131
+ - **Без галюцинацій:** не вигадуй полів `package.json`, версій, шляхів —
132
+ лише те, що реально прочитав з CWD. Якщо чогось бракує — явно так і
133
+ напиши ("`engines.node` відсутнє").
134
+ - **Без імперативного тону до користувача:** промпт адресований **іншому
135
+ агенту**, не людині. Використовуй "зроби", "онови", "додай".
136
+ - **Без секцій-пустушок:** якщо немає `Обмежень` — пропусти секцію.
137
+ - **Не вмикай у промпт:** секрети, `.env`, `node_modules`, бінарні файли,
138
+ довгі логи.
139
+ - **Усе в одному блоці:** результат — це **один** ` ```markdown ` блок;
140
+ ніяких додаткових міркувань поза ним (крім фінального підпису
141
+ "готово до копіювання").
142
+
143
+ ## Що скіл **не** робить
144
+
145
+ - Не завантажує tarball з npm registry, не клонує git-репозиторії.
146
+ - Не редагує жоден файл у поточному проєкті (і поза ним).
147
+ - Не виконує сам "патч" — лише готує промпт для іншого агента.
148
+ - Не оптимізує під Gemini / GPT — лише Claude / Cursor agent.
149
+
150
+ ## Приклад виклику й результату
151
+
152
+ Виклик:
153
+
154
+ ```
155
+ /n-llm-patch у @nitra/eslint-config підняти engines.node до >=25
156
+ ```
157
+
158
+ Очікуваний вивід (схематично):
159
+
160
+ ````
161
+ ```markdown
162
+ # Завдання
163
+
164
+ Підняти `engines.node` у `@nitra/eslint-config` до `>=25` і переглянути
165
+ сумісність peer-залежностей з новою версією.
166
+
167
+ # Контекст проєкту
168
+
169
+ - Назва / версія: `@nitra/eslint-config@2.4.0`
170
+ - Тип: shared eslint preset (library)
171
+ - Стек: Node `>=22` (поточне), ESM, eslint `^9`
172
+ - Документи правил: `.cursor/rules/n-js-lint.mdc`
173
+
174
+ ## Структура (skim)
175
+
176
+
177
+
178
+ # Релевантні файли
179
+
180
+ ## `package.json`
181
+
182
+ ```json
183
+ { "engines": { "node": ">=22" }, "peerDependencies": { "eslint": "^9" } }
184
+ ```
185
+
186
+ # Що треба зробити
187
+
188
+ - `package.json` → `engines.node`: `>=22` → `>=25`
189
+ - Перевірити, чи `peerDependencies.eslint ^9` сумісне з Node 25
190
+ - Bump `version` (minor) + запис у `CHANGELOG.md`
191
+
192
+ # Як перевірити
193
+
194
+ - `bun test`
195
+ - `node -v` у CI ≥ 25
196
+ ```
197
+ готово до копіювання — встав у чат з агентом у цільовому проєкті
198
+ ````