@nitra/cursor 1.8.105 → 1.8.108

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'])
@@ -78,8 +78,10 @@ const PATCH_PREEM_FALSE_RE = /\bpreem:\s*['"]?false['"]?\b/u
78
78
  const PATCH_YANDEX_PREEMPTIBLE_FALSE_RE = /yandex\.cloud\/preemptible:\s*['"]?false['"]?/u
79
79
  const TRAILING_SLASH_RE = /\/$/u
80
80
  const PATCH_HOSTNAMES_PATH_RE = /path:\s*\/spec\/hostnames\b/mu
81
- const PATCH_PARENT_REF_NS_UA_RE = /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/mu
82
- const PATCH_PARENT_REF_NS_RU_RE = /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/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
83
85
  const WEBSOCKET_ANNOTATION_RE = /gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/mu
84
86
  const LEADING_EMPTY_LINE_RE = /^\s*\n/u
85
87
  const REMOVE_CLUSTER_IP_AFTER_OP_RE = /op:\s*remove\b[\s\S]{0,200}?path:\s*\/spec\/clusterIP\b/mu
@@ -91,7 +93,7 @@ const REMOVE_CLUSTER_IPS_BEFORE_OP_RE = /path:\s*\/spec\/clusterIPs\b[\s\S]{0,20
91
93
  export const ABIE_REQUIRED_IGNORE_BRANCHES = ['dev', 'ua', 'ru']
92
94
 
93
95
  /**
94
- * Чи відносний шлях вказує на **`ru/kustomization.yaml`** (сегмент **`ru`** перед ім’ям файлу) — специфіка abie overlay.
96
+ * Чи відносний шлях вказує на **`ru/kustomization.yaml`** (сегмент **`ru`** перед ім'ям файлу) — специфіка abie overlay.
95
97
  * @param {string} rel шлях від кореня репозиторію
96
98
  * @returns {boolean} true, якщо це `…/ru/kustomization.yaml`
97
99
  */
@@ -101,7 +103,7 @@ export function isRuKustomizationPath(rel) {
101
103
  }
102
104
 
103
105
  /**
104
- * Чи відносний шлях вказує на **`ua/kustomization.yaml`** (сегмент **`ua`** перед ім’ям файлу) — специфіка abie overlay.
106
+ * Чи відносний шлях вказує на **`ua/kustomization.yaml`** (сегмент **`ua`** перед ім'ям файлу) — специфіка abie overlay.
105
107
  * @param {string} rel шлях від кореня репозиторію
106
108
  * @returns {boolean} true, якщо це `…/ua/kustomization.yaml`
107
109
  */
@@ -473,43 +475,41 @@ export function jsonPatchTextSetsServiceTypeNodePort(patchText) {
473
475
  return true
474
476
  }
475
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
+
476
495
  /**
477
496
  * З одного документа **Kustomization** збирає пари **Service name → patch text** для **inline patches** з **target.kind: Service**.
478
497
  * @param {import('yaml').Document} doc документ після **parseAllDocuments**
479
- * @returns {Map<string, string>} ім’я сервісу → текст **patch**
498
+ * @returns {Map<string, string>} ім'я сервісу → текст **patch**
480
499
  */
481
500
  function collectAbieServicePatchTextsByNameFromKustomizationDoc(doc) {
482
501
  /** @type {Map<string, string>} */
483
502
  const out = new Map()
484
- if (doc.errors.length > 0) {
485
- return out
486
- }
503
+ if (doc.errors.length > 0) return out
487
504
  const root = doc.toJSON()
488
- if (root === null || typeof root !== 'object' || Array.isArray(root)) {
489
- return out
490
- }
505
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return out
491
506
  const rec = /** @type {Record<string, unknown>} */ (root)
492
- if (rec.kind !== 'Kustomization') {
493
- return out
494
- }
495
- const patches = rec.patches
496
- if (!Array.isArray(patches)) {
497
- return out
498
- }
499
- for (const p of patches) {
500
- if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
501
- const pr = /** @type {Record<string, unknown>} */ (p)
502
- const target = pr.target
503
- if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
504
- const tg = /** @type {Record<string, unknown>} */ (target)
505
- if (tg.kind === 'Service' && typeof tg.name === 'string' && tg.name.trim() !== '') {
506
- const patchStr = pr.patch
507
- if (typeof patchStr === 'string' && patchStr.trim() !== '') {
508
- const prev = out.get(tg.name)
509
- out.set(tg.name, prev === undefined ? patchStr : `${prev}\n${patchStr}`)
510
- }
511
- }
512
- }
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}`)
513
513
  }
514
514
  }
515
515
  return out
@@ -518,7 +518,7 @@ function collectAbieServicePatchTextsByNameFromKustomizationDoc(doc) {
518
518
  /**
519
519
  * Збирає тексти **patch** на **Service** з **kustomization.yaml** (усі документи).
520
520
  * @param {string} raw повний текст **kustomization.yaml**
521
- * @returns {Map<string, string>} **target.name** → об’єднаний текст **patch**
521
+ * @returns {Map<string, string>} **target.name** → об'єднаний текст **patch**
522
522
  */
523
523
  function collectAbieRuServicePatchTextByTargetNameFromRaw(raw) {
524
524
  const body = stripBom(raw)
@@ -544,41 +544,46 @@ function collectAbieRuServicePatchTextByTargetNameFromRaw(raw) {
544
544
  return byName
545
545
  }
546
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
+
547
574
  /**
548
575
  * Повідомлення про порушення patch **Service** у **ru/kustomization.yaml** (abie.mdc).
549
576
  * @param {string} raw повний текст **kustomization.yaml**
550
- * @param {Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove?: boolean }>} targetsByName ім’я **Service** → прапорці patch
577
+ * @param {Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove?: boolean }>} targetsByName ім'я **Service** → прапорці patch
551
578
  * @returns {string[]} порожньо, якщо все OK
552
579
  */
553
580
  export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
554
- if (targetsByName.size === 0) {
555
- return []
556
- }
581
+ if (targetsByName.size === 0) return []
557
582
  const byName = collectAbieRuServicePatchTextByTargetNameFromRaw(raw)
558
583
  /** @type {string[]} */
559
584
  const errors = []
560
585
  for (const name of [...targetsByName.keys()].toSorted((a, b) => a.localeCompare(b))) {
561
- const flags = targetsByName.get(name)
562
- const requiresClusterIPRemove = flags?.requiresClusterIPNoneClear === true
563
- const requiresClusterIPsRemove = flags?.requiresClusterIPsRemove === true
564
- const pt = byName.get(name)
565
- if (pt === undefined || String(pt).trim() === '') {
566
- errors.push(`${name}: немає inline patch для kind: Service`)
567
- } else {
568
- if (!jsonPatchTextSetsServiceTypeNodePort(pt)) {
569
- errors.push(`${name}: потрібен JSON6902 path /spec/type та value NodePort`)
570
- }
571
- if (requiresClusterIPRemove && !jsonPatchTextClearsHeadlessServiceClusterIPNone(pt)) {
572
- errors.push(
573
- `${name}: для spec.clusterIP: None додай у той самий patch op: remove для path /spec/clusterIP (abie.mdc)`
574
- )
575
- }
576
- if (requiresClusterIPsRemove && !jsonPatchRemovesPath(pt, '/spec/clusterIPs')) {
577
- errors.push(
578
- `${name}: у base задано spec.clusterIPs — додай op: remove для path /spec/clusterIPs (інакше NodePort з None у clusterIPs; abie.mdc)`
579
- )
580
- }
581
- }
586
+ collectServicePatchErrors(name, byName.get(name), targetsByName.get(name), errors)
582
587
  }
583
588
  return errors
584
589
  }
@@ -588,68 +593,64 @@ export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
588
593
  * @param {string} root корінь репозиторію
589
594
  * @param {string[]} yamlAbs абсолютні шляхи yaml під **k8s**
590
595
  * @param {(msg: string) => void} fail реєстрація помилки читання/парсингу
591
- * @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
592
644
  */
593
645
  async function collectAbieRuNodePortServiceTargetsByPackage(root, yamlAbs, fail) {
594
646
  /** @type {Map<string, Map<string, { requiresClusterIPNoneClear: boolean, requiresClusterIPsRemove: boolean }>>} */
595
647
  const map = new Map()
596
648
  for (const abs of yamlAbs) {
597
649
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
598
- if (k8sYamlRelOutsideUaRuOverlays(rel)) {
599
- const pkgAbs = abiePackageDirFromK8sYamlRel(root, rel)
600
- if (pkgAbs) {
601
- let raw
602
- let readOk = false
603
- try {
604
- raw = await readFile(abs, 'utf8')
605
- readOk = true
606
- } catch (error) {
607
- const msg = error instanceof Error ? error.message : String(error)
608
- fail(`${rel}: не вдалося прочитати (${msg})`)
609
- }
610
- if (readOk) {
611
- const body = stripBom(raw)
612
- const lines = body.split(LINE_SPLIT_RE)
613
- const first = lines[0] ?? ''
614
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
615
- /** @type {import('yaml').Document[]} */
616
- let docs
617
- let parseOk = false
618
- try {
619
- docs = parseAllDocuments(rest)
620
- parseOk = true
621
- } catch (error) {
622
- const msg = error instanceof Error ? error.message : String(error)
623
- fail(`${rel}: YAML (${msg})`)
624
- }
625
- if (parseOk) {
626
- for (const doc of docs) {
627
- if (doc.errors.length === 0) {
628
- const obj = doc.toJSON()
629
- if (serviceDocumentRequiresAbieRuNodePortOverlay(obj)) {
630
- const rec = /** @type {Record<string, unknown>} */ (obj)
631
- const meta = /** @type {Record<string, unknown>} */ (rec.metadata)
632
- const n = meta.name
633
- if (typeof n === 'string' && n.trim() !== '') {
634
- let inner = map.get(pkgAbs)
635
- if (!inner) {
636
- inner = new Map()
637
- map.set(pkgAbs, inner)
638
- }
639
- const needClear = serviceDocumentRequiresRuClusterIPNoneRemoval(obj)
640
- const needClusterIPsRemove = serviceDocumentBaseDeclaresClusterIPsField(obj)
641
- const prev = inner.get(n)
642
- inner.set(n, {
643
- requiresClusterIPNoneClear: prev?.requiresClusterIPNoneClear === true || needClear,
644
- requiresClusterIPsRemove: prev?.requiresClusterIPsRemove === true || needClusterIPsRemove
645
- })
646
- }
647
- }
648
- }
649
- }
650
- }
651
- }
652
- }
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)
653
654
  }
654
655
  }
655
656
  return map
@@ -709,38 +710,12 @@ async function collectDeploymentDirs(root, yamlAbs, fail) {
709
710
  /** @type {Set<string>} */
710
711
  const dirs = new Set()
711
712
  for (const abs of yamlAbs) {
712
- let raw
713
- let readOk = false
714
- try {
715
- raw = await readFile(abs, 'utf8')
716
- readOk = true
717
- } catch (error) {
718
- const msg = error instanceof Error ? error.message : String(error)
719
- fail(`${relative(root, abs) || abs}: не вдалося прочитати (${msg})`)
720
- }
721
- if (readOk) {
722
- const body = stripBom(raw)
723
- const lines = body.split(LINE_SPLIT_RE)
724
- const first = lines[0] ?? ''
725
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
726
- /** @type {import('yaml').Document[]} */
727
- let docs
728
- let parseOk = false
729
- try {
730
- docs = parseAllDocuments(rest)
731
- parseOk = true
732
- } catch (error) {
733
- const msg = error instanceof Error ? error.message : String(error)
734
- fail(`${relative(root, abs) || abs}: YAML (${msg})`)
735
- }
736
- if (parseOk) {
737
- for (const doc of docs) {
738
- if (doc.errors.length === 0) {
739
- const obj = doc.toJSON()
740
- if (isDeploymentDoc(obj)) {
741
- dirs.add(dirname(abs))
742
- }
743
- }
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))
744
719
  }
745
720
  }
746
721
  }
@@ -748,6 +723,31 @@ async function collectDeploymentDirs(root, yamlAbs, fail) {
748
723
  return dirs
749
724
  }
750
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
+
751
751
  /**
752
752
  * Для кожного **Deployment** у YAML під **`k8s`** з шляхом **`…/base/…`** вимагає **`spec.template.spec.nodeSelector.preem: true`** (abie.mdc).
753
753
  * @param {string} root корінь репозиторію
@@ -757,48 +757,15 @@ async function collectDeploymentDirs(root, yamlAbs, fail) {
757
757
  * @returns {Promise<void>}
758
758
  */
759
759
  async function ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFilesAbs, fail, passFn) {
760
- const baseFiles = yamlFilesAbs.filter(abs => {
761
- const rel = relative(root, abs).replaceAll('\\', '/') || abs
762
- return isAbieK8sBaseYamlPath(rel)
763
- })
760
+ const baseFiles = yamlFilesAbs.filter(abs => isAbieK8sBaseYamlPath(relative(root, abs).replaceAll('\\', '/') || abs))
764
761
  let anyBaseDeployment = false
765
762
  for (const abs of baseFiles) {
766
763
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
767
- let raw
768
- try {
769
- raw = await readFile(abs, 'utf8')
770
- } catch (error) {
771
- const msg = error instanceof Error ? error.message : String(error)
772
- fail(`${rel}: не вдалося прочитати (${msg})`)
773
- return
774
- }
775
- const body = stripBom(raw)
776
- const lines = body.split(LINE_SPLIT_RE)
777
- const first = lines[0] ?? ''
778
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
779
- /** @type {import('yaml').Document[]} */
780
- let docs
781
- try {
782
- docs = parseAllDocuments(rest)
783
- } catch (error) {
784
- const msg = error instanceof Error ? error.message : String(error)
785
- fail(`${rel}: YAML (${msg})`)
786
- return
787
- }
788
- for (const doc of docs) {
789
- if (doc.errors.length === 0) {
790
- const obj = doc.toJSON()
791
- if (isDeploymentDoc(obj)) {
792
- anyBaseDeployment = true
793
- if (!deploymentDocumentHasAbieBasePreemNodeSelector(obj)) {
794
- fail(
795
- `${rel}: Deployment у base: потрібен spec.template.spec.nodeSelector.preem: true (або 'true') — abie.mdc`
796
- )
797
- return
798
- }
799
- }
800
- }
801
- }
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
802
769
  }
803
770
  if (anyBaseDeployment) {
804
771
  passFn('Deployment у …/base/…: nodeSelector.preem відповідає abie.mdc')
@@ -816,6 +783,46 @@ function stripBom(s) {
816
783
  return s.startsWith('\uFEFF') ? s.slice(1) : s
817
784
  }
818
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
+
819
826
  /**
820
827
  * Чи рядок inline JSON6902 patch містить очікуваний **ua** nodeSelector (**preem: false** на **`/spec/template/spec/nodeSelector`**).
821
828
  * Конкретний **`op`** не перевіряється — див. **k8s.mdc**.
@@ -959,6 +966,24 @@ export function isK8sYamlInAbiePackageExcludingUaRuOverlays(relFromRoot, pkgRelF
959
966
  return !after.startsWith('ua/') && !after.startsWith('ru/')
960
967
  }
961
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
+
962
987
  /**
963
988
  * З HTTPRoute-документа рахує **`backendRefs`** до **`auth-run-hl`** / **`filelint-hl`** і порушення **`namespace: dev`**.
964
989
  * @param {unknown} obj корінь YAML
@@ -968,38 +993,20 @@ export function isK8sYamlInAbiePackageExcludingUaRuOverlays(relFromRoot, pkgRelF
968
993
  function httpRouteDocSharedCrossNsBackendStats(obj, rel) {
969
994
  /** @type {string[]} */
970
995
  const errors = []
971
- if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
972
- return { refCount: 0, errors }
973
- }
996
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return { refCount: 0, errors }
974
997
  const rec = /** @type {Record<string, unknown>} */ (obj)
975
- if (rec.kind !== 'HTTPRoute') {
976
- return { refCount: 0, errors }
977
- }
998
+ if (rec.kind !== 'HTTPRoute') return { refCount: 0, errors }
978
999
  const spec = rec.spec
979
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
980
- return { refCount: 0, errors }
981
- }
1000
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return { refCount: 0, errors }
982
1001
  const rules = /** @type {Record<string, unknown>} */ (spec).rules
983
- if (!Array.isArray(rules)) {
984
- return { refCount: 0, errors }
985
- }
1002
+ if (!Array.isArray(rules)) return { refCount: 0, errors }
986
1003
  let refCount = 0
987
1004
  for (const rule of rules) {
988
1005
  if (rule !== null && typeof rule === 'object' && !Array.isArray(rule)) {
989
1006
  const brs = /** @type {Record<string, unknown>} */ (rule).backendRefs
990
1007
  if (Array.isArray(brs)) {
991
1008
  for (const br of brs) {
992
- if (br !== null && typeof br === 'object' && !Array.isArray(br)) {
993
- const brRec = /** @type {Record<string, unknown>} */ (br)
994
- const name = brRec.name
995
- if (typeof name === 'string' && ABIE_SHARED_CROSS_NS_BACKEND_SET.has(name)) {
996
- refCount++
997
- const ns = brRec.namespace
998
- if (typeof ns !== 'string' || ns !== 'dev') {
999
- errors.push(`${rel}: HTTPRoute backendRefs до ${name} має містити namespace: dev (abie.mdc)`)
1000
- }
1001
- }
1002
- }
1009
+ refCount += checkSharedBackendRef(br, rel, errors)
1003
1010
  }
1004
1011
  }
1005
1012
  }
@@ -1022,32 +1029,13 @@ export async function analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yam
1022
1029
  for (const abs of yamlFilesAbs) {
1023
1030
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1024
1031
  if (isK8sYamlInAbiePackageExcludingUaRuOverlays(rel, pkgRel)) {
1025
- let raw
1026
- try {
1027
- raw = await readFile(abs, 'utf8')
1028
- } catch {
1029
- raw = undefined
1030
- }
1031
- if (raw !== undefined) {
1032
- const body = stripBom(raw)
1033
- const lines = body.split(LINE_SPLIT_RE)
1034
- const first = lines[0] ?? ''
1035
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
1036
- /** @type {import('yaml').Document[] | undefined} */
1037
- let docs
1038
- try {
1039
- docs = parseAllDocuments(rest)
1040
- } catch {
1041
- docs = undefined
1042
- }
1043
- if (docs !== undefined) {
1044
- for (const doc of docs) {
1045
- if (doc.errors.length === 0) {
1046
- const obj = doc.toJSON()
1047
- const st = httpRouteDocSharedCrossNsBackendStats(obj, rel)
1048
- refCount += st.refCount
1049
- baseErrors.push(...st.errors)
1050
- }
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)
1051
1039
  }
1052
1040
  }
1053
1041
  }
@@ -1081,43 +1069,38 @@ const ABIE_RU_HTTPROUTE_HOST_MARKERS = [
1081
1069
  '*.выбирайонлайн.рф'
1082
1070
  ]
1083
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
+
1084
1088
  /**
1085
1089
  * Збирає тексти inline **patch** для **HTTPRoute** (будь-який непорожній **target.name**) з одного документа **Kustomization**.
1086
1090
  * @param {import('yaml').Document} doc документ після **parseAllDocuments**
1087
1091
  * @returns {string[]} непорожні рядки **patch**
1088
1092
  */
1089
1093
  function collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc) {
1090
- if (doc.errors.length > 0) {
1091
- return []
1092
- }
1094
+ if (doc.errors.length > 0) return []
1093
1095
  const root = doc.toJSON()
1094
- if (root === null || typeof root !== 'object' || Array.isArray(root)) {
1095
- return []
1096
- }
1096
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return []
1097
1097
  const rec = /** @type {Record<string, unknown>} */ (root)
1098
- if (rec.kind !== 'Kustomization') {
1099
- return []
1100
- }
1101
- const patches = rec.patches
1102
- if (!Array.isArray(patches)) {
1103
- return []
1104
- }
1098
+ if (rec.kind !== 'Kustomization' || !Array.isArray(rec.patches)) return []
1105
1099
  /** @type {string[]} */
1106
1100
  const out = []
1107
- for (const p of patches) {
1108
- if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
1109
- const pr = /** @type {Record<string, unknown>} */ (p)
1110
- const target = pr.target
1111
- if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
1112
- const tg = /** @type {Record<string, unknown>} */ (target)
1113
- if (tg.kind === 'HTTPRoute' && typeof tg.name === 'string' && tg.name.trim() !== '') {
1114
- const patchStr = pr.patch
1115
- if (typeof patchStr === 'string' && patchStr.trim() !== '') {
1116
- out.push(patchStr)
1117
- }
1118
- }
1119
- }
1120
- }
1101
+ for (const p of rec.patches) {
1102
+ const s = extractHttpRoutePatchString(p)
1103
+ if (s !== null) out.push(s)
1121
1104
  }
1122
1105
  return out
1123
1106
  }
@@ -1208,54 +1191,12 @@ export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
1208
1191
  }
1209
1192
 
1210
1193
  /**
1211
- * Перевіряє **hc.yaml** на відповідність abie.mdc.
1212
- * @param {string} raw повний текст файлу
1213
- * @param {string} relPath відносний шлях для повідомлень
1214
- * @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
1215
1198
  */
1216
- export function validateAbieHcYaml(raw, relPath) {
1217
- const body = stripBom(raw)
1218
- const lines = body.split(LINE_SPLIT_RE)
1219
- if (lines.length === 0 || lines[0].trim() === '') {
1220
- return `${relPath}: перший рядок порожній — потрібен # yaml-language-server: $schema=… (abie.mdc)`
1221
- }
1222
- const m = lines[0].match(MODELINE_RE)
1223
- if (!m) {
1224
- return `${relPath}: перший рядок має бути modeline $schema (abie.mdc)`
1225
- }
1226
- if (m[1] !== ABIE_HC_SCHEMA_URL) {
1227
- return `${relPath}: $schema має бути\n ${ABIE_HC_SCHEMA_URL}\n (abie.mdc)`
1228
- }
1229
- const yamlBody = lines
1230
- .slice(1)
1231
- .join('\n')
1232
- .replace(LEADING_EMPTY_LINE_RE, '')
1233
- /** @type {import('yaml').Document[]} */
1234
- let docs
1235
- try {
1236
- docs = parseAllDocuments(yamlBody)
1237
- } catch (error) {
1238
- const msg = error instanceof Error ? error.message : String(error)
1239
- return `${relPath}: не вдалося розібрати YAML (${msg})`
1240
- }
1241
- /** @type {Record<string, unknown> | null} */
1242
- let policy = null
1243
- for (const doc of docs) {
1244
- if (doc.errors.length > 0) {
1245
- return `${relPath}: YAML: ${doc.errors.map(e => e.message).join('; ')}`
1246
- }
1247
- const obj = doc.toJSON()
1248
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1249
- const rec = /** @type {Record<string, unknown>} */ (obj)
1250
- if (rec.kind === 'HealthCheckPolicy') {
1251
- policy = rec
1252
- break
1253
- }
1254
- }
1255
- }
1256
- if (!policy) {
1257
- return `${relPath}: очікується документ kind: HealthCheckPolicy (abie.mdc)`
1258
- }
1199
+ function validateAbieHcPolicy(policy, relPath) {
1259
1200
  if (policy.apiVersion !== 'networking.gke.io/v1') {
1260
1201
  return `${relPath}: apiVersion має бути networking.gke.io/v1 (abie.mdc)`
1261
1202
  }
@@ -1271,7 +1212,8 @@ export function validateAbieHcYaml(raw, relPath) {
1271
1212
  if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
1272
1213
  return `${relPath}: відсутній spec (abie.mdc)`
1273
1214
  }
1274
- const def = /** @type {Record<string, unknown>} */ (spec).default
1215
+ const specRec = /** @type {Record<string, unknown>} */ (spec)
1216
+ const def = specRec.default
1275
1217
  if (def === null || typeof def !== 'object' || Array.isArray(def)) {
1276
1218
  return `${relPath}: відсутній spec.default (abie.mdc)`
1277
1219
  }
@@ -1279,34 +1221,99 @@ export function validateAbieHcYaml(raw, relPath) {
1279
1221
  if (config === null || typeof config !== 'object' || Array.isArray(config)) {
1280
1222
  return `${relPath}: відсутній spec.default.config (abie.mdc)`
1281
1223
  }
1282
- if (config.type !== 'HTTP') {
1283
- return `${relPath}: spec.default.config.type має бути HTTP (abie.mdc)`
1284
- }
1224
+ if (config.type !== 'HTTP') return `${relPath}: spec.default.config.type має бути HTTP (abie.mdc)`
1285
1225
  const httpHc = /** @type {Record<string, unknown>} */ (config).httpHealthCheck
1286
1226
  if (httpHc === null || typeof httpHc !== 'object' || Array.isArray(httpHc)) {
1287
1227
  return `${relPath}: відсутній httpHealthCheck (abie.mdc)`
1288
1228
  }
1289
- if (httpHc.requestPath !== '/healthz') {
1290
- return `${relPath}: httpHealthCheck.requestPath має бути /healthz (abie.mdc)`
1291
- }
1292
- if (httpHc.port !== 8080) {
1293
- return `${relPath}: httpHealthCheck.port має бути 8080 (abie.mdc)`
1294
- }
1295
- 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
1296
1232
  if (targetRef === null || typeof targetRef !== 'object' || Array.isArray(targetRef)) {
1297
1233
  return `${relPath}: відсутній targetRef (abie.mdc)`
1298
1234
  }
1299
- if (targetRef.kind !== 'Service') {
1300
- return `${relPath}: targetRef.kind має бути Service (abie.mdc)`
1301
- }
1302
- const svcName = targetRef.name
1235
+ const tr = /** @type {Record<string, unknown>} */ (targetRef)
1236
+ if (tr.kind !== 'Service') return `${relPath}: targetRef.kind має бути Service (abie.mdc)`
1303
1237
  const expectedHl = name.endsWith('-hl') ? name : `${name}-hl`
1304
- if (typeof svcName !== 'string' || svcName !== expectedHl) {
1238
+ if (typeof tr.name !== 'string' || tr.name !== expectedHl) {
1305
1239
  return `${relPath}: targetRef.name має посилатися на headless Service (очікується ${expectedHl}, суфікс -hl) (abie.mdc)`
1306
1240
  }
1307
1241
  return null
1308
1242
  }
1309
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
+
1310
1317
  /**
1311
1318
  * Збирає відносні шляхи файлів із **HealthCheckPolicy** у дереві k8s.
1312
1319
  * @param {string} root корінь
@@ -1318,34 +1325,8 @@ async function collectHealthCheckPolicyRelPaths(root, yamlAbs) {
1318
1325
  const out = []
1319
1326
  for (const abs of yamlAbs) {
1320
1327
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1321
- let raw
1322
- try {
1323
- raw = await readFile(abs, 'utf8')
1324
- } catch {
1325
- raw = null
1326
- }
1327
- if (raw !== null) {
1328
- const body = stripBom(raw)
1329
- const lines = body.split(LINE_SPLIT_RE)
1330
- const first = lines[0] ?? ''
1331
- const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
1332
- try {
1333
- const docs = parseAllDocuments(rest)
1334
- for (const doc of docs) {
1335
- if (doc.errors.length === 0) {
1336
- const obj = doc.toJSON()
1337
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1338
- const rec = /** @type {Record<string, unknown>} */ (obj)
1339
- if (rec.kind === 'HealthCheckPolicy' && !out.includes(rel)) {
1340
- out.push(rel)
1341
- }
1342
- }
1343
- }
1344
- }
1345
- } catch {
1346
- /* пропускаємо пошкоджені файли — їх ловить check-k8s */
1347
- }
1348
- }
1328
+ const docs = await readAndParseYamlDocs(abs, rel, silentFail)
1329
+ if (docs) collectHealthCheckPolicyRelFromDocs(docs, rel, out)
1349
1330
  }
