@nitra/cursor 1.8.73 → 1.8.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mdc/abie.mdc CHANGED
@@ -1,14 +1,18 @@
1
1
  ---
2
2
  description: Правила для проєктів AbInBev Efes
3
3
  alwaysApply: true
4
- version: '1.4'
4
+ version: '1.7'
5
5
  ---
6
6
 
7
- Правило **abie** для споживачів **@nitra/cursor**: **k8s** (**Deployment** + **HealthCheckPolicy**), overlay **ua** / **ru** (**nodeSelector**, видалення **HealthCheckPolicy** у **ru**) і гілки в **clean-merged-branch**. **`npx @nitra/cursor check abie`** виконується лише якщо в **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень.
7
+ Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**), видалення **HealthCheckPolicy** у **ru**), а також гілки **dev**, **ua**, **ru** у **clean-merged-branch**.
8
+
9
+ **`npx @nitra/cursor check abie`** виконується лише якщо в **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень.
10
+
11
+ **Канон перевірки** — **`npm/scripts/check-abie.mjs`**: верхній JSDoc і реалізація задають точні умови, допустимі домени для hostnames, тексти помилок. Нижче — зміст правила й орієнтовні фрагменти YAML; не дублюй тут покроковий алгоритм зі скрипта.
8
12
 
9
13
  ## k8s: `hc.yaml` поруч із Deployment
10
14
 
11
- Якщо під **`k8s`** є **`kind: Deployment`**, у **тій самій директорії** має бути **`hc.yaml`** з **HealthCheckPolicy** (**`networking.gke.io/v1`**): коректний modeline **`$schema`**, **`/healthz`**, порт **8080**, **`targetRef.name`** = **`metadata.name`**. Деталі — **`validateAbieHcYaml`** у **`npm/scripts/check-abie.mjs`**.
15
+ Якщо під **`k8s`** є **Deployment**, у **тій самій директорії** має бути **`hc.yaml`** з **HealthCheckPolicy** (**`networking.gke.io/v1`**): коректний modeline **`$schema`**, **`/healthz`**, порт **8080**, **`targetRef.name`** = **`metadata.name`**.
12
16
 
13
17
  ```yaml title="hc.yaml"
14
18
  # yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json
@@ -30,19 +34,19 @@ spec:
30
34
  name: СЕРВІС
31
35
  ```
32
36
 
33
- ## k8s: overlay **HTTPRoute** `nginx-run` (**ua** / **ru**)
37
+ ## k8s: overlay **HTTPRoute** (**ua** / **ru**)
34
38
 
35
- Якщо під **`k8s`** є **Deployment**, у **кожному** **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** має бути inline **JSON6902** у **`patches[].patch`** на **`target.kind: HTTPRoute`**, **`name: nginx-run`**: **replace** **`/spec/hostnames`** (допустимі домени — як у прикладах нижче) і **replace** **`/spec/parentRefs/0/namespace`** (**`ua`** або **`ru`**). Для **ru** додатково потрібна анотація **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`** (зазвичай **op: add**, **`/metadata/annotations`**). Критерії**`validateAbieNginxRunHttpRoutePatches`** / **`getCombinedNginxRunPatchTextFromKustomization`** у **`npm/scripts/check-abie.mjs`**.
39
+ За наявності **Deployment** під **k8s** у **кожному** **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** потрібні **inline JSON6902** у **`patches`**: **target** **`kind: HTTPRoute`**, **непорожній `name`** (як у маніфесті маршруту). Мають бути зміни **`/spec/hostnames`** (домени abie — у скрипті) та **`/spec/parentRefs/0/namespace`** (**`ua`** / **`ru`**). Для **ru** анотація **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`**. Як обирати **`op`** (**add** / **replace** тощо) у patch **k8s.mdc** (розділ про JSON patch у kustomization).
36
40
 
37
41
  ```yaml title="…/ua/kustomization.yaml (фрагмент)"
38
42
  - target:
39
43
  kind: HTTPRoute
40
- name: nginx-run
44
+ name: my-httproute
41
45
  patch: |-
42
46
  - op: replace
43
47
  path: /spec/hostnames
44
48
  value:
45
- - "abie.app" # також допускається vybeerai.com.ua, *.vybeerai.com.ua, *.abie.app
49
+ - "abie.app" # зокрема vybeerai.com.ua, *.vybeerai.com.ua, *.abie.app
46
50
  - op: replace
47
51
  path: /spec/parentRefs/0/namespace
48
52
  value: ua
@@ -51,12 +55,12 @@ spec:
51
55
  ```yaml title="…/ru/kustomization.yaml (фрагмент)"
52
56
  - target:
53
57
  kind: HTTPRoute
54
- name: nginx-run
58
+ name: my-httproute
55
59
  patch: |-
56
60
  - op: replace
57
61
  path: /spec/hostnames
58
62
  value:
