@nitra/cursor 1.8.199 → 1.8.201

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,28 @@
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.201] - 2026-05-07
8
+
9
+ ### Changed
10
+
11
+ - `check-hasura.mjs`: `INTERNAL_HASURA_URL_RE` тепер приймає **обидва** кластерні DNS-суфікси у `HASURA_GRAPHQL_ENDPOINT` — `<cluster>.internal` (GKE/GCP, наприклад `abie-dev` / `abie-ua`) **і** `cluster.local` (стандартний k8s / Yandex Cloud). Раніше regex вимагав літеральний `.internal` у кінці, тож URL виду `http://apruv-h-hl.ru-apruv.svc.cluster.local:8080` (типовий для YC-кластера ru) помилково відхилявся. `parseInternalHasuraEndpoint` для YC повертає `cluster: 'cluster.local'` як повний суфікс, для GKE — ім'я кластера без `.internal` (зворотньо сумісно з попередньою поведінкою). Текст помилки в `checkEnvFile` оновлено — згадує обидва допустимі формати.
12
+ - `abie.mdc` (v1.17 → v1.19): нова секція «Внутрішньокластерні URL у env-файлах (dev / ua / ru)». Правило стосується **будь-якого** internal URL у env-файлах abie-проєкту — не лише `HASURA_GRAPHQL_ENDPOINT`, а й KVCMS, `auth-run-hl`, `file-link-hl` тощо. Таблиця `dev.env` / `ua.env` / `ru.env` → namespace-префікс + DNS-суфікс кластера (dev → `abie-dev.internal` + `dev-…`, ua → `abie-ua.internal` + `ua-…`, ru → `cluster.local` + `ru-…`); приклади з двома сервісами в одному файлі (Hasura + KVCMS). Загальне правило про **внутрішній** URL замість публічного домену для `HASURA_GRAPHQL_ENDPOINT` лишається у `hasura.mdc` (для nitra та abie).
13
+
14
+ ### Added
15
+
16
+ - `check-abie.mjs`: новий валідатор `validateAbieEnvInternalUrls` (`String.prototype.matchAll` за `ABIE_INTERNAL_URL_GLOBAL_RE`) і helper `abieEnvNameFromBasename`. У функції `check()` додано крок `ensureAbieEnvFilesMatchClusterDns`, що сканує всі `*.env`-файли (basename `dev.env` / `ua.env` / `ru.env` опційно з провідною крапкою; `.env` без імені пропускається — як у `check-hasura.mjs`) і для **кожного** знайденого URL виду `http://<svc>.<ns>.svc.<dns>` перевіряє відповідність DNS-суфікса й namespace-префікса середовищу env-файла. Помилки додаються через `fail`, без зупинки на першому файлі — звіт показує всі порушення в усіх env-файлах одразу.
17
+ - `tests/check-hasura.test.mjs`: тести `parseInternalHasuraEndpoint` для GKE-style `abie-dev.internal` / `abie-ua.internal` та YC-style `cluster.local`; негативний кейс на сторонній суфікс (`svc.example.com`); інтеграційний тест `check()` для `hasura/.ru.env` з `cluster.local`.
18
+ - `tests/check-abie.test.mjs`: 7 unit-тестів на `abieEnvNameFromBasename` і `validateAbieEnvInternalUrls` (узгоджений dev/ua/ru, URL без порту, dev URL у ua-файлі, internal-суфікс у ru-файлі, ігнорування зовнішніх `https://` / `localhost`, кілька URL з різними порушеннями) і 4 інтеграційні (`.dev.env`+`.ua.env`+`.ru.env` узгоджені — 0; ua з dev URL у KVCMS — 1; ru з `.internal` замість `cluster.local` — 1; `.env` без імені пропускається).
19
+
20
+ ## [1.8.200] - 2026-05-07
21
+
22
+ ### Added
23
+
24
+ - `policy/ga/clean-ga-workflows.rego` + новий PoC-крок у `scripts/lint-ga.mjs`: запускає `conftest test` на `.github/workflows/clean-ga-workflows.yml` проти Rego-полісі (структура `name` / `on` / `concurrency` / `jobs.cleanup_old_workflows.steps[0]`). Якщо `conftest` не в PATH — `ℹ` skip без помилки (паралельні JS-перевірки в `check-ga.mjs` залишаються джерелом істини). Додав `policy` у `files` пакету.
25
+ - `check-k8s.mjs`: структурний сорт `patches[]` у `kustomization.yaml` за tuple `[target.kind, target.name, target.namespace, path]` (`localeCompare('en', base)`); поля `target.group` / `target.version` у tuple не входять (діє правило «patches[].target: лише kind і name»). Додатково: вміст inline `patches[i].patch` (literal block scalar — масив JSON6902) сортується за `path`, **але лише** коли всі ops — `add` / `replace` і всі `path` попарно дизʼюнктні (жоден не префікс іншого) — інакше порядок не чіпається, бо `move` / `copy` / `test` / `remove` чи спільні шляхи семантично залежні (RFC 6902). Експортовані чисті валідатори: `kustomizationPatchesSortedViolation`, `kustomizationInlinePatchOpsSortedViolation`.
26
+ - `tests/check-k8s-schema.test.mjs`: тести на обидва нові валідатори (приклад із `k8s.mdc`: `ReferenceGrant atlas/apruv` → `apruv/atlas`; `add /spec/minReplicas` + `replace /spec/maxReplicas` → пересорт за `path`; пропуск для `test` / `move` / `copy` / `remove` і недизʼюнктних шляхів типу `/spec` vs `/spec/template`).
27
+ - `mdc/k8s.mdc`: розділ «Структурний сорт `patches[]` і inline JSON6902» з обома прикладами «❌/✅».
28
+
7
29
  ## [1.8.199] - 2026-05-07
8
30
 
9
31
  ### Added
package/mdc/abie.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Правила для проєктів AbInBev Efes
3
3
  alwaysApply: true
4
- version: '1.17'
4
+ version: '1.19'
5
5
  ---
6
6
 
7
7
  Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** — **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**, для спільних сервісів **`auth-run-hl`** / **`file-link-hl`** — **`namespace: dev`** у base та patch **`…/backendRefs/…/namespace`** у **ua** / **ru**), у overlay **ru** — кожен **Service** (у т. ч. **headless** / **`-hl`**) → **`spec.type: NodePort`** через **JSON6902** у **`kustomization.yaml`**, видалення **HealthCheckPolicy** у **ru**), гілки **dev**, **ua**, **ru** у **clean-merged-branch**, а також заборона тримати артефакти **Firebase Hosting** у **підкаталогах першого рівня** (безпосередні діти кореня репозиторію; у самому корені ці імена не вимагаються до видалення).
@@ -332,6 +332,46 @@ spec:
332
332
  preem: 'true' # буде замінено через kustomize