1350
1331
  return out
1351
1332
  }
@@ -1388,14 +1369,45 @@ async function ensureRuKustomizationHealthCheckDelete(root, yamlFilesAbs, health
1388
1369
  }
1389
1370
 
1390
1371
  /**
1391
- * Якщо є **Deployment** під **k8s**, вимагає в overlay **ua** та **ru** (**kustomization.yaml**) JSON6902 patch nodeSelector (abie.mdc)
1392
- * лише для 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)
1393
1377
  * @param {string} root корінь репозиторію
1394
- * @param {string[]} yamlFilesAbs yaml під k8s
1395
- * @param {Set<string>} deploymentDirs абсолютні каталоги YAML-файлів із **Deployment**
1396
- * @param {(msg: string) => void} fail callback
1397
- * @param {(msg: string) => void} passFn успішне повідомлення
1398
- * @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 при успішній перевірці
1399
1411
  */
1400
1412
  async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, deploymentDirs, fail, passFn) {
1401
1413
  const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
@@ -1407,25 +1419,8 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, deploymentD
1407
1419
  }
1408
1420
  for (const abs of uaAbsList) {
1409
1421
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1410
- if (abieOverlayK8sTreeHasDeployment(deploymentDirs, root, abs)) {
1411
- let raw
1412
- try {
1413
- raw = await readFile(abs, 'utf8')
1414
- } catch (error) {
1415
- const msg = error instanceof Error ? error.message : String(error)
1416
- fail(`${rel}: не вдалося прочитати (${msg})`)
1417
- return
1418
- }
1419
- if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, 'ua')) {
1420
- fail(
1421
- `${rel}: потрібен patch target kind Deployment: path /spec/template/spec/nodeSelector та preem: false (abie.mdc)`
1422
- )
1423
- return
1424
- }
1425
- passFn(`${rel}: nodeSelector patch (ua) відповідає abie.mdc`)
1426
- } else {
1427
- passFn(`${rel}: nodeSelector patch (ua) не застосовується — немає Deployment у дереві k8s цього пакета (abie)`)
1428
- }
1422
+ const ok = await checkNodeSelectorKustomization(abs, rel, 'ua', deploymentDirs, root, fail, passFn)
1423
+ if (!ok) return
1429
1424
  }
