@nitra/cursor 1.8.217 → 1.8.219

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,29 @@
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.219] - 2026-05-09
8
+
9
+ ### Added
10
+
11
+ - **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`.
12
+
13
+ ### Changed
14
+
15
+ - **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`).
16
+ - **k8s / check-k8s:** заборона локальних `base/hpa.yaml` і `base/pdb.yaml` (file-existence для обох). Якщо в дереві base-kustomize лишилися HPA або PDB через `resources` / `components` — fail (`HPA/PDB заборонені у base — переведіть у components/`).
17
+ - **k8s / check-k8s:** прод-overlay тригерить вимоги патчів `/spec/minReplicas`, `/spec/maxReplicas` (HPA), `/spec/minAvailable` (PDB), коли overlay-tree містить HPA/PDB (тобто overlay підключив `components/`).
18
+ - **k8s.mdc:** оновлено опис, приклади `components/kustomization.yaml`, `components/hpa.yaml`, `components/pdb.yaml` і прикладу прод-overlay із `components: [- ../components]` + JSON6902-патчами.
19
+
20
+ ### Removed
21
+
22
+ - **k8s / check-k8s:** прибрано застарілий механізм `$patch: delete` для HorizontalPodAutoscaler у `base/kustomization.yaml`. Видалено функції `verifyK8sBaseKustomizeHpaDeletedWhenInherited`, `kustomizationDeclaresHpaStrategicDelete`, `patchTextDeclaresHpaStrategicDelete` і константи-регекспи `HPA_STRATEGIC_DELETE_RE`, `HPA_KIND_LINE_RE` як мертві. Відповідні тести оновлено / видалено.
23
+
24
+ ## [1.8.218] - 2026-05-09
25
+
26
+ ### Added
27
+
28
+ - **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` поки лишається стартовим — наповнюється окремо.
29
+
7
30
  ## [1.8.217] - 2026-05-09
8
31
 
9
32
  ### Added
@@ -28,7 +51,7 @@
28
51
 
29
52
  ### Fixed
30
53
 
31
- - **k8s / check-k8s:** конвертація image-replace patches → `images:` падала з `byPatch.keys(...).toSorted is not a function`, бо `Map.keys()` повертає ітератор без `toSorted`. Тепер ключі спершу матеріалізуються у масив (`[...byPatch.keys()].toSorted(...)`).
54
+ - **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
55
 
33
56
  ### Changed
34
57
 
@@ -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,27 @@ 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
+ У **не-base** оверлеях (без `components/`) поруч із `Deployment` лишається звична схема: окремий **`hpa.yaml`** / **`pdb.yaml`**, якщо такі потрібні для цього середовища. **`check k8s`** звіряє прив'язку за іменами:
381
391
 
382
392
  - **`hpa.yaml`** (поза **`…/base/`**) — `autoscaling/v2`, `HorizontalPodAutoscaler`, `spec.scaleTargetRef.name` **= `metadata.name`** Deployment.
383
393
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
384
394
  - **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
385
395
 
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`**.
396
+ **Перевірка структури `components/`** (для кожного Deployment у `base/`): наявність каталогу, валідний `kustomization.yaml` як Component, `hpa.yaml` і `pdb.yaml` з відповідністю до Deployment-name / app-label. Алгоритм функція `validateComponentsForBaseDeployment` у **`check-k8s.mjs`**.
387
397
 
388
398
  **Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
389
399
 
@@ -403,14 +413,21 @@ images:
403
413
 
404
414
  ### Прод-оверрайди у `kustomization.yaml`
405
415
 
406
- У прод-накладенні `kustomization.yaml` у `patches[]` **обов’язкові** перевизначення залежно від того, що дає успадковане **base**-дерево:
416
+ У прод-накладенні `kustomization.yaml`, що підключає `components: [- ../components]` (тобто overlay-tree містить **HorizontalPodAutoscaler** і **PodDisruptionBudget**), у `patches[]` **обов'язкові** JSON6902-перевизначення прод-значень:
407
417
 
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 на ресурс, що з’являється з іншого шару) з прод-мінімумами.
418
+ - **`HorizontalPodAutoscaler`**: `/spec/minReplicas` і `/spec/maxReplicas` (мінімум 2).
419
+ - **`PodDisruptionBudget`**: `/spec/minAvailable` (мінімум 1).
410
420
 
