@nitra/cursor 1.10.0 → 1.11.0

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/bin/n-cursor.js +29 -29
  3. package/package.json +2 -1
  4. package/rules/abie/js/applies/check.mjs +24 -0
  5. package/rules/abie/js/env_dns/check.mjs +53 -0
  6. package/rules/abie/js/firebase_hosting/check.mjs +49 -0
  7. package/rules/abie/js/hc_pairing/check.mjs +58 -0
  8. package/rules/abie/js/ua_http_route/check.mjs +86 -0
  9. package/rules/abie/js/ua_node_selector/check.mjs +65 -0
  10. package/rules/abie/policy/base_deployment_preem/target.json +10 -0
  11. package/rules/abie/policy/clean_merged_ignore_branches/target.json +4 -0
  12. package/rules/abie/policy/health_check_policy/target.json +4 -0
  13. package/rules/abie/policy/http_route_base/target.json +4 -0
  14. package/rules/abie/utils/enabled.mjs +35 -0
  15. package/rules/abie/utils/env-dns.mjs +81 -0
  16. package/rules/abie/utils/hc-yaml.mjs +27 -0
  17. package/rules/abie/utils/http-route.mjs +93 -0
  18. package/rules/abie/utils/k8s-tree.mjs +102 -0
  19. package/rules/abie/utils/kustomization-patches.mjs +224 -0
  20. package/rules/abie/utils/overlay-paths.mjs +97 -0
  21. package/rules/abie/utils/yaml.mjs +72 -0
  22. package/rules/adr/policy/settings_json/target.json +4 -0
  23. package/rules/adr/policy/settings_local_json/target.json +4 -0
  24. package/rules/bun/policy/bunfig/target.json +4 -0
  25. package/rules/bun/policy/package_json/target.json +4 -0
  26. package/rules/capacitor/policy/package_json/target.json +4 -0
  27. package/rules/docker/policy/lint_docker_yml/target.json +4 -0
  28. package/rules/docker/policy/package_json/target.json +4 -0
  29. package/rules/hasura/policy/svc_hl/target.json +4 -0
  30. package/rules/image-avif/policy/package_json/target.json +4 -0
  31. package/rules/image-compress/policy/package_json/target.json +4 -0
  32. package/rules/js-bun-db/policy/package_json/target.json +4 -0
  33. package/rules/js-bun-redis/policy/package_json/target.json +4 -0
  34. package/rules/js-lint/policy/lint_js_yml/target.json +4 -0
  35. package/rules/js-lint/policy/package_json/target.json +4 -0
  36. package/rules/js-mssql/policy/package_json/target.json +4 -0
  37. package/rules/js-run/policy/configmap/target.json +4 -0
  38. package/rules/js-run/policy/package_json/target.json +4 -0
  39. package/rules/k8s/policy/base_kustomization/target.json +4 -0
  40. package/rules/k8s/policy/base_manifest/target.json +10 -0
  41. package/rules/k8s/policy/gateway/target.json +4 -0
  42. package/rules/k8s/policy/hpa_pdb/target.json +4 -0
  43. package/rules/k8s/policy/kustomization/target.json +4 -0
  44. package/rules/k8s/policy/manifest/target.json +4 -0
  45. package/rules/k8s/policy/svc_hl_yaml/target.json +4 -0
  46. package/rules/k8s/policy/svc_yaml/target.json +4 -0
  47. package/rules/npm-module/policy/emit_types_config/target.json +4 -0
  48. package/rules/npm-module/policy/npm_package_json/target.json +4 -0
  49. package/rules/npm-module/policy/npm_publish_yml/target.json +4 -0
  50. package/rules/npm-module/policy/root_package_json/target.json +4 -0
  51. package/rules/php/policy/lint_php_yml/target.json +4 -0
  52. package/rules/php/policy/package_json/target.json +4 -0
  53. package/rules/rego/js/applies/check.mjs +54 -0
  54. package/rules/rego/policy/package_json/target.json +5 -0
  55. package/rules/rego/policy/vscode_extensions/target.json +5 -0
  56. package/rules/rego/policy/vscode_settings/target.json +5 -0
  57. package/rules/style-lint/policy/lint_style_yml/target.json +4 -0
  58. package/rules/style-lint/policy/package_json/target.json +4 -0
  59. package/rules/style-lint/policy/vscode_extensions/target.json +4 -0
  60. package/rules/style-lint/policy/vscode_settings/target.json +4 -0
  61. package/rules/text/policy/cspell/target.json +4 -0
  62. package/rules/text/policy/markdownlint/target.json +4 -0
  63. package/rules/text/policy/oxfmtrc/target.json +4 -0
  64. package/rules/text/policy/package_json/target.json +4 -0
  65. package/rules/text/policy/vscode_extensions/target.json +4 -0
  66. package/rules/text/policy/vscode_settings/target.json +4 -0
  67. package/rules/vue/policy/package_json/target.json +4 -0
  68. package/schemas/target.json +58 -0
  69. package/scripts/lint-conftest.mjs +65 -414
  70. package/scripts/utils/discover-checkable-rules.mjs +123 -0
  71. package/scripts/utils/resolve-target-files.mjs +109 -0
  72. package/scripts/utils/run-rule.mjs +131 -0
  73. package/rules/abie/js/check.mjs +0 -1152
  74. package/rules/rego/js/check.mjs +0 -106