1430
1425
 
1431
1426
  const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
@@ -1437,26 +1432,59 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, deploymentD
1437
1432
  }
1438
1433
  for (const abs of ruAbsList) {
1439
1434
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1440
- if (abieOverlayK8sTreeHasDeployment(deploymentDirs, root, abs)) {
1441
- let raw
1442
- try {
1443
- raw = await readFile(abs, 'utf8')
1444
- } catch (error) {
1445
- const msg = error instanceof Error ? error.message : String(error)
1446
- fail(`${rel}: не вдалося прочитати (${msg})`)
1447
- return
1448
- }
1449
- if (!kustomizationHasAbieDeploymentNodeSelectorPatch(raw, 'ru')) {
1450
- fail(
1451
- `${rel}: потрібен patch target kind Deployment: path /spec/template/spec/nodeSelector та yandex.cloud/preemptible: false (abie.mdc)`
1452
- )
1453
- return
1454
- }
1455
- passFn(`${rel}: nodeSelector patch (ru) відповідає abie.mdc`)
1456
- } else {
1457
- passFn(`${rel}: nodeSelector patch (ru) не застосовується немає Deployment у дереві k8s цього пакета (abie)`)
1458
- }
1435
+ const ok = await checkNodeSelectorKustomization(abs, rel, 'ru', deploymentDirs, root, fail, passFn)
1436
+ if (!ok) return
1437
+ }
1438
+ }
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
1459
1485
  }
