@nitra/cursor 1.8.104 → 1.8.106

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.
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * **k8s:** якщо під деревом із сегментом **`k8s`** є YAML з **`kind: Deployment`**, у тій самій директорії
14
14
  * має існувати **`hc.yaml`** із **`HealthCheckPolicy`** (**`networking.gke.io/v1`**), modeline **`$schema`**
15
- * як у abie.mdc, **`/healthz`**, порт **8080**, **`targetRef`** на **headless Service** (ім’я з суфіксом **`-hl`**):
15
+ * як у abie.mdc, **`/healthz`**, порт **8080**, **`targetRef`** на **headless Service** (ім'я з суфіксом **`-hl`**):
16
16
  * якщо **`metadata.name`** уже закінчується на **`-hl`**, **`targetRef.name`** має збігатися з ним; інакше **`targetRef.name`** = **`${metadata.name}-hl`**.
17
17
  * Загальні вимоги до **`# yaml-language-server: $schema`** для інших YAML під **`k8s`** — у **check-k8s.mjs** / **k8s.mdc** (наприклад **HttpBackendGroup** `alb.yc.io/v1alpha1` — **без** modeline).
18
18
  * Якщо в дереві **k8s** є **HealthCheckPolicy**, перевіряється **`ru/kustomization.yaml`** з patch **`$patch: delete`**
@@ -53,7 +53,7 @@ const CONFIG_FILE = '.n-cursor.json'
53
53
  const HASURA_JWT_SECRET_IN_KUSTOMIZATION = 'HASURA_GRAPHQL_JWT_SECRET'
54
54
 
55
55
  /**
56
- * Спільні **Service** (**`-hl`**) у **dev**: у base-**HTTPRoute** обов’язково **`namespace: dev`**, у overlay — patch **`…/backendRefs/…/namespace`** (abie.mdc).
56
+ * Спільні **Service** (**`-hl`**) у **dev**: у base-**HTTPRoute** обов'язково **`namespace: dev`**, у overlay — patch **`…/backendRefs/…/namespace`** (abie.mdc).
57
57
  * Експорт для споживачів / тестів.
58
58
  */
59
59
  export const ABIE_SHARED_CROSS_NS_BACKEND_NAMES = Object.freeze(['auth-run-hl', 'filelint-hl'])
@@ -64,28 +64,52 @@ const ABIE_SHARED_CROSS_NS_BACKEND_SET = new Set(ABIE_SHARED_CROSS_NS_BACKEND_NA
64
64
  export const ABIE_HC_SCHEMA_URL = 'https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json'
65
65
 
66
66
  const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
67
+ const LINE_SPLIT_RE = /\r?\n/u
68
+ const RU_KUSTOMIZATION_PATH_RE = /(^|\/)ru\/kustomization\.yaml$/u
69
+ const UA_KUSTOMIZATION_PATH_RE = /(^|\/)ua\/kustomization\.yaml$/u
70
+ const OVERLAY_PACKAGE_DIR_RE = /^(.+)\/k8s\/(?:ua|ru)\/kustomization\.yaml$/u
71
+ const BASE_SEGMENT_RE = /(^|\/)base\//u
72
+ const YAML_EXTENSION_RE = /\.ya?ml$/iu
73
+ const K8S_PACKAGE_DIR_RE = /^(.+)\/k8s\//u
74
+ const PATCH_PATH_TYPE_RE = /path:\s*\/spec\/type\b/u
75
+ const PATCH_VALUE_NODE_PORT_RE = /value:\s*['"]?NodePort['"]?(?:\s|$)/iu
76
+ const PATCH_NODE_SELECTOR_PATH_RE = /path:\s*\/spec\/template\/spec\/nodeSelector\b/u
77
+ const PATCH_PREEM_FALSE_RE = /\bpreem:\s*['"]?false['"]?\b/u
78
+ const PATCH_YANDEX_PREEMPTIBLE_FALSE_RE = /yandex\.cloud\/preemptible:\s*['"]?false['"]?/u
79
+ const TRAILING_SLASH_RE = /\/$/u
80
+ const PATCH_HOSTNAMES_PATH_RE = /path:\s*\/spec\/hostnames\b/mu
81
+ const PATCH_PARENT_REF_NS_UA_RE =
82
+ /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/mu
83
+ const PATCH_PARENT_REF_NS_RU_RE =
84
+ /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/mu
85
+ const WEBSOCKET_ANNOTATION_RE = /gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/mu
86
+ const LEADING_EMPTY_LINE_RE = /^\s*\n/u
87
+ const REMOVE_CLUSTER_IP_AFTER_OP_RE = /op:\s*remove\b[\s\S]{0,200}?path:\s*\/spec\/clusterIP\b/mu
88
+ const REMOVE_CLUSTER_IP_BEFORE_OP_RE = /path:\s*\/spec\/clusterIP\b[\s\S]{0,200}?op:\s*remove\b/mu
89
+ const REMOVE_CLUSTER_IPS_AFTER_OP_RE = /op:\s*remove\b[\s\S]{0,200}?path:\s*\/spec\/clusterIPs\b/mu
90
+ const REMOVE_CLUSTER_IPS_BEFORE_OP_RE = /path:\s*\/spec\/clusterIPs\b[\s\S]{0,200}?op:\s*remove\b/mu
67
91
 
68
92
  /** Гілки, які мають бути в **`ignore_branches`** за abie.mdc. */
69
93
  export const ABIE_REQUIRED_IGNORE_BRANCHES = ['dev', 'ua', 'ru']
70
94
 
71
95
  /**
72
- * Чи відносний шлях вказує на **`ru/kustomization.yaml`** (сегмент **`ru`** перед ім’ям файлу) — специфіка abie overlay.
96
+ * Чи відносний шлях вказує на **`ru/kustomization.yaml`** (сегмент **`ru`** перед ім'ям файлу) — специфіка abie overlay.
73
97
  * @param {string} rel шлях від кореня репозиторію
74
98
  * @returns {boolean} true, якщо це `…/ru/kustomization.yaml`
75
99
  */
76
100
  export function isRuKustomizationPath(rel) {
77
101
  const norm = rel.replaceAll('\\', '/')
78
- return /(^|\/)ru\/kustomization\.yaml$/u.test(norm)
102
+ return RU_KUSTOMIZATION_PATH_RE.test(norm)
79
103
  }
80
104
 
81
105
  /**
82
- * Чи відносний шлях вказує на **`ua/kustomization.yaml`** (сегмент **`ua`** перед ім’ям файлу) — специфіка abie overlay.
106
+ * Чи відносний шлях вказує на **`ua/kustomization.yaml`** (сегмент **`ua`** перед ім'ям файлу) — специфіка abie overlay.
83
107
  * @param {string} rel шлях від кореня репозиторію
84
108
  * @returns {boolean} true, якщо це `…/ua/kustomization.yaml`
85
109
  */
86
110
  export function isUaKustomizationPath(rel) {
87
111
  const norm = rel.replaceAll('\\', '/')
88
- return /(^|\/)ua\/kustomization\.yaml$/u.test(norm)
112
+ return UA_KUSTOMIZATION_PATH_RE.test(norm)
89
113
  }
90
114
 
91
115
  /**
@@ -96,7 +120,7 @@ export function isUaKustomizationPath(rel) {
96
120
  */
97
121
  export function abiePackageDirFromK8sOverlay(root, kustomizationAbs) {
98
122
  const rel = relative(root, kustomizationAbs).replaceAll('\\', '/') || kustomizationAbs
99
- const m = rel.match(/^(.+)\/k8s\/(?:ua|ru)\/kustomization\.yaml$/u)
123
+ const m = rel.match(OVERLAY_PACKAGE_DIR_RE)
100
124
  return m ? join(root, m[1]) : null
101
125
  }
102
126
 
@@ -147,7 +171,7 @@ export function abieOverlayK8sTreeHasDeployment(deploymentDirs, root, kustomizat
147
171
  */
148
172
  export function isAbieK8sBaseYamlPath(rel) {
149
173
  const norm = rel.replaceAll('\\', '/')
150
- return /(^|\/)base\//u.test(norm)
174
+ return BASE_SEGMENT_RE.test(norm)
151
175
  }
152
176
 
153
177
  /**
@@ -276,7 +300,7 @@ async function findK8sYamlFiles(root) {
276
300
  if (!pathHasK8sSegment(p)) {
277
301
  return
278
302
  }
279
- if (!/\.ya?ml$/iu.test(p)) {
303
+ if (!YAML_EXTENSION_RE.test(p)) {
280
304
  return
281
305
  }
282
306
  out.push(p)
@@ -335,7 +359,7 @@ function k8sYamlRelOutsideUaRuOverlays(relFromRoot) {
335
359
  */
336
360
  function abiePackageDirFromK8sYamlRel(root, relFromRoot) {
337
361
  const norm = relFromRoot.replaceAll('\\', '/')
338
- const m = norm.match(/^(.+)\/k8s\//u)
362
+ const m = norm.match(K8S_PACKAGE_DIR_RE)
339
363
  return m ? join(root, m[1]) : null
340
364
  }
341
365
 
@@ -418,12 +442,10 @@ export function jsonPatchRemovesPath(patchText, posixPath) {
418
442
  if (posixPath !== '/spec/clusterIP' && posixPath !== '/spec/clusterIPs') {
419
443
  return false
420
444
  }
421
- const pathRe =
422
- posixPath === '/spec/clusterIP'
423
- ? String.raw`path:\s*\/spec\/clusterIP\b`
424
- : String.raw`path:\s*\/spec\/clusterIPs\b`
425
- const opRe = String.raw`op:\s*remove\b`
426
- return new RegExp(String.raw`${opRe}[\s\S]{0,200}?${pathRe}`, 'mu').test(patchText) || new RegExp(String.raw`${pathRe}[\s\S]{0,200}?${opRe}`, 'mu').test(patchText)
445
+ if (posixPath === '/spec/clusterIP') {
446
+ return REMOVE_CLUSTER_IP_AFTER_OP_RE.test(patchText) || REMOVE_CLUSTER_IP_BEFORE_OP_RE.test(patchText)
447
+ }
448
+ return REMOVE_CLUSTER_IPS_AFTER_OP_RE.test(patchText) || REMOVE_CLUSTER_IPS_BEFORE_OP_RE.test(patchText)
427
449
  }
428
450
 
429
451
  /**
@@ -444,52 +466,50 @@ export function jsonPatchTextSetsServiceTypeNodePort(patchText) {
444
466
  if (typeof patchText !== 'string' || patchText.trim() === '') {
445
467
  return false
446
468
  }
447
- if (!/path:\s*\/spec\/type\b/u.test(patchText)) {
469
+ if (!PATCH_PATH_TYPE_RE.test(patchText)) {
448
470
  return false
449
471
  }
450
- if (!/value:\s*['"]?NodePort['"]?(?:\s|$)/iu.test(patchText)) {
472
+ if (!PATCH_VALUE_NODE_PORT_RE.test(patchText)) {
451
473
  return false
452
474
  }
453
475
  return true
454
476
  }
455
477
 
478
+ /**
479
+ * Витягує ім'я та текст patch для Service з елемента patches.
480
+ * @param {unknown} p елемент масиву patches
481
+ * @returns {{ name: string, patchStr: string } | null} ім'я та текст patch або null
482
+ */
483
+ function extractServicePatchEntry(p) {
484
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) return null
485
+ const pr = /** @type {Record<string, unknown>} */ (p)
486
+ const target = pr.target
487
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) return null
488
+ const tg = /** @type {Record<string, unknown>} */ (target)
489
+ if (tg.kind !== 'Service' || typeof tg.name !== 'string' || tg.name.trim() === '') return null
490
+ const patchStr = pr.patch
491
+ if (typeof patchStr !== 'string' || patchStr.trim() === '') return null
492
+ return { name: tg.name, patchStr }
493
+ }
494
+
456
495
  /**
457
496
  * З одного документа **Kustomization** збирає пари **Service name → patch text** для **inline patches** з **target.kind: Service**.
458
497
  * @param {import('yaml').Document} doc документ після **parseAllDocuments**
459
- * @returns {Map<string, string>} ім’я сервісу → текст **patch**
498
+ * @returns {Map<string, string>} ім'я сервісу → текст **patch**
460
499
  */
461
500
  function collectAbieServicePatchTextsByNameFromKustomizationDoc(doc) {
462
501
  /** @type {Map<string, string>} */
463
502
  const out = new Map()
464
- if (doc.errors.length > 0) {
465
- return out
466
- }
503
+ if (doc.errors.length > 0) return out
467
504
  const root = doc.toJSON()
468
- if (root === null || typeof root !== 'object' || Array.isArray(root)) {
469
- return out
470
- }
505
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return out
471
506
  const rec = /** @type {Record<string, unknown>} */ (root)
472
- if (rec.kind !== 'Kustomization') {
473
- return out
474
- }
475
- const patches = rec.patches
476
- if (!Array.isArray(patches)) {
477
- return out
478
- }
479
- for (const p of patches) {
480
- if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
481
- const pr = /** @type {Record<string, unknown>} */ (p)
482
- const target = pr.target
483
- if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
484
- const tg = /** @type {Record<string, unknown>} */ (target)
485
- if (tg.kind === 'Service' && typeof tg.name === 'string' && tg.name.trim() !== '') {
486
- const patchStr = pr.patch
487
- if (typeof patchStr === 'string' && patchStr.trim() !== '') {
488
- const prev = out.get(tg.name)
489
- out.set(tg.name, prev === undefined ? patchStr : `${prev}\n${patchStr}`)
490
- }
491
- }
492
- }
507
+ if (rec.kind !== 'Kustomization' || !Array.isArray(rec.patches)) return out
508
+ for (const p of rec.patches) {
509
+ const entry = extractServicePatchEntry(p)
510
+ if (entry) {
511
+ const prev = out.get(entry.name)
512
+ out.set(entry.name, prev === undefined ? entry.patchStr : `${prev}\n${entry.patchStr}`)
493
513
  }
494
514
  }
495
515
  return out
@@ -498,11 +518,11 @@ function collectAbieServicePatchTextsByNameFromKustomizationDoc(doc) {
498
518
  /**
499
519
  * Збирає тексти **patch** на **Service** з **kustomization.yaml** (усі документи).
500
520
  * @param {string} raw повний текст **kustomization.yaml**
501
- * @returns {Map<string, string>} **target.name** → об’єднаний текст **patch**
521
+ * @returns {Map<string, string>} **target.name** → об'єднаний текст **patch**
502
522
  */
503
523
  function collectAbieRuServicePatchTextByTargetNameFromRaw(raw) {
504
524
  const body = stripBom(raw)
505
- const lines = body.split(/\r?\n/u)
525
+ const lines = body.split(LINE_SPLIT_RE)
506
526
  const first = lines[0] ?? ''
507
527
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
508
528
  /** @type {Map<string, string>} */
@@ -524,41 +544,46 @@ function collectAbieRuServicePatchTextByTargetNameFromRaw(raw) {
524
544
  return byName
525
545
  }
526
546
 
547
+ /**
548
+ * Збирає помилки patch для одного Service за ім'ям.
549
+ * @param {string} name ім'я Service
550
+ * @param {string | undefined} pt текст patch або undefined
551
+ * @param {{ requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove?: boolean } | undefined} flags прапорці
552
+ * @param {string[]} errors масив для запису помилок
553
+ */
554
+ function collectServicePatchErrors(name, pt, flags, errors) {
555
+ if (pt === undefined || String(pt).trim() === '') {
556
+ errors.push(`${name}: немає inline patch для kind: Service`)
557
+ return
558
+ }
559
+ if (!jsonPatchTextSetsServiceTypeNodePort(pt)) {
560
+ errors.push(`${name}: потрібен JSON6902 path /spec/type та value NodePort`)
561
+ }
562
+ if (flags?.requiresClusterIPNoneClear === true && !jsonPatchTextClearsHeadlessServiceClusterIPNone(pt)) {
563
+ errors.push(
564
+ `${name}: для spec.clusterIP: None додай у той самий patch op: remove для path /spec/clusterIP (abie.mdc)`
565
+ )
566
+ }
567
+ if (flags?.requiresClusterIPsRemove === true && !jsonPatchRemovesPath(pt, '/spec/clusterIPs')) {
568
+ errors.push(
569
+ `${name}: у base задано spec.clusterIPs — додай op: remove для path /spec/clusterIPs (інакше NodePort з None у clusterIPs; abie.mdc)`
570
+ )
571
+ }
572
+ }
573
+
527
574
  /**
528
575
  * Повідомлення про порушення patch **Service** у **ru/kustomization.yaml** (abie.mdc).
529
576
  * @param {string} raw повний текст **kustomization.yaml**
530
- * @param {Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove?: boolean }>} targetsByName ім’я **Service** → прапорці patch
577
+ * @param {Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove?: boolean }>} targetsByName ім'я **Service** → прапорці patch
531
578
  * @returns {string[]} порожньо, якщо все OK
532
579
  */
533
580
  export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
534
- if (targetsByName.size === 0) {
535
- return []
536
- }
581
+ if (targetsByName.size === 0) return []
537
582
  const byName = collectAbieRuServicePatchTextByTargetNameFromRaw(raw)
538
583
  /** @type {string[]} */
539
584
  const errors = []
540
585
  for (const name of [...targetsByName.keys()].toSorted((a, b) => a.localeCompare(b))) {
541
- const flags = targetsByName.get(name)
542
- const requiresClusterIPRemove = flags?.requiresClusterIPNoneClear === true
543
- const requiresClusterIPsRemove = flags?.requiresClusterIPsRemove === true
544
- const pt = byName.get(name)
545
- if (pt === undefined || String(pt).trim() === '') {
546
- errors.push(`${name}: немає inline patch для kind: Service`)
547
- } else {
548
- if (!jsonPatchTextSetsServiceTypeNodePort(pt)) {
549
- errors.push(`${name}: потрібен JSON6902 path /spec/type та value NodePort`)
550
- }
551
- if (requiresClusterIPRemove && !jsonPatchTextClearsHeadlessServiceClusterIPNone(pt)) {
552
- errors.push(
553
- `${name}: для spec.clusterIP: None додай у той самий patch op: remove для path /spec/clusterIP (abie.mdc)`
554
- )
555
- }
556
- if (requiresClusterIPsRemove && !jsonPatchRemovesPath(pt, '/spec/clusterIPs')) {
557
- errors.push(
558
- `${name}: у base задано spec.clusterIPs — додай op: remove для path /spec/clusterIPs (інакше NodePort з None у clusterIPs; abie.mdc)`
559
- )
560
- }
561
- }
586
+ collectServicePatchErrors(name, byName.get(name), targetsByName.get(name), errors)
562
587
  }
563
588
  return errors
564
589
  }
@@ -568,68 +593,64 @@ export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
568
593
  * @param {string} root корінь репозиторію
569
594
  * @param {string[]} yamlAbs абсолютні шляхи yaml під **k8s**
570
595
  * @param {(msg: string) => void} fail реєстрація помилки читання/парсингу
571
- * @returns {Promise<Map<string, Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove: boolean }>>>} **pkgAbs** → (**ім’я** → прапорці)
596
+ * @returns {Promise<Map<string, Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove: boolean }>>>} **pkgAbs** → (**ім'я** → прапорці)
597
+ */
598
+ /**
599
+ * Обробляє один Service-документ для збору NodePort-патч цілей.
600
+ * @param {unknown} obj YAML-документ (toJSON)
601
+ * @param {string} pkgAbs абсолютний шлях до пакета
602
+ * @param {Map<string, Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove: boolean }>>} map результуючий Map
603
+ */
604
+ function processServiceDocForNodePortTargets(obj, pkgAbs, map) {
605
+ if (!serviceDocumentRequiresAbieRuNodePortOverlay(obj)) return
606
+ const rec = /** @type {Record<string, unknown>} */ (obj)
607
+ const meta = /** @type {Record<string, unknown>} */ (rec.metadata)
608
+ const n = meta.name
609
+ if (typeof n !== 'string' || n.trim() === '') return
610
+ let inner = map.get(pkgAbs)
611
+ if (!inner) {
612
+ inner = new Map()
613
+ map.set(pkgAbs, inner)
614
+ }
615
+ const needClear = serviceDocumentRequiresRuClusterIPNoneRemoval(obj)
616
+ const needClusterIPsRemove = serviceDocumentBaseDeclaresClusterIPsField(obj)
617
+ const prev = inner.get(n)
618
+ inner.set(n, {
619
+ requiresClusterIPNoneClear: prev?.requiresClusterIPNoneClear === true || needClear,
620
+ requiresClusterIPsRemove: prev?.requiresClusterIPsRemove === true || needClusterIPsRemove
621
+ })
622
+ }
623
+
624
+ /**
625
+ * Обробляє YAML-документи з одного файлу для збору NodePort-патч цілей.
626
+ * @param {import('yaml').Document[]} docs документи з файлу
627
+ * @param {string} pkgAbs абсолютний шлях пакета
628
+ * @param {Map<string, Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove: boolean }>>} map результуючий Map
629
+ */
630
+ function collectNodePortTargetsFromDocs(docs, pkgAbs, map) {
631
+ for (const doc of docs) {
632
+ if (doc.errors.length === 0) {
633
+ processServiceDocForNodePortTargets(doc.toJSON(), pkgAbs, map)
634
+ }
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Для кожного пакета збирає **Service**, які в overlay **ru** мають стати **NodePort** (abie.mdc).
640
+ * @param {string} root корінь репозиторію
641
+ * @param {string[]} yamlAbs абсолютні шляхи yaml під **k8s**
642
+ * @param {(msg: string) => void} fail реєстрація помилки читання/парсингу
643
+ * @returns {Promise<Map<string, Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove: boolean }>>>} пакет → назва сервісу → прапори NodePort
572
644
  */
573
645
  async function collectAbieRuNodePortServiceTargetsByPackage(root, yamlAbs, fail) {
574
646
  /** @type {Map<string, Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove: boolean }>>} */
575
647
  const map = new Map()
576
648
  for (const abs of yamlAbs) {
577
649
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
578
- if (k8sYamlRelOutsideUaRuOverlays(rel)) {
579
- const pkgAbs = abiePackageDirFromK8sYamlRel(root, rel)
580
- if (pkgAbs) {
581
- let raw
582
- let readOk = false
583
- try {
584
- raw = await readFile(abs, 'utf8')
585
- readOk = true
586
- } catch (error) {
587
- const msg = error instanceof Error ? error.message : String(error)
588
- fail(`${rel}: не вдалося прочитати (${msg})`)
589
- }
590
- if (readOk) {
591
- const body = stripBom(raw)
592
- const lines = body.split(/\r?\n/u)
593
- const first = lines[0] ?? ''
594
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
595
- /** @type {import('yaml').Document[]} */
596
- let docs
597
- let parseOk = false
598
- try {
599
- docs = parseAllDocuments(rest)
600
- parseOk = true
601
- } catch (error) {
602
- const msg = error instanceof Error ? error.message : String(error)
603
- fail(`${rel}: YAML (${msg})`)
604
- }
605
- if (parseOk) {
606
- for (const doc of docs) {
607
- if (doc.errors.length === 0) {
608
- const obj = doc.toJSON()
609
- if (serviceDocumentRequiresAbieRuNodePortOverlay(obj)) {
610
- const rec = /** @type {Record<string, unknown>} */ (obj)
611
- const meta = /** @type {Record<string, unknown>} */ (rec.metadata)
612
- const n = meta.name
613
- if (typeof n === 'string' && n.trim() !== '') {
614
- let inner = map.get(pkgAbs)
615
- if (!inner) {
616
- inner = new Map()
617
- map.set(pkgAbs, inner)
618
- }
619
- const needClear = serviceDocumentRequiresRuClusterIPNoneRemoval(obj)
620
- const needClusterIPsRemove = serviceDocumentBaseDeclaresClusterIPsField(obj)
621
- const prev = inner.get(n)
622
- inner.set(n, {
623
- requiresClusterIPNoneClear: (prev?.requiresClusterIPNoneClear === true) || needClear,
624
- requiresClusterIPsRemove: (prev?.requiresClusterIPsRemove === true) || needClusterIPsRemove
625
- })
626
- }
627
- }
628
- }
629
- }
630
- }
631
- }
632
- }
650
+ const pkgAbs = k8sYamlRelOutsideUaRuOverlays(rel) ? abiePackageDirFromK8sYamlRel(root, rel) : null
651
+ if (pkgAbs) {
652
+ const docs = await readAndParseYamlDocs(abs, rel, fail)
653
+ if (docs) collectNodePortTargetsFromDocs(docs, pkgAbs, map)
633
654
  }
634
655
  }
635
656
  return map
@@ -689,38 +710,12 @@ async function collectDeploymentDirs(root, yamlAbs, fail) {
689
710
  /** @type {Set<string>} */
690
711
  const dirs = new Set()
691
712
  for (const abs of yamlAbs) {
692
- let raw
693
- let readOk = false
694
- try {
695
- raw = await readFile(abs, 'utf8')
696
- readOk = true
697
- } catch (error) {
698
- const msg = error instanceof Error ? error.message : String(error)
699
- fail(`${relative(root, abs) || abs}: не вдалося прочитати (${msg})`)
700
- }
701
- if (readOk) {
702
- const body = stripBom(raw)
703
- const lines = body.split(/\r?\n/u)
704
- const first = lines[0] ?? ''
705
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
706
- /** @type {import('yaml').Document[]} */
707
- let docs
708
- let parseOk = false
709
- try {
710
- docs = parseAllDocuments(rest)
711
- parseOk = true
712
- } catch (error) {
713
- const msg = error instanceof Error ? error.message : String(error)
714
- fail(`${relative(root, abs) || abs}: YAML (${msg})`)
715
- }
716
- if (parseOk) {
717
- for (const doc of docs) {
718
- if (doc.errors.length === 0) {
719
- const obj = doc.toJSON()
720
- if (isDeploymentDoc(obj)) {
721
- dirs.add(dirname(abs))
722
- }
723
- }
713
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
714
+ const docs = await readAndParseYamlDocs(abs, rel, fail)
715
+ if (docs) {
716
+ for (const doc of docs) {
717
+ if (doc.errors.length === 0 && isDeploymentDoc(doc.toJSON())) {
718
+ dirs.add(dirname(abs))
724
719
  }
725
720
  }
726
721
  }
@@ -728,6 +723,31 @@ async function collectDeploymentDirs(root, yamlAbs, fail) {
728
723
  return dirs
729
724
  }
730
725
 
726
+ /**
727
+ * Перевіряє документи з одного файлу на наявність Deployment з preem nodeSelector.
728
+ * @param {import('yaml').Document[]} docs документи з файлу
729
+ * @param {string} rel відносний шлях файлу
730
+ * @param {(msg: string) => void} fail callback
731
+ * @returns {'violation' | 'found' | 'none'} результат перевірки
732
+ */
733
+ function checkBaseDeploymentDocsForPreem(docs, rel, fail) {
734
+ for (const doc of docs) {
735
+ if (doc.errors.length === 0) {
736
+ const obj = doc.toJSON()
737
+ if (isDeploymentDoc(obj)) {
738
+ if (!deploymentDocumentHasAbieBasePreemNodeSelector(obj)) {
739
+ fail(
740
+ `${rel}: Deployment у base: потрібен spec.template.spec.nodeSelector.preem: true (або 'true') — abie.mdc`
741
+ )
742
+ return 'violation'
743
+ }
744
+ return 'found'
745
+ }
746
+ }
747
+ }
748
+ return 'none'
749
+ }
750
+
731
751
  /**
732
752
  * Для кожного **Deployment** у YAML під **`k8s`** з шляхом **`…/base/…`** вимагає **`spec.template.spec.nodeSelector.preem: true`** (abie.mdc).
733
753
  * @param {string} root корінь репозиторію
@@ -737,48 +757,15 @@ async function collectDeploymentDirs(root, yamlAbs, fail) {
737
757
  * @returns {Promise<void>}
738
758
  */
739
759
  async function ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFilesAbs, fail, passFn) {
740
- const baseFiles = yamlFilesAbs.filter(abs => {
741
- const rel = relative(root, abs).replaceAll('\\', '/') || abs
742
- return isAbieK8sBaseYamlPath(rel)
743
- })
760
+ const baseFiles = yamlFilesAbs.filter(abs => isAbieK8sBaseYamlPath(relative(root, abs).replaceAll('\\', '/') || abs))
744
761
  let anyBaseDeployment = false
745
762
  for (const abs of baseFiles) {
746
763
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
747
- let raw
748
- try {
749
- raw = await readFile(abs, 'utf8')
750
- } catch (error) {
751
- const msg = error instanceof Error ? error.message : String(error)
752
- fail(`${rel}: не вдалося прочитати (${msg})`)
753
- return
754
- }
755
- const body = stripBom(raw)
756
- const lines = body.split(/\r?\n/u)
757
- const first = lines[0] ?? ''
758
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
759
- /** @type {import('yaml').Document[]} */
760
- let docs
761
- try {
762
- docs = parseAllDocuments(rest)
763
- } catch (error) {
764
- const msg = error instanceof Error ? error.message : String(error)
765
- fail(`${rel}: YAML (${msg})`)
766
- return
767
- }
768
- for (const doc of docs) {
769
- if (doc.errors.length === 0) {
770
- const obj = doc.toJSON()
771
- if (isDeploymentDoc(obj)) {
772
- anyBaseDeployment = true
773
- if (!deploymentDocumentHasAbieBasePreemNodeSelector(obj)) {
774
- fail(
775
- `${rel}: Deployment у base: потрібен spec.template.spec.nodeSelector.preem: true (або 'true') — abie.mdc`
776
- )
777
- return
778
- }
779
- }
780
- }
781
- }
764
+ const docs = await readAndParseYamlDocs(abs, rel, fail)
765
+ if (!docs) return
766
+ const r = checkBaseDeploymentDocsForPreem(docs, rel, fail)
767
+ if (r === 'violation') return
768
+ if (r === 'found') anyBaseDeployment = true
782
769
  }
783
770
  if (anyBaseDeployment) {
784
771
  passFn('Deployment у …/base/…: nodeSelector.preem відповідає abie.mdc')
@@ -796,6 +783,46 @@ function stripBom(s) {
796
783
  return s.startsWith('\uFEFF') ? s.slice(1) : s
797
784
  }
798
785
 
786
+ /**
787
+ * Зчитує та парсить YAML-документи з файлу.
788
+ * При помилці читання викликає `failFn` і повертає `null`.
789
+ * При помилці парсингу викликає `failFn` і повертає `null`.
790
+ * Автоматично видаляє BOM та modeline (перший рядок з `$schema`).
791
+ * @param {string} abs абсолютний шлях до файлу
792
+ * @param {string} rel відносний шлях (для повідомлень)
793
+ * @param {(msg: string) => void} failFn callback при помилці
794
+ * @returns {Promise<import('yaml').Document[] | null>} масив документів або null при помилці
795
+ */
796
+ async function readAndParseYamlDocs(abs, rel, failFn) {
797
+ let raw
798
+ try {
799
+ raw = await readFile(abs, 'utf8')
800
+ } catch (error) {
801
+ const msg = error instanceof Error ? error.message : String(error)
802
+ failFn(`${rel}: не вдалося прочитати (${msg})`)
803
+ return null
804
+ }
805
+ const body = stripBom(raw)
806
+ const lines = body.split(LINE_SPLIT_RE)
807
+ const first = lines[0] ?? ''
808
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
809
+ try {
810
+ return parseAllDocuments(rest)
811
+ } catch (error) {
812
+ const msg = error instanceof Error ? error.message : String(error)
813
+ failFn(`${rel}: YAML (${msg})`)
814
+ return null
815
+ }
816
+ }
817
+
818
+ /**
819
+ * No-op fail handler для функцій, що повертають null/порожній масив при помилці.
820
+ * @param {string} _msg повідомлення ігнорується
821
+ */
822
+ const silentFail = _msg => {
823
+ /* silent — пошкоджені файли ловить check-k8s */
824
+ }
825
+
799
826
  /**
800
827
  * Чи рядок inline JSON6902 patch містить очікуваний **ua** nodeSelector (**preem: false** на **`/spec/template/spec/nodeSelector`**).
801
828
  * Конкретний **`op`** не перевіряється — див. **k8s.mdc**.
@@ -806,10 +833,10 @@ function jsonPatchTextHasUaDeploymentNodeSelector(patchText) {
806
833
  if (typeof patchText !== 'string' || patchText.trim() === '') {
807
834
  return false
808
835
  }
809
- if (!/path:\s*\/spec\/template\/spec\/nodeSelector\b/u.test(patchText)) {
836
+ if (!PATCH_NODE_SELECTOR_PATH_RE.test(patchText)) {
810
837
  return false
811
838
  }
812
- if (!/\bpreem:\s*['"]?false['"]?\b/u.test(patchText)) {
839
+ if (!PATCH_PREEM_FALSE_RE.test(patchText)) {
813
840
  return false
814
841
  }
815
842
  return true
@@ -825,10 +852,10 @@ function jsonPatchTextHasRuDeploymentNodeSelector(patchText) {
825
852
  if (typeof patchText !== 'string' || patchText.trim() === '') {
826
853
  return false
827
854
  }
828
- if (!/path:\s*\/spec\/template\/spec\/nodeSelector\b/u.test(patchText)) {
855
+ if (!PATCH_NODE_SELECTOR_PATH_RE.test(patchText)) {
829
856
  return false
830
857
  }
831
- if (!/yandex\.cloud\/preemptible:\s*['"]?false['"]?/u.test(patchText)) {
858
+ if (!PATCH_YANDEX_PREEMPTIBLE_FALSE_RE.test(patchText)) {
832
859
  return false
833
860
  }
834
861
  return true
@@ -904,7 +931,7 @@ function kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode) {
904
931
  */
905
932
  export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
906
933
  const body = stripBom(raw)
907
- const lines = body.split(/\r?\n/u)
934
+ const lines = body.split(LINE_SPLIT_RE)
908
935
  const first = lines[0] ?? ''
909
936
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
910
937
  /** @type {import('yaml').Document[]} */
@@ -930,7 +957,7 @@ export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
930
957
  */
931
958
  export function isK8sYamlInAbiePackageExcludingUaRuOverlays(relFromRoot, pkgRelFromRoot) {
932
959
  const normRel = relFromRoot.replaceAll('\\', '/')
933
- const pkg = pkgRelFromRoot.replaceAll('\\', '/').replace(/\/$/u, '')
960
+ const pkg = pkgRelFromRoot.replaceAll('\\', '/').replace(TRAILING_SLASH_RE, '')
934
961
  const prefix = `${pkg}/k8s/`
935
962
  if (!normRel.startsWith(prefix)) {
936
963
  return false
@@ -939,6 +966,24 @@ export function isK8sYamlInAbiePackageExcludingUaRuOverlays(relFromRoot, pkgRelF
939
966
  return !after.startsWith('ua/') && !after.startsWith('ru/')
940
967
  }
941
968
 
969
+ /**
970
+ * Перевіряє один backendRef на відповідність abie.mdc.
971
+ * @param {unknown} br параметр br
972
+ * @param {string} rel відносний шлях (для повідомлень)
973
+ * @param {string[]} errors масив для запису помилок
974
+ * @returns {number} 1 якщо знайдено shared backend, 0 інакше
975
+ */
976
+ function checkSharedBackendRef(br, rel, errors) {
977
+ if (br === null || typeof br !== 'object' || Array.isArray(br)) return 0
978
+ const brRec = /** @type {Record<string, unknown>} */ (br)
979
+ const name = brRec.name
980
+ if (typeof name !== 'string' || !ABIE_SHARED_CROSS_NS_BACKEND_SET.has(name)) return 0
981
+ if (typeof brRec.namespace !== 'string' || brRec.namespace !== 'dev') {
982
+ errors.push(`${rel}: HTTPRoute backendRefs до ${name} має містити namespace: dev (abie.mdc)`)
983
+ }
984
+ return 1
985
+ }
986
+
942
987
  /**
943
988
  * З HTTPRoute-документа рахує **`backendRefs`** до **`auth-run-hl`** / **`filelint-hl`** і порушення **`namespace: dev`**.
944
989
  * @param {unknown} obj корінь YAML
@@ -948,38 +993,20 @@ export function isK8sYamlInAbiePackageExcludingUaRuOverlays(relFromRoot, pkgRelF
948
993
  function httpRouteDocSharedCrossNsBackendStats(obj, rel) {
949
994
  /** @type {string[]} */
950
995
  const errors = []
951
- if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
952
- return { refCount: 0, errors }
953
- }
996
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return { refCount: 0, errors }
954
997
  const rec = /** @type {Record<string, unknown>} */ (obj)
955
- if (rec.kind !== 'HTTPRoute') {
956
- return { refCount: 0, errors }
957
- }
998
+ if (rec.kind !== 'HTTPRoute') return { refCount: 0, errors }
958
999
  const spec = rec.spec
959
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
960
- return { refCount: 0, errors }
961
- }
1000
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return { refCount: 0, errors }
962
1001
  const rules = /** @type {Record<string, unknown>} */ (spec).rules
963
- if (!Array.isArray(rules)) {
964
- return { refCount: 0, errors }
965
- }
1002
+ if (!Array.isArray(rules)) return { refCount: 0, errors }
966
1003
  let refCount = 0
967
1004
  for (const rule of rules) {
968
1005
  if (rule !== null && typeof rule === 'object' && !Array.isArray(rule)) {
969
1006
  const brs = /** @type {Record<string, unknown>} */ (rule).backendRefs
970
1007
  if (Array.isArray(brs)) {
971
1008
  for (const br of brs) {
972
- if (br !== null && typeof br === 'object' && !Array.isArray(br)) {
973
- const brRec = /** @type {Record<string, unknown>} */ (br)
974
- const name = brRec.name
975
- if (typeof name === 'string' && ABIE_SHARED_CROSS_NS_BACKEND_SET.has(name)) {
976
- refCount++
977
- const ns = brRec.namespace
978
- if (typeof ns !== 'string' || ns !== 'dev') {
979
- errors.push(`${rel}: HTTPRoute backendRefs до ${name} має містити namespace: dev (abie.mdc)`)
980
- }
981
- }
982
- }
1009
+ refCount += checkSharedBackendRef(br, rel, errors)
983
1010
  }
984
1011
  }
985
1012
  }
@@ -1002,32 +1029,13 @@ export async function analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yam
1002
1029
  for (const abs of yamlFilesAbs) {
1003
1030
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1004
1031
  if (isK8sYamlInAbiePackageExcludingUaRuOverlays(rel, pkgRel)) {
1005
- let raw
1006
- try {
1007
- raw = await readFile(abs, 'utf8')
1008
- } catch {
1009
- raw = undefined
1010
- }
1011
- if (raw !== undefined) {
1012
- const body = stripBom(raw)
1013
- const lines = body.split(/\r?\n/u)
1014
- const first = lines[0] ?? ''
1015
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
1016
- /** @type {import('yaml').Document[] | undefined} */
1017
- let docs
1018
- try {
1019
- docs = parseAllDocuments(rest)
1020
- } catch {
1021
- docs = undefined
1022
- }
1023
- if (docs !== undefined) {
1024
- for (const doc of docs) {
1025
- if (doc.errors.length === 0) {
1026
- const obj = doc.toJSON()
1027
- const st = httpRouteDocSharedCrossNsBackendStats(obj, rel)
1028
- refCount += st.refCount
1029
- baseErrors.push(...st.errors)
1030
- }
1032
+ const docs = await readAndParseYamlDocs(abs, rel, silentFail)
1033
+ if (docs) {
1034
+ for (const doc of docs) {
1035
+ if (doc.errors.length === 0) {
1036
+ const st = httpRouteDocSharedCrossNsBackendStats(doc.toJSON(), rel)
1037
+ refCount += st.refCount
1038
+ baseErrors.push(...st.errors)
1031
1039
  }
1032
1040
  }
1033
1041
  }
@@ -1061,43 +1069,38 @@ const ABIE_RU_HTTPROUTE_HOST_MARKERS = [
1061
1069
  '*.выбирайонлайн.рф'
1062
1070
  ]
1063
1071
 
1072
+ /**
1073
+ * Витягує текст patch HTTPRoute з елемента patches.
1074
+ * @param {unknown} p елемент масиву patches
1075
+ * @returns {string | null} текст patch або null
1076
+ */
1077
+ function extractHttpRoutePatchString(p) {
1078
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) return null
1079
+ const pr = /** @type {Record<string, unknown>} */ (p)
1080
+ const target = pr.target
1081
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) return null
1082
+ const tg = /** @type {Record<string, unknown>} */ (target)
1083
+ if (tg.kind !== 'HTTPRoute' || typeof tg.name !== 'string' || tg.name.trim() === '') return null
1084
+ const patchStr = pr.patch
1085
+ return typeof patchStr === 'string' && patchStr.trim() !== '' ? patchStr : null
1086
+ }
1087
+
1064
1088
  /**
1065
1089
  * Збирає тексти inline **patch** для **HTTPRoute** (будь-який непорожній **target.name**) з одного документа **Kustomization**.
1066
1090
  * @param {import('yaml').Document} doc документ після **parseAllDocuments**
1067
1091
  * @returns {string[]} непорожні рядки **patch**
1068
1092
  */
1069
1093
  function collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc) {
1070
- if (doc.errors.length > 0) {
1071
- return []
1072
- }
1094
+ if (doc.errors.length > 0) return []
1073
1095
  const root = doc.toJSON()
1074
- if (root === null || typeof root !== 'object' || Array.isArray(root)) {
1075
- return []
1076
- }
1096
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return []
1077
1097
  const rec = /** @type {Record<string, unknown>} */ (root)
1078
- if (rec.kind !== 'Kustomization') {
1079
- return []
1080
- }
1081
- const patches = rec.patches
1082
- if (!Array.isArray(patches)) {
1083
- return []
1084
- }
1098
+ if (rec.kind !== 'Kustomization' || !Array.isArray(rec.patches)) return []
1085
1099
  /** @type {string[]} */
1086
1100
  const out = []
1087
- for (const p of patches) {
1088
- if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
1089
- const pr = /** @type {Record<string, unknown>} */ (p)
1090
- const target = pr.target
1091
- if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
1092
- const tg = /** @type {Record<string, unknown>} */ (target)
1093
- if (tg.kind === 'HTTPRoute' && typeof tg.name === 'string' && tg.name.trim() !== '') {
1094
- const patchStr = pr.patch
1095
- if (typeof patchStr === 'string' && patchStr.trim() !== '') {
1096
- out.push(patchStr)
1097
- }
1098
- }
1099
- }
1100
- }
1101
+ for (const p of rec.patches) {
1102
+ const s = extractHttpRoutePatchString(p)
1103
+ if (s !== null) out.push(s)
1101
1104
  }
1102
1105
  return out
1103
1106
  }
@@ -1109,7 +1112,7 @@ function collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc) {
1109
1112
  */
1110
1113
  export function getCombinedNginxRunPatchTextFromKustomization(raw) {
1111
1114
  const body = stripBom(raw)
1112
- const lines = body.split(/\r?\n/u)
1115
+ const lines = body.split(LINE_SPLIT_RE)
1113
1116
  const first = lines[0] ?? ''
1114
1117
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
1115
1118
  /** @type {import('yaml').Document[]} */
@@ -1144,7 +1147,7 @@ export function validateAbieNginxRunHttpRoutePatches(
1144
1147
  if (typeof combined !== 'string' || combined.trim() === '') {
1145
1148
  return `очікується patch target kind HTTPRoute з непорожнім target.name (hostnames, parentRefs namespace ${mode}; для ru — gwin… websocket лише за наявності HASURA_GRAPHQL_JWT_SECRET у файлі) — abie.mdc`
1146
1149
  }
1147
- if (!/path:\s*\/spec\/hostnames\b/m.test(combined)) {
1150
+ if (!PATCH_HOSTNAMES_PATH_RE.test(combined)) {
1148
1151
  return 'HTTPRoute: потрібен path /spec/hostnames у patch (abie.mdc)'
1149
1152
  }
1150
1153
  const markers = mode === 'ua' ? ABIE_UA_HTTPROUTE_HOST_MARKERS : ABIE_RU_HTTPROUTE_HOST_MARKERS
@@ -1152,9 +1155,7 @@ export function validateAbieNginxRunHttpRoutePatches(
1152
1155
  return `HTTPRoute: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
1153
1156
  }
1154
1157
  const namespaceOk =
1155
- mode === 'ua'
1156
- ? /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/mu.test(combined)
1157
- : /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/mu.test(combined)
1158
+ mode === 'ua' ? PATCH_PARENT_REF_NS_UA_RE.test(combined) : PATCH_PARENT_REF_NS_RU_RE.test(combined)
1158
1159
  if (!namespaceOk) {
1159
1160
  return `HTTPRoute: потрібен path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
1160
1161
  }
@@ -1162,7 +1163,7 @@ export function validateAbieNginxRunHttpRoutePatches(
1162
1163
  mode === 'ru' &&
1163
1164
  typeof fullKustomizationRaw === 'string' &&
1164
1165
  fullKustomizationRaw.includes(HASURA_JWT_SECRET_IN_KUSTOMIZATION)
1165
- if (ruNeedsWebsocket && !/gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/m.test(combined)) {
1166
+ if (ruNeedsWebsocket && !WEBSOCKET_ANNOTATION_RE.test(combined)) {
1166
1167
  return 'HTTPRoute (ru): за наявності HASURA_GRAPHQL_JWT_SECRET у kustomization потрібна анотація gwin.yandex.cloud/rules.http.upgradeTypes: websocket (abie.mdc)'
1167
1168
  }
1168
1169
  const sharedCount =
@@ -1190,54 +1191,12 @@ export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
1190
1191
  }
1191
1192
 
1192
1193
  /**
1193
- * Перевіряє **hc.yaml** на відповідність abie.mdc.
1194
- * @param {string} raw повний текст файлу
1195
- * @param {string} relPath відносний шлях для повідомлень
1196
- * @returns {string | null} текст помилки або **null**
1194
+ * Перевіряє об'єкт HealthCheckPolicy на відповідність abie.mdc.
1195
+ * @param {Record<string, unknown>} policy розібраний HealthCheckPolicy
1196
+ * @param {string} relPath відносний шлях (для повідомлень)
1197
+ * @returns {string | null} текст помилки або null якщо OK
1197
1198
  */
1198
- export function validateAbieHcYaml(raw, relPath) {
1199
- const body = stripBom(raw)
1200
- const lines = body.split(/\r?\n/u)
1201
- if (lines.length === 0 || lines[0].trim() === '') {
1202
- return `${relPath}: перший рядок порожній — потрібен # yaml-language-server: $schema=… (abie.mdc)`
1203
- }
1204
- const m = lines[0].match(MODELINE_RE)
1205
- if (!m) {
1206
- return `${relPath}: перший рядок має бути modeline $schema (abie.mdc)`
1207
- }
1208
- if (m[1] !== ABIE_HC_SCHEMA_URL) {
1209
- return `${relPath}: $schema має бути\n ${ABIE_HC_SCHEMA_URL}\n (abie.mdc)`
1210
- }
1211
- const yamlBody = lines
1212
- .slice(1)
1213
- .join('\n')
1214
- .replace(/^\s*\n/u, '')
1215
- /** @type {import('yaml').Document[]} */
1216
- let docs
1217
- try {
1218
- docs = parseAllDocuments(yamlBody)
1219
- } catch (error) {
1220
- const msg = error instanceof Error ? error.message : String(error)
1221
- return `${relPath}: не вдалося розібрати YAML (${msg})`
1222
- }
1223
- /** @type {Record<string, unknown> | null} */
1224
- let policy = null
1225
- for (const doc of docs) {
1226
- if (doc.errors.length > 0) {
1227
- return `${relPath}: YAML: ${doc.errors.map(e => e.message).join('; ')}`
1228
- }
1229
- const obj = doc.toJSON()
1230
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1231
- const rec = /** @type {Record<string, unknown>} */ (obj)
1232
- if (rec.kind === 'HealthCheckPolicy') {
1233
- policy = rec
1234
- break
1235
- }
1236
- }
1237
- }
1238
- if (!policy) {
1239
- return `${relPath}: очікується документ kind: HealthCheckPolicy (abie.mdc)`
1240
- }
1199
+ function validateAbieHcPolicy(policy, relPath) {
1241
1200
  if (policy.apiVersion !== 'networking.gke.io/v1') {
1242
1201
  return `${relPath}: apiVersion має бути networking.gke.io/v1 (abie.mdc)`
1243
1202
  }
@@ -1253,7 +1212,8 @@ export function validateAbieHcYaml(raw, relPath) {
1253
1212
  if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
1254
1213
  return `${relPath}: відсутній spec (abie.mdc)`
1255
1214
  }
1256
- const def = /** @type {Record<string, unknown>} */ (spec).default
1215
+ const specRec = /** @type {Record<string, unknown>} */ (spec)
1216
+ const def = specRec.default
1257
1217
  if (def === null || typeof def !== 'object' || Array.isArray(def)) {
1258
1218
  return `${relPath}: відсутній spec.default (abie.mdc)`
1259
1219
  }
@@ -1261,34 +1221,99 @@ export function validateAbieHcYaml(raw, relPath) {
1261
1221
  if (config === null || typeof config !== 'object' || Array.isArray(config)) {
1262
1222
  return `${relPath}: відсутній spec.default.config (abie.mdc)`
1263
1223
  }
1264
- if (config.type !== 'HTTP') {
1265
- return `${relPath}: spec.default.config.type має бути HTTP (abie.mdc)`
1266
- }
1224
+ if (config.type !== 'HTTP') return `${relPath}: spec.default.config.type має бути HTTP (abie.mdc)`
1267
1225
  const httpHc = /** @type {Record<string, unknown>} */ (config).httpHealthCheck
1268
1226
  if (httpHc === null || typeof httpHc !== 'object' || Array.isArray(httpHc)) {
1269
1227
  return `${relPath}: відсутній httpHealthCheck (abie.mdc)`
1270
1228
  }
1271
- if (httpHc.requestPath !== '/healthz') {
1272
- return `${relPath}: httpHealthCheck.requestPath має бути /healthz (abie.mdc)`
1273
- }
1274
- if (httpHc.port !== 8080) {
1275
- return `${relPath}: httpHealthCheck.port має бути 8080 (abie.mdc)`
1276
- }
1277
- const targetRef = /** @type {Record<string, unknown>} */ (spec).targetRef
1229
+ if (httpHc.requestPath !== '/healthz') return `${relPath}: httpHealthCheck.requestPath має бути /healthz (abie.mdc)`
1230
+ if (httpHc.port !== 8080) return `${relPath}: httpHealthCheck.port має бути 8080 (abie.mdc)`
1231
+ const targetRef = specRec.targetRef
1278
1232
  if (targetRef === null || typeof targetRef !== 'object' || Array.isArray(targetRef)) {
1279
1233
  return `${relPath}: відсутній targetRef (abie.mdc)`
1280
1234
  }
1281
- if (targetRef.kind !== 'Service') {
1282
- return `${relPath}: targetRef.kind має бути Service (abie.mdc)`
1283
- }
1284
- const svcName = targetRef.name
1235
+ const tr = /** @type {Record<string, unknown>} */ (targetRef)
1236
+ if (tr.kind !== 'Service') return `${relPath}: targetRef.kind має бути Service (abie.mdc)`
1285
1237
  const expectedHl = name.endsWith('-hl') ? name : `${name}-hl`
1286
- if (typeof svcName !== 'string' || svcName !== expectedHl) {
1238
+ if (typeof tr.name !== 'string' || tr.name !== expectedHl) {
1287
1239
  return `${relPath}: targetRef.name має посилатися на headless Service (очікується ${expectedHl}, суфікс -hl) (abie.mdc)`
1288
1240
  }
1289
1241
  return null
1290
1242
  }
1291
1243
 
1244
+ /**
1245
+ * Шукає HealthCheckPolicy серед YAML-документів.
1246
+ * @param {import('yaml').Document[]} docs документи
1247
+ * @param {string} relPath відносний шлях для повідомлень
1248
+ * @returns {{ policy: Record<string, unknown> } | { error: string }} знайдений документ або помилка
1249
+ */
1250
+ function findHealthCheckPolicyInDocs(docs, relPath) {
1251
+ for (const doc of docs) {
1252
+ if (doc.errors.length > 0) {
1253
+ return { error: `${relPath}: YAML: ${doc.errors.map(e => e.message).join('; ')}` }
1254
+ }
1255
+ const obj = doc.toJSON()
1256
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1257
+ const rec = /** @type {Record<string, unknown>} */ (obj)
1258
+ if (rec.kind === 'HealthCheckPolicy') {
1259
+ return { policy: rec }
1260
+ }
1261
+ }
1262
+ }
1263
+ return { policy: /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (null)) }
1264
+ }
1265
+
1266
+ /**
1267
+ * Перевіряє hc.yaml на відповідність схемі та структурі HealthCheckPolicy (abie.mdc).
1268
+ * @param {string} raw вміст файлу
1269
+ * @param {string} relPath відносний шлях (для повідомлень)
1270
+ * @returns {string | null} null якщо OK, рядок з помилкою
1271
+ */
1272
+ export function validateAbieHcYaml(raw, relPath) {
1273
+ const body = stripBom(raw)
1274
+ const lines = body.split(LINE_SPLIT_RE)
1275
+ if (lines.length === 0 || lines[0].trim() === '') {
1276
+ return `${relPath}: перший рядок порожній — потрібен # yaml-language-server: $schema=… (abie.mdc)`
1277
+ }
1278
+ const m = lines[0].match(MODELINE_RE)
1279
+ if (!m) return `${relPath}: перший рядок має бути modeline $schema (abie.mdc)`
1280
+ if (m[1] !== ABIE_HC_SCHEMA_URL) return `${relPath}: $schema має бути\n ${ABIE_HC_SCHEMA_URL}\n (abie.mdc)`
1281
+
1282
+ const yamlBody = lines.slice(1).join('\n').replace(LEADING_EMPTY_LINE_RE, '')
1283
+ /** @type {import('yaml').Document[]} */
1284
+ let docs
1285
+ try {
1286
+ docs = parseAllDocuments(yamlBody)
1287
+ } catch (error) {
1288
+ const msg = error instanceof Error ? error.message : String(error)
1289
+ return `${relPath}: не вдалося розібрати YAML (${msg})`
1290
+ }
1291
+ const result = findHealthCheckPolicyInDocs(docs, relPath)
1292
+ if ('error' in result) return result.error
1293
+ if (!result.policy) return `${relPath}: очікується документ kind: HealthCheckPolicy (abie.mdc)`
1294
+ return validateAbieHcPolicy(result.policy, relPath)
1295
+ }
1296
+
1297
+ /**
1298
+ * Збирає відносний шлях із документів, що містять HealthCheckPolicy.
1299
+ * @param {import('yaml').Document[]} docs документи з файлу
1300
+ * @param {string} rel відносний шлях файлу
1301
+ * @param {string[]} out масив для запису шляхів
1302
+ */
1303
+ function collectHealthCheckPolicyRelFromDocs(docs, rel, out) {
1304
+ for (const doc of docs) {
1305
+ if (doc.errors.length === 0) {
1306
+ const obj = doc.toJSON()
1307
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1308
+ const rec = /** @type {Record<string, unknown>} */ (obj)
1309
+ if (rec.kind === 'HealthCheckPolicy' && !out.includes(rel)) {
1310
+ out.push(rel)
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ }
1316
+
1292
1317
  /**
1293
1318
  * Збирає відносні шляхи файлів із **HealthCheckPolicy** у дереві k8s.
1294
1319
  * @param {string} root корінь
@@ -1300,34 +1325,8 @@ async function collectHealthCheckPolicyRelPaths(root, yamlAbs) {
1300
1325
  const out = []
1301
1326
  for (const abs of yamlAbs) {
1302
1327
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1303
- let raw
1304
- try {
1305
- raw = await readFile(abs, 'utf8')
1306
- } catch {
1307
- raw = null
1308
- }
1309
- if (raw !== null) {
1310
- const body = stripBom(raw)
1311
- const lines = body.split(/\r?\n/u)
1312
- const first = lines[0] ?? ''
1313
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
1314
- try {
1315
- const docs = parseAllDocuments(rest)
1316
- for (const doc of docs) {
1317
- if (doc.errors.length === 0) {
1318
- const obj = doc.toJSON()
1319
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1320
- const rec = /** @type {Record<string, unknown>} */ (obj)
1321
- if (rec.kind === 'HealthCheckPolicy' && !out.includes(rel)) {
1322
- out.push(rel)
1323
- }
1324
- }
1325
- }
1326
- }
1327
- } catch {
1328
- /* пропускаємо пошкоджені файли — їх ловить check-k8s */
1329
- }
1330
- }
1328
+ const docs = await readAndParseYamlDocs(abs, rel, silentFail)
1329
+ if (docs) collectHealthCheckPolicyRelFromDocs(docs, rel, out)
1331
1330
  }
1332
1331
  return out
1333
1332
  }
@@ -1370,14 +1369,45 @@ async function ensureRuKustomizationHealthCheckDelete(root, yamlFilesAbs, health
1370
1369
  }
1371
1370
 
1372
1371
  /**
1373
- * Якщо є **Deployment** під **k8s**, вимагає в overlay **ua** та **ru** (**kustomization.yaml**) JSON6902 patch nodeSelector (abie.mdc)
1374
- * лише для kustomization того пакета, у дереві **k8s** якого є **Deployment**.
1372
+ * Перевіряє одну kustomization.yaml на nodeSelector patch для заданого overlay.
1373
+ * @param {string} abs абсолютний шлях до файлу
1374
+ * @param {string} rel відносний шлях (для повідомлень)
1375
+ * @param {'ua' | 'ru'} mode параметр mode
1376
+ * @param {Set<string>} deploymentDirs директорії з Deployment (Set)
1375
1377
  * @param {string} root корінь репозиторію
1376
- * @param {string[]} yamlFilesAbs yaml під k8s
1377
- * @param {Set<string>} deploymentDirs абсолютні каталоги YAML-файлів із **Deployment**
1378
- * @param {(msg: string) => void} fail callback
1379
- * @param {(msg: string) => void} passFn успішне повідомлення
1380
- * @returns {Promise<void>}
1378
+ * @param {(msg: string) => void} fail callback при помилці
1379
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
1380
+ * @returns {Promise<boolean>} false якщо виявлено помилку і слід зупинитись
1381
+ */
1382
+ async function checkNodeSelectorKustomization(abs, rel, mode, deploymentDirs, root, fail, passFn) {
1383
+ if (!abieOverlayK8sTreeHasDeployment(deploymentDirs, root, abs)) {
1384
+ passFn(`${rel}: nodeSelector patch (${mode}) не застосовується — немає Deployment у дереві k8s цього пакета (abie)`)
1385
+ return true
1386
+ }
1387
+ let raw
1388
+ try {
1389
+ raw = await readFile(abs, 'utf8')
1390
+ } catch (error) {
1391
+ const msg = error instanceof Error ? error.message : String(error)
1392
+ fail(`${rel}: не вдалося прочитати (${msg})`)
1393
+ return false
1394
+ }
1395
+ if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode)) {
1396
+ const detail = mode === 'ua' ? 'preem: false' : 'yandex.cloud/preemptible: false'
1397
+ fail(`${rel}: потрібен patch target kind Deployment: path /spec/template/spec/nodeSelector та ${detail} (abie.mdc)`)
1398
+ return false
1399
+ }
1400
+ passFn(`${rel}: nodeSelector patch (${mode}) відповідає abie.mdc`)
1401
+ return true
1402
+ }
1403
+
1404
+ /**
1405
+ * Перевіряє наявність патчів nodeSelector для ua/ru overlay у k8s.
1406
+ * @param {string} root корінь репозиторію
1407
+ * @param {string[]} yamlFilesAbs абсолютні шляхи yaml-файлів під k8s
1408
+ * @param {Set<string>} deploymentDirs директорії з Deployment (Set)
1409
+ * @param {(msg: string) => void} fail callback при помилці
1410
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
1381
1411
  */
1382
1412
  async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, deploymentDirs, fail, passFn) {
1383
1413
  const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
@@ -1389,25 +1419,8 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, deploymentD
1389
1419
  }
1390
1420
  for (const abs of uaAbsList) {
1391
1421
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1392
- if (abieOverlayK8sTreeHasDeployment(deploymentDirs, root, abs)) {
1393
- let raw
1394
- try {
1395
- raw = await readFile(abs, 'utf8')
1396
- } catch (error) {
1397
- const msg = error instanceof Error ? error.message : String(error)
1398
- fail(`${rel}: не вдалося прочитати (${msg})`)
1399
- return
1400
- }
1401
- if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, 'ua')) {
1402
- fail(
1403
- `${rel}: потрібен patch target kind Deployment: path /spec/template/spec/nodeSelector та preem: false (abie.mdc)`
1404
- )
1405
- return
1406
- }
1407
- passFn(`${rel}: nodeSelector patch (ua) відповідає abie.mdc`)
1408
- } else {
1409
- passFn(`${rel}: nodeSelector patch (ua) не застосовується — немає Deployment у дереві k8s цього пакета (abie)`)
1410
- }
1422
+ const ok = await checkNodeSelectorKustomization(abs, rel, 'ua', deploymentDirs, root, fail, passFn)
1423
+ if (!ok) return
1411
1424
  }
1412
1425
 
1413
1426
  const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
@@ -1419,52 +1432,72 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, deploymentD
1419
1432
  }
1420
1433
  for (const abs of ruAbsList) {
1421
1434
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1422
- if (abieOverlayK8sTreeHasDeployment(deploymentDirs, root, abs)) {
1423
- let raw
1424
- try {
1425
- raw = await readFile(abs, 'utf8')
1426
- } catch (error) {
1427
- const msg = error instanceof Error ? error.message : String(error)
1428
- fail(`${rel}: не вдалося прочитати (${msg})`)
1429
- return
1430
- }
1431
- if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, 'ru')) {
1432
- fail(
1433
- `${rel}: потрібен patch target kind Deployment: path /spec/template/spec/nodeSelector та yandex.cloud/preemptible: false (abie.mdc)`
1434
- )
1435
- return
1436
- }
1437
- passFn(`${rel}: nodeSelector patch (ru) відповідає abie.mdc`)
1438
- } else {
1439
- passFn(`${rel}: nodeSelector patch (ru) не застосовується — немає Deployment у дереві k8s цього пакета (abie)`)
1440
- }
1435
+ const ok = await checkNodeSelectorKustomization(abs, rel, 'ru', deploymentDirs, root, fail, passFn)
1436
+ if (!ok) return
1441
1437
  }
1442
1438
  }
1443
1439
 
1440
+ /**
1441
+ * Перевіряє HTTPRoute patch для одного overlay (ua/ru).
1442
+ * @param {string} abs абсолютний шлях до kustomization.yaml
1443
+ * @param {string} rel відносний шлях (для повідомлень)
1444
+ * @param {'ua' | 'ru'} mode overlay
1445
+ * @param {string} root корінь репозиторію
1446
+ * @param {string[]} yamlFilesAbs абсолютні шляхи yaml-файлів під k8s
1447
+ * @param {Map<string, Promise<{ refCount: number, baseErrors: string[] }>>} cache кеш аналізу shared backend refs
1448
+ * @param {(msg: string) => void} fail callback при помилці
1449
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
1450
+ * @returns {Promise<boolean>} false якщо виявлено помилку і слід зупинитись
1451
+ */
1452
+ async function checkHttpRouteKustomization(abs, rel, mode, root, yamlFilesAbs, cache, fail, passFn) {
1453
+ if (!abieOverlayRequiresHttpRouteByVite(root, abs)) {
1454
+ passFn(`${rel}: HTTPRoute patch (${mode}) не застосовується — немає vite.config.{js,mjs,ts} у пакеті (abie)`)
1455
+ return true
1456
+ }
1457
+ const pkgAbs = abiePackageDirFromK8sOverlay(root, abs)
1458
+ if (!pkgAbs) {
1459
+ fail(`${rel}: внутрішня помилка abie overlay (немає каталогу пакета)`)
1460
+ return false
1461
+ }
1462
+ let p = cache.get(pkgAbs)
1463
+ if (!p) {
1464
+ p = analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yamlFilesAbs)
1465
+ cache.set(pkgAbs, p)
1466
+ }
1467
+ const sharedAnalysis = await p
1468
+ for (const err of sharedAnalysis.baseErrors) {
1469
+ fail(err)
1470
+ return false
1471
+ }
1472
+ let raw
1473
+ try {
1474
+ raw = await readFile(abs, 'utf8')
1475
+ } catch (error) {
1476
+ const msg = error instanceof Error ? error.message : String(error)
1477
+ fail(`${rel}: не вдалося прочитати (${msg})`)
1478
+ return false
1479
+ }
1480
+ const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
1481
+ const v = validateAbieNginxRunHttpRoutePatches(combined, mode, raw, sharedAnalysis.refCount)
1482
+ if (v !== null) {
1483
+ fail(`${rel}: ${v}`)
1484
+ return false
1485
+ }
1486
+ passFn(`${rel}: HTTPRoute patch (${mode}) відповідає abie.mdc`)
1487
+ return true
1488
+ }
1489
+
1444
1490
  /**
1445
1491
  * Якщо є **Deployment** під **k8s**, вимагає в overlay **ua** та **ru** patch **HTTPRoute** (непорожній **target.name**) за abie.mdc
1446
1492
  * лише для пакетів з **vite.config.{js,mjs,ts}** у каталозі пакета (батько **k8s**).
1447
1493
  * @param {string} root корінь репозиторію
1448
1494
  * @param {string[]} yamlFilesAbs yaml під k8s
1449
- * @param {(msg: string) => void} fail callback
1450
- * @param {(msg: string) => void} passFn успішне повідомлення
1451
- * @returns {Promise<void>}
1495
+ * @param {(msg: string) => void} fail callback при помилці
1496
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
1452
1497
  */
1453
1498
  async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn) {
1454
1499
  /** @type {Map<string, Promise<{ refCount: number, baseErrors: string[] }>>} */
1455
- const sharedBackendAnalysisByPkg = new Map()
1456
- /**
1457
- * @param {string} pkgAbs абсолютний шлях до каталогу пакета
1458
- * @returns {Promise<{ refCount: number, baseErrors: string[] }>} кількість посилань і базові помилки
1459
- */
1460
- const getSharedBackendAnalysis = pkgAbs => {
1461
- let p = sharedBackendAnalysisByPkg.get(pkgAbs)
1462
- if (!p) {
1463
- p = analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yamlFilesAbs)
1464
- sharedBackendAnalysisByPkg.set(pkgAbs, p)
1465
- }
1466
- return p
1467
- }
1500
+ const cache = new Map()
1468
1501
 
1469
1502
  const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
1470
1503
  if (uaAbsList.length === 0) {
@@ -1474,35 +1507,8 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
1474
1507
  }
1475
1508
  for (const abs of uaAbsList) {
1476
1509
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1477
- if (abieOverlayRequiresHttpRouteByVite(root, abs)) {
1478
- const pkgAbs = abiePackageDirFromK8sOverlay(root, abs)
1479
- if (!pkgAbs) {
1480
- fail(`${rel}: внутрішня помилка abie overlay (немає каталогу пакета)`)
1481
- return
1482
- }
1483
- const sharedAnalysis = await getSharedBackendAnalysis(pkgAbs)
1484
- for (const err of sharedAnalysis.baseErrors) {
1485
- fail(err)
1486
- return
1487
- }
1488
- let raw
1489
- try {
1490
- raw = await readFile(abs, 'utf8')
1491
- } catch (error) {
1492
- const msg = error instanceof Error ? error.message : String(error)
1493
- fail(`${rel}: не вдалося прочитати (${msg})`)
1494
- return
1495
- }
1496
- const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
1497
- const v = validateAbieNginxRunHttpRoutePatches(combined, 'ua', raw, sharedAnalysis.refCount)
1498
- if (v !== null) {
1499
- fail(`${rel}: ${v}`)
1500
- return
1501
- }
1502
- passFn(`${rel}: HTTPRoute patch (ua) відповідає abie.mdc`)
1503
- } else {
1504
- passFn(`${rel}: HTTPRoute patch (ua) не застосовується — немає vite.config.{js,mjs,ts} у пакеті (abie)`)
1505
- }
1510
+ const ok = await checkHttpRouteKustomization(abs, rel, 'ua', root, yamlFilesAbs, cache, fail, passFn)
1511
+ if (!ok) return
1506
1512
  }
1507
1513
 
1508
1514
  const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
@@ -1513,35 +1519,8 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
1513
1519
  }
1514
1520
  for (const abs of ruAbsList) {
1515
1521
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1516
- if (abieOverlayRequiresHttpRouteByVite(root, abs)) {
1517
- const pkgAbs = abiePackageDirFromK8sOverlay(root, abs)
1518
- if (!pkgAbs) {
1519
- fail(`${rel}: внутрішня помилка abie overlay (немає каталогу пакета)`)
1520
- return
1521
- }
1522
- const sharedAnalysis = await getSharedBackendAnalysis(pkgAbs)
1523
- for (const err of sharedAnalysis.baseErrors) {
1524
- fail(err)
1525
- return
1526
- }
1527
- let raw
1528
- try {
1529
- raw = await readFile(abs, 'utf8')
1530
- } catch (error) {
1531
- const msg = error instanceof Error ? error.message : String(error)
1532
- fail(`${rel}: не вдалося прочитати (${msg})`)
1533
- return
1534
- }
1535
- const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
1536
- const v = validateAbieNginxRunHttpRoutePatches(combined, 'ru', raw, sharedAnalysis.refCount)
1537
- if (v !== null) {
1538
- fail(`${rel}: ${v}`)
1539
- return
1540
- }
1541
- passFn(`${rel}: HTTPRoute patch (ru) відповідає abie.mdc`)
1542
- } else {
1543
- passFn(`${rel}: HTTPRoute patch (ru) не застосовується — немає vite.config.{js,mjs,ts} у пакеті (abie)`)
1544
- }
1522
+ const ok = await checkHttpRouteKustomization(abs, rel, 'ru', root, yamlFilesAbs, cache, fail, passFn)
1523
+ if (!ok) return
1545
1524
  }
1546
1525
  }
1547
1526
 
@@ -1573,6 +1552,85 @@ function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
1573
1552
  * Перевіряє відповідність проєкту правилам abie.mdc.
1574
1553
  * @returns {Promise<number>} 0 — OK, 1 — є порушення
1575
1554
  */
1555
+ /**
1556
+ * Перевіряє clean-merged-branch.yml на ignore_branches.
1557
+ * @param {string} root корінь репозиторію
1558
+ * @param {(msg: string) => void} pass callback при успішній перевірці
1559
+ * @param {(msg: string) => void} fail callback при помилці
1560
+ */
1561
+ async function checkCleanMergedBranch(root, pass, fail) {
1562
+ const cleanMergedPath = join(root, '.github/workflows/clean-merged-branch.yml')
1563
+ if (!existsSync(cleanMergedPath)) {
1564
+ fail(`Відсутній ${cleanMergedPath} — потрібен для ignore_branches (abie.mdc)`)
1565
+ return
1566
+ }
1567
+ let wfRaw
1568
+ try {
1569
+ wfRaw = await readFile(cleanMergedPath, 'utf8')
1570
+ } catch (error) {
1571
+ const msg = error instanceof Error ? error.message : String(error)
1572
+ fail(`Не вдалося прочитати clean-merged-branch.yml (${msg})`)
1573
+ return
1574
+ }
1575
+ const ib = parseCleanMergedIgnoreBranches(wfRaw)
1576
+ if (ib === null || ib.trim() === '') {
1577
+ fail(
1578
+ 'clean-merged-branch.yml: не знайдено with.ignore_branches у кроці phpdocker-io/github-actions-delete-abandoned-branches (abie.mdc)'
1579
+ )
1580
+ } else if (ignoreBranchesIncludesRequired(ib, ABIE_REQUIRED_IGNORE_BRANCHES)) {
1581
+ pass('clean-merged-branch.yml: ignore_branches містить dev, ua, ru')
1582
+ } else {
1583
+ fail(`clean-merged-branch.yml: ignore_branches має містити dev, ua та ru (зараз: ${JSON.stringify(ib)}) — abie.mdc`)
1584
+ }
1585
+ }
1586
+
1587
+ /**
1588
+ * Перевіряє один файл hc.yaml на відповідність abie.mdc.
1589
+ * @param {string} root корінь репозиторію
1590
+ * @param {string} dir директорія з Deployment
1591
+ * @param {(msg: string) => void} pass callback при успішній перевірці
1592
+ * @param {(msg: string) => void} fail callback при помилці
1593
+ */
1594
+ async function checkHcYamlFile(root, dir, pass, fail) {
1595
+ const hcAbs = join(dir, 'hc.yaml')
1596
+ const relHc = relative(root, hcAbs).replaceAll('\\', '/') || 'hc.yaml'
1597
+ if (!existsSync(hcAbs)) {
1598
+ fail(`${relative(root, dir) || dir}: є Deployment, але немає hc.yaml поруч — додай HealthCheckPolicy (abie.mdc)`)
1599
+ return
1600
+ }
1601
+ let hcRaw
1602
+ try {
1603
+ hcRaw = await readFile(hcAbs, 'utf8')
1604
+ } catch (error) {
1605
+ const msg = error instanceof Error ? error.message : String(error)
1606
+ fail(`${relHc}: не вдалося прочитати (${msg})`)
1607
+ return
1608
+ }
1609
+ const v = validateAbieHcYaml(hcRaw, relHc)
1610
+ if (v === null) {
1611
+ pass(`${relHc}: відповідає abie.mdc`)
1612
+ } else {
1613
+ fail(v)
1614
+ }
1615
+ }
1616
+
1617
+ /**
1618
+ * Перевіряє hc.yaml у директоріях з Deployment.
1619
+ * @param {string} root корінь репозиторію
1620
+ * @param {Set<string>} deploymentDirs директорії з Deployment (Set)
1621
+ * @param {(msg: string) => void} pass callback при успішній перевірці
1622
+ * @param {(msg: string) => void} fail callback при помилці
1623
+ */
1624
+ async function checkHcYamlFiles(root, deploymentDirs, pass, fail) {
1625
+ for (const dir of [...deploymentDirs].toSorted((a, b) => a.localeCompare(b))) {
1626
+ await checkHcYamlFile(root, dir, pass, fail)
1627
+ }
1628
+ }
1629
+
1630
+ /**
1631
+ * Перевіряє відповідність проєкту правилам abie.mdc.
1632
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
1633
+ */
1576
1634
  export async function check() {
1577
1635
  const reporter = createCheckReporter()
1578
1636
  const { pass, fail } = reporter
@@ -1586,67 +1644,14 @@ export async function check() {
1586
1644
 
1587
1645
  pass('Правило abie увімкнено — виконуємо перевірки')
1588
1646
  ensureNoFirebaseHostingArtifacts(root, pass, fail)
1589
-
1590
- const cleanMergedPath = join(root, '.github/workflows/clean-merged-branch.yml')
1591
- if (existsSync(cleanMergedPath)) {
1592
- /** @type {string | undefined} */
1593
- let wfRaw
1594
- try {
1595
- wfRaw = await readFile(cleanMergedPath, 'utf8')
1596
- } catch (error) {
1597
- const msg = error instanceof Error ? error.message : String(error)
1598
- fail(`Не вдалося прочитати clean-merged-branch.yml (${msg})`)
1599
- }
1600
- if (wfRaw !== undefined) {
1601
- const ib = parseCleanMergedIgnoreBranches(wfRaw)
1602
- if (ib === null || ib.trim() === '') {
1603
- fail(
1604
- 'clean-merged-branch.yml: не знайдено with.ignore_branches у кроці phpdocker-io/github-actions-delete-abandoned-branches (abie.mdc)'
1605
- )
1606
- } else if (ignoreBranchesIncludesRequired(ib, ABIE_REQUIRED_IGNORE_BRANCHES)) {
1607
- pass('clean-merged-branch.yml: ignore_branches містить dev, ua, ru')
1608
- } else {
1609
- fail(
1610
- `clean-merged-branch.yml: ignore_branches має містити dev, ua та ru (зараз: ${JSON.stringify(ib)}) — abie.mdc`
1611
- )
1612
- }
1613
- }
1614
- } else {
1615
- fail(`Відсутній ${cleanMergedPath} — потрібен для ignore_branches (abie.mdc)`)
1616
- }
1647
+ await checkCleanMergedBranch(root, pass, fail)
1617
1648
 
1618
1649
  const yamlFiles = await findK8sYamlFiles(root)
1619
1650
  const deploymentDirs = await collectDeploymentDirs(root, yamlFiles, fail)
1620
1651
 
1621
1652
  if (deploymentDirs.size > 0) {
1622
1653
  pass(`Знайдено Deployment у ${deploymentDirs.size} директорія(ї/й) k8s — перевіряємо hc.yaml`)
1623
- for (const dir of [...deploymentDirs].toSorted((a, b) => a.localeCompare(b))) {
1624
- const hcAbs = join(dir, 'hc.yaml')
1625
- const relHc = relative(root, hcAbs).replaceAll('\\', '/') || 'hc.yaml'
1626
- if (existsSync(hcAbs)) {
1627
- let hcRaw
1628
- let hcReadOk = false
1629
- try {
1630
- hcRaw = await readFile(hcAbs, 'utf8')
1631
- hcReadOk = true
1632
- } catch (error) {
1633
- const msg = error instanceof Error ? error.message : String(error)
1634
- fail(`${relHc}: не вдалося прочитати (${msg})`)
1635
- }
1636
- if (hcReadOk) {
1637
- const v = validateAbieHcYaml(hcRaw, relHc)
1638
- if (v === null) {
1639
- pass(`${relHc}: відповідає abie.mdc`)
1640
- } else {
1641
- fail(v)
1642
- }
1643
- }
1644
- } else {
1645
- fail(
1646
- `${relative(root, dir) || dir}: є Deployment, але немає hc.yaml поруч — додай HealthCheckPolicy (abie.mdc)`
1647
- )
1648
- }
1649
- }
1654
+ await checkHcYamlFiles(root, deploymentDirs, pass, fail)
1650
1655
  pass('Є Deployment — перевіряємо base: spec.template.spec.nodeSelector.preem (abie.mdc)')
1651
1656
  await ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFiles, fail, pass)
1652
1657
  } else {