@@ -1,1152 +0,0 @@
1
- /**
2
- * Перевіряє відповідність проєкту правилу abie.mdc (проєкти AbInBev Efes).
3
- *
4
- * Застосовується лише якщо у **`.n-cursor.json`** у масиві **`rules`** є **`abie`** — інакше вихід **0**
5
- * без перевірок (щоб не суперечити типовому **ga.mdc** з **`ignore_branches: main,dev`**).
6
- *
7
- * **Гілки:** у **`.github/workflows/clean-merged-branch.yml`** у кроці з
8
- * **`phpdocker-io/github-actions-delete-abandoned-branches`** у **`with.ignore_branches`** мають бути
9
- * **dev** та **ua** (разом з іншими гілками, якщо потрібно).
10
- *
11
- * **Firebase Hosting:** у **підкаталогах першого рівня** (безпосередні діти кореня репозиторію; `node_modules` / `.git` пропускаються) не має бути
12
- * **`.firebaserc`**, **`firebase.json`** та каталогу **`.firebase/`**; у **самому** корені репозиторію ці імена не перевіряються.
13
- *
14
- * **k8s:** якщо під деревом із сегментом **`k8s`** є YAML з **`kind: Deployment`**, у тій самій директорії
15
- * має існувати **`hc.yaml`** із **`HealthCheckPolicy`** (**`networking.gke.io/v1`**), modeline **`$schema`**
16
- * як у abie.mdc, **`requestPath`** — непорожній шлях від кореня (рядок, що починається з **`/`**: **`/healthz`**, **`/IsAlive`**, **`/api/live`** тощо), порт **8080**, **`targetRef`** на **headless Service** (ім'я з суфіксом **`-hl`**):
17
- * якщо **`metadata.name`** уже закінчується на **`-hl`**, **`targetRef.name`** має збігатися з ним; інакше **`targetRef.name`** = **`${metadata.name}-hl`**.
18
- * Загальні вимоги до **`# yaml-language-server: $schema`** для інших YAML під **`k8s`** — у **check-k8s.mjs** / **k8s.mdc**.
19
- *
20
- * **nodeSelector (base):** якщо **Deployment** лежить у шляху з сегментом **`base`** (наприклад **`…/k8s/base/deploy.yaml`**),
21
- * у **`spec.template.spec.nodeSelector`** має бути **`preem`** з булевим значенням **true** або рядком **`'true'`** — overlay **ua** далі підміняє селектор.
22
- *
23
- * **nodeSelector (overlay ua):** якщо в дереві **k8s** пакета є **Deployment**, у **`ua/kustomization.yaml`** цього пакета — inline patch на **`kind: Deployment`**
24
- * з **`path: /spec/template/spec/nodeSelector`** та **`preem: false`**.
25
- * Узагальнені вимоги **k8s.mdc** до JSON6902 (зокрема заборона **remove** + **add** на той самий **path**) перевіряє **check-k8s.mjs**; **check-abie** — лише abie-специфічний вміст (без дублювання цього правила).
26
- *
27
- * **HTTPRoute (overlay ua):** лише якщо в каталозі пакета (батько **`k8s`**) є **`vite.config.js`**, **`vite.config.mjs`** або **`vite.config.ts`**
28
- * — тоді в **`ua/kustomization.yaml`** потрібен patch на **`kind: HTTPRoute`**, **непорожній `target.name`**: **`/spec/hostnames`**
29
- * (домени abie.mdc), **`/spec/parentRefs/0/namespace`** (**ua**, також дозволені префікси **`ua-*`**).
30
- * **HTTPRoute (base / dev):** у маніфесті **HTTPRoute** у шляху з сегментом **`base`** (наприклад **`…/k8s/base/hr.yaml`**) у **`spec.hostnames`** дозволені лише **`aiml.live`**, **`*.aiml.live`** та інші піддомени **aiml.live** (канонічно порівняння без урахування регістру).
31
- * **Спільні бекенди (`auth-run-hl`, `file-link-hl`):** у **HTTPRoute** під **`k8s`** поза overlay **ua** (шлях не містить **`k8s/ua/`**) кожен такий **`backendRefs`** має **`namespace: dev`** і порт **8080**;
32
- * у patch overlay **ua** — по одному **JSON6902** на **`/spec/rules/…/backendRefs/…/namespace`** з **`value`**: **ua** (кількість patch-ів = кількість таких **`backendRefs`** у пакеті).
33
- * Вибір **`op`** — **k8s.mdc**.
34
- *
35
- * **env→cluster DNS:** abie живе у двох GKE-кластерах (dev / ua), тож DNS-суфікс і namespace-префікс у будь-якому
36
- * **внутрішньокластерному** URL виду `http://<svc>.<ns>.svc.<dns>` мають відповідати імені env-файла. Скануються всі `*.env` файли,
37
- * basename яких збігається з `dev.env` / `ua.env` (опційно з провідною крапкою — `.dev.env` тощо). Для кожного знайденого
38
- * internal URL у файлі (не лише `HASURA_GRAPHQL_ENDPOINT`, а й KVCMS, auth-run, file-link тощо) валідатор `validateAbieEnvInternalUrls`
39
- * вимагає: для `dev.env` — DNS `abie-dev.internal` і namespace починається з `dev-`; для `ua.env` — `abie-ua.internal` + `ua-`.
40
- * Файл `.env` без імені (локальний для розробника) виключено зі сканування — як і у `check-hasura.mjs`.
41
- */
42
- import { existsSync } from 'node:fs'
43
- import { readdir, readFile } from 'node:fs/promises'
44
- import { basename, dirname, join, relative } from 'node:path'
45
-
46
- import { parseAllDocuments } from 'yaml'
47
-
48
- import { pathHasK8sSegment } from '../../k8s/js/check.mjs'
49
- import { createCheckReporter } from '../../../scripts/utils/check-reporter.mjs'
50
- import { loadCursorIgnorePaths } from '../../../scripts/utils/load-cursor-config.mjs'
51
- import { runConftestBatch } from '../../../scripts/utils/run-conftest-batch.mjs'
52
- import { walkDir } from '../../../scripts/utils/walkDir.mjs'
53
-
54
- const CONFIG_FILE = '.n-cursor.json'
55
-
56
- /** Каталоги-діти в корені, які пропускаються при скануванні на артефакти Firebase Hosting (abie). */
57
- const ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES = new Set(['.git', 'node_modules'])
58
-
59
- /**
60
- * Спільні **Service** (**`-hl`**) у **dev**: у base-**HTTPRoute** обов'язково **`namespace: dev`**, у overlay — patch **`…/backendRefs/…/namespace`** (abie.mdc).
61
- * Експорт для споживачів / тестів.
62
- */
63
- export const ABIE_SHARED_CROSS_NS_BACKEND_NAMES = Object.freeze(['auth-run-hl', 'file-link-hl'])
64
-
65
- const ABIE_SHARED_CROSS_NS_BACKEND_SET = new Set(ABIE_SHARED_CROSS_NS_BACKEND_NAMES)
66
-
67
- /** Очікуваний URL **`$schema`** для **hc.yaml** (abie.mdc). */
68
- export const ABIE_HC_SCHEMA_URL = 'https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json'
69
-
70
- /** Кореневий домен **`spec.hostnames`** для **HTTPRoute** у **`…/k8s/base/…`** (середовище dev, abie.mdc). */
71
- export const ABIE_BASE_DEV_HTTPROUTE_HOST_ROOT = 'aiml.live'
72
-
73
- const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
74
- const LINE_SPLIT_RE = /\r?\n/u
75
- const UA_KUSTOMIZATION_PATH_RE = /(^|\/)ua\/kustomization\.yaml$/u
76
- const OVERLAY_PACKAGE_DIR_RE = /^(.+)\/k8s\/ua\/kustomization\.yaml$/u
77
- const BASE_SEGMENT_RE = /(^|\/)base\//u
78
- const YAML_EXTENSION_RE = /\.ya?ml$/iu
79
- const PATCH_NODE_SELECTOR_PATH_RE = /path:\s*\/spec\/template\/spec\/nodeSelector\b/u
80
- const PATCH_PREEM_FALSE_RE = /\bpreem:\s*['"]?false['"]?\b/u
81
- const TRAILING_SLASH_RE = /\/$/u
82
- const PATCH_HOSTNAMES_PATH_RE = /path:\s*\/spec\/hostnames\b/mu
83
- // Overlay namespaces: allow `ua` and `ua-*` (e.g. ua-b2b).
84
- const PATCH_PARENT_REF_NS_UA_RE =
85
- /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/imu
86
-
87
- /**
88
- * Регекс basename env-файлу abie: `dev.env` / `ua.env`, опційно з провідною крапкою (`.dev.env` тощо).
89
- * Файл рівно `.env` (без імені) — виключення з правила: локальний файл розробника, `check-abie` його не сканує
90
- * (так само як `check-hasura`, див. `isEnvFile`).
91
- */
92
- const ABIE_ENV_FILE_BASENAME_RE = /^\.?(dev|ua)\.env$/u
93
-
94
- /**
95
- * Глобальний регекс кластерного internal URL у тексті env-файлу.
96
- * Використовується з `String.prototype.matchAll`, тому має флаг `g`.
97
- * Допустимий DNS-формат — `<cluster>.internal` (GKE).
98
- * Порт необов'язковий — у KVCMS-конфігах інколи лежить URL без порту (8080 додається сервісом за замовчуванням).
99
- */
100
- const ABIE_INTERNAL_URL_GLOBAL_RE =
101
- /\bhttp:\/\/([a-z0-9][a-z0-9-]*)\.([a-z0-9][a-z0-9-]*)\.svc\.([a-z0-9][a-z0-9-]*\.internal)(?::\d+)?(?:\/[^\s"'`]*)?/giu
102
-
103
- /**
104
- * Очікуваний кластерний DNS-суфікс і namespace-префікс для кожного env-файлу abie.
105
- * `dev` / `ua` живуть у двох GKE-кластерах з власним `<cluster>.internal`.
106
- */
107
- const ABIE_ENV_CLUSTER_DNS_MAP = Object.freeze({
108
- dev: Object.freeze({ clusterDns: 'abie-dev.internal', namespacePrefix: 'dev-' }),
109
- ua: Object.freeze({ clusterDns: 'abie-ua.internal', namespacePrefix: 'ua-' })
110
- })
111
-
112
- /**
113
- * Дістає ім'я env (`dev` / `ua`) з basename env-файлу abie.
114
- * Для не-abie env-файлів (наприклад `production.env`, `.env` без імені) повертає `null`.
115
- * @param {string} basenameOfEnvFile basename файла (без шляху)
116
- * @returns {('dev' | 'ua') | null} ім'я env або `null`
117
- */
118
- export function abieEnvNameFromBasename(basenameOfEnvFile) {
119
- const m = basenameOfEnvFile.match(ABIE_ENV_FILE_BASENAME_RE)
120
- return m ? /** @type {'dev' | 'ua'} */ (m[1]) : null
121
- }
122
-
123
- /**
124
- * Сканує вміст env-файлу abie і повертає помилки невідповідності кластерного DNS / namespace
125
- * для кожного знайденого internal URL. URL шукається глобально (`matchAll`), тож одне й те саме
126
- * порушення в кількох змінних дасть стільки ж окремих помилок.
127
- * @param {string} content вміст env-файлу (UTF-8)
128
- * @param {'dev' | 'ua'} envName ім'я env, отримане з `abieEnvNameFromBasename`
129
- * @returns {string[]} порожній масив, якщо все OK; інакше — список повідомлень про порушення
130
- */
131
- export function validateAbieEnvInternalUrls(content, envName) {
132
- const expected = ABIE_ENV_CLUSTER_DNS_MAP[envName]
133
- if (!expected) return []
134
- /** @type {string[]} */
135
- const errors = []
136
- for (const match of content.matchAll(ABIE_INTERNAL_URL_GLOBAL_RE)) {
137
- const [fullUrl, , namespace, clusterDns] = match
138
- if (clusterDns !== expected.clusterDns) {
139
- errors.push(
140
- `${fullUrl}: кластерний DNS "${clusterDns}" не відповідає env "${envName}" (очікується "${expected.clusterDns}")`
141
- )
142
- }
143
- if (!namespace.startsWith(expected.namespacePrefix)) {
144
- errors.push(
145
- `${fullUrl}: namespace "${namespace}" не починається з "${expected.namespacePrefix}" (env "${envName}")`
146
- )
147
- }
148
- }
149
- return errors
150
- }
151
-
152
- /**
153
- * Чи відносний шлях вказує на **`ua/kustomization.yaml`** (сегмент **`ua`** перед ім'ям файлу) — специфіка abie overlay.
154
- * @param {string} rel шлях від кореня репозиторію
155
- * @returns {boolean} true, якщо це `…/ua/kustomization.yaml`
156
- */
157
- export function isUaKustomizationPath(rel) {
158
- const norm = rel.replaceAll('\\', '/')
159
- return UA_KUSTOMIZATION_PATH_RE.test(norm)
160
- }
161
-
162
- /**
163
- * Каталог пакета: шлях перед сегментом **`/k8s/`** для overlay **`…/k8s/ua/kustomization.yaml`**.
164
- * @param {string} root корінь репозиторію
165
- * @param {string} kustomizationAbs абсолютний шлях до **ua** kustomization.yaml
166
- * @returns {string | null} абсолютний шлях до каталогу пакета або null, якщо шлях не overlay ua
167
- */
168
- export function abiePackageDirFromK8sOverlay(root, kustomizationAbs) {
169
- const rel = relative(root, kustomizationAbs).replaceAll('\\', '/') || kustomizationAbs
170
- const m = rel.match(OVERLAY_PACKAGE_DIR_RE)
171
- return m ? join(root, m[1]) : null
172
- }
173
-
174
- /**
175
- * Чи для цього overlay застосовувати вимоги **HTTPRoute** (лише Vite-пакети).
176
- * @param {string} root корінь репозиторію
177
- * @param {string} kustomizationAbs абсолютний шлях до **ua** kustomization.yaml
178
- * @returns {boolean} **true**, якщо поруч із **k8s** є **vite.config** (**js** / **mjs** / **ts**)
179
- */
180
- export function abieOverlayRequiresHttpRouteByVite(root, kustomizationAbs) {
181
- const pkg = abiePackageDirFromK8sOverlay(root, kustomizationAbs)
182
- if (!pkg) {
183
- return false
184
- }
185
- return (
186
- existsSync(join(pkg, 'vite.config.js')) ||
187
- existsSync(join(pkg, 'vite.config.mjs')) ||
188
- existsSync(join(pkg, 'vite.config.ts'))
189
- )
190
- }
191
-
192
- /**
193
- * Чи в дереві **k8s** того ж пакета, що й overlay **ua**, є **Deployment** (за каталогами з **collectDeploymentDirs**).
194
- * @param {Set<string>} deploymentDirs абсолютні каталоги YAML-файлів із **Deployment**
195
- * @param {string} root корінь репозиторію
196
- * @param {string} kustomizationAbs абсолютний шлях до **ua** kustomization.yaml
197
- * @returns {boolean} **true**, якщо хоч один каталог із **deploymentDirs** лежить під **`…/k8s/`** цього пакета
198
- */
199
- export function abieOverlayK8sTreeHasDeployment(deploymentDirs, root, kustomizationAbs) {
200
- const pkg = abiePackageDirFromK8sOverlay(root, kustomizationAbs)
201
- if (!pkg) {
202
- return false
203
- }
204
- const k8sRoot = join(pkg, 'k8s').replaceAll('\\', '/')
205
- for (const dir of deploymentDirs) {
206
- const norm = dir.replaceAll('\\', '/')
207
- if (norm === k8sRoot || norm.startsWith(`${k8sRoot}/`)) {
208
- return true
209
- }
210
- }
211
- return false
212
- }
213
-
214
- /**
215
- * Чи відносний шлях до YAML під **k8s** вказує на файл у каталозі **`base`** (сегмент **`base`** у шляху), abie.mdc.
216
- * @param {string} rel шлях від кореня репозиторію
217
- * @returns {boolean} true, якщо в шляху є **`/base/`**
218
- */
219
- export function isAbieK8sBaseYamlPath(rel) {
220
- const norm = rel.replaceAll('\\', '/')
221
- return BASE_SEGMENT_RE.test(norm)
222
- }
223
-
224
- // Per-document валідація hostnames у `…/k8s/.../base/.../*.yaml` HTTPRoute
225
- // (Plan B: Rego-authoritative) — повністю в `npm/policy/abie/http_route_base/`.
226
- // Per-document валідація `nodeSelector.preem` для Deployment у base — у
227
- // `npm/policy/abie/base_deployment_preem/`. JS у `check-abie.mjs` робить лише
228
- // path-фільтрацію + батч-виклик conftest через `runConftestBatch`.
229
-
230
- /**
231
- * Чи увімкнено правило **abie** у конфігу репозиторію.
232
- * @param {string} root корінь репозиторію (cwd)
233
- * @returns {Promise<boolean>} true, якщо **rules** містить **abie**
234
- */
235
- export async function isAbieRuleEnabled(root) {
236
- const p = join(root, CONFIG_FILE)
237
- if (!existsSync(p)) {
238
- return false
239
- }
240
- let raw
241
- try {
242
- raw = await readFile(p, 'utf8')
243
- } catch {
244
- return false
245
- }
246
- let cfg
247
- try {
248
- cfg = JSON.parse(raw)
249
- } catch {
250
- return false
251
- }
252
- const rules = cfg?.rules
253
- if (!Array.isArray(rules)) {
254
- return false
255
- }
256
- return rules.some(r => String(r).trim().toLowerCase() === 'abie')
257
- }
258
-
259
- // Per-document валідація `clean-merged-branch.yml` (with.ignore_branches з
260
- // dev/ua) делегована rego-пакету `abie.clean_merged_ignore_branches`
261
- // (`npm/policy/abie/clean_merged_ignore_branches/`). JS викликає
262
- // `runConftestBatch` у `checkCleanMergedBranch`.
263
-
264
- /**
265
- * Збирає абсолютні шляхи до **.yaml** / **.yml** під деревом, де є сегмент **k8s**.
266
- * @param {string} root корінь репозиторію
267
- * @param {string[]} [ignorePaths] абсолютні шляхи каталогів, повністю виключених з обходу
268
- * @returns {Promise<string[]>} відсортовані шляхи
269
- */
270
- async function findK8sYamlFiles(root, ignorePaths = []) {
271
- /** @type {string[]} */
272
- const out = []
273
- await walkDir(
274
- root,
275
- p => {
276
- const rel = relative(root, p).replaceAll('\\', '/')
277
- // `.github/` належить `ga.mdc`; check-abie не зачіпає workflow-файли.
278
- if (rel.startsWith('.github/')) {
279
- return
280
- }
281
- if (!pathHasK8sSegment(p, root)) {
282
- return
283
- }
284
- if (!YAML_EXTENSION_RE.test(p)) {
285
- return
286
- }
287
- out.push(p)
288
- },
289
- ignorePaths
290
- )
291
- return [...out].toSorted((a, b) => a.localeCompare(b))
292
- }
293
-
294
- /**
295
- * Чи документ — **Deployment**.
296
- * @param {unknown} obj корінь YAML-документа
297
- * @returns {boolean} true, якщо **kind** документа — **Deployment**
298
- */
299
- function isDeploymentDoc(obj) {
300
- return (
301
- obj !== null &&
302
- typeof obj === 'object' &&
303
- !Array.isArray(obj) &&
304
- /** @type {Record<string, unknown>} */ (obj).kind === 'Deployment'
305
- )
306
- }
307
-
308
- /**
309
- * Директорії, де є хоча б один **Deployment** у файлах **k8s**.
310
- * @param {string} root корінь cwd
311
- * @param {string[]} yamlAbs абсолютні шляхи yaml під k8s
312
- * @param {(msg: string) => void} fail реєстрація помилки парсингу
313
- * @returns {Promise<Set<string>>} абсолютні шляхи директорій
314
- */
315
- async function collectDeploymentDirs(root, yamlAbs, fail) {
316
- /** @type {Set<string>} */
317
- const dirs = new Set()
318
- for (const abs of yamlAbs) {
319
- const rel = relative(root, abs).replaceAll('\\', '/') || abs
320
- const docs = await readAndParseYamlDocs(abs, rel, fail)
321
- if (docs) {
322
- for (const doc of docs) {
323
- if (doc.errors.length === 0 && isDeploymentDoc(doc.toJSON())) {
324
- dirs.add(dirname(abs))
325
- }
326
- }
327
- }
328
- }
329
- return dirs
330
- }
331
-
332
- /**
333
- * Для кожного **Deployment** у YAML під **`k8s`** з шляхом **`…/base/…`** вимагає **`spec.template.spec.nodeSelector.preem: true`** (abie.mdc).
334
- *
335
- * Per-document валідація делегована у rego-пакет **`abie.base_deployment_preem`**
336
- * (`npm/policy/abie/base_deployment_preem/`) — JS лише фільтрує файли за path-патерном `base/` і батчем спавнить conftest.
337
- * @param {string} root корінь репозиторію
338
- * @param {string[]} yamlFilesAbs yaml під k8s
339
- * @param {(msg: string) => void} fail callback
340
- * @param {(msg: string) => void} passFn успішне повідомлення
341
- * @returns {void}
342
- */
343
- function ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFilesAbs, fail, passFn) {
344
- const baseFiles = yamlFilesAbs.filter(abs => isAbieK8sBaseYamlPath(relative(root, abs).replaceAll('\\', '/') || abs))
345
- if (baseFiles.length === 0) {
346
- passFn('Немає файлів у шляхах …/base/… — перевірку preem у base пропущено')
347
- return
348
- }
349
- const violations = runConftestBatch({
350
- policyDirRel: 'abie/base_deployment_preem',
351
- namespace: 'abie.base_deployment_preem',
352
- files: baseFiles
353
- })
354
- for (const v of violations) {
355
- const rel = relative(root, v.filename).replaceAll('\\', '/') || v.filename
356
- fail(`${rel}: ${v.message}`)
357
- }
358
- if (violations.length === 0) {
359
- passFn('Deployment у …/base/…: nodeSelector.preem відповідає abie.mdc (rego)')
360
- }
361
- }
362
-
363
- /**
364
- * Прибирає BOM на початку файлу.
365
- * @param {string} s вміст
366
- * @returns {string} той самий рядок без BOM (U+FEFF) на початку
367
- */
368
- function stripBom(s) {
369
- return s.startsWith('') ? s.slice(1) : s
370
- }
371
-
372
- /**
373
- * Зчитує та парсить YAML-документи з файлу.
374
- * При помилці читання викликає `failFn` і повертає `null`.
375
- * При помилці парсингу викликає `failFn` і повертає `null`.
376
- * Автоматично видаляє BOM та modeline (перший рядок з `$schema`).
377
- * @param {string} abs абсолютний шлях до файлу
378
- * @param {string} rel відносний шлях (для повідомлень)
379
- * @param {(msg: string) => void} failFn callback при помилці
380
- * @returns {Promise<import('yaml').Document[] | null>} масив документів або null при помилці
381
- */
382
- async function readAndParseYamlDocs(abs, rel, failFn) {
383
- let raw
384
- try {
385
- raw = await readFile(abs, 'utf8')
386
- } catch (error) {
387
- const msg = error instanceof Error ? error.message : String(error)
388
- failFn(`${rel}: не вдалося прочитати (${msg})`)
389
- return null
390
- }
391
- const body = stripBom(raw)
392
- const lines = body.split(LINE_SPLIT_RE)
393
- const first = lines[0] ?? ''
394
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
395
- try {
396
- return parseAllDocuments(rest)
397
- } catch (error) {
398
- const msg = error instanceof Error ? error.message : String(error)
399
- failFn(`${rel}: YAML (${msg})`)
400
- return null
401
- }
402
- }
403
-
404
- /**
405
- * No-op fail handler для функцій, що повертають null/порожній масив при помилці.
406
- * @param {string} _msg повідомлення ігнорується
407
- */
408
- const silentFail = _msg => {
409
- /* silent — пошкоджені файли ловить check-k8s */
410
- }
411
-
412
- /**
413
- * Чи рядок inline JSON6902 patch містить очікуваний **ua** nodeSelector (**preem: false** на **`/spec/template/spec/nodeSelector`**).
414
- * Конкретний **`op`** не перевіряється — див. **k8s.mdc**.
415
- * @param {string} patchText поле **patch** у kustomization
416
- * @returns {boolean} true, якщо критерії abie.mdc виконано
417
- */
418
- function jsonPatchTextHasUaDeploymentNodeSelector(patchText) {
419
- if (typeof patchText !== 'string' || patchText.trim() === '') {
420
- return false
421
- }
422
- if (!PATCH_NODE_SELECTOR_PATH_RE.test(patchText)) {
423
- return false
424
- }
425
- if (!PATCH_PREEM_FALSE_RE.test(patchText)) {
426
- return false
427
- }
428
- return true
429
- }
430
-
431
- /**
432
- * Чи один елемент **patches** у kustomization відповідає abie nodeSelector для заданого **mode**.
433
- * @param {unknown} p елемент масиву **patches**
434
- * @param {'ua'} mode який overlay перевіряти
435
- * @returns {boolean} true, якщо patch відповідає abie для **mode**
436
- */
437
- function inlineKustomizationPatchMatchesAbieMode(p, mode) {
438
- if (p === null || typeof p !== 'object' || Array.isArray(p)) {
439
- return false
440
- }
441
- const pr = /** @type {Record<string, unknown>} */ (p)
442
- const target = pr.target
443
- if (target === null || typeof target !== 'object' || Array.isArray(target)) {
444
- return false
445
- }
446
- const tg = /** @type {Record<string, unknown>} */ (target)
447
- if (tg.kind !== 'Deployment') {
448
- return false
449
- }
450
- const patchStr = pr.patch
451
- if (typeof patchStr !== 'string') {
452
- return false
453
- }
454
- if (mode === 'ua' && jsonPatchTextHasUaDeploymentNodeSelector(patchStr)) {
455
- return true
456
- }
457
- return false
458
- }
459
-
460
- /**
461
- * Чи один YAML-документ kustomization містить відповідний inline patch на Deployment.
462
- * @param {import('yaml').Document} doc документ після **parseAllDocuments**
463
- * @param {'ua'} mode який overlay перевіряти
464
- * @returns {boolean} true, якщо знайдено відповідний patch
465
- */
466
- function kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode) {
467
- if (doc.errors.length > 0) {
468
- return false
469
- }
470
- const root = doc.toJSON()
471
- if (root === null || typeof root !== 'object' || Array.isArray(root)) {
472
- return false
473
- }
474
- const rec = /** @type {Record<string, unknown>} */ (root)
475
- if (rec.kind !== 'Kustomization') {
476
- return false
477
- }
478
- const patches = rec.patches
479
- if (!Array.isArray(patches)) {
480
- return false
481
- }
482
- for (const p of patches) {
483
- if (inlineKustomizationPatchMatchesAbieMode(p, mode)) {
484
- return true
485
- }
486
- }
487
- return false
488
- }
489
-
490
- /**
491
- * Чи **kustomization.yaml** містить inline **patches** на **Deployment** з nodeSelector за abie.mdc (overlay **ua**).
492
- * @param {string} raw повний текст файлу
493
- * @param {'ua'} mode який overlay перевіряти
494
- * @returns {boolean} true, якщо знайдено відповідний patch
495
- */
496
- export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
497
- const body = stripBom(raw)
498
- const lines = body.split(LINE_SPLIT_RE)
499
- const first = lines[0] ?? ''
500
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
501
- /** @type {import('yaml').Document[]} */
502
- let docs
503
- try {
504
- docs = parseAllDocuments(rest)
505
- } catch {
506
- return false
507
- }
508
- for (const doc of docs) {
509
- if (kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode)) {
510
- return true
511
- }
512
- }
513
- return false
514
- }
515
-
516
- /**
517
- * Чи YAML відносно кореня належить до **`${pkgRel}/k8s/**`** поза піддеревом **`ua/`** (base-шар abie).
518
- * @param {string} relFromRoot відносний шлях від кореня
519
- * @param {string} pkgRelFromRoot каталог пакета відносно кореня (без завершального слеша після імені пакета)
520
- * @returns {boolean} `true`, якщо шлях належить до base-шару abie
521
- */
522
- export function isK8sYamlInAbiePackageExcludingUaOverlay(relFromRoot, pkgRelFromRoot) {
523
- const normRel = relFromRoot.replaceAll('\\', '/')
524
- const pkg = pkgRelFromRoot.replaceAll('\\', '/').replace(TRAILING_SLASH_RE, '')
525
- const prefix = `${pkg}/k8s/`
526
- if (!normRel.startsWith(prefix)) {
527
- return false
528
- }
529
- const after = normRel.slice(prefix.length)
530
- return !after.startsWith('ua/')
531
- }
532
-
533
- /**
534
- * Перевіряє один backendRef на відповідність abie.mdc.
535
- * @param {unknown} br параметр br
536
- * @param {string} rel відносний шлях (для повідомлень)
537
- * @param {string[]} errors масив для запису помилок
538
- * @returns {number} 1 якщо знайдено shared backend, 0 інакше
539
- */
540
- function checkSharedBackendRef(br, rel, errors) {
541
- if (br === null || typeof br !== 'object' || Array.isArray(br)) return 0
542
- const brRec = /** @type {Record<string, unknown>} */ (br)
543
- const name = brRec.name
544
- if (typeof name !== 'string' || !ABIE_SHARED_CROSS_NS_BACKEND_SET.has(name)) return 0
545
- if (typeof brRec.namespace !== 'string' || brRec.namespace !== 'dev') {
546
- errors.push(`${rel}: HTTPRoute backendRefs до ${name} має містити namespace: dev (abie.mdc)`)
547
- }
548
- return 1
549
- }
550
-
551
- /**
552
- * З HTTPRoute-документа рахує **`backendRefs`** до **`auth-run-hl`** / **`file-link-hl`** і порушення **`namespace: dev`**.
553
- * @param {unknown} obj корінь YAML
554
- * @param {string} rel відносний шлях (повідомлення)
555
- * @returns {{ refCount: number, errors: string[] }} кількість посилань і список порушень
556
- */
557
- function httpRouteDocSharedCrossNsBackendStats(obj, rel) {
558
- /** @type {string[]} */
559
- const errors = []
560
- if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return { refCount: 0, errors }
561
- const rec = /** @type {Record<string, unknown>} */ (obj)
562
- if (rec.kind !== 'HTTPRoute') return { refCount: 0, errors }
563
- const spec = rec.spec
564
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return { refCount: 0, errors }
565
- const rules = /** @type {Record<string, unknown>} */ (spec).rules
566
- if (!Array.isArray(rules)) return { refCount: 0, errors }
567
- let refCount = 0
568
- for (const rule of rules) {
569
- if (rule !== null && typeof rule === 'object' && !Array.isArray(rule)) {
570
- const brs = /** @type {Record<string, unknown>} */ (rule).backendRefs
571
- if (Array.isArray(brs)) {
572
- for (const br of brs) {
573
- refCount += checkSharedBackendRef(br, rel, errors)
574
- }
575
- }
576
- }
577
- }
578
- return { refCount, errors }
579
- }
580
-
581
- /**
582
- * З YAML під **k8s** пакета (без overlay **ua**) збирає кількість **`backendRefs`** до **`auth-run-hl`** і **`file-link-hl`** і порушення **`namespace: dev`**.
583
- * @param {string} root корінь репозиторію
584
- * @param {string} pkgAbs абсолютний шлях до каталогу пакета
585
- * @param {string[]} yamlFilesAbs усі **yaml** під **k8s** (як **findK8sYamlFiles**)
586
- * @returns {Promise<{ refCount: number, baseErrors: string[] }>} кількість посилань і базові помилки
587
- */
588
- export async function analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yamlFilesAbs) {
589
- const pkgRel = relative(root, pkgAbs).replaceAll('\\', '/') || pkgAbs
590
- let refCount = 0
591
- /** @type {string[]} */
592
- const baseErrors = []
593
- for (const abs of yamlFilesAbs) {
594
- const rel = relative(root, abs).replaceAll('\\', '/') || abs
595
- if (isK8sYamlInAbiePackageExcludingUaOverlay(rel, pkgRel)) {
596
- const docs = await readAndParseYamlDocs(abs, rel, silentFail)
597
- if (docs) {
598
- for (const doc of docs) {
599
- if (doc.errors.length === 0) {
600
- const json = doc.toJSON()
601
- const st = httpRouteDocSharedCrossNsBackendStats(json, rel)
602
- refCount += st.refCount
603
- baseErrors.push(...st.errors)
604
- }
605
- }
606
- }
607
- }
608
- }
609
- return { refCount, baseErrors }
610
- }
611
-
612
- /**
613
- * Рахує операції JSON6902 з **`path`**: **`/spec/rules/…/backendRefs/…/namespace`** та **`value`** overlay.
614
- * @param {string} combined сукупний текст patch **HTTPRoute**
615
- * @param {'ua'} mode overlay
616
- * @returns {number} кількість знайдених патчів namespace
617
- */
618
- function countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode) {
619
- if (mode !== 'ua') return 0
620
- const re =
621
- /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/gimu
622
- return [...combined.matchAll(re)].length
623
- }
624
-
625
- /** Домени **hostnames** для overlay **ua** (підрядки у JSON6902-тексті patch), abie.mdc. */
626
- const ABIE_UA_HTTPROUTE_HOST_MARKERS = ['abie.app', 'vybeerai.com.ua', '*.abie.app', '*.vybeerai.com.ua']
627
-
628
- /**
629
- * Витягує текст patch HTTPRoute з елемента patches.
630
- * @param {unknown} p елемент масиву patches
631
- * @returns {string | null} текст patch або null
632
- */
633
- function extractHttpRoutePatchString(p) {
634
- if (p === null || typeof p !== 'object' || Array.isArray(p)) return null
635
- const pr = /** @type {Record<string, unknown>} */ (p)
636
- const target = pr.target
637
- if (target === null || typeof target !== 'object' || Array.isArray(target)) return null
638
- const tg = /** @type {Record<string, unknown>} */ (target)
639
- if (tg.kind !== 'HTTPRoute' || typeof tg.name !== 'string' || tg.name.trim() === '') return null
640
- const patchStr = pr.patch
641
- return typeof patchStr === 'string' && patchStr.trim() !== '' ? patchStr : null
642
- }
643
-
644
- /**
645
- * Збирає тексти inline **patch** для **HTTPRoute** (будь-який непорожній **target.name**) з одного документа **Kustomization**.
646
- * @param {import('yaml').Document} doc документ після **parseAllDocuments**
647
- * @returns {string[]} непорожні рядки **patch**
648
- */
649
- function collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc) {
650
- if (doc.errors.length > 0) return []
651
- const root = doc.toJSON()
652
- if (root === null || typeof root !== 'object' || Array.isArray(root)) return []
653
- const rec = /** @type {Record<string, unknown>} */ (root)
654
- if (rec.kind !== 'Kustomization' || !Array.isArray(rec.patches)) return []
655
- /** @type {string[]} */
656
- const out = []
657
- for (const p of rec.patches) {
658
- const s = extractHttpRoutePatchString(p)
659
- if (s !== null) out.push(s)
660
- }
661
- return out
662
- }
663
-
664
- /**
665
- * Збирає всі inline **JSON6902**-фрагменти для **HTTPRoute** (непорожній **target.name**) у **kustomization.yaml** (усі документи у файлі).
666
- * @param {string} raw повний текст файлу
667
- * @returns {string} текст для **`validateAbieNginxRunHttpRoutePatches`** (може бути порожнім)
668
- */
669
- export function getCombinedNginxRunPatchTextFromKustomization(raw) {
670
- const body = stripBom(raw)
671
- const lines = body.split(LINE_SPLIT_RE)
672
- const first = lines[0] ?? ''
673
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
674
- /** @type {import('yaml').Document[]} */
675
- let docs
676
- try {
677
- docs = parseAllDocuments(rest)
678
- } catch {
679
- return ''
680
- }
681
- /** @type {string[]} */
682
- const chunks = []
683
- for (const doc of docs) {
684
- chunks.push(...collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc))
685
- }
686
- return chunks.join('\n')
687
- }
688
-
689
- /**
690
- * Перевіряє сукупний текст patch(ів) **HTTPRoute** (будь-яке **target.name**) на відповідність abie.mdc.
691
- * @param {string} combined текст одного або кількох inline **patch**, розділених символом нового рядка
692
- * @param {'ua'} mode overlay (наразі лише **ua**)
693
- * @param {string} [_fullKustomizationRaw] збережено для зворотної сумісності API (не використовується)
694
- * @param {number} [sharedCrossNsBackendRefCount] скільки **`backendRefs`** до **`auth-run-hl`** і **`file-link-hl`** у base **HTTPRoute** пакета — стільки ж patch-ів **`…/backendRefs/…/namespace`** з **`value`** overlay
695
- * @returns {string | null} повідомлення про помилку або **null**
696
- */
697
- export function validateAbieNginxRunHttpRoutePatches(
698
- combined,
699
- mode,
700
- _fullKustomizationRaw,
701
- sharedCrossNsBackendRefCount = 0
702
- ) {
703
- if (typeof combined !== 'string' || combined.trim() === '') {
704
- return `очікується patch target kind HTTPRoute з непорожнім target.name (hostnames, parentRefs namespace ${mode}) — abie.mdc`
705
- }
706
- if (!PATCH_HOSTNAMES_PATH_RE.test(combined)) {
707
- return 'HTTPRoute: потрібен path /spec/hostnames у patch (abie.mdc)'
708
- }
709
- const markers = ABIE_UA_HTTPROUTE_HOST_MARKERS
710
- if (!markers.some(m => combined.includes(m))) {
711
- return `HTTPRoute: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
712
- }
713
- if (!PATCH_PARENT_REF_NS_UA_RE.test(combined)) {
714
- return `HTTPRoute: потрібен path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
715
- }
716
- const sharedCount =
717
- typeof sharedCrossNsBackendRefCount === 'number' && Number.isFinite(sharedCrossNsBackendRefCount)
718
- ? Math.max(0, Math.floor(sharedCrossNsBackendRefCount))
719
- : 0
720
- if (sharedCount > 0) {
721
- const patchHits = countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode)
722
- if (patchHits < sharedCount) {
723
- return `HTTPRoute: для backendRefs до спільних сервісів auth-run-hl, file-link-hl очікується ${sharedCount} JSON6902 patch(ів) з path /spec/rules/…/backendRefs/…/namespace та value ${mode} (зараз ${patchHits}) — abie.mdc`
724
- }
725
- }
726
- return null
727
- }
728
-
729
- /**
730
- * Чи **kustomization** містить валідні для abie **patch** для **HTTPRoute** з непорожнім **target.name** (**ua**).
731
- * @param {string} raw повний текст **kustomization.yaml**
732
- * @param {'ua'} mode overlay
733
- * @returns {boolean} true, якщо **`validateAbieNginxRunHttpRoutePatches`** повертає **null**
734
- */
735
- export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
736
- const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
737
- return validateAbieNginxRunHttpRoutePatches(combined, mode, raw) === null
738
- }
739
-
740
- // Per-document валідація HealthCheckPolicy (apiVersion / spec.default.config /
741
- // httpHealthCheck / targetRef з суфіксом `-hl` exact match) делегована
742
- // rego-пакету `abie.health_check_policy` (`npm/policy/abie/health_check_policy/`).
743
- // JS у `checkHcYamlFiles` робить лише modeline-перевірку (`validateAbieHcModeline`)
744
- // і батч-виклик conftest.
745
-
746
- /**
747
- * JS-частина перевірки hc.yaml — лише modeline (`# yaml-language-server: $schema=…`).
748
- * Парсинг YAML і структурна валідація HealthCheckPolicy делеговано в rego-пакет
749
- * **`abie.health_check_policy`** (`npm/policy/abie/health_check_policy/`),
750
- * викликається з `checkHcYamlFile` через `runConftestBatch`.
751
- * @param {string} raw вміст файлу
752
- * @param {string} relPath відносний шлях (для повідомлень)
753
- * @returns {string | null} null якщо OK, рядок з помилкою
754
- */
755
- export function validateAbieHcModeline(raw, relPath) {
756
- const body = stripBom(raw)
757
- const lines = body.split(LINE_SPLIT_RE)
758
- if (lines.length === 0 || lines[0].trim() === '') {
759
- return `${relPath}: перший рядок порожній — потрібен # yaml-language-server: $schema=… (abie.mdc)`
760
- }
761
- const m = lines[0].match(MODELINE_RE)
762
- if (!m) return `${relPath}: перший рядок має бути modeline $schema (abie.mdc)`
763
- if (m[1] !== ABIE_HC_SCHEMA_URL) return `${relPath}: $schema має бути\n ${ABIE_HC_SCHEMA_URL}\n (abie.mdc)`
764
- return null
765
- }
766
-
767
- /**
768
- * Перевіряє одну kustomization.yaml на nodeSelector patch для заданого overlay.
769
- * @param {string} abs абсолютний шлях до файлу
770
- * @param {string} rel відносний шлях (для повідомлень)
771
- * @param {'ua'} mode параметр mode
772
- * @param {Set<string>} deploymentDirs директорії з Deployment (Set)
773
- * @param {string} root корінь репозиторію
774
- * @param {(msg: string) => void} fail callback при помилці
775
- * @param {(msg: string) => void} passFn callback при успішній перевірці
776
- * @returns {Promise<boolean>} false якщо виявлено помилку і слід зупинитись
777
- */
778
- async function checkNodeSelectorKustomization(abs, rel, mode, deploymentDirs, root, fail, passFn) {
779
- if (!abieOverlayK8sTreeHasDeployment(deploymentDirs, root, abs)) {
780
- passFn(`${rel}: nodeSelector patch (${mode}) не застосовується — немає Deployment у дереві k8s цього пакета (abie)`)
781
- return true
782
- }
783
- let raw
784
- try {
785
- raw = await readFile(abs, 'utf8')
786
- } catch (error) {
787
- const msg = error instanceof Error ? error.message : String(error)
788
- fail(`${rel}: не вдалося прочитати (${msg})`)
789
- return false
790
- }
791
- if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode)) {
792
- fail(`${rel}: потрібен patch target kind Deployment: path /spec/template/spec/nodeSelector та preem: false (abie.mdc)`)
793
- return false
794
- }
795
- passFn(`${rel}: nodeSelector patch (${mode}) відповідає abie.mdc`)
796
- return true
797
- }
798
-
799
- /**
800
- * Перевіряє наявність патчів nodeSelector для ua overlay у k8s.
801
- * @param {string} root корінь репозиторію
802
- * @param {string[]} yamlFilesAbs абсолютні шляхи yaml-файлів під k8s
803
- * @param {Set<string>} deploymentDirs директорії з Deployment (Set)
804
- * @param {(msg: string) => void} fail callback при помилці
805
- * @param {(msg: string) => void} passFn callback при успішній перевірці
806
- */
807
- async function ensureUaAbieNodeSelectorPatches(root, yamlFilesAbs, deploymentDirs, fail, passFn) {
808
- const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
809
- if (uaAbsList.length === 0) {
810
- fail(
811
- 'Є Deployment у k8s — додай ua/kustomization.yaml з patch на Deployment: path /spec/template/spec/nodeSelector, preem false (abie.mdc)'
812
- )
813
- return
814
- }
815
- for (const abs of uaAbsList) {
816
- const rel = relative(root, abs).replaceAll('\\', '/') || abs
817
- const ok = await checkNodeSelectorKustomization(abs, rel, 'ua', deploymentDirs, root, fail, passFn)
818
- if (!ok) return
819
- }
820
- }
821
-
822
- /**
823
- * Перевіряє HTTPRoute patch для одного overlay (ua).
824
- * @param {string} abs абсолютний шлях до kustomization.yaml
825
- * @param {string} rel відносний шлях (для повідомлень)
826
- * @param {'ua'} mode overlay
827
- * @param {string} root корінь репозиторію
828
- * @param {string[]} yamlFilesAbs абсолютні шляхи yaml-файлів під k8s
829
- * @param {Map<string, Promise<{ refCount: number, baseErrors: string[] }>>} cache кеш аналізу shared backend refs
830
- * @param {(msg: string) => void} fail callback при помилці
831
- * @param {(msg: string) => void} passFn callback при успішній перевірці
832
- * @returns {Promise<boolean>} false якщо виявлено помилку і слід зупинитись
833
- */
834
- async function checkHttpRouteKustomization(abs, rel, mode, root, yamlFilesAbs, cache, fail, passFn) {
835
- if (!abieOverlayRequiresHttpRouteByVite(root, abs)) {
836
- passFn(`${rel}: HTTPRoute patch (${mode}) не застосовується — немає vite.config.{js,mjs,ts} у пакеті (abie)`)
837
- return true
838
- }
839
- const pkgAbs = abiePackageDirFromK8sOverlay(root, abs)
840
- if (!pkgAbs) {
841
- fail(`${rel}: внутрішня помилка abie overlay (немає каталогу пакета)`)
842
- return false
843
- }
844
- let p = cache.get(pkgAbs)
845
- if (!p) {
846
- p = analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yamlFilesAbs)
847
- cache.set(pkgAbs, p)
848
- }
849
- const sharedAnalysis = await p
850
- for (const err of sharedAnalysis.baseErrors) {
851
- fail(err)
852
- return false
853
- }
854
- let raw
855
- try {
856
- raw = await readFile(abs, 'utf8')
857
- } catch (error) {
858
- const msg = error instanceof Error ? error.message : String(error)
859
- fail(`${rel}: не вдалося прочитати (${msg})`)
860
- return false
861
- }
862
- const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
863
- const v = validateAbieNginxRunHttpRoutePatches(combined, mode, raw, sharedAnalysis.refCount)
864
- if (v !== null) {
865
- fail(`${rel}: ${v}`)
866
- return false
867
- }
868
- passFn(`${rel}: HTTPRoute patch (${mode}) відповідає abie.mdc`)
869
- return true
870
- }
871
-
872
- /**
873
- * Для кожного **HTTPRoute** у **`…/k8s/base/…`** з непорожніми **`spec.hostnames`** — лише **aiml.live** та піддомени (abie.mdc).
874
- * @param {string} root корінь репозиторію
875
- * @param {string[]} yamlFilesAbs yaml під k8s
876
- * @param {(msg: string) => void} fail callback при помилці
877
- * @param {(msg: string) => void} passFn callback при успішній перевірці
878
- * @returns {void}
879
- */
880
- function ensureAbieBaseHttpRouteHostnames(root, yamlFilesAbs, fail, passFn) {
881
- const baseFiles = yamlFilesAbs.filter(abs => isAbieK8sBaseYamlPath(relative(root, abs).replaceAll('\\', '/') || abs))
882
- if (baseFiles.length === 0) {
883
- passFn('Немає файлів у шляхах …/k8s/base/… — перевірку HTTPRoute hostnames пропущено')
884
- return
885
- }
886
- // Per-document валідація делегована rego-пакету `abie.http_route_base`
887
- // (`npm/policy/abie/http_route_base/`) — rego гейтує по `kind == "HTTPRoute"`.
888
- const violations = runConftestBatch({
889
- policyDirRel: 'abie/http_route_base',
890
- namespace: 'abie.http_route_base',
891
- files: baseFiles
892
- })
893
- for (const v of violations) {
894
- const rel = relative(root, v.filename).replaceAll('\\', '/') || v.filename
895
- fail(`${rel}: ${v.message}`)
896
- }
897
- if (violations.length === 0) {
898
- passFn(
899
- `HTTPRoute у …/k8s/base/…: spec.hostnames відповідають ${ABIE_BASE_DEV_HTTPROUTE_HOST_ROOT} та піддоменам (rego)`
900
- )
901
- }
902
- }
903
-
904
- /**
905
- * Якщо є **Deployment** під **k8s**, вимагає в overlay **ua** patch **HTTPRoute** (непорожній **target.name**) за abie.mdc
906
- * лише для пакетів з **vite.config.{js,mjs,ts}** у каталозі пакета (батько **k8s**).
907
- * @param {string} root корінь репозиторію
908
- * @param {string[]} yamlFilesAbs yaml під k8s
909
- * @param {(msg: string) => void} fail callback при помилці
910
- * @param {(msg: string) => void} passFn callback при успішній перевірці
911
- */
912
- async function ensureUaAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn) {
913
- /** @type {Map<string, Promise<{ refCount: number, baseErrors: string[] }>>} */
914
- const cache = new Map()
915
-
916
- const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
917
- if (uaAbsList.length === 0) {
918
- passFn(
919
- 'Немає ua/kustomization.yaml у дереві k8s — patch HTTPRoute (ua) не вимагається (abie.mdc, лише Vite-пакети)'
920
- )
921
- }
922
- for (const abs of uaAbsList) {
923
- const rel = relative(root, abs).replaceAll('\\', '/') || abs
924
- const ok = await checkHttpRouteKustomization(abs, rel, 'ua', root, yamlFilesAbs, cache, fail, passFn)
925
- if (!ok) return
926
- }
927
- }
928
-
929
- /**
930
- * Перевіряє відсутність артефактів Firebase Hosting у **кожному** **підкаталозі першого рівня** від кореня
931
- * (не в самому корені репозиторію) — abie.mdc. Каталоги **`.git`** і **`node_modules`** у скануванні пропускаються.
932
- * @param {string} root корінь репозиторію
933
- * @param {(msg: string) => void} passFn успішне повідомлення
934
- * @param {(msg: string) => void} failFn повідомлення про порушення
935
- * @returns {Promise<void>}
936
- */
937
- async function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
938
- let entries
939
- try {
940
- entries = await readdir(root, { withFileTypes: true })
941
- } catch (error) {
942
- const msg = error instanceof Error ? error.message : String(error)
943
- failFn(`Не вдалося прочитати ${root} для перевірки Firebase Hosting: ${msg} (abie.mdc)`)
944
- return
945
- }
946
- const topDirs = entries.filter(e => e.isDirectory() && !ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES.has(e.name))
947
- let hasViolation = false
948
- for (const e of topDirs) {
949
- for (const name of ['.firebaserc', 'firebase.json']) {
950
- const rel = join(e.name, name).replaceAll('\\', '/')
951
- if (existsSync(join(root, e.name, name))) {
952
- failFn(`Знайдено заборонений файл Firebase Hosting: ${rel} — видали його (abie.mdc)`)
953
- hasViolation = true
954
- }
955
- }
956
- if (existsSync(join(root, e.name, '.firebase'))) {
957
- failFn(`Знайдено заборонену директорію: ${e.name}/.firebase/ — видали її (abie.mdc)`)
958
- hasViolation = true
959
- }
960
- }
961
- if (hasViolation) {
962
- return
963
- }
964
- passFn('Підкаталоги кореня (1-й рівень, без .git/node_modules): артефактів Firebase Hosting не знайдено (abie.mdc)')
965
- }
966
-
967
- /**
968
- * Перевіряє clean-merged-branch.yml на ignore_branches.
969
- * @param {string} root корінь репозиторію
970
- * @param {(msg: string) => void} pass callback при успішній перевірці
971
- * @param {(msg: string) => void} fail callback при помилці
972
- * @returns {void}
973
- */
974
- function checkCleanMergedBranch(root, pass, fail) {
975
- const cleanMergedPath = join(root, '.github/workflows/clean-merged-branch.yml')
976
- if (!existsSync(cleanMergedPath)) {
977
- fail(`Відсутній ${cleanMergedPath} — потрібен для ignore_branches (abie.mdc)`)
978
- return
979
- }
980
- // Per-document валідація делегована у rego-пакет `abie.clean_merged_ignore_branches`
981
- // (`npm/policy/abie/clean_merged_ignore_branches/`). conftest сам читає та парсить YAML.
982
- const violations = runConftestBatch({
983
- policyDirRel: 'abie/clean_merged_ignore_branches',
984
- namespace: 'abie.clean_merged_ignore_branches',
985
- files: [cleanMergedPath]
986
- })
987
- for (const v of violations) fail(v.message)
988
- if (violations.length === 0) {
989
- pass('clean-merged-branch.yml: ignore_branches містить dev, ua (rego)')
990
- }
991
- }
992
-
993
- /**
994
- * Перевіряє hc.yaml у директоріях з Deployment. JS перевіряє modeline, далі
995
- * один батч conftest для усіх знайдених hc.yaml — структурна валідація HCP
996
- * делегується rego (`abie.health_check_policy`).
997
- * @param {string} root корінь репозиторію
998
- * @param {Set<string>} deploymentDirs директорії з Deployment (Set)
999
- * @param {(msg: string) => void} pass callback при успішній перевірці
1000
- * @param {(msg: string) => void} fail callback при помилці
1001
- */
1002
- async function checkHcYamlFiles(root, deploymentDirs, pass, fail) {
1003
- /** @type {string[]} файли, які пройшли modeline-check і йдуть у conftest */
1004
- const hcFilesForRego = []
1005
- for (const dir of [...deploymentDirs].toSorted((a, b) => a.localeCompare(b))) {
1006
- const hcAbs = join(dir, 'hc.yaml')
1007
- const relHc = relative(root, hcAbs).replaceAll('\\', '/') || 'hc.yaml'
1008
- if (!existsSync(hcAbs)) {
1009
- fail(`${relative(root, dir) || dir}: є Deployment, але немає hc.yaml поруч — додай HealthCheckPolicy (abie.mdc)`)
1010
- continue
1011
- }
1012
- let hcRaw
1013
- try {
1014
- hcRaw = await readFile(hcAbs, 'utf8')
1015
- } catch (error) {
1016
- const msg = error instanceof Error ? error.message : String(error)
1017
- fail(`${relHc}: не вдалося прочитати (${msg})`)
1018
- continue
1019
- }
1020
- const modelineErr = validateAbieHcModeline(hcRaw, relHc)
1021
- if (modelineErr !== null) {
1022
- fail(modelineErr)
1023
- continue
1024
- }
1025
- hcFilesForRego.push(hcAbs)
1026
- }
1027
- if (hcFilesForRego.length === 0) return
1028
- const violations = runConftestBatch({
1029
- policyDirRel: 'abie/health_check_policy',
1030
- namespace: 'abie.health_check_policy',
1031
- files: hcFilesForRego
1032
- })
1033
- for (const v of violations) {
1034
- const rel = relative(root, v.filename).replaceAll('\\', '/') || v.filename
1035
- fail(`${rel}: ${v.message}`)
1036
- }
1037
- if (violations.length === 0 && hcFilesForRego.length > 0) {
1038
- pass(`HealthCheckPolicy: ${hcFilesForRego.length} файл(ів) hc.yaml відповідають abie.mdc (rego)`)
1039
- }
1040
- }
1041
-
1042
- /**
1043
- * Збирає всі `*.env` файли в дереві (за виключенням `node_modules`, `.git` та інших службових каталогів),
1044
- * basename яких — abie env-файл (`dev.env` / `ua.env` опційно з провідною крапкою). Файл `.env`
1045
- * без імені виключається — як і у `check-hasura.mjs`.
1046
- * @param {string} root корінь репозиторію
1047
- * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
1048
- * @returns {Promise<string[]>} відсортовані абсолютні шляхи env-файлів abie
1049
- */
1050
- async function collectAbieEnvFiles(root, ignorePaths) {
1051
- /** @type {string[]} */
1052
- const out = []
1053
- await walkDir(
1054
- root,
1055
- absPath => {
1056
- if (abieEnvNameFromBasename(basename(absPath)) !== null) {
1057
- out.push(absPath)
1058
- }
1059
- },
1060
- ignorePaths
1061
- )
1062
- return out.toSorted((a, b) => a.localeCompare(b))
1063
- }
1064
-
1065
- /**
1066
- * Сканує всі `*.env` файли abie (`.dev.env` / `.ua.env`) і для кожного знайденого
1067
- * **внутрішньокластерного** URL (`http://<svc>.<ns>.svc.<dns>`) перевіряє, що DNS-суфікс і namespace-префікс
1068
- * відповідають середовищу env-файла. Не лише `HASURA_GRAPHQL_ENDPOINT`, а й будь-який сервіс у env (KVCMS,
1069
- * `auth-run-hl`, `file-link-hl` тощо) мусить мати кластер, що відповідає env: dev → `abie-dev.internal`,
1070
- * ua → `abie-ua.internal`.
1071
- * @param {string} root корінь репозиторію
1072
- * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
1073
- * @param {(msg: string) => void} pass успішне повідомлення
1074
- * @param {(msg: string) => void} fail повідомлення про порушення
1075
- * @returns {Promise<void>}
1076
- */
1077
- async function ensureAbieEnvFilesMatchClusterDns(root, ignorePaths, pass, fail) {
1078
- const envFiles = await collectAbieEnvFiles(root, ignorePaths)
1079
- if (envFiles.length === 0) {
1080
- pass('Не знайдено dev.env / ua.env у репозиторії — перевірку env→cluster DNS пропущено (abie.mdc)')
1081
- return
1082
- }
1083
- for (const abs of envFiles) {
1084
- const rel = relative(root, abs).replaceAll('\\', '/') || abs
1085
- const envName = abieEnvNameFromBasename(basename(abs))
1086
- if (envName === null) continue
1087
- let raw
1088
- try {
1089
- raw = await readFile(abs, 'utf8')
1090
- } catch (error) {
1091
- const msg = error instanceof Error ? error.message : String(error)
1092
- fail(`${rel}: не вдалося прочитати (${msg})`)
1093
- continue
1094
- }
1095
- const errors = validateAbieEnvInternalUrls(raw, envName)
1096
- if (errors.length === 0) {
1097
- pass(`${rel}: усі внутрішні URL відповідають env "${envName}" (abie.mdc)`)
1098
- } else {
1099
- for (const err of errors) {
1100
- fail(`${rel}: ${err} (abie.mdc)`)
1101
- }
1102
- }
1103
- }
1104
- }
1105
-
1106
- /**
1107
- * Перевіряє відповідність проєкту правилам abie.mdc.
1108
- * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
1109
- */
1110
- export async function check() {
1111
- const reporter = createCheckReporter()
1112
- const { pass, fail } = reporter
1113
-
1114
- const root = process.cwd()
1115
- const enabled = await isAbieRuleEnabled(root)
1116
- if (!enabled) {
1117
- pass(`Правило abie не увімкнено в ${CONFIG_FILE} (rules) — перевірку пропущено`)
1118
- return reporter.getExitCode()
1119
- }
1120
-
1121
- pass('Правило abie увімкнено — виконуємо перевірки')
1122
- await ensureNoFirebaseHostingArtifacts(root, pass, fail)
1123
- await checkCleanMergedBranch(root, pass, fail)
1124
-
1125
- const ignorePaths = await loadCursorIgnorePaths(root)
1126
- const yamlFiles = await findK8sYamlFiles(root, ignorePaths)
1127
- const deploymentDirs = await collectDeploymentDirs(root, yamlFiles, fail)
1128
-
1129
- if (deploymentDirs.size > 0) {
1130
- pass(`Знайдено Deployment у ${deploymentDirs.size} директорія(ї/й) k8s — перевіряємо hc.yaml`)
1131
- await checkHcYamlFiles(root, deploymentDirs, pass, fail)
1132
- pass('Є Deployment — перевіряємо base: spec.template.spec.nodeSelector.preem (abie.mdc)')
1133
- await ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFiles, fail, pass)
1134
- } else {
1135
- pass('Немає Deployment у дереві k8s — перевірку hc.yaml пропущено')
1136
- }
1137
-
1138
- pass('Перевіряємо HTTPRoute spec.hostnames у …/k8s/base/… (aiml.live, abie.mdc)')
1139
- await ensureAbieBaseHttpRouteHostnames(root, yamlFiles, fail, pass)
1140
-
1141
- if (deploymentDirs.size > 0) {
1142
- pass('Є Deployment — перевіряємо nodeSelector у ua/kustomization (abie.mdc)')
1143
- await ensureUaAbieNodeSelectorPatches(root, yamlFiles, deploymentDirs, fail, pass)
1144
- pass('Є Deployment — перевіряємо HTTPRoute у ua/kustomization (abie.mdc)')
1145
- await ensureUaAbieHttpRoutePatches(root, yamlFiles, fail, pass)
1146
- }
1147
-
1148
- pass('Перевіряємо env→cluster DNS у dev.env / ua.env (abie.mdc)')
1149
- await ensureAbieEnvFilesMatchClusterDns(root, ignorePaths, pass, fail)
1150
-
1151
- return reporter.getExitCode()
1152
- }