1486
+ passFn(`${rel}: HTTPRoute patch (${mode}) відповідає abie.mdc`)
1487
+ return true
1460
1488
  }
1461
1489
 
1462
1490
  /**
@@ -1464,25 +1492,12 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, deploymentD
1464
1492
  * лише для пакетів з **vite.config.{js,mjs,ts}** у каталозі пакета (батько **k8s**).
1465
1493
  * @param {string} root корінь репозиторію
1466
1494
  * @param {string[]} yamlFilesAbs yaml під k8s
1467
- * @param {(msg: string) => void} fail callback
1468
- * @param {(msg: string) => void} passFn успішне повідомлення
1469
- * @returns {Promise<void>}
1495
+ * @param {(msg: string) => void} fail callback при помилці
1496
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
1470
1497
  */
1471
1498
  async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn) {
1472
1499
  /** @type {Map<string, Promise<{ refCount: number, baseErrors: string[] }>>} */
1473
- const sharedBackendAnalysisByPkg = new Map()
1474
- /**
1475
- * @param {string} pkgAbs абсолютний шлях до каталогу пакета
1476
- * @returns {Promise<{ refCount: number, baseErrors: string[] }>} кількість посилань і базові помилки
1477
- */
1478
- const getSharedBackendAnalysis = pkgAbs => {
1479
- let p = sharedBackendAnalysisByPkg.get(pkgAbs)
1480
- if (!p) {
1481
- p = analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yamlFilesAbs)
1482
- sharedBackendAnalysisByPkg.set(pkgAbs, p)
1483
- }
1484
- return p
1485
- }
1500
+ const cache = new Map()
1486
1501
 
