@nitra/cursor 1.8.64 → 1.8.71

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,20 +1,22 @@
1
1
  ---
2
- description: Правила для проєктів abinbevefes
2
+ description: Правила для проєктів AbInBev Efes
3
3
  alwaysApply: true
4
- version: '1.0'
4
+ version: '1.4'
5
5
  ---
6
6
 
7
- ## k8s
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** без зауважень.
8
8
 
9
- Якщо в проекті є k8s deployment, рядом з ним повинен бути:
9
+ ## k8s: `hc.yaml` поруч із Deployment
10
+
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`**.
10
12
 
11
13
  ```yaml title="hc.yaml"
12
14
  # yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json
13
15
  apiVersion: networking.gke.io/v1
14
16
  kind: HealthCheckPolicy
15
17
  metadata:
16
- name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
17
- namespace: dev # буде замінено через kustomize
18
+ name: СЕРВІС
19
+ namespace: dev # kustomize overlay
18
20
  spec:
19
21
  default:
20
22
  config:
@@ -25,34 +27,117 @@ spec:
25
27
  targetRef:
26
28
  group: ''
27
29
  kind: Service
28
- name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
30
+ name: СЕРВІС
29
31
  ```
30
32
 
31
- **Overlay `ru`:** у **`…/ru/kustomization.yaml`** під **`k8s`** додай **видалення** ресурсу HealthCheckPolicy для середовища, де політика не потрібна (підстав **реальне ім’я** замість `НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ`):
33
+ ## k8s: overlay **HTTPRoute** `nginx-run` (**ua** / **ru**)
34
+
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`**.
32
36
 
33
- ```yaml title="kustomization.yaml"
37
+ ```yaml title="…/ua/kustomization.yaml (фрагмент)"
38
+ - target:
39
+ kind: HTTPRoute
40
+ name: nginx-run
41
+ patch: |-
42
+ - op: replace
43
+ path: /spec/hostnames
44
+ value:
45
+ - "abie.app" # також допускається vybeerai.com.ua, *.vybeerai.com.ua, *.abie.app
46
+ - op: replace
47
+ path: /spec/parentRefs/0/namespace
48
+ value: ua
49
+ ```
50
+
51
+ ```yaml title="…/ru/kustomization.yaml (фрагмент)"
52
+ - target:
53
+ kind: HTTPRoute
54
+ name: nginx-run
55
+ patch: |-
56
+ - op: replace
57
+ path: /spec/hostnames
58
+ value:
59
+ - "napitkivmeste.tech" # також допускається выбирайонлайн.рф, *.napitkivmeste.tech, *.выбирайонлайн.рф
60
+ - op: replace
61
+ path: /spec/parentRefs/0/namespace
62
+ value: ru
63
+ ```
64
+
65
+ ```yaml title="…/ru/kustomization.yaml (фрагмент)"
66
+ - target:
67
+ kind: HTTPRoute
68
+ name: nginx-run
69
+ patch: |-
70
+ - op: add
71
+ path: /metadata/annotations
72
+ value:
73
+ gwin.yandex.cloud/rules.http.upgradeTypes: "websocket"
74
+ ```
75
+
76
+ ## k8s: overlay **`ru`** і HealthCheckPolicy
77
+
78
+ Якщо в дереві **k8s** є **HealthCheckPolicy**, **check abie** вимагає **`ru/kustomization.yaml`** з patch **`$patch: delete`** для політики (узгоджено з **k8s.mdc** / **check-k8s**, **`ruKustomizationHasHealthCheckDeletePatch`** у **`npm/scripts/check-k8s.mjs`**). Підстав реальне ім’я замість **`СЕРВІС`**:
79
+
80
+ ```yaml title="…/ru/kustomization.yaml (фрагмент)"
34
81
  patches:
35
82
  - target:
36
83
  kind: HealthCheckPolicy
84
+ name: СЕРВІС
37
85
  patch: |-
38
86
  kind: HealthCheckPolicy
39
87
  metadata:
40
- name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
88
+ name: СЕРВІС
41
89
  $patch: delete
42
90
  ```
43
91
 
44
- ## branch
92
+ ## k8s: overlay **`ua`** / **`ru`** і nodeSelector
45
93
 
46
- В lean-merged-branch.yml список гілок, які повинні бути:
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`**.
47
95
 