333
333
  ```
334
334
 
335
+ ## Внутрішньокластерні URL у env-файлах (dev / ua / ru)
336
+
337
+ Правило стосується **будь-якого** внутрішньокластерного URL у env-файлах abie-проєкту, а не лише `HASURA_GRAPHQL_ENDPOINT`. Це може бути URL до Hasura, KVCMS, `auth-run-hl`, `file-link-hl` чи будь-якого іншого Service у кластері — у всіх випадках DNS-суфікс і namespace-префікс мають відповідати **середовищу** з імені env-файлу.
338
+
339
+ abie-проєкти живуть у **трьох різних кластерах** (dev / ua у GKE, ru у Yandex Cloud), тож DNS-суфікс і namespace у URL відрізняються між `*.env`-файлами:
340
+
341
+ | env-файл (basename) | namespace-префікс у URL | DNS-суфікс кластера | примітка |
342
+ | --- | --- | --- | --- |
343
+ | `dev.env`, `.dev.env` | `dev-…` | `abie-dev.internal` | GKE-кластер dev |
344
+ | `ua.env`, `.ua.env` | `ua-…` | `abie-ua.internal` | GKE-кластер ua |
345
+ | `ru.env`, `.ru.env` | `ru-…` | `cluster.local` | YC-кластер ru, стандартний k8s DNS |
346
+
347
+ Канонічна форма URL — `http://<service>.<namespace>.svc.<cluster-dns-suffix>:<port>`. Для GKE (dev / ua) суфікс — `<cluster>.internal`; для YC (ru) — фіксований `cluster.local` (у YC у DNS сервісу немає окремого імені кластера).
348
+
349
+ Приклади для одного env-файлу з двома сервісами (Hasura + KVCMS):
350
+
351
+ ```env title="hasura/.dev.env"
352
+ HASURA_GRAPHQL_ENDPOINT=http://apruv-h-hl.dev-apruv.svc.abie-dev.internal:8080
353
+ KVCMS_URL=http://kvcms-hl.dev-apruv.svc.abie-dev.internal:8080
354
+ ```
355
+
356
+ ```env title="hasura/.ua.env"
357
+ HASURA_GRAPHQL_ENDPOINT=http://apruv-h-hl.ua-apruv.svc.abie-ua.internal:8080
358
+ KVCMS_URL=http://kvcms-hl.ua-apruv.svc.abie-ua.internal:8080
359
+ ```
360
+
361
+ ```env title="hasura/.ru.env"
362
+ HASURA_GRAPHQL_ENDPOINT=http://apruv-h-hl.ru-apruv.svc.cluster.local:8080
363
+ KVCMS_URL=http://kvcms-hl.ru-apruv.svc.cluster.local:8080
364
+ ```
365
+
366
+ `<namespace>` (наприклад `dev-apruv` / `ua-apruv` / `ru-apruv`) — `metadata.name` цільового namespace після kustomize-overlay для відповідного середовища; `<service>` — `metadata.name` headless Service (`-hl`) того сервісу, до якого йде URL.
367
+
368
+ **Перевірка `check-abie.mjs`** сканує всі `*.env` файли, basename яких збігається з `dev.env` / `ua.env` / `ru.env` (з провідною крапкою чи без), знаходить **усі** internal URL (`http://<svc>.<ns>.svc.<dns>` — як для Hasura-ендпоінта, так і для KVCMS чи будь-якого іншого) і вимагає, щоб для кожного:
369
+
370
+ - DNS-суфікс відповідав env: `abie-dev.internal` / `abie-ua.internal` / `cluster.local`;
371
+ - namespace починався з `dev-` / `ua-` / `ru-` відповідно.
372
+
373
+ Загальне правило про **внутрішній** URL (не публічний домен) для `HASURA_GRAPHQL_ENDPOINT` лишається у **`hasura.mdc`** (для nitra і abie) — `check-hasura.mjs` приймає обидва кластерні DNS-формати (`<cluster>.internal` і `cluster.local`).
374
+
335
375
  ## Firebase Hosting
336
376
 
337
377
  У **кожному** підкаталозі, що лежить **безпосередньо** в корені репозиторію, не тримати конфіг і кеш **Firebase Hosting**: у таких каталогах не повинно бути **`.firebaserc`**, **`firebase.json`** та каталогу **`.firebase/`** (у **самому** корені репозиторію ці імена перевіркою abie **не** розглядаються; `node_modules` / `.git` зі скану вилучаються).
package/mdc/k8s.mdc CHANGED
@@ -531,6 +531,52 @@ patches:
531
531
 
532
532
  **Виняток:** залишай `group` / `version`, лише якщо в дереві overlay реально співіснують ресурси з однаковими `kind`+`name`, але різними API-групами/версіями (наприклад, дві CRD з одним `kind`). У такому разі вкажи мінімальний набір полів, потрібний для дисамбігуації.
533
533
 
534
+ ### Структурний сорт `patches[]` і inline JSON6902
535
+
536
+ `patches[]` у `kustomization.yaml` має бути відсортовано за tuple **`target.kind` → `target.name` → `target.namespace` → `path`** (`localeCompare('en', { sensitivity: 'base' })`). Це робить діфи передбачуваними і прибирає «гойдання» порядку при додаванні нових цілей. Поля `target.group` / `target.version` у tuple не входять — для них діє правило «patches[].target: лише kind і name».
537
+
538
+ ```yaml
539
+ # ❌ atlas йде перед apruv
540
+ patches:
541
+ - target:
542
+ kind: ReferenceGrant
543
+ name: atlas-to-base
544
+ - target:
545
+ kind: ReferenceGrant
546
+ name: apruv-to-base
547
+
548
+ # ✅
549
+ patches:
550
+ - target:
551
+ kind: ReferenceGrant
552
+ name: apruv-to-base
553
+ - target:
554
+ kind: ReferenceGrant
555
+ name: atlas-to-base
556
+ ```
557
+
558
+ Усередині кожного inline `patches[i].patch` (literal block scalar — масив JSON6902-операцій) операції теж сортуються за **`path`**, **але лише** коли набір «безпечний»: усі ops — `add` / `replace` і всі `path` попарно дизʼюнктні (жоден не префікс іншого, наприклад `/spec` і `/spec/replicas`). Інакше порядок не чіпається — у `move` / `copy` / `test` / `remove` чи на спільних шляхах послідовність ops семантично значуща (RFC 6902), і пересорт ламає логіку.
559
+
560
+ ```yaml
561
+ # ❌ minReplicas перед maxReplicas (за алфавітом max < min)
562
+ patch: |-
563
+ - op: add
564
+ path: /spec/minReplicas
565
+ value: 2
566
+ - op: replace
567
+ path: /spec/maxReplicas
568
+ value: 10
569
+
570
+ # ✅
571
+ patch: |-
572
+ - op: replace
573
+ path: /spec/maxReplicas
574
+ value: 10
575
+ - op: add
576
+ path: /spec/minReplicas
577
+ value: 2
578
+ ```
579
+
534
580
  ## Перевірка
535
581
 