1487
1502
  const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
1488
1503
  if (uaAbsList.length === 0) {
@@ -1492,35 +1507,8 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
1492
1507
  }
1493
1508
  for (const abs of uaAbsList) {
1494
1509
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1495
- if (abieOverlayRequiresHttpRouteByVite(root, abs)) {
1496
- const pkgAbs = abiePackageDirFromK8sOverlay(root, abs)
1497
- if (!pkgAbs) {
1498
- fail(`${rel}: внутрішня помилка abie overlay (немає каталогу пакета)`)
1499
- return
1500
- }
1501
- const sharedAnalysis = await getSharedBackendAnalysis(pkgAbs)
1502
- for (const err of sharedAnalysis.baseErrors) {
1503
- fail(err)
1504
- return
1505
- }
1506
- let raw
1507
- try {
1508
- raw = await readFile(abs, 'utf8')
1509
- } catch (error) {
1510
- const msg = error instanceof Error ? error.message : String(error)
1511
- fail(`${rel}: не вдалося прочитати (${msg})`)
1512
- return
1513
- }
1514
- const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
1515
- const v = validateAbieNginxRunHttpRoutePatches(combined, 'ua', raw, sharedAnalysis.refCount)
1516
- if (v !== null) {
1517
- fail(`${rel}: ${v}`)
1518
- return
1519
- }
1520
- passFn(`${rel}: HTTPRoute patch (ua) відповідає abie.mdc`)
1521
- } else {
1522
- passFn(`${rel}: HTTPRoute patch (ua) не застосовується — немає vite.config.{js,mjs,ts} у пакеті (abie)`)
1523
- }
1510
+ const ok = await checkHttpRouteKustomization(abs, rel, 'ua', root, yamlFilesAbs, cache, fail, passFn)
1511
+ if (!ok) return
1524
1512
  }