59
- - "napitkivmeste.tech" # також допускається выбирайонлайн.рф, *.napitkivmeste.tech, *.выбирайонлайн.рф
63
+ - "napitkivmeste.tech" # зокрема выбирайонлайн.рф, *.napitkivmeste.tech, *.выбирайонлайн.рф
60
64
  - op: replace
61
65
  path: /spec/parentRefs/0/namespace
62
66
  value: ru
@@ -65,7 +69,7 @@ spec:
65
69
  ```yaml title="…/ru/kustomization.yaml (фрагмент)"
66
70
  - target:
67
71
  kind: HTTPRoute
68
- name: nginx-run
72
+ name: my-httproute
69
73
  patch: |-
70
74
  - op: add
71
75
  path: /metadata/annotations
@@ -73,9 +77,9 @@ spec:
73
77
  gwin.yandex.cloud/rules.http.upgradeTypes: "websocket"
74
78
  ```
75
79
 
76
- ## k8s: overlay **`ru`** і HealthCheckPolicy
80
+ ## k8s: overlay **ru** і HealthCheckPolicy
77
81
 
78
- Якщо в дереві **k8s** є **HealthCheckPolicy**, **check abie** вимагає **`ru/kustomization.yaml`** з patch **`$patch: delete`** для політики (узгоджено з **k8s.mdc** / **check-k8s**, **`ruKustomizationHasHealthCheckDeletePatch`** у **`npm/scripts/check-k8s.mjs`**). Підстав реальне ім’я замість **`СЕРВІС`**:
82
+ Якщо в дереві **k8s** є **HealthCheckPolicy**, у **`ru/kustomization.yaml`** має бути patch **`$patch: delete`** для політики (узгоджено з **k8s.mdc**; перевірка в **`check-k8s.mjs`**, **`ruKustomizationHasHealthCheckDeletePatch`**). Підстав реальне ім’я замість **`СЕРВІС`**:
79
83
 
80
84
  ```yaml title="…/ru/kustomization.yaml (фрагмент)"
81
85
  patches:
@@ -89,9 +93,9 @@ patches:
89
93
  $patch: delete
90
94
  ```
91
95
 
92
- ## k8s: overlay **`ua`** / **`ru`** і nodeSelector
96
+ ## k8s: overlay **ua** / **ru** і nodeSelector
93
97
 
94
- Якщо під **`k8s`** є **Deployment**, у **кожному** **`…/ua/kustomization.yaml`** та **`…/ru/kustomization.yaml`** має бути inline **JSON6902** у **`patches[].patch`** на **`target.kind: Deployment`** з **nodeSelector** за політикою abie (**ua** — **add** + **preem** false; **ru** — **replace** + **yandex.cloud/preemptible** false). Критерії збігу **`kustomizationHasAbieDeploymentNodeSelectorPatch`** у **`npm/scripts/check-abie.mjs`**.
98
+ За наявності **Deployment** під **k8s** у **кожному** **`…/ua/kustomization.yaml`** та **`…/ru/kustomization.yaml`** patch на **`kind: Deployment`**: **ua** — **`spec.template.spec.nodeSelector`** з **`preem: false`**; **ru** — **`spec.template.spec.nodeSelector`** з **`yandex.cloud/preemptible: false`**. Форму **JSON6902** (шлях **`/spec/template/spec/nodeSelector`**, **`op`**) див. **k8s.mdc**.
95
99
 
96
100
  ```yaml title="…/ua/kustomization.yaml (фрагмент)"
97
101
  patches:
@@ -119,7 +123,7 @@ patches:
119
123
 
120
124
  ### Базовий Deployment (`…/base/`)
121
125
 
122
- Якщо **Deployment** у YAML під **`k8s`** лежить у шляху з сегментом **`base`** (наприклад **`…/k8s/base/deploy.yaml`**), у **`spec.template.spec.nodeSelector`** має бути **`preem`** зі значенням **істинно** (**`true`** або рядок **`'true'`**) overlay **ua** / **ru** підміняє селектор через kustomize. Деталі — **`deploymentDocumentHasAbieBasePreemNodeSelector`** / **`isAbieK8sBaseYamlPath`** у **`npm/scripts/check-abie.mjs`**.
126
+ Якщо **Deployment** у YAML під **`k8s`** лежить у шляху з сегментом **`base`**, у **`spec.template.spec.nodeSelector`** має бути **`preem`** зі значенням **істинно** (**`true`** або рядок **`'true'`**); overlay **ua** / **ru** підміняє селектор.
123
127
 
124
128
  ```yaml title="…/base/deploy.yaml (фрагмент)"
125
129
  spec:
@@ -131,7 +135,7 @@ spec:
131
135
 
132
136
  ## Git branches
133
137
 