411
421
  Формат patch — JSON6902 або Strategic Merge; `check k8s` перевіряє **наявність** відповідних JSON Pointer-ів у **`patches[]`**.
412
422
 
413
423
  ```yaml title="k8s/prod/kustomization.yaml (фрагмент)"
424
+ apiVersion: kustomize.config.k8s.io/v1beta1
425
+ kind: Kustomization
426
+ namespace: prod
427
+ resources:
428
+ - ../base
429
+ components:
430
+ - ../components
414
431
  patches:
415
432
  - target:
416
433
  kind: HorizontalPodAutoscaler
@@ -431,30 +448,17 @@ patches:
431
448
  value: 1
432
449
  ```
433
450
 
434
- ### Приклад: прибрати HPA у `base/kustomization.yaml`
435
-
436
- Якщо **HPA** підключений з **`../component`** (або іншого шляху в **`resources`**), а у dev він не потрібен:
451
+ ### Приклади `components/`
437
452
 
438
- ```yaml title="k8s/base/kustomization.yaml (фрагмент)"
453
+ ```yaml title="k8s/components/kustomization.yaml"
454
+ apiVersion: kustomize.config.k8s.io/v1alpha1
455
+ kind: Component
439
456
  resources:
440
- - ../component
441
- - deploy.yaml
457
+ - hpa.yaml
442
458
  - 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
459
  ```
454
460
 
455
- ### Приклади
456
-
457
- ```yaml title="k8s/prod/hpa.yaml (або компонент з HPA — не в …/base/ локальному hpa.yaml)"
461
+ ```yaml title="k8s/components/hpa.yaml"
458
462
  # yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/horizontalpodautoscaler-autoscaling-v2.json
459
463
  apiVersion: autoscaling/v2
460
464
  kind: HorizontalPodAutoscaler
@@ -465,8 +469,8 @@ spec:
465
469
  apiVersion: apps/v1
466
470
  kind: Deployment
467
471
  name: backend-api
468
- minReplicas: 2
469
- maxReplicas: 10
472
+ minReplicas: 1 # прод overlay підіймає до >= 2
473
+ maxReplicas: 1 # прод overlay підіймає до >= 2
470
474
  metrics:
471
475
  - type: Resource
472
476
  resource:
@@ -494,14 +498,14 @@ spec:
494
498
  selectPolicy: Min
495
499
  ```
496
500
 
497
- ```yaml title="k8s/base/pdb.yaml"
501
+ ```yaml title="k8s/components/pdb.yaml"
498
502
  # yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/poddisruptionbudget-policy-v1.json
499
503
  apiVersion: policy/v1
500
504
  kind: PodDisruptionBudget
501
505
  metadata:
502
506
  name: backend-api
503
507
  spec:
504
- minAvailable: 0 # прод: >= 1
508
+ minAvailable: 0 # прод overlay підіймає до >= 1
505
509
  selector:
506
510
  matchLabels:
507
511
  app: backend-api
@@ -527,7 +531,7 @@ spec:
527
531
  app: backend-api