48
- ```yaml title=".github/workflows/clean-merged-branch.yml"
49
- ignore_branches: dev,ua,ru
96
+ ```yaml title="…/ua/kustomization.yaml (фрагмент)"
97
+ patches:
98
+ - target:
99
+ kind: Deployment
100
+ name: my-app
101
+ patch: |-
102
+ - op: add
103
+ path: /spec/template/spec/nodeSelector
104
+ value:
105
+ preem: 'false'
50
106
  ```
51
107
 
52
- ## Перевірка
108
+ ```yaml title="…/ru/kustomization.yaml (фрагмент)"
109
+ patches:
110
+ - target:
111
+ kind: Deployment
112
+ name: my-app
113
+ patch: |-
114
+ - op: replace
115
+ path: /spec/template/spec/nodeSelector
116
+ value:
117
+ yandex.cloud/preemptible: "false"
118
+ ```
119
+
120
+ ### Базовий Deployment (`…/base/`)
53
121
 
54
- `npx @nitra/cursor check abie`
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`**.
55
123
 
56
- Повна матриця полів **`hc.yaml`**, **`ignore_branches`** і умов для **`ru/kustomization.yaml`** — у **`npm/scripts/check-abie.mjs`**.
124
+ ```yaml title="…/base/deploy.yaml (фрагмент)"
125
+ spec:
126
+ template:
127
+ spec:
128
+ nodeSelector:
129
+ preem: 'true' # буде замінено через kustomize
130
+ ```
131
+
132
+ ## Git branches
133
+
134
+ У **`.github/workflows/clean-merged-branch.yml`** у кроці **`phpdocker-io/github-actions-delete-abandoned-branches`** значення **`with.ignore_branches`** має містити **dev**, **ua** та **ru** (разом з іншими гілками, якщо потрібно), наприклад:
135
+
136
+ ```yaml title=".github/workflows/clean-merged-branch.yml (фрагмент)"
137
+ with:
138
+ ignore_branches: main,dev,ua,ru
139
+ ```
140
+
141
+ ## Перевірка
57
142
 