134
- У **`.github/workflows/clean-merged-branch.yml`** у кроці **`phpdocker-io/github-actions-delete-abandoned-branches`** значення **`with.ignore_branches`** має містити **dev**, **ua** та **ru** (разом з іншими гілками, якщо потрібно), наприклад:
138
+ У **`.github/workflows/clean-merged-branch.yml`** у кроці **`phpdocker-io/github-actions-delete-abandoned-branches`** значення **`with.ignore_branches`** має містити **dev**, **ua** та **ru** (разом з іншими гілками, якщо потрібно):
135
139
 
136
140
  ```yaml title=".github/workflows/clean-merged-branch.yml (фрагмент)"
137
141
  with:
package/mdc/k8s.mdc CHANGED
@@ -206,20 +206,10 @@ patches:
206
206
  value: dev
207
207
  ```
208
208
 
209
- 4. Якщо в kustomization.yaml є remove разом з add на однаковий path:
210
-
211
- ```yaml title="overlay/kustomization.yaml (фрагмент)"
212
- - op: remove
213
- path: /spec/template/spec/nodeSelector
214
- - op: add
215
- path: /spec/template/spec/nodeSelector
216
- value:
217
- preem: "false"
218
- ```
219
-
220
- заміняй на replace:
209
+ 4. **JSON patch у kustomization:** де можливо, змінюй ресурс через **`op: replace`** (одна операція на `path`), а не пару **`remove` + `add`** на той самий шлях. **`add`** / **`remove`** лишай лише коли **`replace`** не підходить (наприклад додати новий ключ або прибрати поле без заміни).
221
210
 
222
211
  ```yaml title="overlay/kustomization.yaml (фрагмент)"
212
+ patches:
223
213
  - target:
224
214
  kind: Deployment
225
215
  name: x
@@ -236,11 +226,7 @@ patches:
236
226
 
237
227
  **Не входить у check k8s:** наприклад **ReferenceGrant** і доступ між **namespace** (лише рекомендації тут), **kubeconform** / **kubescape** — це **`bun run lint-k8s`**.
238
228
 
239
- ## Що саме в скрипті `check-k8s.mjs`
240
-
241
- Повний перелік умов, константи (**`YANNH_PIN`**, **`CLUSTER_SCOPED_KINDS`**, **`HASURA_GRAPHQL_ENGINE_IMAGE`** тощо) і допоміжні функції — у файлі скрипта; змінив вимогу для **check** — онови **JSDoc** і за потреби тести в **`npm/tests/check-k8s-schema.test.mjs`**.
242
229
 
243
- При зміні **PIN** версії Kubernetes узгодь **`check-k8s.mjs`**, **`run-k8s.mjs`** (**`KUBERNETES_VERSION`**, **`DATREE_CRD_SCHEMA_LOCATION`**) і цей файл (**lint-k8s**, **Визначення схеми YAML**).
244
230
 
245
231
  ## Коли застосовувати (агентам)
246
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.73",
3
+ "version": "1.8.75",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -17,13 +17,13 @@
17
17
  * **nodeSelector (base):** якщо **Deployment** лежить у шляху з сегментом **`base`** (наприклад **`…/k8s/base/deploy.yaml`**),
18
18
  * у **`spec.template.spec.nodeSelector`** має бути **`preem`** з булевим значенням **true** або рядком **`'true'`** — overlay **ua** та **ru** далі підміняють селектор.
19
19
  *
20
- * **nodeSelector (overlay):** якщо є **Deployment** під **k8s**, у кожному **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`**
21
- * має бути inline **JSON6902** patch на **`kind: Deployment`**: для **ua** — **`op: add`**, **`path: /spec/template/spec/nodeSelector`**,
22
- * **`preem: false`**; для **ru** **`op: replace`**, той самий **path**, **`yandex.cloud/preemptible: false`** (див. abie.mdc).
20
+ * **nodeSelector (overlay):** якщо є **Deployment** під **k8s**, у **`ua`/`ru` kustomization** inline patch на **`kind: Deployment`**
21
+ * з **`path: /spec/template/spec/nodeSelector`**: **ua** **`preem: false`**; **ru** — **`yandex.cloud/preemptible: false`**.
22
+ * Узагальнені вимоги **k8s.mdc** до JSON6902 (зокрема заборона **remove** + **add** на той самий **path**) перевіряє **check-k8s.mjs**; **check-abie** — лише abie-специфічний вміст (без дублювання цього правила).
23
23
  *
24
- * **HTTPRoute nginx-run:** за тієї ж умови (**Deployment** під **k8s**) у **кожному** **`ua`/`ru` kustomization** має бути
25
- * inline **JSON6902** на **`kind: HTTPRoute`**, **`name: nginx-run`**: **replace** **`/spec/hostnames`** (домени з abie.mdc),
26
- * **replace** **`/spec/parentRefs/0/namespace`** (**ua** / **ru**); для **ru** також **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`**.
24
+ * **HTTPRoute (overlay):** тієї ж умови patch на **`kind: HTTPRoute`**, **непорожній `target.name`**: **`/spec/hostnames`**
25
+ * (домени abie.mdc), **`/spec/parentRefs/0/namespace`** (**ua** / **ru**); для **ru** — **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`**.
26
+ * Вибір **`op`** **k8s.mdc**.
27
27
  */