528
532
  ```
529
533
 
530
- В overlays для прод-середовища `minReplicas`, `maxReplicas`, `minAvailable` підіймаєш до прод-мінімумів через **Kustomize patches** або окремі `hpa.yaml` / `pdb.yaml` у каталозі overlay (тоді перевірка спрацьовує за їхнім середовищем).
534
+ Точні умови та повідомлення `fail` JSDoc на початку `npm/scripts/check-k8s.mjs` і функції `validateComponentsForBaseDeployment` / `prodOverlayHpaPdbOverrideNeeds`.
531
535
 
532
536
  ## HorizontalPodAutoscaler: `autoscaling/v2`
533
537
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.217",
3
+ "version": "1.8.219",
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,13 @@ function checkProdOverridesInKustomization(kust, rel, fail, passFn, needs) {
5299
5226
  }
5300
5227
 
5301
5228
  /**
5302
- * Які прод-оверрайди потрібні для **kustomization.yaml** (не dev-like), що посилається на **…/k8s/…/base**.
5303
- *
5304
- * **HPA:** patches на **`minReplicas`/`maxReplicas`** лише якщо в base-дереві є HPA **і** base **не** містить
5305
- * strategic-merge **`$patch: delete`** для **HorizontalPodAutoscaler** (k8s.mdc).
5229
+ * Які прод-оверрайди потрібні для **kustomization.yaml** (не dev-like).
5306
5230
  *
5307
- * **PDB:** **`minAvailable`** якщо в base-дереві є Deployment і PDB.
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).
5308
5236
  * @param {string} rootNorm нормалізований корінь репозиторію
5309
5237
  * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5310
5238
  * @returns {Promise<ProdOverlayHpaPdbOverrideNeeds>} прапорці потрібних перевизначень
@@ -5316,28 +5244,11 @@ export async function prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs) {
5316
5244
  return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
5317
5245
  }
5318
5246
 
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 }
5326
-
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
- }
5247
+ const flags = await kustomizeResourceTreeHpaPdbDeploymentFlags(kustAbs, rootNorm)
5248
+ return {
5249
+ needsHpaReplicaPatches: flags.hasHpa,
5250
+ needsPdbMinAvailablePatch: flags.hasPdb
5339
5251
  }
5340
- return { needsHpaReplicaPatches, needsPdbMinAvailablePatch }
5341
5252
  }
5342
5253
 
5343
5254
  /**
@@ -5459,12 +5370,143 @@ function validatePdbForDeployment(pdbDocs, deployName, appLabel, isDevLike, pdbR
5459
5370
  }
5460
5371
  }
5461
5372
 
5373
+ /**
5374
+ * Перевіряє sibling каталог `…/k8s/…/components/` для одного **Deployment** з шару `…/k8s/…/base/`.
5375
+ *
5376
+ * Канон (k8s.mdc):
5377
+ * - Існує каталог `<baseDir>/../components/`.
5378
+ * - У ньому `kustomization.yaml` з `apiVersion: kustomize.config.k8s.io/v1alpha1`, `kind: Component` і
5379
+ * `resources` що містять `hpa.yaml` і `pdb.yaml`.
5380
+ * - `components/hpa.yaml` — валідний `autoscaling/v2` `HorizontalPodAutoscaler` зі `scaleTargetRef.name`,
5381
+ * що дорівнює `metadata.name` цього Deployment, з dev-like значеннями `min=max=1`.
5382
+ * - `components/pdb.yaml` — валідний `policy/v1` `PodDisruptionBudget` зі `selector.matchLabels.app`,
5383
+ * що дорівнює мітці `app` Deployment, з dev-like `minAvailable=0`.
5384
+ * @param {string} baseDir абсолютний шлях до `…/k8s/…/base/`
5385
+ * @param {string} deployName ім'я Deployment з base
5386
+ * @param {string} appLabel мітка `app` з `spec.selector.matchLabels.app`
5387
+ * @param {string} root корінь репозиторію
5388
+ * @param {(msg: string) => void} fail callback при помилці
5389
+ * @param {(msg: string) => void} passFn callback при успіху
5390
+ * @returns {Promise<void>}
5391
+ */
5392
+ export async function validateComponentsForBaseDeployment(baseDir, deployName, appLabel, root, fail, passFn) {
5393
+ const componentsDir = resolve(baseDir, '..', COMPONENTS_DIR)
5394
+ const componentsRel = (relative(root, componentsDir) || componentsDir).replaceAll('\\', '/')
5395
+ if (!existsSync(componentsDir)) {
5396
+ fail(
5397
+ `${componentsRel}: для Deployment '${deployName}' з sibling base/ обов'язковий каталог components/ з hpa.yaml і pdb.yaml (Kustomize Component) (k8s.mdc)`
5398
+ )
5399
+ return
5400
+ }
5401
+ let stat0
5402
+ try {
5403
+ stat0 = await stat(componentsDir)
5404
+ } catch {
5405
+ stat0 = null
5406
+ }
5407
+ if (stat0 === null || !stat0.isDirectory()) {
5408
+ fail(`${componentsRel}: очікується каталог Kustomize Component (k8s.mdc)`)
5409
+ return
5410
+ }
5411
+ await validateComponentsKustomizationManifest(componentsDir, componentsRel, fail, passFn)
5412
+ await validateComponentsHpaFile(componentsDir, componentsRel, deployName, fail, passFn)
5413
+ await validateComponentsPdbFile(componentsDir, componentsRel, deployName, appLabel, fail, passFn)
5414
+ }
5415
+
5416
+ /**
5417
+ * Перевіряє `components/kustomization.yaml`: `apiVersion: kustomize.config.k8s.io/v1alpha1`, `kind: Component`,
5418
+ * `resources` містить `hpa.yaml` і `pdb.yaml` (як мінімум).
5419
+ * @param {string} componentsDir абсолютний шлях до каталогу `components/`
5420
+ * @param {string} componentsRel відносний шлях для повідомлень
5421
+ * @param {(msg: string) => void} fail callback при помилці
5422
+ * @param {(msg: string) => void} passFn callback при успіху
5423
+ * @returns {Promise<void>}
5424
+ */
5425
+ async function validateComponentsKustomizationManifest(componentsDir, componentsRel, fail, passFn) {
5426
+ const kustAbs = join(componentsDir, 'kustomization.yaml')
5427
+ if (!existsSync(kustAbs)) {
5428
+ fail(`${componentsRel}/kustomization.yaml: відсутній — додай Kustomize Component-маніфест (k8s.mdc)`)
5429
+ return
5430
+ }
5431
+ const obj = await readFirstYamlObject(kustAbs)
5432
+ if (obj === null) {
5433
+ fail(`${componentsRel}/kustomization.yaml: не вдалося розпарсити перший YAML-документ (k8s.mdc)`)
5434
+ return
5435
+ }
5436
+ if (obj.apiVersion !== KUSTOMIZE_COMPONENT_API_VERSION) {
5437
+ fail(
5438
+ `${componentsRel}/kustomization.yaml: apiVersion має бути '${KUSTOMIZE_COMPONENT_API_VERSION}' (зараз: ${JSON.stringify(obj.apiVersion)}) (k8s.mdc)`
5439
+ )
5440
+ }
5441
+ if (obj.kind !== 'Component') {
5442
+ fail(
5443
+ `${componentsRel}/kustomization.yaml: kind має бути 'Component' (зараз: ${JSON.stringify(obj.kind)}) (k8s.mdc)`
5444
+ )
5445
+ }
5446
+ const resources = Array.isArray(obj.resources) ? obj.resources.filter(x => typeof x === 'string') : []
5447
+ const hasHpa = resources.includes(HPA_FILENAME)
5448
+ const hasPdb = resources.includes(PDB_FILENAME)
5449
+ if (!hasHpa) {
5450
+ fail(`${componentsRel}/kustomization.yaml: у resources має бути '${HPA_FILENAME}' (k8s.mdc)`)
5451
+ }
5452
+ if (!hasPdb) {
5453
+ fail(`${componentsRel}/kustomization.yaml: у resources має бути '${PDB_FILENAME}' (k8s.mdc)`)
5454
+ }
5455
+ if (obj.apiVersion === KUSTOMIZE_COMPONENT_API_VERSION && obj.kind === 'Component' && hasHpa && hasPdb) {
5456
+ passFn(`${componentsRel}/kustomization.yaml: канонічний Kustomize Component з hpa.yaml і pdb.yaml (k8s.mdc)`)
5457
+ }
5458
+ }
5459
+
5460
+ /**
5461
+ * Перевіряє `components/hpa.yaml`: HPA для Deployment, dev-like `min=max=1`.
5462
+ * @param {string} componentsDir абсолютний шлях до каталогу `components/`
5463
+ * @param {string} componentsRel відносний шлях для повідомлень
5464
+ * @param {string} deployName ім'я Deployment з base
5465
+ * @param {(msg: string) => void} fail callback при помилці
5466
+ * @param {(msg: string) => void} passFn callback при успіху
5467
+ * @returns {Promise<void>}
5468
+ */
5469
+ async function validateComponentsHpaFile(componentsDir, componentsRel, deployName, fail, passFn) {
5470
+ const hpaAbs = join(componentsDir, HPA_FILENAME)
5471
+ const hpaRel = `${componentsRel}/${HPA_FILENAME}`
5472
+ if (!existsSync(hpaAbs)) {
5473
+ fail(`${hpaRel}: відсутній — додай HorizontalPodAutoscaler для Deployment '${deployName}' (k8s.mdc)`)
5474
+ return
5475
+ }
5476
+ const hpaDocs = await readAllDocsByKindFromFile(hpaAbs, 'HorizontalPodAutoscaler')
5477
+ validateHpaForDeployment(hpaDocs, deployName, true, hpaRel, fail, passFn)
5478
+ }
5479
+
5480
+ /**
5481
+ * Перевіряє `components/pdb.yaml`: PDB для Deployment, dev-like `minAvailable=0`.
5482
+ * @param {string} componentsDir абсолютний шлях до каталогу `components/`
5483
+ * @param {string} componentsRel відносний шлях для повідомлень
5484
+ * @param {string} deployName ім'я Deployment з base
5485
+ * @param {string} appLabel мітка `app` Deployment
5486
+ * @param {(msg: string) => void} fail callback при помилці
5487
+ * @param {(msg: string) => void} passFn callback при успіху
5488
+ * @returns {Promise<void>}
5489
+ */
5490
+ async function validateComponentsPdbFile(componentsDir, componentsRel, deployName, appLabel, fail, passFn) {
5491
+ const pdbAbs = join(componentsDir, PDB_FILENAME)
5492
+ const pdbRel = `${componentsRel}/${PDB_FILENAME}`
5493
+ if (!existsSync(pdbAbs)) {
5494
+ fail(`${pdbRel}: відсутній — додай PodDisruptionBudget для Deployment '${deployName}' (k8s.mdc)`)
5495
+ return
5496
+ }
5497
+ const pdbDocs = await readAllDocsByKindFromFile(pdbAbs, 'PodDisruptionBudget')
5498
+ validatePdbForDeployment(pdbDocs, deployName, appLabel, true, pdbRel, fail, passFn)
5499
+ }
5500
+
5462
5501
  /**
5463
5502
  * Перевіряє один Deployment: topologySpreadConstraints, HPA та PDB.
5503
+ *
5504
+ * Для **base-шару** HPA/PDB не вимагаються поруч із Deployment — натомість викликач має звіряти sibling
5505
+ * каталог `components/` через `validateComponentsForBaseDeployment`.
5464
5506
  * @param {Record<string, unknown>} deployment об'єкт Deployment
5465
5507
  * @param {string} deployRel відносний шлях каталогу для повідомлень
5466
5508
  * @param {boolean} isDevLike чи середовище dev-like
5467
- * @param {boolean} isK8sBaseLayer чи каталог під **`…/k8s/…/base/`** (HPA поруч не вимагаємо — прибирається в kustomization)
5509
+ * @param {boolean} isK8sBaseLayer чи каталог під **`…/k8s/…/base/`** (HPA/PDB поруч не вимагаємо — живуть у `components/`)
5468
5510
  * @param {Record<string, unknown>[]} hpaDocs HPA-документи каталогу
5469
5511
  * @param {Record<string, unknown>[]} pdbDocs PDB-документи каталогу
5470
5512
  * @param {(msg: string) => void} fail callback при помилці
@@ -5496,9 +5538,10 @@ function validateSingleDeploymentHpaPdbTopology(
5496
5538
  } else {
5497
5539
  fail(`${deployRel}: Deployment '${deployName}': ${tscViolation}`)
5498
5540
  }
5499
- if (!isK8sBaseLayer) {
5500
- validateHpaForDeployment(hpaDocs, deployName, isDevLike, `${deployRel}/${HPA_FILENAME}`, fail, passFn)
5541
+ if (isK8sBaseLayer) {
5542
+ return
5501
5543
  }
5544
+ validateHpaForDeployment(hpaDocs, deployName, isDevLike, `${deployRel}/${HPA_FILENAME}`, fail, passFn)
5502
5545
  validatePdbForDeployment(pdbDocs, deployName, appLabel, isDevLike, `${deployRel}/${PDB_FILENAME}`, fail, passFn)
5503
5546
  }
5504
5547
 
@@ -5515,18 +5558,23 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
5515
5558
  const segment = k8sEnvSegmentFromRelPath(relDir + '/')
5516
5559
  const isDevLike = isDevLikeK8sEnvSegment(segment)
5517
5560
  const isK8sBaseLayer = isK8sYamlUnderBaseDirectory(`${relDir}/probe.yaml`)
5561
+ const deployRel = relDir === '' ? '.' : relDir
5518
5562
  if (isK8sBaseLayer && deployments.length > 0) {
5519
5563
  const hpaAbs = join(dir, HPA_FILENAME)
5520
5564
  if (existsSync(hpaAbs)) {
5521
- const prefix = relDir === '' ? '.' : relDir
5522
5565
  fail(
5523
- `${prefix}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — прибери HorizontalPodAutoscaler через $patch: delete у base/kustomization.yaml (k8s.mdc)`
5566
+ `${deployRel}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — HPA живе у sibling components/ (k8s.mdc)`
5567
+ )
5568
+ }
5569
+ const pdbAbs = join(dir, PDB_FILENAME)
5570
+ if (existsSync(pdbAbs)) {
5571
+ fail(
5572
+ `${deployRel}/${PDB_FILENAME}: у шарі k8s/.../base не тримай локальний pdb.yaml — PDB живе у sibling components/ (k8s.mdc)`
5524
5573
  )
5525
5574
  }
5526
5575
  }
5527
- const hpaDocs = await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
5528
- const pdbDocs = await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
5529
- const deployRel = relDir === '' ? '.' : relDir
5576
+ const hpaDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
5577
+ const pdbDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
5530
5578
  for (const deployment of deployments) {
5531
5579
  validateSingleDeploymentHpaPdbTopology(
5532
5580
  deployment,
@@ -5538,6 +5586,13 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
5538
5586
  fail,
5539
5587
  passFn
5540
5588
  )
5589
+ if (isK8sBaseLayer) {
5590
+ const deployName = manifestMetadataName(deployment)
5591
+ const appLabel = deploymentAppLabel(deployment)
5592
+ if (deployName !== null && appLabel !== null) {
5593
+ await validateComponentsForBaseDeployment(dir, deployName, appLabel, root, fail, passFn)
5594
+ }
5595
+ }
5541
5596
  }
5542
5597
  }
5543
5598
 
@@ -5556,8 +5611,9 @@ async function extractDeploymentsFromFile(filePath) {
5556
5611
 
5557
5612
  /**
5558
5613
  * Для кожного **Deployment** у шарі **`…/k8s/…/base/`** (будь-який YAML у відповідному каталозі) перевіряє:
5559
- * заборона локального **`hpa.yaml`**; **`pdb.yaml`** (валідний `policy/v1`); канонічні **topologySpreadConstraints**.
5560
- * HPA для dev прибирається в **`base/kustomization.yaml`** через **`$patch: delete`** (див. `verifyK8sBaseKustomizeHpaDeletedWhenInherited`).
5614
+ * заборона локальних **`hpa.yaml`** і **`pdb.yaml`** (file-existence); канонічні **topologySpreadConstraints**;
5615
+ * наявність і канон sibling каталогу **`components/`** (Kustomize Component) з `hpa.yaml` і `pdb.yaml` через
5616
+ * `validateComponentsForBaseDeployment`. У не-base шарах — звична схема (`hpa.yaml` / `pdb.yaml` поруч).
5561
5617
  * Env-залежні межі — за сегментом після `/k8s/`: dev-like vs прод.
5562
5618
  * @param {string} root корінь репозиторію
5563
5619
  * @param {string[]} yamlFilesAbs yaml під k8s
@@ -6294,7 +6350,7 @@ function rewriteInlinePatchWithoutOps(patchText, opIndices) {
6294
6350
  const seq = inner.contents
6295
6351
  if (!isSeq(seq)) return null
6296
6352
 
6297
- const toRemove = new Set(opIndices).toSorted((a, b) => b - a)
6353
+ const toRemove = [...new Set(opIndices)].toSorted((a, b) => b - a)
6298
6354
  for (const i of toRemove) {
6299
6355
  if (i < 0 || i >= seq.items.length) return null
6300
6356
  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
+ ````