58
- Програмна перевірка (**`check-abie.mjs`**) виконується лише якщо у **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень (щоб не вимагати **ua**/**ru** у репозиторіях без цього правила).
143
+ **`npx @nitra/cursor check abie`**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.64",
3
+ "version": "1.8.71",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Перевіряє відповідність проєкту правилу abie.mdc (проєкти abinbevefes).
2
+ * Перевіряє відповідність проєкту правилу abie.mdc (проєкти AbInBev Efes).
3
3
  *
4
4
  * Застосовується лише якщо у **`.n-cursor.json`** у масиві **`rules`** є **`abie`** — інакше вихід **0**
5
5
  * без перевірок (щоб не суперечити типовому **ga.mdc** з **`ignore_branches: main,dev`**).
@@ -12,7 +12,18 @@
12
12
  * має існувати **`hc.yaml`** із **`HealthCheckPolicy`** (**`networking.gke.io/v1`**), modeline **`$schema`**
13
13
  * як у abie.mdc, **`/healthz`**, порт **8080**, **`targetRef`** на **Service** з тим самим **`metadata.name`**.
14
14
  * Якщо в дереві **k8s** є **HealthCheckPolicy**, перевіряється **`ru/kustomization.yaml`** з patch **`$patch: delete`**
15
- * (узгоджено з **k8s.mdc** / **check-k8s.mjs**).
15
+ * (логіка вмісту **`ruKustomizationHasHealthCheckDeletePatch`** у **check-k8s.mjs**, узгоджено з **k8s.mdc**).
16
+ *
17
+ * **nodeSelector (base):** якщо **Deployment** лежить у шляху з сегментом **`base`** (наприклад **`…/k8s/base/deploy.yaml`**),
18
+ * у **`spec.template.spec.nodeSelector`** має бути **`preem`** з булевим значенням **true** або рядком **`'true'`** — overlay **ua** та **ru** далі підміняють селектор.
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).
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`**.
16
27
  */
17
28
  import { existsSync } from 'node:fs'
18
29
  import { readFile } from 'node:fs/promises'
@@ -20,7 +31,7 @@ import { dirname, join, relative } from 'node:path'
20
31
 
21
32
  import { parseAllDocuments } from 'yaml'
22
33
 
23
- import { isRuKustomizationPath, pathHasK8sSegment, ruKustomizationHasHealthCheckDeletePatch } from './check-k8s.mjs'
34
+ import { pathHasK8sSegment, ruKustomizationHasHealthCheckDeletePatch } from './check-k8s.mjs'
24
35
  import { pass } from './utils/pass.mjs'
25
36
  import { flattenWorkflowSteps, getStepUses, parseWorkflowYaml } from './utils/gha-workflow.mjs'
26
37
  import { walkDir } from './utils/walkDir.mjs'
@@ -35,6 +46,80 @@ const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
35
46
  /** Гілки, які мають бути в **`ignore_branches`** за abie.mdc. */
36
47
  export const ABIE_REQUIRED_IGNORE_BRANCHES = ['dev', 'ua', 'ru']
37
48
 
49
+ /**
50
+ * Чи відносний шлях вказує на **`ru/kustomization.yaml`** (сегмент **`ru`** перед ім’ям файлу) — специфіка abie overlay.
51
+ * @param {string} rel шлях від кореня репозиторію
52
+ * @returns {boolean} true, якщо це `…/ru/kustomization.yaml`
53
+ */
54
+ export function isRuKustomizationPath(rel) {
55
+ const norm = rel.replaceAll('\\', '/')
56
+ return /(^|\/)ru\/kustomization\.yaml$/u.test(norm)
57
+ }
58
+
59
+ /**
60
+ * Чи відносний шлях вказує на **`ua/kustomization.yaml`** (сегмент **`ua`** перед ім’ям файлу) — специфіка abie overlay.
61
+ * @param {string} rel шлях від кореня репозиторію
62
+ * @returns {boolean} true, якщо це `…/ua/kustomization.yaml`
63
+ */
64
+ export function isUaKustomizationPath(rel) {
65
+ const norm = rel.replaceAll('\\', '/')
66
+ return /(^|\/)ua\/kustomization\.yaml$/u.test(norm)
67
+ }
68
+
69
+ /**
70
+ * Чи відносний шлях до YAML під **k8s** вказує на файл у каталозі **`base`** (сегмент **`base`** у шляху), abie.mdc.
71
+ * @param {string} rel шлях від кореня репозиторію
72
+ * @returns {boolean} true, якщо в шляху є **`/base/`**
73
+ */
74
+ export function isAbieK8sBaseYamlPath(rel) {
75
+ const norm = rel.replaceAll('\\', '/')
76
+ return /(^|\/)base\//u.test(norm)
77
+ }
78
+
79
+ /**
80
+ * Чи значення **`preem`** у base **Deployment** вважається «істинним» за abie.mdc (**true** або рядок **`true`** без урахування регістру).
81
+ * @param {unknown} v значення з YAML
82
+ * @returns {boolean}
83
+ */
84
+ function isAbiePreemTrueish(v) {
85
+ if (v === true) {
86
+ return true
87
+ }
88
+ if (typeof v === 'string' && v.trim().toLowerCase() === 'true') {
89
+ return true
90
+ }
91
+ return false
92
+ }
93
+
94
+ /**
95
+ * Чи документ **Deployment** у **`…/base/…`** містить **`spec.template.spec.nodeSelector.preem`** зі значенням **true** (abie.mdc).
96
+ * @param {unknown} obj корінь YAML-документа (**Deployment**)
97
+ * @returns {boolean} true, якщо критерії виконано
98
+ */
99
+ export function deploymentDocumentHasAbieBasePreemNodeSelector(obj) {
100
+ if (!isDeploymentDoc(obj)) {
101
+ return false
102
+ }
103
+ const rec = /** @type {Record<string, unknown>} */ (obj)
104
+ const spec = rec.spec
105
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
106
+ return false
107
+ }
108
+ const template = /** @type {Record<string, unknown>} */ (spec).template
109
+ if (template === null || typeof template !== 'object' || Array.isArray(template)) {
110
+ return false
111
+ }
112
+ const podSpec = /** @type {Record<string, unknown>} */ (template).spec
113
+ if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec)) {
114
+ return false
115
+ }
116
+ const nodeSelector = /** @type {Record<string, unknown>} */ (podSpec).nodeSelector
117
+ if (nodeSelector === null || typeof nodeSelector !== 'object' || Array.isArray(nodeSelector)) {
118
+ return false
119
+ }
120
+ return isAbiePreemTrueish(nodeSelector.preem)
121
+ }
122
+
38
123
  /**
39
124
  * Чи увімкнено правило **abie** у конфігу репозиторію.
40
125
  * @param {string} root корінь репозиторію (cwd)
@@ -189,6 +274,64 @@ async function collectDeploymentDirs(root, yamlAbs, fail) {
189
274
  return dirs
190
275
  }
191
276
 
277
+ /**
278
+ * Для кожного **Deployment** у YAML під **`k8s`** з шляхом **`…/base/…`** вимагає **`spec.template.spec.nodeSelector.preem: true`** (abie.mdc).
279
+ * @param {string} root корінь репозиторію
280
+ * @param {string[]} yamlFilesAbs yaml під k8s
281
+ * @param {(msg: string) => void} fail callback
282
+ * @returns {Promise<void>}
283
+ */
284
+ async function ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFilesAbs, fail) {
285
+ const baseFiles = yamlFilesAbs.filter(abs => {
286
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
287
+ return isAbieK8sBaseYamlPath(rel)
288
+ })
289
+ let anyBaseDeployment = false
290
+ for (const abs of baseFiles) {
291
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
292
+ let raw
293
+ try {
294
+ raw = await readFile(abs, 'utf8')
295
+ } catch (error) {
296
+ const msg = error instanceof Error ? error.message : String(error)
297
+ fail(`${rel}: не вдалося прочитати (${msg})`)
298
+ return
299
+ }
300
+ const body = stripBom(raw)
301
+ const lines = body.split(/\r?\n/u)
302
+ const first = lines[0] ?? ''
303
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
304
+ /** @type {import('yaml').Document[]} */
305
+ let docs
306
+ try {
307
+ docs = parseAllDocuments(rest)
308
+ } catch (error) {
309
+ const msg = error instanceof Error ? error.message : String(error)
310
+ fail(`${rel}: YAML (${msg})`)
311
+ return
312
+ }
313
+ for (const doc of docs) {
314
+ if (doc.errors.length > 0) {
315
+ continue
316
+ }
317
+ const obj = doc.toJSON()
318
+ if (!isDeploymentDoc(obj)) {
319
+ continue
320
+ }
321
+ anyBaseDeployment = true
322
+ if (!deploymentDocumentHasAbieBasePreemNodeSelector(obj)) {
323
+ fail(`${rel}: Deployment у base: потрібен spec.template.spec.nodeSelector.preem: true (або 'true') — abie.mdc`)
324
+ return
325
+ }
326
+ }
327
+ }
328
+ if (anyBaseDeployment) {
329
+ pass('Deployment у …/base/…: nodeSelector.preem відповідає abie.mdc')
330
+ } else {
331
+ pass('Немає Deployment у шляхах …/base/… — перевірку preem у base пропущено')
332
+ }
333
+ }
334
+
192
335
  /**
193
336
  * Прибирає BOM на початку файлу.
194
337
  * @param {string} s вміст
@@ -198,6 +341,261 @@ function stripBom(s) {
198
341
  return s.startsWith('\uFEFF') ? s.slice(1) : s
199
342
  }
200
343
 
344
+ /**
345
+ * Чи рядок inline JSON6902 patch містить очікуваний **ua** nodeSelector (**op: add**, **preem: false**).
346
+ * @param {string} patchText поле **patch** у kustomization
347
+ * @returns {boolean} true, якщо критерії abie.mdc виконано
348
+ */
349
+ function jsonPatchTextHasUaDeploymentNodeSelector(patchText) {
350
+ if (typeof patchText !== 'string' || patchText.trim() === '') {
351
+ return false
352
+ }
353
+ if (!/op:\s*add\b/u.test(patchText)) {
354
+ return false
355
+ }
356
+ if (!/path:\s*\/spec\/template\/spec\/nodeSelector\b/u.test(patchText)) {
357
+ return false
358
+ }
359
+ if (!/\bpreem:\s*['"]?false['"]?\b/u.test(patchText)) {
360
+ return false
361
+ }
362
+ return true
363
+ }
364
+
365
+ /**
366
+ * Чи рядок inline JSON6902 patch містить очікуваний **ru** nodeSelector (**op: replace**, **yandex.cloud/preemptible: false**).
367
+ * @param {string} patchText поле **patch** у kustomization
368
+ * @returns {boolean} true, якщо критерії abie.mdc виконано
369
+ */
370
+ function jsonPatchTextHasRuDeploymentNodeSelector(patchText) {
371
+ if (typeof patchText !== 'string' || patchText.trim() === '') {
372
+ return false
373
+ }
374
+ if (!/op:\s*replace\b/u.test(patchText)) {
375
+ return false
376
+ }
377
+ if (!/path:\s*\/spec\/template\/spec\/nodeSelector\b/u.test(patchText)) {
378
+ return false
379
+ }
380
+ if (!/yandex\.cloud\/preemptible:\s*['"]?false['"]?/u.test(patchText)) {
381
+ return false
382
+ }
383
+ return true
384
+ }
385
+
386
+ /**
387
+ * Чи один елемент **patches** у kustomization відповідає abie nodeSelector для заданого **mode**.
388
+ * @param {unknown} p елемент масиву **patches**
389
+ * @param {'ua' | 'ru'} mode який overlay перевіряти
390
+ * @returns {boolean} true, якщо patch відповідає abie для **mode**
391
+ */
392
+ function inlineKustomizationPatchMatchesAbieMode(p, mode) {
393
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) {
394
+ return false
395
+ }
396
+ const pr = /** @type {Record<string, unknown>} */ (p)
397
+ const target = pr.target
398
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) {
399
+ return false
400
+ }
401
+ const tg = /** @type {Record<string, unknown>} */ (target)
402
+ if (tg.kind !== 'Deployment') {
403
+ return false
404
+ }
405
+ const patchStr = pr.patch
406
+ if (typeof patchStr !== 'string') {
407
+ return false
408
+ }
409
+ if (mode === 'ua' && jsonPatchTextHasUaDeploymentNodeSelector(patchStr)) {
410
+ return true
411
+ }
412
+ if (mode === 'ru' && jsonPatchTextHasRuDeploymentNodeSelector(patchStr)) {
413
+ return true
414
+ }
415
+ return false
416
+ }
417
+
418
+ /**
419
+ * Чи один YAML-документ kustomization містить відповідний inline patch на Deployment.
420
+ * @param {import('yaml').Document} doc документ після **parseAllDocuments**
421
+ * @param {'ua' | 'ru'} mode який overlay перевіряти
422
+ * @returns {boolean} true, якщо знайдено відповідний patch
423
+ */
424
+ function kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode) {
425
+ if (doc.errors.length > 0) {
426
+ return false
427
+ }
428
+ const root = doc.toJSON()
429
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
430
+ return false
431
+ }
432
+ const rec = /** @type {Record<string, unknown>} */ (root)
433
+ if (rec.kind !== 'Kustomization') {
434
+ return false
435
+ }
436
+ const patches = rec.patches
437
+ if (!Array.isArray(patches)) {
438
+ return false
439
+ }
440
+ for (const p of patches) {
441
+ if (inlineKustomizationPatchMatchesAbieMode(p, mode)) {
442
+ return true
443
+ }
444
+ }
445
+ return false
446
+ }
447
+
448
+ /**
449
+ * Чи **kustomization.yaml** містить inline **patches** на **Deployment** з nodeSelector за abie.mdc (**ua** або **ru**).
450
+ * @param {string} raw повний текст файлу
451
+ * @param {'ua' | 'ru'} mode який overlay перевіряти
452
+ * @returns {boolean} true, якщо знайдено відповідний patch
453
+ */
454
+ export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
455
+ const body = stripBom(raw)
456
+ const lines = body.split(/\r?\n/u)
457
+ const first = lines[0] ?? ''
458
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
459
+ /** @type {import('yaml').Document[]} */
460
+ let docs
461
+ try {
462
+ docs = parseAllDocuments(rest)
463
+ } catch {
464
+ return false
465
+ }
466
+ for (const doc of docs) {
467
+ if (kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode)) {
468
+ return true
469
+ }
470
+ }
471
+ return false
472
+ }
473
+
474
+ /** Домени **hostnames** для overlay **ua** (підрядки у JSON6902-тексті patch), abie.mdc. */
475
+ const ABIE_UA_HTTPROUTE_HOST_MARKERS = ['abie.app', 'vybeerai.com.ua', '*.abie.app', '*.vybeerai.com.ua']
476
+
477
+ /** Домени **hostnames** для overlay **ru** (підрядки), abie.mdc. */
478
+ const ABIE_RU_HTTPROUTE_HOST_MARKERS = [
479
+ 'napitkivmeste.tech',
480
+ 'выбирайонлайн.рф',
481
+ '*.napitkivmeste.tech',
482
+ '*.выбирайонлайн.рф'
483
+ ]
484
+
485
+ /**
486
+ * Збирає тексти inline **patch** для **HTTPRoute/nginx-run** з одного розібраного документа **Kustomization**.
487
+ * @param {import('yaml').Document} doc документ після **parseAllDocuments**
488
+ * @returns {string[]} непорожні рядки **patch**
489
+ */
490
+ function collectNginxRunPatchStringsFromKustomizationDoc(doc) {
491
+ if (doc.errors.length > 0) {
492
+ return []
493
+ }
494
+ const root = doc.toJSON()
495
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
496
+ return []
497
+ }
498
+ const rec = /** @type {Record<string, unknown>} */ (root)
499
+ if (rec.kind !== 'Kustomization') {
500
+ return []
501
+ }
502
+ const patches = rec.patches
503
+ if (!Array.isArray(patches)) {
504
+ return []
505
+ }
506
+ /** @type {string[]} */
507
+ const out = []
508
+ for (const p of patches) {
509
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) {
510
+ continue
511
+ }
512
+ const pr = /** @type {Record<string, unknown>} */ (p)
513
+ const target = pr.target
514
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) {
515
+ continue
516
+ }
517
+ const tg = /** @type {Record<string, unknown>} */ (target)
518
+ if (tg.kind !== 'HTTPRoute' || tg.name !== 'nginx-run') {
519
+ continue
520
+ }
521
+ const patchStr = pr.patch
522
+ if (typeof patchStr === 'string' && patchStr.trim() !== '') {
523
+ out.push(patchStr)
524
+ }
525
+ }
526
+ return out
527
+ }
528
+
529
+ /**
530
+ * Об’єднує всі inline **JSON6902**-фрагменти для **HTTPRoute/nginx-run** у **kustomization.yaml** (усі документи у файлі).
531
+ * @param {string} raw повний текст файлу
532
+ * @returns {string} текст для **`validateAbieNginxRunHttpRoutePatches`** (може бути порожнім)
533
+ */
534
+ export function getCombinedNginxRunPatchTextFromKustomization(raw) {
535
+ const body = stripBom(raw)
536
+ const lines = body.split(/\r?\n/u)
537
+ const first = lines[0] ?? ''
538
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
539
+ /** @type {import('yaml').Document[]} */
540
+ let docs
541
+ try {
542
+ docs = parseAllDocuments(rest)
543
+ } catch {
544
+ return ''
545
+ }
546
+ /** @type {string[]} */
547
+ const chunks = []
548
+ for (const doc of docs) {
549
+ chunks.push(...collectNginxRunPatchStringsFromKustomizationDoc(doc))
550
+ }
551
+ return chunks.join('\n')
552
+ }
553
+
554
+ /**
555
+ * Перевіряє об’єднаний текст patch(ів) **HTTPRoute/nginx-run** на відповідність abie.mdc.
556
+ * @param {string} combined текст одного або кількох inline **patch** (з’єднаних перевідом рядка)
557
+ * @param {'ua' | 'ru'} mode **ua** або **ru**
558
+ * @returns {string | null} повідомлення про помилку або **null**
559
+ */
560
+ export function validateAbieNginxRunHttpRoutePatches(combined, mode) {
561
+ if (typeof combined !== 'string' || combined.trim() === '') {
562
+ return `очікується patch target kind HTTPRoute name nginx-run (replace hostnames, parentRefs namespace ${mode}; для ru — також gwin… upgradeTypes websocket) — abie.mdc`
563
+ }
564
+ const hasHostnamesReplace = /-\s*op:\s*replace\b[\s\S]{0,200}?path:\s*\/spec\/hostnames\b/m.test(combined)
565
+ if (!hasHostnamesReplace) {
566
+ return 'HTTPRoute nginx-run: потрібен блок op replace з path /spec/hostnames (abie.mdc)'
567
+ }
568
+ const markers = mode === 'ua' ? ABIE_UA_HTTPROUTE_HOST_MARKERS : ABIE_RU_HTTPROUTE_HOST_MARKERS
569
+ if (!markers.some(m => combined.includes(m))) {
570
+ return `HTTPRoute nginx-run: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
571
+ }
572
+ const ns = mode === 'ua' ? 'ua' : 'ru'
573
+ const nsRe = new RegExp(
574
+ String.raw`path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?${ns}['"]?(?:\s|$)`,
575
+ 'mu'
576
+ )
577
+ if (!nsRe.test(combined)) {
578
+ return `HTTPRoute nginx-run: потрібен replace path /spec/parentRefs/0/namespace з value ${ns} (abie.mdc)`
579
+ }
580
+ if (mode === 'ru') {
581
+ if (!/gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/m.test(combined)) {
582
+ return 'HTTPRoute nginx-run (ru): потрібна анотація gwin.yandex.cloud/rules.http.upgradeTypes: websocket (abie.mdc)'
583
+ }
584
+ }
585
+ return null
586
+ }
587
+
588
+ /**
589
+ * Чи **kustomization** містить валідні для abie патчі **HTTPRoute/nginx-run** (**ua** або **ru**).
590
+ * @param {string} raw повний текст **kustomization.yaml**
591
+ * @param {'ua' | 'ru'} mode overlay
592
+ * @returns {boolean} true, якщо **`validateAbieNginxRunHttpRoutePatches`** повертає **null**
593
+ */
594
+ export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
595
+ const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
596
+ return validateAbieNginxRunHttpRoutePatches(combined, mode) === null
597
+ }
598
+
201
599
  /**
202
600
  * Перевіряє **hc.yaml** на відповідність abie.mdc.
203
601
  * @param {string} raw повний текст файлу
@@ -341,7 +739,7 @@ async function collectHealthCheckPolicyRelPaths(root, yamlAbs) {
341
739
  }
342
740
 
343
741
  /**
344
- * Якщо є **HealthCheckPolicy**, вимагає **ru/kustomization.yaml** з patch видалення (як **check-k8s**).
742
+ * Якщо є **HealthCheckPolicy**, вимагає **ru/kustomization.yaml** з patch видалення (**ruKustomizationHasHealthCheckDeletePatch** у **check-k8s**).
345
743
  * @param {string} root корінь
346
744
  * @param {string[]} yamlFilesAbs абсолютні шляхи yaml k8s
347
745
  * @param {string[]} healthCheckPolicyRelativePaths відносні шляхи
@@ -377,6 +775,128 @@ async function ensureRuKustomizationHealthCheckDelete(root, yamlFilesAbs, health
377
775
  )
378
776
  }
379
777
 
778
+ /**
779
+ * Якщо є **Deployment** під **k8s**, вимагає в кожному overlay **ua** та **ru** (**kustomization.yaml**) JSON6902 patch nodeSelector (abie.mdc).
780
+ * @param {string} root корінь репозиторію
781
+ * @param {string[]} yamlFilesAbs yaml під k8s
782
+ * @param {(msg: string) => void} fail callback
783
+ * @returns {Promise<void>}
784
+ */
785
+ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail) {
786
+ const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
787
+ if (uaAbsList.length === 0) {
788
+ fail(
789
+ 'Є Deployment у k8s — додай ua/kustomization.yaml з inline patch на Deployment: op add, path /spec/template/spec/nodeSelector, preem false (abie.mdc)'
790
+ )
791
+ return
792
+ }
793
+ for (const abs of uaAbsList) {
794
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
795
+ let raw
796
+ try {
797
+ raw = await readFile(abs, 'utf8')
798
+ } catch (error) {
799
+ const msg = error instanceof Error ? error.message : String(error)
800
+ fail(`${rel}: не вдалося прочитати (${msg})`)
801
+ return
802
+ }
803
+ if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, 'ua')) {
804
+ fail(
805
+ `${rel}: потрібен patch target kind Deployment з op: add, path /spec/template/spec/nodeSelector та preem: false (abie.mdc)`
806
+ )
807
+ return
808
+ }
809
+ pass(`${rel}: nodeSelector patch (ua) відповідає abie.mdc`)
810
+ }
811
+
812
+ const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
813
+ if (ruAbsList.length === 0) {
814
+ fail(
815
+ 'Є Deployment у k8s — додай ru/kustomization.yaml з inline patch на Deployment: op replace, path /spec/template/spec/nodeSelector, yandex.cloud/preemptible false (abie.mdc)'
816
+ )
817
+ return
818
+ }
819
+ for (const abs of ruAbsList) {
820
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
821
+ let raw
822
+ try {
823
+ raw = await readFile(abs, 'utf8')
824
+ } catch (error) {
825
+ const msg = error instanceof Error ? error.message : String(error)
826
+ fail(`${rel}: не вдалося прочитати (${msg})`)
827
+ return
828
+ }
829
+ if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, 'ru')) {
830
+ fail(
831
+ `${rel}: потрібен patch target kind Deployment з op: replace, path /spec/template/spec/nodeSelector та yandex.cloud/preemptible: false (abie.mdc)`
832
+ )
833
+ return
834
+ }
835
+ pass(`${rel}: nodeSelector patch (ru) відповідає abie.mdc`)
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Якщо є **Deployment** під **k8s**, вимагає в кожному overlay **ua** та **ru** patch **HTTPRoute/nginx-run** (abie.mdc).
841
+ * @param {string} root корінь репозиторію
842
+ * @param {string[]} yamlFilesAbs yaml під k8s
843
+ * @param {(msg: string) => void} fail callback
844
+ * @returns {Promise<void>}
845
+ */
846
+ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail) {
847
+ const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
848
+ if (uaAbsList.length === 0) {
849
+ fail(
850
+ 'Є Deployment у k8s — додай ua/kustomization.yaml з patch HTTPRoute nginx-run (hostnames, parentRefs namespace ua) — abie.mdc'
851
+ )
852
+ return
853
+ }
854
+ for (const abs of uaAbsList) {
855
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
856
+ let raw
857
+ try {
858
+ raw = await readFile(abs, 'utf8')
859
+ } catch (error) {
860
+ const msg = error instanceof Error ? error.message : String(error)
861
+ fail(`${rel}: не вдалося прочитати (${msg})`)
862
+ return
863
+ }
864
+ const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
865
+ const v = validateAbieNginxRunHttpRoutePatches(combined, 'ua')
866
+ if (v !== null) {
867
+ fail(`${rel}: ${v}`)
868
+ return
869
+ }
870
+ pass(`${rel}: HTTPRoute nginx-run (ua) відповідає abie.mdc`)
871
+ }
872
+
873
+ const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
874
+ if (ruAbsList.length === 0) {
875
+ fail(
876
+ 'Є Deployment у k8s — додай ru/kustomization.yaml з patch HTTPRoute nginx-run (hostnames, namespace ru, gwin websocket) — abie.mdc'
877
+ )
878
+ return
879
+ }
880
+ for (const abs of ruAbsList) {
881
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
882
+ let raw
883
+ try {
884
+ raw = await readFile(abs, 'utf8')
885
+ } catch (error) {
886
+ const msg = error instanceof Error ? error.message : String(error)
887
+ fail(`${rel}: не вдалося прочитати (${msg})`)
888
+ return
889
+ }
890
+ const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
891
+ const v = validateAbieNginxRunHttpRoutePatches(combined, 'ru')
892
+ if (v !== null) {
893
+ fail(`${rel}: ${v}`)
894
+ return
895
+ }
896
+ pass(`${rel}: HTTPRoute nginx-run (ru) відповідає abie.mdc`)
897
+ }
898
+ }
899
+
380
900
  /**
381
901
  * Перевіряє відповідність проєкту правилам abie.mdc.
382
902
  * @returns {Promise<number>} 0 — OK, 1 — є порушення
@@ -457,6 +977,8 @@ export async function check() {
457
977
  )
458
978
  }
459
979
  }
980
+ pass('Є Deployment — перевіряємо base: spec.template.spec.nodeSelector.preem (abie.mdc)')
981
+ await ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFiles, fail)
460
982
  } else {
461
983
  pass('Немає Deployment у дереві k8s — перевірку hc.yaml пропущено')
462
984
  }
@@ -464,5 +986,12 @@ export async function check() {
464
986
  const healthCheckPolicyRelativePaths = await collectHealthCheckPolicyRelPaths(root, yamlFiles)
465
987
  await ensureRuKustomizationHealthCheckDelete(root, yamlFiles, healthCheckPolicyRelativePaths, fail)
466
988
 
989
+ if (deploymentDirs.size > 0) {
990
+ pass('Є Deployment — перевіряємо nodeSelector у ua/ru kustomization (abie.mdc)')
991
+ await ensureUaRuAbieNodeSelectorPatches(root, yamlFiles, fail)
992
+ pass('Є Deployment — перевіряємо HTTPRoute nginx-run у ua/ru kustomization (abie.mdc)')
993
+ await ensureUaRuAbieHttpRoutePatches(root, yamlFiles, fail)
994
+ }
995
+
467
996
  return exitCode
468
997
  }
@@ -666,16 +666,6 @@ function extractApiVersionAndKind(doc) {
666
666
  }
667
667
  }
668
668
 
669
- /**
670
- * Чи відносний шлях вказує на **`ru/kustomization.yaml`** (сегмент **`ru`** перед ім’ям файлу).
671
- * @param {string} rel шлях від кореня репозиторію
672
- * @returns {boolean} true, якщо це `…/ru/kustomization.yaml`
673
- */
674
- export function isRuKustomizationPath(rel) {
675
- const norm = rel.replaceAll('\\', '/')
676
- return /(^|\/)ru\/kustomization\.yaml$/u.test(norm)
677
- }
678
-
679
669
  /**
680
670
  * Чи вміст overlay **`ru/kustomization.yaml`** містить Kustomize patch видалення **HealthCheckPolicy**.
681
671
  * @param {string} raw повний текст файлу