28
28
  import { existsSync } from 'node:fs'
29
29
  import { readFile } from 'node:fs/promises'
@@ -343,7 +343,8 @@ function stripBom(s) {
343
343
  }
344
344
 
345
345
  /**
346
- * Чи рядок inline JSON6902 patch містить очікуваний **ua** nodeSelector (**op: add**, **preem: false**).
346
+ * Чи рядок inline JSON6902 patch містить очікуваний **ua** nodeSelector (**preem: false** на **`/spec/template/spec/nodeSelector`**).
347
+ * Конкретний **`op`** не перевіряється — див. **k8s.mdc**.
347
348
  * @param {string} patchText поле **patch** у kustomization
348
349
  * @returns {boolean} true, якщо критерії abie.mdc виконано
349
350
  */
@@ -351,9 +352,6 @@ function jsonPatchTextHasUaDeploymentNodeSelector(patchText) {
351
352
  if (typeof patchText !== 'string' || patchText.trim() === '') {
352
353
  return false
353
354
  }
354
- if (!/op:\s*add\b/u.test(patchText)) {
355
- return false
356
- }
357
355
  if (!/path:\s*\/spec\/template\/spec\/nodeSelector\b/u.test(patchText)) {
358
356
  return false
359
357
  }
@@ -364,7 +362,8 @@ function jsonPatchTextHasUaDeploymentNodeSelector(patchText) {
364
362
  }
365
363
 
366
364
  /**
367
- * Чи рядок inline JSON6902 patch містить очікуваний **ru** nodeSelector (**op: replace**, **yandex.cloud/preemptible: false**).
365
+ * Чи рядок inline JSON6902 patch містить очікуваний **ru** nodeSelector (**yandex.cloud/preemptible: false** на **`/spec/template/spec/nodeSelector`**).
366
+ * Конкретний **`op`** не перевіряється — див. **k8s.mdc**.
368
367
  * @param {string} patchText поле **patch** у kustomization
369
368
  * @returns {boolean} true, якщо критерії abie.mdc виконано
370
369
  */
@@ -372,9 +371,6 @@ function jsonPatchTextHasRuDeploymentNodeSelector(patchText) {
372
371
  if (typeof patchText !== 'string' || patchText.trim() === '') {
373
372
  return false
374
373
  }
375
- if (!/op:\s*replace\b/u.test(patchText)) {
376
- return false
377
- }
378
374
  if (!/path:\s*\/spec\/template\/spec\/nodeSelector\b/u.test(patchText)) {
379
375
  return false
380
376
  }
@@ -484,11 +480,11 @@ const ABIE_RU_HTTPROUTE_HOST_MARKERS = [
484
480
  ]
485
481
 
486
482
  /**
487
- * Збирає тексти inline **patch** для **HTTPRoute/nginx-run** з одного розібраного документа **Kustomization**.
483
+ * Збирає тексти inline **patch** для **HTTPRoute** (будь-який непорожній **target.name**) з одного документа **Kustomization**.
488
484
  * @param {import('yaml').Document} doc документ після **parseAllDocuments**
489
485
  * @returns {string[]} непорожні рядки **patch**
490
486
  */
491
- function collectNginxRunPatchStringsFromKustomizationDoc(doc) {
487
+ function collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc) {
492
488
  if (doc.errors.length > 0) {
493
489
  return []
494
490
  }
@@ -512,7 +508,7 @@ function collectNginxRunPatchStringsFromKustomizationDoc(doc) {
512
508
  const target = pr.target
513
509
  if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
514
510
  const tg = /** @type {Record<string, unknown>} */ (target)
515
- if (tg.kind === 'HTTPRoute' && tg.name === 'nginx-run') {
511
+ if (tg.kind === 'HTTPRoute' && typeof tg.name === 'string' && tg.name.trim() !== '') {
516
512
  const patchStr = pr.patch
517
513
  if (typeof patchStr === 'string' && patchStr.trim() !== '') {
518
514
  out.push(patchStr)
@@ -525,7 +521,7 @@ function collectNginxRunPatchStringsFromKustomizationDoc(doc) {
525
521
  }
526
522
 
527
523
  /**
528
- * Збирає всі inline **JSON6902**-фрагменти для **HTTPRoute/nginx-run** у **kustomization.yaml** (усі документи у файлі).
524
+ * Збирає всі inline **JSON6902**-фрагменти для **HTTPRoute** (непорожній **target.name**) у **kustomization.yaml** (усі документи у файлі).
529
525
  * @param {string} raw повний текст файлу
530
526
  * @returns {string} текст для **`validateAbieNginxRunHttpRoutePatches`** (може бути порожнім)
531
527
  */
@@ -544,44 +540,43 @@ export function getCombinedNginxRunPatchTextFromKustomization(raw) {
544
540
  /** @type {string[]} */
545
541
  const chunks = []
546
542
  for (const doc of docs) {
547
- chunks.push(...collectNginxRunPatchStringsFromKustomizationDoc(doc))
543
+ chunks.push(...collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc))
548
544
  }
549
545
  return chunks.join('\n')
550
546
  }
551
547
 
552
548
  /**
553
- * Перевіряє сукупний текст patch(ів) **HTTPRoute/nginx-run** на відповідність abie.mdc.
549
+ * Перевіряє сукупний текст patch(ів) **HTTPRoute** (будь-яке **target.name**) на відповідність abie.mdc.
554
550
  * @param {string} combined текст одного або кількох inline **patch**, розділених символом нового рядка
555
551
  * @param {'ua' | 'ru'} mode **ua** або **ru**
556
552
  * @returns {string | null} повідомлення про помилку або **null**
557
553
  */
558
554
  export function validateAbieNginxRunHttpRoutePatches(combined, mode) {
559
555
  if (typeof combined !== 'string' || combined.trim() === '') {
560
- return `очікується patch target kind HTTPRoute name nginx-run (replace hostnames, parentRefs namespace ${mode}; для ru — також gwin… upgradeTypes websocket) — abie.mdc`
556
+ return `очікується patch target kind HTTPRoute з непорожнім target.name (hostnames, parentRefs namespace ${mode}; для ru — також gwin… websocket) — abie.mdc`
561
557
  }
562
- const hasHostnamesReplace = /-\s*op:\s*replace\b[\s\S]{0,200}?path:\s*\/spec\/hostnames\b/m.test(combined)
563
- if (!hasHostnamesReplace) {
564
- return 'HTTPRoute nginx-run: потрібен блок op replace з path /spec/hostnames (abie.mdc)'
558
+ if (!/path:\s*\/spec\/hostnames\b/m.test(combined)) {
559
+ return 'HTTPRoute: потрібен path /spec/hostnames у patch (abie.mdc)'
565
560
  }
566
561
  const markers = mode === 'ua' ? ABIE_UA_HTTPROUTE_HOST_MARKERS : ABIE_RU_HTTPROUTE_HOST_MARKERS
567
562
  if (!markers.some(m => combined.includes(m))) {
568
- return `HTTPRoute nginx-run: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
563
+ return `HTTPRoute: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
569
564
  }
570
565
  const namespaceOk =
571
566
  mode === 'ua'
572
567
  ? /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/mu.test(combined)
573
568
  : /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/mu.test(combined)
574
569
  if (!namespaceOk) {
575
- return `HTTPRoute nginx-run: потрібен replace path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
570
+ return `HTTPRoute: потрібен path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
576
571
  }
577
572
  if (mode === 'ru' && !/gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/m.test(combined)) {
578
- return 'HTTPRoute nginx-run (ru): потрібна анотація gwin.yandex.cloud/rules.http.upgradeTypes: websocket (abie.mdc)'
573
+ return 'HTTPRoute (ru): потрібна анотація gwin.yandex.cloud/rules.http.upgradeTypes: websocket (abie.mdc)'
579
574
  }
580
575
  return null
581
576
  }
582
577
 
583
578
  /**
584
- * Чи **kustomization** містить валідні для abie записи **patch** для **HTTPRoute/nginx-run** (**ua** або **ru**).
579
+ * Чи **kustomization** містить валідні для abie **patch** для **HTTPRoute** з непорожнім **target.name** (**ua** або **ru**).
585
580
  * @param {string} raw повний текст **kustomization.yaml**
586
581
  * @param {'ua' | 'ru'} mode overlay
587
582
  * @returns {boolean} true, якщо **`validateAbieNginxRunHttpRoutePatches`** повертає **null**
@@ -782,7 +777,7 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail, passF
782
777
  const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
783
778
  if (uaAbsList.length === 0) {
784
779
  fail(
785
- 'Є Deployment у k8s — додай ua/kustomization.yaml з inline patch на Deployment: op add, path /spec/template/spec/nodeSelector, preem false (abie.mdc)'
780
+ 'Є Deployment у k8s — додай ua/kustomization.yaml з patch на Deployment: path /spec/template/spec/nodeSelector, preem false (abie.mdc)'
786
781
  )
787
782
  return
788
783
  }
@@ -798,7 +793,7 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail, passF
798
793
  }
799
794
  if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, 'ua')) {
800
795
  fail(
801
- `${rel}: потрібен patch target kind Deployment з op: add, path /spec/template/spec/nodeSelector та preem: false (abie.mdc)`
796
+ `${rel}: потрібен patch target kind Deployment: path /spec/template/spec/nodeSelector та preem: false (abie.mdc)`
802
797
  )
803
798
  return
804
799
  }
@@ -808,7 +803,7 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail, passF
808
803
  const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
809
804
  if (ruAbsList.length === 0) {
810
805
  fail(
811
- 'Є Deployment у k8s — додай ru/kustomization.yaml з inline patch на Deployment: op replace, path /spec/template/spec/nodeSelector, yandex.cloud/preemptible false (abie.mdc)'
806
+ 'Є Deployment у k8s — додай ru/kustomization.yaml з patch на Deployment: path /spec/template/spec/nodeSelector, yandex.cloud/preemptible false (abie.mdc)'
812
807
  )
813
808
  return
814
809
  }
@@ -824,7 +819,7 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail, passF
824
819
  }
825
820
  if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, 'ru')) {
826
821
  fail(
827
- `${rel}: потрібен patch target kind Deployment з op: replace, path /spec/template/spec/nodeSelector та yandex.cloud/preemptible: false (abie.mdc)`
822
+ `${rel}: потрібен patch target kind Deployment: path /spec/template/spec/nodeSelector та yandex.cloud/preemptible: false (abie.mdc)`
828
823
  )
829
824
  return
830
825
  }
@@ -833,7 +828,7 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail, passF
833
828
  }
834
829
 
835
830
  /**
836
- * Якщо є **Deployment** під **k8s**, вимагає в кожному overlay **ua** та **ru** patch **HTTPRoute/nginx-run** (abie.mdc).
831
+ * Якщо є **Deployment** під **k8s**, вимагає в кожному overlay **ua** та **ru** patch **HTTPRoute** (непорожній **target.name**) за abie.mdc.
837
832
  * @param {string} root корінь репозиторію
838
833
  * @param {string[]} yamlFilesAbs yaml під k8s
839
834
  * @param {(msg: string) => void} fail callback
@@ -844,7 +839,7 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
844
839
  const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
845
840
  if (uaAbsList.length === 0) {
846
841
  fail(
847
- 'Є Deployment у k8s — додай ua/kustomization.yaml з patch HTTPRoute nginx-run (hostnames, parentRefs namespace ua) — abie.mdc'
842
+ 'Є Deployment у k8s — додай ua/kustomization.yaml з patch HTTPRoute (будь-який target.name: hostnames, parentRefs namespace ua) — abie.mdc'
848
843
  )
849
844
  return
850
845
  }
@@ -864,13 +859,13 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
864
859
  fail(`${rel}: ${v}`)
865
860
  return
866
861
  }
867
- passFn(`${rel}: HTTPRoute nginx-run (ua) відповідає abie.mdc`)
862
+ passFn(`${rel}: HTTPRoute patch (ua) відповідає abie.mdc`)
868
863
  }
869
864
 
870
865
  const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
871
866
  if (ruAbsList.length === 0) {
872
867
  fail(
873
- 'Є Deployment у k8s — додай ru/kustomization.yaml з patch HTTPRoute nginx-run (hostnames, namespace ru, gwin websocket) — abie.mdc'
868
+ 'Є Deployment у k8s — додай ru/kustomization.yaml з patch HTTPRoute (будь-який target.name: hostnames, namespace ru, gwin websocket) — abie.mdc'
874
869
  )
875
870
  return
876
871
  }
@@ -890,7 +885,7 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
890
885
  fail(`${rel}: ${v}`)
891
886
  return
892
887
  }
893
- passFn(`${rel}: HTTPRoute nginx-run (ru) відповідає abie.mdc`)
888
+ passFn(`${rel}: HTTPRoute patch (ru) відповідає abie.mdc`)
894
889
  }
895
890
  }
896
891
 
@@ -983,7 +978,7 @@ export async function check() {
983
978
  if (deploymentDirs.size > 0) {
984
979
  pass('Є Deployment — перевіряємо nodeSelector у ua/ru kustomization (abie.mdc)')
985
980
  await ensureUaRuAbieNodeSelectorPatches(root, yamlFiles, fail, pass)
986
- pass('Є Deployment — перевіряємо HTTPRoute nginx-run у ua/ru kustomization (abie.mdc)')
981
+ pass('Є Deployment — перевіряємо HTTPRoute у ua/ru kustomization (abie.mdc)')
987
982
  await ensureUaRuAbieHttpRoutePatches(root, yamlFiles, fail, pass)
988
983
  }
989
984
 
@@ -40,6 +40,9 @@
40
40
  * Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; у **`k8s/base/kustomization.yaml`**
41
41
  * завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує).
42
42
  *
43
+ * **Inline JSON6902** у **`patches`** (і зовнішні файли з **`patches[].path`** під **`k8s`**, якщо вміст — масив JSON Patch): не допускається пара **`remove`** і **`add`**
44
+ * на один і той самий **`path`** у межах одного фрагмента — потрібен **`op: replace`** (k8s.mdc). **check-k8s** це перевіряє.
45
+ *
43
46
  * Явні винятки до загальної логіки yannh/datree — таблиця **`EXPLICIT_K8S_SCHEMAS`** (`Map`): ключ
44
47
  * **`apiVersion`, `kind`, `type`** (для CRD без поля `type` у маніфесті — зірочка **`*`** як третій
45
48
  * компонент). Спочатку шукається збіг за фактичним `type`, потім за **`*`**.
@@ -679,6 +682,224 @@ export function ruKustomizationHasHealthCheckDeletePatch(raw) {
679
682
  return true
680
683
  }
681
684
 
685
+ /**
686
+ * Чи абсолютний шлях лежить усередині кореня репозиторію (без виходу через `..`).
687
+ * @param {string} rootAbs абсолютний корінь
688
+ * @param {string} fileAbs абсолютний шлях до файлу
689
+ * @returns {boolean} true, якщо `fileAbs` усередині `rootAbs`
690
+ */
691
+ function resolvedFilePathIsUnderRoot(rootAbs, fileAbs) {
692
+ const r = resolve(rootAbs)
693
+ const f = resolve(fileAbs)
694
+ const rel = relative(r, f).replaceAll('\\', '/')
695
+ if (rel === '') {
696
+ return true
697
+ }
698
+ return !rel.startsWith('../') && rel !== '..'
699
+ }
700
+
701
+ /**
702
+ * Нормалізує **`path`** з операції JSON Patch (RFC 6902).
703
+ * @param {string} p значення поля **path**
704
+ * @returns {string} обрізаний рядок
705
+ */
706
+ function normalizeJsonPatchPath(p) {
707
+ return typeof p === 'string' ? p.trim() : ''
708
+ }
709
+
710
+ /**
711
+ * Витягує пари **op** / **path** з масиву операцій JSON6902.
712
+ * @param {unknown[]} arr корінь-масив з YAML/JSON
713
+ * @returns {Array<{ op: string, path: string }>} **op** у нижньому регістрі
714
+ */
715
+ function extractJson6902OpsFromArray(arr) {
716
+ /** @type {Array<{ op: string, path: string }>} */
717
+ const out = []
718
+ for (const item of arr) {
719
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
720
+ const rec = /** @type {Record<string, unknown>} */ (item)
721
+ const op = rec.op
722
+ const path = rec.path
723
+ if (typeof op === 'string' && typeof path === 'string') {
724
+ const p = normalizeJsonPatchPath(path)
725
+ if (p !== '') {
726
+ out.push({ op: op.trim().toLowerCase(), path: p })
727
+ }
728
+ }
729
+ }
730
+ }
731
+ return out
732
+ }
733
+
734
+ /**
735
+ * Витягує операції JSON6902 з тексту inline **patch** або окремого файлу patch (YAML-масив або JSON-масив).
736
+ * Інший вміст (strategic merge, `$patch: delete` тощо) дає порожній масив.
737
+ * @param {string} patchText вміст поля **patch** або файлу
738
+ * @returns {Array<{ op: string, path: string }>}
739
+ */
740
+ export function collectJson6902OperationsFromPatchText(patchText) {
741
+ const t = typeof patchText === 'string' ? patchText.trim() : ''
742
+ if (t === '') {
743
+ return []
744
+ }
745
+ try {
746
+ const docs = parseAllDocuments(t)
747
+ for (const d of docs) {
748
+ if (d.errors.length > 0) {
749
+ continue
750
+ }
751
+ const j = d.toJSON()
752
+ if (Array.isArray(j)) {
753
+ return extractJson6902OpsFromArray(j)
754
+ }
755
+ }
756
+ } catch {
757
+ /* пробуємо JSON */
758
+ }
759
+ if (t.startsWith('[')) {
760
+ try {
761
+ const j = JSON.parse(t)
762
+ if (Array.isArray(j)) {
763
+ return extractJson6902OpsFromArray(j)
764
+ }
765
+ } catch {
766
+ /* ignore */
767
+ }
768
+ }
769
+ return []
770
+ }
771
+
772
+ /**
773
+ * Шляхи JSON Patch, де в одному наборі операцій є і **remove**, і **add** (k8s.mdc: краще **replace**).
774
+ * @param {Array<{ op: string, path: string }>} ops нормалізовані **op**
775
+ * @returns {string[]} унікальні **path** з порушенням (відсортовано)
776
+ */
777
+ export function json6902PathsWithRemoveAndAddOnSamePath(ops) {
778
+ /** @type {Map<string, Set<string>>} */
779
+ const byPath = new Map()
780
+ for (const { op, path } of ops) {
781
+ if (!path) {
782
+ continue
783
+ }
784
+ if (!byPath.has(path)) {
785
+ byPath.set(path, new Set())
786
+ }
787
+ byPath.get(path).add(op)
788
+ }
789
+ /** @type {string[]} */
790
+ const out = []
791
+ for (const [path, set] of byPath) {
792
+ if (set.has('remove') && set.has('add')) {
793
+ out.push(path)
794
+ }
795
+ }
796
+ return out.toSorted((a, b) => a.localeCompare(b))
797
+ }
798
+
799
+ /**
800
+ * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: у inline **`patch`** і у зовнішніх patch-файлах не має бути **remove** і **add** на той самий **path**.
801
+ * @param {string} root корінь репозиторію
802
+ * @param {string[]} yamlFilesAbs абсолютні шляхи до yaml під k8s
803
+ * @param {(msg: string) => void} fail реєстрація порушення
804
+ * @returns {Promise<void>}
805
+ */
806
+ async function validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFilesAbs, fail) {
807
+ const rootNorm = resolve(root)
808
+ for (const kustAbs of yamlFilesAbs) {
809
+ if (basename(kustAbs).toLowerCase() !== 'kustomization.yaml') {
810
+ continue
811
+ }
812
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
813
+ let raw
814
+ try {
815
+ raw = await readFile(kustAbs, 'utf8')
816
+ } catch (error) {
817
+ const msg = error instanceof Error ? error.message : String(error)
818
+ fail(`${rel}: не вдалося прочитати для перевірки JSON6902 (${msg})`)
819
+ continue
820
+ }
821
+ const lines = toLines(raw)
822
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
823
+ /** @type {import('yaml').Document[]} */
824
+ let docs
825
+ try {
826
+ docs = parseAllDocuments(body)
827
+ } catch {
828
+ continue
829
+ }
830
+ for (const doc of docs) {
831
+ if (doc.errors.length > 0) {
832
+ continue
833
+ }
834
+ const rootObj = doc.toJSON()
835
+ if (rootObj === null || typeof rootObj !== 'object' || Array.isArray(rootObj)) {
836
+ continue
837
+ }
838
+ const rec = /** @type {Record<string, unknown>} */ (rootObj)
839
+ if (rec.kind !== 'Kustomization') {
840
+ continue
841
+ }
842
+ const patches = rec.patches
843
+ if (!Array.isArray(patches)) {
844
+ continue
845
+ }
846
+ let patchIdx = 0
847
+ for (const p of patches) {
848
+ patchIdx++
849
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) {
850
+ continue
851
+ }
852
+ const pr = /** @type {Record<string, unknown>} */ (p)
853
+ if (typeof pr.patch === 'string' && pr.patch.trim() !== '') {
854
+ const ops = collectJson6902OperationsFromPatchText(pr.patch)
855
+ const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
856
+ if (bad.length > 0) {
857
+ fail(
858
+ `${rel}: patches[${patchIdx}] inline JSON6902: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
859
+ )
860
+ }
861
+ }
862
+ if (typeof pr.path === 'string' && pr.path.trim() !== '') {
863
+ const patchRef = pr.path.trim()
864
+ const resolved = resolve(dirname(kustAbs), patchRef)
865
+ if (!resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
866
+ continue
867
+ }
868
+ if (!existsSync(resolved)) {
869
+ continue
870
+ }
871
+ let st
872
+ try {
873
+ st = await stat(resolved)
874
+ } catch {
875
+ continue
876
+ }
877
+ if (!st.isFile()) {
878
+ continue
879
+ }
880
+ let pRaw
881
+ try {
882
+ pRaw = await readFile(resolved, 'utf8')
883
+ } catch {
884
+ continue
885
+ }
886
+ const ops = collectJson6902OperationsFromPatchText(pRaw)
887
+ if (ops.length === 0) {
888
+ continue
889
+ }
890
+ const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
891
+ if (bad.length > 0) {
892
+ const relPatch = (relative(root, resolved) || patchRef).replaceAll('\\', '/')
893
+ fail(
894
+ `${rel}: patch-файл «${relPatch}»: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
895
+ )
896
+ }
897
+ }
898
+ }
899
+ }
900
+ }
901
+ }
902
+
682
903
  /**
683
904
  * Шукає **Ingress** у розібраних документах; реєструє порушення.
684
905
  * @param {string} rel відносний шлях до файлу
@@ -1510,6 +1731,8 @@ export async function check() {
1510
1731
 
1511
1732
  await validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail)
1512
1733
 
1734
+ await validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFiles, fail)
1735
+
1513
1736
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
1514
1737
 
1515
1738
  return reporter.getExitCode()
@@ -10,7 +10,7 @@ import { pass } from './pass.mjs'
10
10
 
11
11
  /**
12
12
  * Створює пару `pass` / `fail` з накопиченням ненульового коду виходу.
13
- * @returns {{ pass: typeof pass, fail: (msg: string) => void, getExitCode: () => number }}
13
+ * @returns {{ pass: typeof pass, fail: (msg: string) => void, getExitCode: () => number }} об’єкт з `pass`, `fail` і `getExitCode()` (0 або 1 після будь-якого `fail`)
14
14
  */
15
15
  export function createCheckReporter() {
16
16
  let exitCode = 0