1525
1513
 
1526
1514
  const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
@@ -1531,35 +1519,8 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
1531
1519
  }
1532
1520
  for (const abs of ruAbsList) {
1533
1521
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1534
- if (abieOverlayRequiresHttpRouteByVite(root, abs)) {
1535
- const pkgAbs = abiePackageDirFromK8sOverlay(root, abs)
1536
- if (!pkgAbs) {
1537
- fail(`${rel}: внутрішня помилка abie overlay (немає каталогу пакета)`)
1538
- return
1539
- }
1540
- const sharedAnalysis = await getSharedBackendAnalysis(pkgAbs)
1541
- for (const err of sharedAnalysis.baseErrors) {
1542
- fail(err)
1543
- return
1544
- }
1545
- let raw
1546
- try {
1547
- raw = await readFile(abs, 'utf8')
1548
- } catch (error) {
1549
- const msg = error instanceof Error ? error.message : String(error)
1550
- fail(`${rel}: не вдалося прочитати (${msg})`)
1551
- return
1552
- }
1553
- const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
1554
- const v = validateAbieNginxRunHttpRoutePatches(combined, 'ru', raw, sharedAnalysis.refCount)
1555
- if (v !== null) {
1556
- fail(`${rel}: ${v}`)
1557
- return
1558
- }
1559
- passFn(`${rel}: HTTPRoute patch (ru) відповідає abie.mdc`)
1560
- } else {
1561
- passFn(`${rel}: HTTPRoute patch (ru) не застосовується — немає vite.config.{js,mjs,ts} у пакеті (abie)`)
1562
- }
1522
+ const ok = await checkHttpRouteKustomization(abs, rel, 'ru', root, yamlFilesAbs, cache, fail, passFn)
1523
+ if (!ok) return
1563
1524
  }