536
582
  **`npx @nitra/cursor check k8s`** — програмні критерії в **JSDoc на початку** **`npm/scripts/check-k8s.mjs`**. Якщо під **`k8s`** немає **`*.yaml`** — крок пропущено. Канон **`$schema`** для редактора — розділ **«Визначення схеми YAML`** нижче.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.199",
3
+ "version": "1.8.201",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -27,6 +27,7 @@
27
27
  "mdc",
28
28
  "bin",
29
29
  "github-actions",
30
+ "policy",
30
31
  "schemas",
31
32
  "scripts",
32
33
  "skills",
@@ -0,0 +1,133 @@
1
+ # PoC-порт перевірки `validateCleanGaWorkflows` з `npm/scripts/check-ga.mjs`.
2
+ #
3
+ # Запуск (локально):
4
+ # conftest test .github/workflows/clean-ga-workflows.yml -p npm/policy/ga
5
+ #
6
+ # Conftest читає YAML і дає його в `input`. Кожне правило `deny contains msg if { … }`,
7
+ # що матчиться, друкується як порушення; пустий список — exit 0.
8
+ #
9
+ # Rego v1 синтаксис (OPA 1.x за замовчуванням; `import rego.v1` робить файл портованим
10
+ # і на старі OPA 0.x): `contains` для partial set rules, `if` перед тілом правила.
11
+ package main
12
+
13
+ import rego.v1
14
+
15
+ # GHA YAML quirk: ключ `on:` парситься як YAML 1.1 boolean `true`, після чого conftest
16
+ # серіалізує його в Rego-input як рядок `"true"`. Тому `input.on` / `input["on"]` /
17
+ # `input[true]` всі недоступні; реальний шлях — `input["true"]`. Виносимо в alias, щоб
18
+ # решта правил читалася як `gha_on.schedule` без бойлерплейту.
19
+ gha_on := input["true"]
20
+
21
+ # `${{ … }}` — це шаблонний синтаксис GitHub Actions, але `{{` у Rego починає
22
+ # string interpolation. Збираємо очікувані рядки з фрагментів, як це зроблено в
23
+ # check-ga.mjs, щоб і Rego-парсер, і людина-читач не плуталися.
24
+ expected_concurrency_group := concat("", ["$", "{{ github.ref }}-$", "{{ github.workflow }}"])
25
+
26
+ expected_github_token := concat("", ["$", "{{ github.token }}"])
27
+
28
+ expected_name := "Clean action for removing completed workflow runs"
29
+
30
+ expected_cron := "0 1 16 * *"
31
+
32
+ # --- name --------------------------------------------------------------------
33
+
34
+ deny contains msg if {
35
+ input.name != expected_name
36
+ msg := sprintf("clean-ga-workflows.yml: name має бути %q (ga.mdc)", [expected_name])
37
+ }
38
+
39
+ # --- on.schedule.cron --------------------------------------------------------
40
+
41
+ deny contains msg if {
42
+ not has_expected_cron
43
+ msg := sprintf("clean-ga-workflows.yml: on.schedule має містити cron: '%s' (ga.mdc)", [expected_cron])
44
+ }
45
+
46
+ has_expected_cron if {
47
+ gha_on.schedule[_].cron == expected_cron
48
+ }
49
+
50
+ # --- on.workflow_dispatch ----------------------------------------------------
51
+
52
+ deny contains msg if {
53
+ not has_workflow_dispatch
54
+ msg := "clean-ga-workflows.yml: має бути workflow_dispatch: {} (ga.mdc)"
55
+ }
56
+
57
+ has_workflow_dispatch if {
58
+ is_object(gha_on.workflow_dispatch)
59
+ }
60
+
61
+ # --- concurrency -------------------------------------------------------------
62
+
63
+ deny contains msg if {
64
+ not is_object(input.concurrency)
65
+ msg := sprintf(
66
+ "clean-ga-workflows.yml: відсутня секція concurrency — додай concurrency.group: %s і cancel-in-progress: true (ga.mdc)",
67
+ [expected_concurrency_group],
68
+ )
69
+ }
70
+
71
+ deny contains msg if {
72
+ is_object(input.concurrency)
73
+ input.concurrency.group != expected_concurrency_group
74
+ msg := sprintf("clean-ga-workflows.yml: concurrency.group має бути %s (ga.mdc)", [expected_concurrency_group])
75
+ }
76
+
77
+ deny contains msg if {
78
+ is_object(input.concurrency)
79
+ input.concurrency["cancel-in-progress"] != true
80
+ msg := "clean-ga-workflows.yml: concurrency.cancel-in-progress має бути true (ga.mdc)"
81
+ }
82
+
83
+ # --- jobs.cleanup_old_workflows ---------------------------------------------
84
+
85
+ deny contains msg if {
86
+ not input.jobs.cleanup_old_workflows
87
+ msg := "clean-ga-workflows.yml: jobs.cleanup_old_workflows відсутній (ga.mdc)"
88
+ }
89
+
90
+ deny contains msg if {
91
+ job := input.jobs.cleanup_old_workflows
92
+ job["runs-on"] != "ubuntu-latest"
93
+ msg := "clean-ga-workflows.yml: runs-on має бути ubuntu-latest (ga.mdc)"
94
+ }
95
+
96
+ deny contains msg if {
97
+ perms := input.jobs.cleanup_old_workflows.permissions
98
+ not actions_write_contents_read(perms)
99
+ msg := "clean-ga-workflows.yml: permissions мають бути actions: write, contents: read (ga.mdc)"
100
+ }
101
+
102
+ actions_write_contents_read(perms) if {
103
+ perms.actions == "write"
104
+ perms.contents == "read"
105
+ }
106
+
107
+ # --- jobs.cleanup_old_workflows.steps[0] ------------------------------------
108
+
109
+ step0 := input.jobs.cleanup_old_workflows.steps[0]
110
+
111
+ deny contains msg if {
112
+ step0.name != "Delete workflow runs"
113
+ msg := "clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)"
114
+ }
115
+
116
+ deny contains msg if {
117
+ step0.uses != "dmvict/clean-workflow-runs@v1"
118
+ msg := "clean-ga-workflows.yml: перший крок має uses: dmvict/clean-workflow-runs@v1 (ga.mdc)"
119
+ }
120
+
121
+ # Триплет полів `with`: token (gh-токен), save_period=31, save_min_runs_number=0.
122
+ # В JS-перевірці помилка спільна для всіх трьох — лишаємо такий самий формат, щоб
123
+ # повідомлення збігалися. Окремі правила нижче роблять діагноз точнішим.
124
+ deny contains msg if {
125
+ not step0_with_canonical
126
+ msg := "clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc"
127
+ }
128
+
129
+ step0_with_canonical if {
130
+ step0.with.token == expected_github_token
131
+ step0.with.save_period == 31
132
+ step0.with.save_min_runs_number == 0
133
+ }
@@ -37,10 +37,17 @@
37
37
  *
38
38
  * **Service (overlay ru):** для кожного **Service**, оголошеного в YAML під **`…/k8s/…`**, де шлях **не** проходить через **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару, у т. ч. **headless** з **`clusterIP: None`** і **`-hl`**), якщо ще не **NodePort** / **LoadBalancer** / **ExternalName**,
39
39
  * у файлі **`k8s/ru/kustomization.yaml`** того ж пакета (overlay середовища **ru**) — inline **JSON6902** на **`kind: Service`** з тим самим **`target.name`**: **`path: /spec/type`**, **`value: NodePort`**; якщо в base було **`spec.clusterIP: None`** — **`op: remove`** для **`/spec/clusterIP`**; якщо в base **явно** задано **`spec.clusterIPs`** — також **`remove`** для **`/spec/clusterIPs`** (інакше **API** може залишити **`None`** для **NodePort**; без ключа **`clusterIPs`** у base **`remove`** на **`/spec/clusterIPs`** ламає **`kubectl kustomize`**).
40
+ *
41
+ * **env→cluster DNS:** abie живе у трьох різних кластерах (GKE dev/ua + YC ru), тож DNS-суфікс і namespace-префікс у будь-якому
42
+ * **внутрішньокластерному** URL виду `http://<svc>.<ns>.svc.<dns>` мають відповідати імені env-файла. Скануються всі `*.env` файли,
43
+ * basename яких збігається з `dev.env` / `ua.env` / `ru.env` (опційно з провідною крапкою — `.dev.env` тощо). Для кожного знайденого
44
+ * internal URL у файлі (не лише `HASURA_GRAPHQL_ENDPOINT`, а й KVCMS, auth-run, file-link тощо) валідатор `validateAbieEnvInternalUrls`
45
+ * вимагає: для `dev.env` — DNS `abie-dev.internal` і namespace починається з `dev-`; для `ua.env` — `abie-ua.internal` + `ua-`;
46
+ * для `ru.env` — `cluster.local` + `ru-`. Файл `.env` без імені (локальний для розробника) виключено зі сканування — як і у `check-hasura.mjs`.
40
47
  */
41
48
  import { existsSync } from 'node:fs'
42
49
  import { readdir, readFile } from 'node:fs/promises'
43
- import { dirname, join, relative } from 'node:path'
50
+ import { basename, dirname, join, relative } from 'node:path'
44
51
 
45
52
  import { parseAllDocuments } from 'yaml'
46
53
 
@@ -119,6 +126,73 @@ const HTTPROUTE_BACKENDREF_PORT_8081_VALUE_FIRST_RE =
119
126
  /** Гілки, які мають бути в **`ignore_branches`** за abie.mdc. */
120
127
  export const ABIE_REQUIRED_IGNORE_BRANCHES = ['dev', 'ua', 'ru']
121
128
 
129
+ /**
130
+ * Регекс basename env-файлу abie: `dev.env` / `ua.env` / `ru.env`, опційно з провідною крапкою (`.dev.env` тощо).
131
+ * Файл рівно `.env` (без імені) — виключення з правила: локальний файл розробника, `check-abie` його не сканує
132
+ * (так само як `check-hasura`, див. `isEnvFile`).
133
+ */
134
+ const ABIE_ENV_FILE_BASENAME_RE = /^\.?(dev|ua|ru)\.env$/u
135
+
136
+ /**
137
+ * Глобальний регекс кластерного internal URL у тексті env-файлу.
138
+ * Використовується з `String.prototype.matchAll`, тому має флаг `g`.
139
+ * Дозволяє два DNS-формати: `<cluster>.internal` (GKE) і `cluster.local` (YC / стандартний k8s).
140
+ * Порт необов'язковий — у KVCMS-конфігах інколи лежить URL без порту (8080 додається сервісом за замовчуванням).
141
+ */
142
+ const ABIE_INTERNAL_URL_GLOBAL_RE =
143
+ /\bhttp:\/\/([a-z0-9][a-z0-9-]*)\.([a-z0-9][a-z0-9-]*)\.svc\.((?:[a-z0-9][a-z0-9-]*\.internal)|cluster\.local)(?::\d+)?(?:\/[^\s"'`]*)?/giu
144
+
145
+ /**
146
+ * Очікуваний кластерний DNS-суфікс і namespace-префікс для кожного env-файлу abie.
147
+ * `dev` / `ua` живуть у GKE з власним `<cluster>.internal`; `ru` — у YC, де DNS-суфікс
148
+ * стандартний `cluster.local` (без імені кластера).
149
+ */
150
+ const ABIE_ENV_CLUSTER_DNS_MAP = Object.freeze({
151
+ dev: Object.freeze({ clusterDns: 'abie-dev.internal', namespacePrefix: 'dev-' }),
152
+ ua: Object.freeze({ clusterDns: 'abie-ua.internal', namespacePrefix: 'ua-' }),
153
+ ru: Object.freeze({ clusterDns: 'cluster.local', namespacePrefix: 'ru-' })
154
+ })
155
+
156
+ /**
157
+ * Дістає ім'я env (`dev` / `ua` / `ru`) з basename env-файлу abie.
158
+ * Для не-abie env-файлів (наприклад `production.env`, `.env` без імені) повертає `null`.
159
+ * @param {string} basenameOfEnvFile basename файла (без шляху)
160
+ * @returns {('dev' | 'ua' | 'ru') | null} ім'я env або `null`
161
+ */
162
+ export function abieEnvNameFromBasename(basenameOfEnvFile) {
163
+ const m = basenameOfEnvFile.match(ABIE_ENV_FILE_BASENAME_RE)
164
+ return m ? /** @type {'dev' | 'ua' | 'ru'} */ (m[1]) : null
165
+ }
166
+
167
+ /**
168
+ * Сканує вміст env-файлу abie і повертає помилки невідповідності кластерного DNS / namespace
169
+ * для кожного знайденого internal URL. URL шукається глобально (`matchAll`), тож одне й те саме
170
+ * порушення в кількох змінних дасть стільки ж окремих помилок.
171
+ * @param {string} content вміст env-файлу (UTF-8)
172
+ * @param {'dev' | 'ua' | 'ru'} envName ім'я env, отримане з `abieEnvNameFromBasename`
173
+ * @returns {string[]} порожній масив, якщо все OK; інакше — список повідомлень про порушення
174
+ */
175
+ export function validateAbieEnvInternalUrls(content, envName) {
176
+ const expected = ABIE_ENV_CLUSTER_DNS_MAP[envName]
177
+ if (!expected) return []
178
+ /** @type {string[]} */
179
+ const errors = []
180
+ for (const match of content.matchAll(ABIE_INTERNAL_URL_GLOBAL_RE)) {
181
+ const [fullUrl, , namespace, clusterDns] = match
182
+ if (clusterDns !== expected.clusterDns) {
183
+ errors.push(
184
+ `${fullUrl}: кластерний DNS "${clusterDns}" не відповідає env "${envName}" (очікується "${expected.clusterDns}")`
185
+ )
186
+ }
187
+ if (!namespace.startsWith(expected.namespacePrefix)) {
188
+ errors.push(
189
+ `${fullUrl}: namespace "${namespace}" не починається з "${expected.namespacePrefix}" (env "${envName}")`
190
+ )
191
+ }
192
+ }
193
+ return errors
194
+ }
195
+
122
196
  /**
123
197
  * Чи відносний шлях вказує на **`ru/kustomization.yaml`** (сегмент **`ru`** перед ім'ям файлу) — специфіка abie overlay.
124
198
  * @param {string} rel шлях від кореня репозиторію
@@ -2078,6 +2152,70 @@ async function ensureAbieNginxSidecarForHasura(root, yamlFilesAbs, fail, passFn)
2078
2152
  }
2079
2153
  }
2080
2154
 
2155
+ /**
2156
+ * Збирає всі `*.env` файли в дереві (за виключенням `node_modules`, `.git` та інших службових каталогів),
2157
+ * basename яких — abie env-файл (`dev.env` / `ua.env` / `ru.env` опційно з провідною крапкою). Файл `.env`
2158
+ * без імені виключається — як і у `check-hasura.mjs`.
2159
+ * @param {string} root корінь репозиторію
2160
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
2161
+ * @returns {Promise<string[]>} відсортовані абсолютні шляхи env-файлів abie
2162
+ */
2163
+ async function collectAbieEnvFiles(root, ignorePaths) {
2164
+ /** @type {string[]} */
2165
+ const out = []
2166
+ await walkDir(
2167
+ root,
2168
+ absPath => {
2169
+ if (abieEnvNameFromBasename(basename(absPath)) !== null) {
2170
+ out.push(absPath)
2171
+ }
2172
+ },
2173
+ ignorePaths
2174
+ )
2175
+ return out.toSorted((a, b) => a.localeCompare(b))
2176
+ }
2177
+
2178
+ /**
2179
+ * Сканує всі `*.env` файли abie (`.dev.env` / `.ua.env` / `.ru.env`) і для кожного знайденого
2180
+ * **внутрішньокластерного** URL (`http://<svc>.<ns>.svc.<dns>`) перевіряє, що DNS-суфікс і namespace-префікс
2181
+ * відповідають середовищу env-файла. Не лише `HASURA_GRAPHQL_ENDPOINT`, а й будь-який сервіс у env (KVCMS,
2182
+ * `auth-run-hl`, `file-link-hl` тощо) мусить мати кластер, що відповідає env: dev → `abie-dev.internal`,
2183
+ * ua → `abie-ua.internal`, ru → `cluster.local`.
2184
+ * @param {string} root корінь репозиторію
2185
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
2186
+ * @param {(msg: string) => void} pass успішне повідомлення
2187
+ * @param {(msg: string) => void} fail повідомлення про порушення
2188
+ * @returns {Promise<void>}
2189
+ */
2190
+ async function ensureAbieEnvFilesMatchClusterDns(root, ignorePaths, pass, fail) {
2191
+ const envFiles = await collectAbieEnvFiles(root, ignorePaths)
2192
+ if (envFiles.length === 0) {
2193
+ pass('Не знайдено dev.env / ua.env / ru.env у репозиторії — перевірку env→cluster DNS пропущено (abie.mdc)')
2194
+ return
2195
+ }
2196
+ for (const abs of envFiles) {
2197
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
2198
+ const envName = abieEnvNameFromBasename(basename(abs))
2199
+ if (envName === null) continue
2200
+ let raw
2201
+ try {
2202
+ raw = await readFile(abs, 'utf8')
2203
+ } catch (error) {
2204
+ const msg = error instanceof Error ? error.message : String(error)
2205
+ fail(`${rel}: не вдалося прочитати (${msg})`)
2206
+ continue
2207
+ }
2208
+ const errors = validateAbieEnvInternalUrls(raw, envName)
2209
+ if (errors.length === 0) {
2210
+ pass(`${rel}: усі внутрішні URL відповідають env "${envName}" (abie.mdc)`)
2211
+ } else {
2212
+ for (const err of errors) {
2213
+ fail(`${rel}: ${err} (abie.mdc)`)
2214
+ }
2215
+ }
2216
+ }
2217
+ }
2218
+
2081
2219
  /**
2082
2220
  * Перевіряє відповідність проєкту правилам abie.mdc.
2083
2221
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -2129,5 +2267,8 @@ export async function check() {
2129
2267
  pass('Перевіряємо nginx-sidecar для Hasura WebSocket у ru (abie.mdc)')
2130
2268
  await ensureAbieNginxSidecarForHasura(root, yamlFiles, fail, pass)
2131
2269
 
2270
+ pass('Перевіряємо env→cluster DNS у dev.env / ua.env / ru.env (abie.mdc)')
2271
+ await ensureAbieEnvFilesMatchClusterDns(root, ignorePaths, pass, fail)
2272
+
2132
2273
  return reporter.getExitCode()
2133
2274
  }
@@ -7,10 +7,11 @@
7
7
  * вказує на `https://github.com/nitra/...` або `https://github.com/abinbevefes/...`
8
8
  * (інші репозиторії пропускаються без помилок — як у check-abie).
9
9
  *
10
- * Очікуваний формат URL:
11
- * `http://<service>.<namespace>.svc.<cluster>.internal:<port>`
12
- *
13
- * приклад: `http://contract-h.ua-contract.svc.abie-ua.internal:8080`
10
+ * Очікуваний формат URL — два варіанти кластерного DNS-суфікса:
11
+ * - GKE / GCP: `http://<service>.<namespace>.svc.<cluster>.internal:<port>`
12
+ * приклад: `http://contract-h.ua-contract.svc.abie-ua.internal:8080`
13
+ * - стандартний k8s / Yandex Cloud: `http://<service>.<namespace>.svc.cluster.local:<port>`
14
+ * приклад: `http://apruv-h-hl.ru-apruv.svc.cluster.local:8080`
14
15
  *
15
16
  * Сегменти беруться з `hasura/k8s/base/svc-hl.yaml` (`metadata.name` —
16
17
  * має закінчуватись на `-h`, headless-сервіс) і `hasura/k8s/base/namespace.yaml`
@@ -42,12 +43,20 @@ const HASURA_NAMESPACE_FILE = `${HASURA_BASE_DIR}/namespace.yaml`
42
43
 
43
44
  const ENV_FILE_RE = /\.env$/u
44
45
  const HASURA_ENDPOINT_LINE_RE = /^[ \t]*(?:export[ \t]+)?HASURA_GRAPHQL_ENDPOINT[ \t]*=[ \t]*['"]?([^'"\r\n#]+)/mu
45
- const INTERNAL_HASURA_URL_RE = /^http:\/\/([^./]+)\.([^./]+)\.svc\.([^./]+)\.internal:(\d+)\/?$/u
46
+ // Дозволяємо два DNS-суфікси кластера: `<name>.internal` (GKE/GCP) і `cluster.local`
47
+ // (стандартний k8s / Yandex Cloud). У YC namespace.yaml + cluster mode дають коротший суфікс.
48
+ const INTERNAL_HASURA_URL_RE =
49
+ /^http:\/\/([^./]+)\.([^./]+)\.svc\.((?:[^./:]+\.internal)|cluster\.local):(\d+)\/?$/u
50
+ const CLUSTER_LOCAL_SUFFIX = 'cluster.local'
51
+ const INTERNAL_DNS_SUFFIX = '.internal'
46
52
 
47
53
  /**
48
54
  * Розбір значення `HASURA_GRAPHQL_ENDPOINT` як внутрішнього кластерного URL.
49
- * Дозволяє лише `http://` (TLS усередині кластера зайвий), вимагає сегментів
50
- * `<service>.<namespace>.svc.<cluster>.internal` та явного порту.
55
+ * Дозволяє лише `http://` (TLS усередині кластера зайвий) та обидва кластерні
56
+ * DNS-суфікси: `<cluster>.internal` (GKE/GCP) і `cluster.local`
57
+ * (стандартний k8s / Yandex Cloud). Поле `cluster` для GKE містить ім'я
58
+ * кластера без `.internal` (наприклад `abie-ua`); для YC — повний суфікс
59
+ * `cluster.local` (бо своєї «назви кластера» в DNS немає).
51
60
  * @param {string} url значення з `.env` (без огорнутих лапок)
52
61
  * @returns {{ ok: true, service: string, namespace: string, cluster: string, port: string } | { ok: false }}
53
62
  * розібрані сегменти або `{ ok: false }`, якщо формат не відповідає внутрішньому кластерному URL
@@ -57,7 +66,9 @@ export function parseInternalHasuraEndpoint(url) {
57
66
  if (!m) {
58
67
  return { ok: false }
59
68
  }
60
- return { ok: true, service: m[1], namespace: m[2], cluster: m[3], port: m[4] }
69
+ const suffix = m[3]
70
+ const cluster = suffix.endsWith(INTERNAL_DNS_SUFFIX) ? suffix.slice(0, -INTERNAL_DNS_SUFFIX.length) : suffix
71
+ return { ok: true, service: m[1], namespace: m[2], cluster, port: m[4] }
61
72
  }
62
73
 
63
74
  /**
@@ -140,7 +151,7 @@ async function checkEnvFile(relPath, expected, reporter) {
140
151
  const parsed = parseInternalHasuraEndpoint(value)
141
152
  if (!parsed.ok) {
142
153
  // eslint-disable-next-line @microsoft/sdl/no-insecure-url, sonarjs/no-clear-text-protocols -- hasura.mdc вимагає саме http:// для кластерного URL (TLS не використовується)
143
- const example = 'http://<service>.<namespace>.svc.<cluster>.internal:<port>'
154
+ const example = 'http://<service>.<namespace>.svc.<cluster>.internal:<port> або http://<service>.<namespace>.svc.cluster.local:<port>'
144
155
  fail(
145
156
  `${relPath}: HASURA_GRAPHQL_ENDPOINT="${value}" — потрібен внутрішній кластерний URL виду ${example} (hasura.mdc)`
146
157
  )
@@ -49,6 +49,13 @@
49
49
  * завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує). У **`apiVersion: kustomize.config.k8s.io/…`**, **`kind: Kustomization`**
50
50
  * перелік **`resources:`** (лише непорожні рядки) має бути відсортовано за алфавітом (**en**, `localeCompare`).
51
51
  *
52
+ * **Структурний сорт `patches[]` у kustomization.yaml:** масив **`patches`** має бути відсортовано за tuple
53
+ * **`[target.kind, target.name, target.namespace, path]`** (`localeCompare('en', base)`). Поля **`group`** / **`version`**
54
+ * у tuple не входять — для них діє правило «patches[].target: лише kind і name». Додатково: вміст
55
+ * **inline `patches[i].patch`** (literal block scalar — масив JSON6902-операцій) має бути відсортовано за **`path`**,
56
+ * але **лише** якщо всі операції — **`add`** / **`replace`** і всі **`path`** попарно дизʼюнктні (жоден не префікс іншого).
57
+ * Інакше порядок не чіпається — `move` / `copy` / `test` / `remove` чи спільні шляхи можуть бути семантично залежні (RFC 6902).
58
+ *
52
59
  * **Inline JSON6902** у **`patches`** (і зовнішні файли з **`patches[].path`** під **`k8s`**, якщо вміст — масив JSON Patch): не допускається пара **`remove`** і **`add`**
53
60
  * на один і той самий **`path`** у межах одного фрагмента — потрібен **`op: replace`** (k8s.mdc). **check-k8s** це перевіряє.
54
61
  *
@@ -471,6 +478,197 @@ async function validateKustomizationResourcesSortedAlphabetically(root, yamlFile
471
478
  }
472
479
  }
473
480
 
481
+ /**
482
+ * Лексичне порівняння двох тuplіе рядків через `localeCompare('en', { sensitivity: 'base' })`.
483
+ * Менший за довжиною список доповнюється порожніми рядками.
484
+ * @param {string[]} a перший tuple
485
+ * @param {string[]} b другий tuple
486
+ * @returns {number} `< 0` якщо `a` менший, `> 0` якщо більший, `0` — рівні
487
+ */
488
+ function compareStringTuplesEn(a, b) {
489
+ const n = Math.max(a.length, b.length)
490
+ for (let i = 0; i < n; i++) {
491
+ const av = a[i] ?? ''
492
+ const bv = b[i] ?? ''
493
+ const c = av.localeCompare(bv, 'en', { sensitivity: 'base' })
494
+ if (c !== 0) return c
495
+ }
496
+ return 0
497
+ }
498
+
499
+ /**
500
+ * Чи послідовність tuple-ключів відсортована за `compareStringTuplesEn`.
501
+ * @param {string[][]} tuples масив tuple-ключів у порядку, як у файлі
502
+ * @returns {boolean} true, якщо порядок неспадний
503
+ */
504
+ function stringTuplesAreSortedEn(tuples) {
505
+ for (let i = 1; i < tuples.length; i++) {
506
+ if (compareStringTuplesEn(tuples[i - 1], tuples[i]) > 0) return false
507
+ }
508
+ return true
509
+ }
510
+
511
+ /**
512
+ * Tuple-ключ для сортування одного запису `patches[]` Kustomization.
513
+ * Порядок ключів: `target.kind` → `target.name` → `target.namespace` → `path`. Відсутні поля = `''`
514
+ * (порожні раніше за заповнені у `localeCompare` — стабільний детермінізм).
515
+ * Поля `target.group` / `target.version` навмисно не входять у ключ — у repo діє правило
516
+ * «patches[].target: лише kind і name», тому опертися на них не можна.
517
+ * @param {unknown} patchItem елемент масиву `patches[]`
518
+ * @returns {string[]} tuple для порівняння
519
+ */
520
+ function kustomizationPatchSortKey(patchItem) {
521
+ if (patchItem === null || typeof patchItem !== 'object' || Array.isArray(patchItem)) {
522
+ return ['', '', '', '']
523
+ }
524
+ const rec = /** @type {Record<string, unknown>} */ (patchItem)
525
+ const t = rec.target
526
+ /** @type {Record<string, unknown>} */
527
+ const target = t !== null && typeof t === 'object' && !Array.isArray(t) ? /** @type {Record<string, unknown>} */ (t) : {}
528
+ const kind = typeof target.kind === 'string' ? target.kind : ''
529
+ const name = typeof target.name === 'string' ? target.name : ''
530
+ const ns = typeof target.namespace === 'string' ? target.namespace : ''
531
+ const path = typeof rec.path === 'string' ? rec.path : ''
532
+ return [kind, name, ns, path]
533
+ }
534
+
535
+ /**
536
+ * Короткий ярлик запису `patches[]` для звітів («kind/name», або «path=…», або «#i»).
537
+ * @param {unknown} patchItem елемент масиву
538
+ * @param {number} i індекс у масиві (для fallback)
539
+ * @returns {string} людинозрозумілий ярлик
540
+ */
541
+ function kustomizationPatchLabel(patchItem, i) {
542
+ const [kind, name, , path] = kustomizationPatchSortKey(patchItem)
543
+ if (kind && name) return `${kind}/${name}`
544
+ if (path) return `path=${path}`
545
+ return `#${i}`
546
+ }
547
+
548
+ /**
549
+ * Порушення сорту **`patches[]`**: лише для **`kustomize.config.k8s.io/…`**, **`kind: Kustomization`**.
550
+ * Сортування за tuple `[target.kind, target.name, target.namespace, path]` (`localeCompare('en', base)`).
551
+ * @param {unknown} obj корінь першого YAML-документа kustomization.yaml
552
+ * @returns {string | null} причина або `null`, якщо обмеження не застосовується чи порядок ОК
553
+ */
554
+ export function kustomizationPatchesSortedViolation(obj) {
555
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return null
556
+ const rec = /** @type {Record<string, unknown>} */ (obj)
557
+ if (rec.kind !== 'Kustomization') return null
558
+ const av = rec.apiVersion
559
+ if (typeof av !== 'string' || !av.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
560
+ const patches = rec.patches
561
+ if (patches === undefined) return null
562
+ if (!Array.isArray(patches)) {
563
+ return 'Kustomization.patches має бути масивом (k8s.mdc)'
564
+ }
565
+ if (patches.length < 2) return null
566
+ const keys = patches.map(p => kustomizationPatchSortKey(p))
567
+ if (stringTuplesAreSortedEn(keys)) return null
568
+ const order = patches.map((p, i) => ({ p, i, key: keys[i] }))
569
+ order.sort((a, b) => compareStringTuplesEn(a.key, b.key) || a.i - b.i)
570
+ const have = patches.map((p, i) => kustomizationPatchLabel(p, i)).join(', ')
571
+ const want = order.map(x => kustomizationPatchLabel(x.p, x.i)).join(', ')
572
+ return `Kustomization.patches має бути за алфавітом (target.kind → target.name → target.namespace → path). Зараз: ${have}; очікувано: ${want} (k8s.mdc)`
573
+ }
574
+
575
+ /** Чи рядок виглядає як JSON-Pointer-шлях `/…` (порожнє і `/` теж приймаються — `/` = корінь). */
576
+ const JSON_POINTER_RE = /^\/[^\s]*$|^$|^\/$/u
577
+
578
+ /**
579
+ * Чи кожен `path` у наборі — окремий вузол JSON-Pointer (немає прямого префікс-збігу типу `/spec` vs `/spec/replicas`).
580
+ * Однакові `path` теж вважаються «недизʼюнктними». Реалізація: `O(n²)` достатня для розмірів реальних patch-наборів.
581
+ * @param {string[]} paths шляхи у тому ж порядку, що й у файлі
582
+ * @returns {boolean} true, якщо всі шляхи попарно дизʼюнктні
583
+ */
584
+ function jsonPointerPathsAreDisjoint(paths) {
585
+ for (let i = 0; i < paths.length; i++) {
586
+ for (let j = 0; j < paths.length; j++) {
587
+ if (i === j) continue
588
+ if (paths[i] === paths[j]) return false
589
+ if (paths[j].startsWith(`${paths[i]}/`)) return false
590
+ }
591
+ }
592
+ return true
593
+ }
594
+
595
+ /**
596
+ * Парсить рядок JSON6902-патчa в плоский масив операцій `{ op, path }` (без значень).
597
+ * Повертає `null`, якщо це не YAML-масив об'єктів з полями `op`/`path` як рядки.
598
+ * @param {string} raw тіло inline `patch:` (literal block scalar)
599
+ * @returns {{ op: string, path: string }[] | null} нормалізований список ops або `null` за невідповідного формату
600
+ */
601
+ function parseJson6902OpsFromText(raw) {
602
+ let parsed
603
+ try {
604
+ parsed = parseDocument(raw).toJSON()
605
+ } catch {
606
+ return null
607
+ }
608
+ if (!Array.isArray(parsed)) return null
609
+ /** @type {{ op: string, path: string }[]} */
610
+ const out = []
611
+ for (const item of parsed) {
612
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) return null
613
+ const rec = /** @type {Record<string, unknown>} */ (item)
614
+ if (typeof rec.op !== 'string' || typeof rec.path !== 'string') return null
615
+ out.push({ op: rec.op, path: rec.path })
616
+ }
617
+ return out
618
+ }
619
+
620
+ /**
621
+ * Порушення сорту inline JSON6902-ops у одному `patches[i].patch`.
622
+ * Сортуємо **тільки** «безпечний» набір: всі `op ∈ { add, replace }` і всі `path` дизʼюнктні
623
+ * (немає префікс-зв'язку між шляхами). Інакше повертаємо `null` — порядок зберігаємо як у файлі,
624
+ * бо `move`/`copy`/`test`/`remove` чи спільні шляхи можуть бути семантично залежні (RFC 6902).
625
+ * @param {string} patchText вміст literal block (inline `patch:`)
626
+ * @returns {string | null} опис порушення або `null`
627
+ */
628
+ export function kustomizationInlinePatchOpsSortedViolation(patchText) {
629
+ const ops = parseJson6902OpsFromText(patchText)
630
+ if (ops === null) return null
631
+ if (ops.length < 2) return null
632
+ for (const o of ops) {
633
+ if (o.op !== 'add' && o.op !== 'replace') return null
634
+ if (!JSON_POINTER_RE.test(o.path)) return null
635
+ }
636
+ const paths = ops.map(o => o.path)
637
+ if (!jsonPointerPathsAreDisjoint(paths)) return null
638
+ /** @type {string[][]} */
639
+ const keys = paths.map(p => [p])
640
+ if (stringTuplesAreSortedEn(keys)) return null
641
+ const want = paths.toSorted((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
642
+ return `inline patch (JSON6902) має бути за алфавітом по path. Зараз: ${paths.join(', ')}; очікувано: ${want.join(', ')} (k8s.mdc)`
643
+ }
644
+
645
+ /**
646
+ * Усі **`kustomization.yaml`**: `patches[]` відсортовано за `[target.kind, target.name, …]`,
647
+ * а вміст inline `patches[i].patch` (де всі ops — `add`/`replace` і шляхи дизʼюнктні) — за `path`.
648
+ * @param {string} root корінь репо
649
+ * @param {string[]} yamlFilesAbs yaml під k8s
650
+ * @param {(msg: string) => void} fail функція для фіксації порушення
651
+ * @returns {Promise<void>} завершується після перевірки всіх kustomization.yaml
652
+ */
653
+ async function validateKustomizationPatchesStructuralSort(root, yamlFilesAbs, fail) {
654
+ for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
655
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
656
+ const kust = await readFirstYamlObject(kustAbs)
657
+ if (kust === null) continue
658
+ const outer = kustomizationPatchesSortedViolation(kust)
659
+ if (outer !== null) fail(`${rel}: ${outer}`)
660
+ const patches = kust.patches
661
+ if (!Array.isArray(patches)) continue
662
+ for (const [i, p] of patches.entries()) {
663
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
664
+ const rec = /** @type {Record<string, unknown>} */ (p)
665
+ if (typeof rec.patch !== 'string') continue
666
+ const v = kustomizationInlinePatchOpsSortedViolation(rec.patch)
667
+ if (v !== null) fail(`${rel}: patches[${i}] (${kustomizationPatchLabel(p, i)}): ${v}`)
668
+ }
669
+ }
670
+ }
671
+
474
672
  /**
475
673
  * Шляхи з полів Kustomization для resolve відносно каталогу **`kustomization.yaml`**.
476
674
  * @param {unknown} obj корінь першого документа Kustomization
@@ -5916,6 +6114,8 @@ export async function check() {
5916
6114
 
5917
6115
  await validateKustomizationResourcesSortedAlphabetically(root, yamlFiles, fail)
5918
6116
 
6117
+ await validateKustomizationPatchesStructuralSort(root, yamlFiles, fail)
6118
+
5919
6119
  await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
5920
6120
 
5921
6121
  await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * CLI-обгортка над канонічним `lint-ga` (ga.mdc): робить preflight на `shellcheck` і `uv` (для `uvx`),
3
- * тоді послідовно виконує `bunx github-actionlint` і `uvx zizmor --offline --collect=workflows .`.
3
+ * тоді послідовно виконує `bunx github-actionlint`, `uvx zizmor --offline --collect=workflows .` і
4
+ * (PoC) `conftest test` на структуру канонічних workflow проти Rego-полісі з `npm/policy/ga/`.
5
+ *
6
+ * Conftest-крок навмисно **не** додається в preflight: якщо бінарник не встановлений, виводимо `ℹ`
7
+ * повідомлення й продовжуємо з кодом 0. Структурні перевірки тих самих workflow паралельно живуть у
8
+ * `npm/scripts/check-ga.mjs`, тож відсутність conftest не пропускає порушення мовчки.
4
9
  *
5
10
  * Без preflight `actionlint` (через `bunx github-actionlint`) мовчки пропускає shell-перевірки в
6
11
  * `run:` блоках, коли `shellcheck` відсутній у PATH; локально `bun lint-ga` лишається зеленим, а CI
@@ -11,11 +16,29 @@
11
16
  *
12
17
  * Експортовано окремо `runLintGaCli` — використовується з `bin/n-cursor.js` як підкоманда `lint-ga`.
13
18
  */
19
+ import { existsSync } from 'node:fs'
14
20
  import { spawnSync } from 'node:child_process'
21
+ import { dirname, join } from 'node:path'
15
22
  import { platform } from 'node:process'
23
+ import { fileURLToPath } from 'node:url'
16
24
 
17
25
  import { resolveCmd } from './utils/resolve-cmd.mjs'
18
26
 
27
+ /** Каталог пакету `@nitra/cursor`, від якого ресолвимо вшиту директорію policy/. */
28
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
29
+
30
+ /** Шлях до Rego-полісі (PoC: лише clean-ga-workflows). У npm-tarball публікується через `files` у package.json. */
31
+ const GA_POLICY_DIR = join(PACKAGE_ROOT, 'policy', 'ga')
32
+
33
+ /**
34
+ * Workflow-файли, для яких маємо відповідну Rego-полісі. PoC: один файл; інші підтягуватимемо в міру міграції
35
+ * перевірок із `npm/scripts/check-ga.mjs`.
36
+ * @type {Array<{ workflow: string, label: string }>}
37
+ */
38
+ const CONFTEST_TARGETS = [
39
+ { workflow: '.github/workflows/clean-ga-workflows.yml', label: 'clean-ga-workflows.yml structure' }
40
+ ]
41
+
19
42
  /**
20
43
  * Опис залежності preflight-ом: бінарник, для чого потрібен, і команди встановлення.
21
44
  * @typedef {object} PreflightDep
@@ -153,5 +176,51 @@ export function runLintGaCli() {
153
176
  if (actionlintCode !== 0) return actionlintCode
154
177
 
155
178
  const zizmorCode = runStep('zizmor', 'uvx', ['zizmor', '--offline', '--collect=workflows', '.'])
156
- return zizmorCode
179
+ if (zizmorCode !== 0) return zizmorCode
180
+
181
+ return runConftestStep()
182
+ }
183
+
184
+ /**
185
+ * PoC-крок: запускає conftest на YAML workflow проти Rego-полісі з пакету (`policy/ga/`).
186
+ *
187
+ * Поведінка fallback:
188
+ * - якщо `conftest` не знайдено в PATH — друкуємо `ℹ` повідомлення з підказкою встановлення й
189
+ * повертаємо 0 (тобто конфтест поки що **не** є обовʼязковою залежністю lint-ga; перевірки лежать
190
+ * паралельно в `check-ga.mjs`, і `npx @nitra/cursor check ga` все одно їх запустить);
191
+ * - якщо `conftest` є й полісі-каталог відсутній (нетипова інсталяція) — також `ℹ` skip;
192
+ * - якщо є цільовий workflow і conftest — запускаємо `conftest test <workflow> -p <policy-dir>` і
193
+ * повертаємо його exit-код, щоб порушення зупиняли lint-ga, як це робить actionlint/zizmor.
194
+ *
195
+ * Локальний `conftest` встановлюється через `brew install conftest` / `go install ...` — деталі в
196
+ * https://www.conftest.dev/install/.
197
+ * @returns {number} 0 — OK або skip, інакше — exit-код conftest
198
+ */
199
+ function runConftestStep() {
200
+ const conftestBin = resolveCmd('conftest')
201
+ if (!conftestBin) {
202
+ console.log(
203
+ '\nℹ conftest не знайдено в PATH — пропускаю PoC-перевірку структури workflow через Rego-полісі.\n' +
204
+ ' Встанови, щоб запустити її локально: brew install conftest (macOS) або https://www.conftest.dev/install/'
205
+ )
206
+ return 0
207
+ }
208
+
209
+ if (!existsSync(GA_POLICY_DIR)) {
210
+ console.log(`\nℹ Каталог Rego-полісі не знайдено (${GA_POLICY_DIR}) — пропускаю conftest.`)
211
+ return 0
212
+ }
213
+
214
+ for (const target of CONFTEST_TARGETS) {
215
+ if (!existsSync(target.workflow)) continue
216
+ const code = runStep(`conftest (${target.label})`, conftestBin, [
217
+ 'test',
218
+ target.workflow,
219
+ '-p',
220
+ GA_POLICY_DIR,
221
+ '--no-color'
222
+ ])
223
+ if (code !== 0) return code
224
+ }
225
+ return 0
157
226
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Запуск `regal lint` по Rego-полісі репозиторію (`conftest.mdc`).
3
+ *
4
+ * Regal (https://docs.styra.com/regal) — статичний лінтер Rego, який ловить v0-синтаксис,
5
+ * неявні set-rules та інші відхилення від `rego.v1`. Без preflight-у на наявність бінарника
6
+ * лінт мовчки злетить з невиразним повідомленням від shell — тут друкуємо явний install-hint
7
+ * (як це робить `lint-ga.mjs` для shellcheck/uv).
8
+ *
9
+ * Цілі лінту: `npm/policy/` (місце, де поки що живуть Rego-полісі пакета `@nitra/cursor`).
10
+ * Якщо в репозиторії з’являться інші *.rego поза цим деревом, додай шлях у `LINT_TARGETS` —
11
+ * `regal lint` приймає кілька шляхів і сам рекурсивно обходить директорії.
12
+ */
13
+ import { spawnSync } from 'node:child_process'
14
+ import { existsSync } from 'node:fs'
15
+ import { resolve } from 'node:path'
16
+
17
+ import { resolveCmd } from './utils/resolve-cmd.mjs'
18
+
19
+ /** Шляхи з Rego-полісі (відносно cwd). Існують не всі на ранніх стадіях — фільтруємо нижче. */
20
+ const LINT_TARGETS = ['npm/policy']
21
+
22
+ /**
23
+ * Друкує підказку зі встановлення `regal`.
24
+ * @returns {void}
25
+ */
26
+ function printRegalInstallHints() {
27
+ process.stderr.write(
28
+ [
29
+ '❌ regal не знайдено в PATH.',
30
+ ' Без нього не перевіряється rego.v1 синтаксис у *.rego (правило `conftest`).',
31
+ ' Встанови:',
32
+ ' macOS: brew install regal',
33
+ ' Universal: https://docs.styra.com/regal#installation',
34
+ ''
35
+ ].join('\n')
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Запускає `regal lint` по існуючих цілях. Якщо жодної цілі немає — пропускає лінт із кодом 0.
41
+ * @param {string} [cwd] робочий каталог (за замовчуванням `process.cwd()`)
42
+ * @returns {number} 0 — OK або skip; інакше код виходу regal
43
+ */
44
+ export function runLintRego(cwd = process.cwd()) {
45
+ const root = resolve(cwd)
46
+ const regal = resolveCmd('regal')
47
+ if (!regal) {
48
+ printRegalInstallHints()
49
+ return 1
50
+ }
51
+
52
+ const targets = LINT_TARGETS.filter(rel => existsSync(resolve(root, rel)))
53
+ if (targets.length === 0) {
54
+ return 0
55
+ }
56
+
57
+ console.log(`▶ regal lint ${targets.join(' ')}`)
58
+ const result = spawnSync(regal, ['lint', ...targets], {
59
+ cwd: root,
60
+ stdio: 'inherit',
61
+ env: process.env
62
+ })
63
+ if (result.error) {
64
+ process.stderr.write(`❌ Не вдалося запустити regal: ${result.error.message}\n`)
65
+ return 1
66
+ }
67
+ return result.status ?? 1
68
+ }
69
+
70
+ process.exitCode = runLintRego()