1564
1525
  }
1565
1526
 
@@ -1591,6 +1552,85 @@ function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
1591
1552
  * Перевіряє відповідність проєкту правилам abie.mdc.
1592
1553
  * @returns {Promise<number>} 0 — OK, 1 — є порушення
1593
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
+ */
1594
1634
  export async function check() {
1595
1635
  const reporter = createCheckReporter()
1596
1636
  const { pass, fail } = reporter
@@ -1604,67 +1644,14 @@ export async function check() {
1604
1644
 
1605
1645
  pass('Правило abie увімкнено — виконуємо перевірки')
1606
1646
  ensureNoFirebaseHostingArtifacts(root, pass, fail)
1607
-
1608
- const cleanMergedPath = join(root, '.github/workflows/clean-merged-branch.yml')
1609
- if (existsSync(cleanMergedPath)) {
1610
- /** @type {string | undefined} */
1611
- let wfRaw
1612
- try {
1613
- wfRaw = await readFile(cleanMergedPath, 'utf8')
1614
- } catch (error) {
1615
- const msg = error instanceof Error ? error.message : String(error)
1616
- fail(`Не вдалося прочитати clean-merged-branch.yml (${msg})`)
1617
- }
1618
- if (wfRaw !== undefined) {
1619
- const ib = parseCleanMergedIgnoreBranches(wfRaw)
1620
- if (ib === null || ib.trim() === '') {
1621
- fail(
1622
- 'clean-merged-branch.yml: не знайдено with.ignore_branches у кроці phpdocker-io/github-actions-delete-abandoned-branches (abie.mdc)'
1623
- )
1624
- } else if (ignoreBranchesIncludesRequired(ib, ABIE_REQUIRED_IGNORE_BRANCHES)) {
1625
- pass('clean-merged-branch.yml: ignore_branches містить dev, ua, ru')
1626
- } else {
1627
- fail(
1628
- `clean-merged-branch.yml: ignore_branches має містити dev, ua та ru (зараз: ${JSON.stringify(ib)}) — abie.mdc`
1629
- )
1630
- }
1631
- }
1632
- } else {
1633
- fail(`Відсутній ${cleanMergedPath} — потрібен для ignore_branches (abie.mdc)`)
1634
- }
1647
+ await checkCleanMergedBranch(root, pass, fail)
1635
1648
 
1636
1649
  const yamlFiles = await findK8sYamlFiles(root)
1637
1650
  const deploymentDirs = await collectDeploymentDirs(root, yamlFiles, fail)
1638
1651
 
1639
1652
  if (deploymentDirs.size > 0) {
1640
1653
  pass(`Знайдено Deployment у ${deploymentDirs.size} директорія(ї/й) k8s — перевіряємо hc.yaml`)
1641
- for (const dir of [...deploymentDirs].toSorted((a, b) => a.localeCompare(b))) {
1642
- const hcAbs = join(dir, 'hc.yaml')
1643
- const relHc = relative(root, hcAbs).replaceAll('\\', '/') || 'hc.yaml'
1644
- if (existsSync(hcAbs)) {
1645
- let hcRaw
1646
- let hcReadOk = false
1647
- try {
1648
- hcRaw = await readFile(hcAbs, 'utf8')
1649
- hcReadOk = true
1650
- } catch (error) {
1651
- const msg = error instanceof Error ? error.message : String(error)
1652
- fail(`${relHc}: не вдалося прочитати (${msg})`)
1653
- }
1654
- if (hcReadOk) {
1655
- const v = validateAbieHcYaml(hcRaw, relHc)
1656
- if (v === null) {
1657
- pass(`${relHc}: відповідає abie.mdc`)
1658
- } else {
1659
- fail(v)
1660
- }
1661
- }
1662
- } else {
1663
- fail(
1664
- `${relative(root, dir) || dir}: є Deployment, але немає hc.yaml поруч — додай HealthCheckPolicy (abie.mdc)`
1665
- )
1666
- }
1667
- }
1654
+ await checkHcYamlFiles(root, deploymentDirs, pass, fail)
1668
1655
  pass('Є Deployment — перевіряємо base: spec.template.spec.nodeSelector.preem (abie.mdc)')
1669
1656
  await ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFiles, fail, pass)
1670
1657
  } else {