@nitra/cursor 1.8.103 → 1.8.105

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.
@@ -45,6 +45,11 @@
45
45
  * **Inline JSON6902** у **`patches`** (і зовнішні файли з **`patches[].path`** під **`k8s`**, якщо вміст — масив JSON Patch): не допускається пара **`remove`** і **`add`**
46
46
  * на один і той самий **`path`** у межах одного фрагмента — потрібен **`op: replace`** (k8s.mdc). **check-k8s** це перевіряє.
47
47
  *
48
+ * **Мішень patch:** у **`patches[].target`** і **`patchesJson6902[].target`** (без **labelSelector** / **annotationSelector**)
49
+ * має існувати відповідний ресурс у зібраному з **`resources`**, **`bases`**, **`components`**, **`crds`** каталозі (рекурсивно для підкаталогів з **`kustomization.yaml`**).
50
+ * Для **`patchesStrategicMerge`** і для **`patches[].path`** без **`target`** і без inline **`patch`** (зовнішній strategic-merge)
51
+ * кожен YAML-документ з кореневим **`kind`** і **`metadata.name`** також звіряється з цим каталогом.
52
+ *
48
53
  * Явні винятки до загальної логіки yannh/datree — таблиця **`EXPLICIT_K8S_SCHEMAS`** (`Map`): ключ
49
54
  * **`apiVersion`, `kind`, `type`** (для CRD без поля `type` у маніфесті — зірочка **`*`** як третій
50
55
  * компонент). Спочатку шукається збіг за фактичним `type`, потім за **`*`**.
@@ -137,15 +142,38 @@ const EXPLICIT_K8S_SCHEMAS = new Map([
137
142
  ]
138
143
  ])
139
144
 
145
+ /**
146
+ * Прибирає зовнішні лапки зі скаляра YAML (`"x"` / `'x'`), якщо вони парні.
147
+ * @param {string | undefined} raw значення з `match(…)[1]` або подібне
148
+ * @returns {string | undefined} рядок без лапок або undefined, якщо вхід undefined
149
+ */
150
+ function trimYamlScalarQuotes(raw) {
151
+ if (raw === undefined) {
152
+ return
153
+ }
154
+ const s = String(raw)
155
+ if (s.length >= 2 && ((s[0] === '"' && s.at(-1) === '"') || (s[0] === "'" && s.at(-1) === "'"))) {
156
+ return s.slice(1, -1)
157
+ }
158
+ return s
159
+ }
160
+
140
161
  /**
141
162
  * Витягує кореневе поле **`type:`** з документа (без повного YAML-парсера).
142
163
  * @param {string} doc фрагмент YAML одного документа
143
164
  * @returns {string | undefined} значення без лапок або undefined, якщо поля немає
144
165
  */
145
166
  function extractTopLevelManifestType(doc) {
146
- const m = doc.match(/^\s*type:\s*(\S+)\s*$/mu)
147
- const raw = m?.[1]?.replaceAll(/^["']|["']$/gu, '')
148
- return raw === undefined || raw === '' ? undefined : raw
167
+ for (const line of doc.split(YAML_LINE_SPLIT_RE)) {
168
+ const m = line.match(TYPE_FIELD_RE)
169
+ if (m) {
170
+ const raw = trimYamlScalarQuotes(m[1])
171
+ if (raw === undefined || raw === '') {
172
+ return
173
+ }
174
+ return raw
175
+ }
176
+ }
149
177
  }
150
178
 
151
179
  /**
@@ -193,6 +221,22 @@ const YANNH_GROUPS = new Set([
193
221
  ])
194
222
 
195
223
  const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
224
+ const PATH_SPLIT_RE = /[/\\]/u
225
+ const YAML_EXTENSION_RE = /\.ya?ml$/iu
226
+ const YAML_LINE_SPLIT_RE = /\r?\n/u
227
+ const API_VERSION_FIELD_RE = /^\s*apiVersion:\s*(\S+)\s*$/
228
+ const KIND_FIELD_RE = /^\s*kind:\s*(\S+)\s*$/
229
+ const TYPE_FIELD_RE = /^\s*type:\s*(\S+)\s*$/
230
+ const YAML_DOC_SEPARATOR_LINE_RE = /^---\s*$/
231
+ const HEALTHCHECK_DELETE_RE = /\$patch:\s*delete/u
232
+ const HEALTHCHECK_KIND_RE = /kind:\s*HealthCheckPolicy/u
233
+ const METADATA_LINE_RE = /metadata:/u
234
+ const NAME_NON_EMPTY_RE = /name:\s*\S+/u
235
+ const K8S_BASE_KUSTOMIZATION_PATH_RE = /(^|\/)k8s\/base\/kustomization\.yaml$/u
236
+ const K8S_BASE_SEGMENT_RE = /(^|\/)k8s\/base\//u
237
+ const OXLINT_SCHEMA_MODELINE_RE = /^\s*#\s*yaml-language-server:\s*\$schema=\S+/u
238
+ const HTTPS_SCHEMA_RE = /^https:/iu
239
+ const HASURA_GRAPHQL_ENGINE_RE = /(^|\/)hasura\/graphql-engine(?::|$)/u
196
240
 
197
241
  /**
198
242
  * Чи містить шлях сегмент директорії `k8s` (рівно ця назва компонента).
@@ -200,7 +244,7 @@ const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
200
244
  * @returns {boolean} true, якщо серед компонентів шляху є каталог `k8s`
201
245
  */
202
246
  export function pathHasK8sSegment(filePath) {
203
- const parts = filePath.split(/[/\\]/u)
247
+ const parts = filePath.split(PATH_SPLIT_RE)
204
248
  return parts.includes('k8s')
205
249
  }
206
250
 
@@ -342,6 +386,45 @@ export function kustomizationSvcYamlMissingSvcHlViolation(kustomizationDir, path
342
386
  return null
343
387
  }
344
388
 
389
+ /**
390
+ * Один файл **`kustomization.yaml`**: **`svc.yaml`** у шляхах має мати парний **`svc-hl.yaml`**.
391
+ * @param {string} root корінь репозиторію
392
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
393
+ * @param {(msg: string) => void} fail реєстрація помилки
394
+ * @returns {Promise<void>}
395
+ */
396
+ async function validateOneKustomizationSvcHlWithSvc(root, kustAbs, fail) {
397
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
398
+ let raw
399
+ try {
400
+ raw = await readFile(kustAbs, 'utf8')
401
+ } catch (error) {
402
+ const msg = error instanceof Error ? error.message : String(error)
403
+ fail(`${rel}: не вдалося прочитати для перевірки svc.yaml/svc-hl.yaml у kustomization (${msg})`)
404
+ return
405
+ }
406
+ const lines = toLines(raw)
407
+ const body = yamlBodyAfterModeline(lines)
408
+ /** @type {import('yaml').Document[] | undefined} */
409
+ let docs
410
+ try {
411
+ docs = parseAllDocuments(body)
412
+ } catch {
413
+ fail(`${rel}: не вдалося розпарсити YAML для перевірки svc.yaml/svc-hl.yaml у kustomization (див. k8s.mdc)`)
414
+ return
415
+ }
416
+ const first = docs[0]?.toJSON()
417
+ if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
418
+ return
419
+ }
420
+ const pathRefs = pathsFromKustomizationObject(first)
421
+ const kustDir = dirname(kustAbs)
422
+ const v = kustomizationSvcYamlMissingSvcHlViolation(kustDir, pathRefs)
423
+ if (v !== null) {
424
+ fail(`${rel}: ${v}`)
425
+ }
426
+ }
427
+
345
428
  /**
346
429
  * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: разом із **`svc.yaml`** має бути **`svc-hl.yaml`** у полях шляхів.
347
430
  * @param {string} root корінь репозиторію
@@ -350,39 +433,8 @@ export function kustomizationSvcYamlMissingSvcHlViolation(kustomizationDir, path
350
433
  * @returns {Promise<void>}
351
434
  */
352
435
  async function validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail) {
353
- for (const kustAbs of yamlFiles) {
354
- if (basename(kustAbs).toLowerCase() === 'kustomization.yaml') {
355
- const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
356
- let raw
357
- try {
358
- raw = await readFile(kustAbs, 'utf8')
359
- } catch (error) {
360
- const msg = error instanceof Error ? error.message : String(error)
361
- fail(`${rel}: не вдалося прочитати для перевірки svc.yaml/svc-hl.yaml у kustomization (${msg})`)
362
- }
363
- if (raw !== undefined) {
364
- const lines = toLines(raw)
365
- const body = yamlBodyAfterModeline(lines)
366
- /** @type {import('yaml').Document[] | undefined} */
367
- let docs
368
- try {
369
- docs = parseAllDocuments(body)
370
- } catch {
371
- fail(`${rel}: не вдалося розпарсити YAML для перевірки svc.yaml/svc-hl.yaml у kustomization (див. k8s.mdc)`)
372
- }
373
- if (docs !== undefined) {
374
- const first = docs[0]?.toJSON()
375
- if (first !== null && first !== undefined && typeof first === 'object' && !Array.isArray(first)) {
376
- const pathRefs = pathsFromKustomizationObject(first)
377
- const kustDir = dirname(kustAbs)
378
- const v = kustomizationSvcYamlMissingSvcHlViolation(kustDir, pathRefs)
379
- if (v !== null) {
380
- fail(`${rel}: ${v}`)
381
- }
382
- }
383
- }
384
- }
385
- }
436
+ for (const kustAbs of yamlFiles.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
437
+ await validateOneKustomizationSvcHlWithSvc(root, kustAbs, fail)
386
438
  }
387
439
  }
388
440
 
@@ -436,32 +488,45 @@ export async function collectKustomizeManagedRelPaths(root, yamlFilesAbs) {
436
488
  const kustDir = dirname(normKust)
437
489
  const pathRefs = pathsFromKustomizationObject(first)
438
490
 
439
- for (const ref of pathRefs) {
440
- if (!ref.includes('://')) {
441
- const resolved = resolve(kustDir, ref)
442
- let st
443
- try {
444
- st = await stat(resolved)
445
- } catch {
446
- st = undefined
447
- }
448
- if (st) {
449
- if (st.isFile()) {
450
- if (/\.ya?ml$/iu.test(resolved)) {
451
- const pr = posixRelFromAbs(root, resolved)
452
- if (pr !== null) managed.add(pr)
453
- }
454
- } else if (st.isDirectory()) {
455
- const childK = existsSync(join(resolved, 'kustomization.yaml'))
456
- ? join(resolved, 'kustomization.yaml')
457
- : null
458
- if (childK !== null) {
459
- await walkKustomization(childK)
460
- }
491
+ /**
492
+ * @param {string} ref шлях з kustomization
493
+ * @returns {Promise<void>}
494
+ */
495
+ async function handleKustomizeManagedPathRef(ref) {
496
+ if (ref.includes('://')) {
497
+ return
498
+ }
499
+ const resolved = resolve(kustDir, ref)
500
+ let st
501
+ try {
502
+ st = await stat(resolved)
503
+ } catch {
504
+ st = undefined
505
+ }
506
+ if (!st) {
507
+ return
508
+ }
509
+ if (st.isFile()) {
510
+ if (YAML_EXTENSION_RE.test(resolved)) {
511
+ const pr = posixRelFromAbs(root, resolved)
512
+ if (pr !== null) {
513
+ managed.add(pr)
461
514
  }
462
515
  }
516
+ return
517
+ }
518
+ if (!st.isDirectory()) {
519
+ return
520
+ }
521
+ const childK = existsSync(join(resolved, 'kustomization.yaml')) ? join(resolved, 'kustomization.yaml') : null
522
+ if (childK !== null) {
523
+ await walkKustomization(childK)
463
524
  }
464
525
  }
526
+
527
+ for (const ref of pathRefs) {
528
+ await handleKustomizeManagedPathRef(ref)
529
+ }
465
530
  }
466
531
 
467
532
  for (const k of kustomizationAbsList) {
@@ -472,235 +537,916 @@ export async function collectKustomizeManagedRelPaths(root, yamlFilesAbs) {
472
537
  }
473
538
 
474
539
  /**
475
- * Чи це **`k8s/base/kustomization.yaml`** (перевірка обов’язкового непорожнього **`namespace:`**).
476
- * @param {string} rel шлях від кореня репозиторію
477
- * @returns {boolean} true для шляху виду `…/k8s/base/kustomization.yaml`
540
+ * Шляхи лише з полів ресурсів Kustomization (**без** patch-файлів).
541
+ * @param {unknown} obj корінь першого документа Kustomization
542
+ * @returns {string[]} відносні посилання
478
543
  */
479
- export function isBaseKustomizationPath(rel) {
480
- const n = rel.replaceAll('\\', '/')
481
- return /(^|\/)k8s\/base\/kustomization\.yaml$/u.test(n)
544
+ function resourcePathRefsFromKustomizationObject(obj) {
545
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return []
546
+ const rec = /** @type {Record<string, unknown>} */ (obj)
547
+ /** @type {string[]} */
548
+ const out = []
549
+ pushStringPaths(rec.resources, out)
550
+ pushStringPaths(rec.bases, out)
551
+ pushStringPaths(rec.components, out)
552
+ pushStringPaths(rec.crds, out)
553
+ return out
482
554
  }
483
555
 
484
556
  /**
485
- * Чи є в Kustomization для **`base`** завжди обов’язкове непорожнє поле **`namespace:`** (k8s.mdc).
486
- * @param {unknown} obj перший документ YAML
487
- * @returns {string | null} текст порушення або null, якщо ок
557
+ * Дескриптор ресурсу для звірки з **`target`** Kustomize / strategic-merge фрагментом.
558
+ * @typedef {{ group: string, version: string, kind: string, name: string, namespace: string }} KustomizeResourceDescriptor
488
559
  */
489
- export function baseKustomizationNamespaceViolation(obj) {
490
- if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
491
- return 'у base/kustomization.yaml завжди має бути непорожній namespace: (див. k8s.mdc)'
492
- }
493
- const rec = /** @type {Record<string, unknown>} */ (obj)
494
- const ns = rec.namespace
495
- if (typeof ns === 'string' && ns.trim() !== '') {
496
- return null
497
- }
498
- return 'у base/kustomization.yaml завжди додай непорожній namespace: (наприклад namespace: dev; див. k8s.mdc)'
499
- }
500
560
 
501
561
  /**
502
- * Збирає всі `*.yaml` та `*.yml` під деревом від кореня cwd, якщо шлях містить сегмент `k8s` (для `.yml` далі — помилка перейменування).
503
- * @param {string} root корінь репозиторію (cwd)
504
- * @returns {Promise<string[]>} відсортовані абсолютні шляхи до файлів
562
+ * Розбиває **`apiVersion`** Kubernetes на **group** і **version**.
563
+ * @param {unknown} apiVersion значення з YAML
564
+ * @returns {{ group: string, version: string }} для `group/version` два сегменти; для `v1` — core (**group** порожній).
505
565
  */
506
- async function findK8sYamlFiles(root) {
507
- /** @type {string[]} */
508
- const out = []
509
- await walkDir(root, p => {
510
- if (!pathHasK8sSegment(p)) return
511
- if (!/\.ya?ml$/iu.test(p)) return
512
- out.push(p)
513
- })
514
-
515
- return out.toSorted((a, b) => a.localeCompare(b))
566
+ export function splitK8sApiVersion(apiVersion) {
567
+ if (typeof apiVersion !== 'string') {
568
+ return { group: '', version: '' }
569
+ }
570
+ const t = apiVersion.trim()
571
+ if (t === '') {
572
+ return { group: '', version: '' }
573
+ }
574
+ const i = t.indexOf('/')
575
+ if (i === -1) {
576
+ return { group: '', version: t }
577
+ }
578
+ return { group: t.slice(0, i), version: t.slice(i + 1) }
516
579
  }
517
580
 
518
581
  /**
519
- * Тіло YAML для політик (Ingress, BackendConfig тощо): якщо перший рядок modeline `$schema`, береться вміст після нього.
520
- * @param {string[]} lines рядки файлу
521
- * @returns {string} фрагмент для `parseAllDocuments`
582
+ * Чи patch-**target** використовує **labelSelector** / **annotationSelector** (тоді статична перевірка за іменем не застосовується).
583
+ * @param {Record<string, unknown>} t об’єкт **target**
584
+ * @returns {boolean} true, якщо є непорожній селектор
522
585
  */
523
- function k8sYamlBodyForDocumentParse(lines) {
524
- if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
525
- return yamlBodyAfterModeline(lines)
586
+ function patchTargetUsesSelector(t) {
587
+ const ls = t.labelSelector
588
+ if (
589
+ ls !== undefined &&
590
+ ls !== null &&
591
+ ls !== '' &&
592
+ ((typeof ls === 'object' && !Array.isArray(ls) && Object.keys(ls).length > 0) ||
593
+ (typeof ls === 'string' && ls.trim() !== ''))
594
+ ) {
595
+ return true
526
596
  }
527
- return lines.join('\n')
597
+ const asel = t.annotationSelector
598
+ if (
599
+ asel !== undefined &&
600
+ asel !== null &&
601
+ asel !== '' &&
602
+ ((typeof asel === 'object' && !Array.isArray(asel) && Object.keys(asel).length > 0) ||
603
+ (typeof asel === 'string' && asel.trim() !== ''))
604
+ ) {
605
+ return true
606
+ }
607
+ return false
528
608
  }
529
609
 
530
610
  /**
531
- * Чи всі нетривіальні документи у тілі **`kind: BackendConfig`**, чи є змішування з іншими kind.
532
- * @param {string} body YAML без обов’язкового modeline (див. `k8sYamlBodyForDocumentParse`)
533
- * @returns {'none' | 'only' | 'mixed' | 'unparsed'} unparsed не вдалося розпарсити YAML
611
+ * Чи варто перевіряти **target** на наявність ресурсу в каталозі (є **kind** і **name**, немає селекторів).
612
+ * @param {unknown} target значення **patches[].target**
613
+ * @returns {boolean} true, якщо перевірка доречна
534
614
  */
535
- export function classifyBackendConfigManifestPresence(body) {
536
- /** @type {import('yaml').Document[]} */
537
- let docs
538
- try {
539
- docs = parseAllDocuments(body)
540
- } catch {
541
- return 'unparsed'
615
+ export function shouldValidateKustomizePatchTarget(target) {
616
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) {
617
+ return false
542
618
  }
543
-
544
- let hasBc = false
545
- let hasOther = false
546
- for (const doc of docs) {
547
- if (doc.errors.length === 0) {
548
- const obj = doc.toJSON()
549
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
550
- const kind = obj.kind
551
- if (kind === 'BackendConfig') {
552
- hasBc = true
553
- } else if (kind !== undefined && kind !== null && String(kind).trim() !== '') {
554
- hasOther = true
555
- }
556
- }
557
- }
619
+ const t = /** @type {Record<string, unknown>} */ (target)
620
+ const kind = t.kind
621
+ const name = t.name
622
+ if (typeof kind !== 'string' || kind.trim() === '' || typeof name !== 'string' || name.trim() === '') {
623
+ return false
558
624
  }
559
-
560
- if (!hasBc) return 'none'
561
- if (hasOther) return 'mixed'
562
- return 'only'
625
+ return !patchTargetUsesSelector(t)
563
626
  }
564
627
 
565
628
  /**
566
- * Видаляє під **`k8s`** YAML-файли, що містять **лише** ресурси **BackendConfig**; змішані файли `fail`.
567
- * @param {string} root корінь репозиторію
568
- * @param {(msg: string) => void} fail реєстрація порушення
569
- * @param {(msg: string) => void} pass реєстрація успіху
570
- * @returns {Promise<void>}
629
+ * Чи **target** Kustomize відповідає дескриптору ресурсу (узгоджено з правилами відбору Kustomize: пропущені поля **target** не звужують).
630
+ * @param {unknown} target об’єкт **target**
631
+ * @param {KustomizeResourceDescriptor} res дескриптор з інвентарю
632
+ * @returns {boolean} true, якщо збігається
571
633
  */
572
- async function removeBackendConfigOnlyK8sYamlFiles(root, fail, pass) {
573
- const yamlFiles = await findK8sYamlFiles(root)
574
- for (const abs of yamlFiles) {
575
- const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
576
- try {
577
- const raw = await readFile(abs, 'utf8')
578
- const lines = toLines(raw)
579
- const body = k8sYamlBodyForDocumentParse(lines)
580
- const bcPresence = classifyBackendConfigManifestPresence(body)
581
-
582
- if (bcPresence === 'mixed') {
583
- fail(
584
- `${rel}: у файлі разом BackendConfig та інші kind — винеси BackendConfig окремо або прибери вручну; автоматичне видалення не застосовується (див. k8s.mdc)`
585
- )
586
- } else if (bcPresence === 'only') {
587
- try {
588
- await unlink(abs)
589
- pass(`${rel}: видалено (лише kind: BackendConfig; див. k8s.mdc)`)
590
- } catch (error) {
591
- fail(`${rel}: не вдалося видалити BackendConfig-файл (${error.message})`)
592
- }
593
- }
594
- } catch (error) {
595
- fail(`${rel}: не вдалося прочитати для перевірки BackendConfig (${error.message})`)
596
- }
634
+ export function kustomizePatchTargetMatchesDescriptor(target, res) {
635
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) {
636
+ return false
637
+ }
638
+ const rec = /** @type {Record<string, unknown>} */ (target)
639
+ const tk = rec.kind
640
+ const tn = rec.name
641
+ if (typeof tk !== 'string' || typeof tn !== 'string') {
642
+ return false
597
643
  }
644
+ if (tk.trim() !== res.kind || tn.trim() !== res.name) {
645
+ return false
646
+ }
647
+ const tgtGroup = rec.group
648
+ if (typeof tgtGroup === 'string' && tgtGroup.trim() !== '' && res.group !== tgtGroup.trim()) {
649
+ return false
650
+ }
651
+ const tgtVersion = rec.version
652
+ if (typeof tgtVersion === 'string' && tgtVersion.trim() !== '' && res.version !== tgtVersion.trim()) {
653
+ return false
654
+ }
655
+ const tgtNs = rec.namespace
656
+ if (typeof tgtNs === 'string' && tgtNs.trim() !== '' && res.namespace !== tgtNs.trim()) {
657
+ return false
658
+ }
659
+ return true
598
660
  }
599
661
 
600
662
  /**
601
- * Прибирає BOM і ділить на рядки.
602
- * @param {string} content вміст файлу
603
- * @returns {string[]} рядки без BOM на початку
663
+ * Чи є в каталозі ресурс, який задовольняє **target**.
664
+ * @param {KustomizeResourceDescriptor[]} catalog зібрані дескриптори
665
+ * @param {unknown} target об’єкт **target**
666
+ * @returns {boolean} true, якщо перевірка не потрібна або знайдено збіг
604
667
  */
605
- function toLines(content) {
606
- const body = content.startsWith('\uFEFF') ? content.slice(1) : content
607
- return body.split(/\r?\n/u)
668
+ export function kustomizeResourceCatalogMatchesPatchTarget(catalog, target) {
669
+ if (!shouldValidateKustomizePatchTarget(target)) {
670
+ return true
671
+ }
672
+ return catalog.some(res => kustomizePatchTargetMatchesDescriptor(target, res))
608
673
  }
609
674
 
610
675
  /**
611
- * Вміст після першого рядка (modeline), без провідних порожніх рядків.
612
- * @param {string[]} lines рядки файлу
613
- * @returns {string} тіло для парсингу першого YAML-документа
676
+ * Чи два дескриптори повністю збігаються (для strategic-merge фрагмента).
677
+ * @param {KustomizeResourceDescriptor} a перший
678
+ * @param {KustomizeResourceDescriptor} b другий
679
+ * @returns {boolean} true, якщо всі поля однакові
614
680
  */
615
- function yamlBodyAfterModeline(lines) {
616
- let i = 1
617
- while (i < lines.length && lines[i].trim() === '') i++
618
- return lines.slice(i).join('\n')
681
+ export function kustomizeResourceDescriptorsIdentityEqual(a, b) {
682
+ return (
683
+ a.group === b.group &&
684
+ a.version === b.version &&
685
+ a.kind === b.kind &&
686
+ a.name === b.name &&
687
+ a.namespace === b.namespace
688
+ )
619
689
  }
620
690
 
621
691
  /**
622
- * Читає k8s YAML і повертає фрагмент після modeline `$schema`, якщо перший рядок — modeline.
623
- * Потрібно для парної перевірки **`svc.yaml`** / **`svc-hl.yaml`**.
624
- * @param {string} abs абсолютний шлях до файлу
625
- * @returns {Promise<string>} тіло для `parseAllDocuments`
692
+ * Непорожнє **`metadata.name`**, якщо задано коректно.
693
+ * @param {unknown} meta значення **metadata**
694
+ * @returns {string} ім’я або порожній рядок
626
695
  */
627
- async function readK8sYamlBodyAfterModelineForSvcPair(abs) {
628
- const raw = await readFile(abs, 'utf8')
629
- const lines = toLines(raw)
630
- if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
631
- return yamlBodyAfterModeline(lines)
696
+ function metadataNameTrimmed(meta) {
697
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
698
+ return ''
632
699
  }
633
- return lines.join('\n')
700
+ const n = /** @type {Record<string, unknown>} */ (meta).name
701
+ return typeof n === 'string' && n.trim() !== '' ? n.trim() : ''
634
702
  }
635
703
 
636
704
  /**
637
- * Розбирає YAML на корені документів (ігнорує зламані документи).
638
- * @param {string} body фрагмент YAML
639
- * @returns {unknown[]} масив успішно розібраних коренів YAML-документів
705
+ * Непорожній **`metadata.namespace`**, якщо задано коректно.
706
+ * @param {unknown} meta значення **metadata**
707
+ * @returns {string} namespace або порожній рядок
640
708
  */
641
- function parseK8sYamlDocumentObjectRoots(body) {
642
- try {
643
- return parseAllDocuments(body)
644
- .filter(d => d.errors.length === 0)
645
- .map(d => d.toJSON())
646
- .filter(x => x !== null && x !== undefined && typeof x === 'object' && !Array.isArray(x))
647
- } catch {
648
- return []
709
+ function metadataNamespaceTrimmed(meta) {
710
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
711
+ return ''
649
712
  }
713
+ const ns = /** @type {Record<string, unknown>} */ (meta).namespace
714
+ return typeof ns === 'string' && ns.trim() !== '' ? ns.trim() : ''
650
715
  }
651
716
 
652
717
  /**
653
- * Перший YAML-документ (до наступного `---` на окремому рядку).
654
- * @param {string} body фрагмент YAML
655
- * @returns {string} перший документ без зайвих пробілів по краях
718
+ * Будує дескриптор з маніфесту (пропускає **Kustomization** та об’єкти без **metadata.name**).
719
+ * @param {Record<string, unknown>} obj корінь документа
720
+ * @param {string} kustomizationDefaultNs значення **`namespace:`** з kustomization, що підключив файл
721
+ * @returns {KustomizeResourceDescriptor | null} дескриптор для звірки або **null**, якщо документ не підходить.
656
722
  */
657
- function firstYamlDocument(body) {
658
- const parts = body.split(/^---\s*$/mu)
659
- return (parts[0] ?? body).trim()
723
+ export function kustomizeResourceDescriptorFromManifest(obj, kustomizationDefaultNs) {
724
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
725
+ return null
726
+ }
727
+ const kindRaw = obj.kind
728
+ if (typeof kindRaw !== 'string' || kindRaw.trim() === '') {
729
+ return null
730
+ }
731
+ const kind = kindRaw.trim()
732
+ if (kind === 'Kustomization') {
733
+ return null
734
+ }
735
+ const meta = obj.metadata
736
+ const name = metadataNameTrimmed(meta)
737
+ if (name === '') {
738
+ return null
739
+ }
740
+ const { group, version } = splitK8sApiVersion(obj.apiVersion)
741
+ let namespace = ''
742
+ if (!isClusterScopedKubernetesKind(kind)) {
743
+ const metaNs = metadataNamespaceTrimmed(meta)
744
+ const def =
745
+ typeof kustomizationDefaultNs === 'string' && kustomizationDefaultNs.trim() !== ''
746
+ ? kustomizationDefaultNs.trim()
747
+ : ''
748
+ namespace = metaNs || def
749
+ }
750
+ return { group, version, kind, name, namespace }
660
751
  }
661
752
 
662
753
  /**
663
- * Витягує `apiVersion` та `kind` з тексту документа (без повного YAML-парсера).
664
- * @param {string} doc фрагмент YAML одного документа
665
- * @returns {{ apiVersion?: string, kind?: string }} знайдені поля або властивості відсутні
754
+ * Читає k8s YAML і повертає корені документів-об’єктів (після modeline, якщо він є).
755
+ * @param {string} abs абсолютний шлях до файлу
756
+ * @returns {Promise<Record<string, unknown>[]>} масив коренів-об’єктів YAML-документів (без масивів на корені).
666
757
  */
667
- function extractApiVersionAndKind(doc) {
668
- const av = doc.match(/^\s*apiVersion:\s*(\S+)\s*$/mu)
669
- const k = doc.match(/^\s*kind:\s*(\S+)\s*$/mu)
670
- return {
671
- apiVersion: av?.[1]?.replaceAll(/^["']|["']$/gu, ''),
672
- kind: k?.[1]?.replaceAll(/^["']|["']$/gu, '')
758
+ async function readK8sYamlDocumentRootsForInventory(abs) {
759
+ let raw
760
+ try {
761
+ raw = await readFile(abs, 'utf8')
762
+ } catch {
763
+ return []
764
+ }
765
+ const lines = toLines(raw)
766
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
767
+ /** @type {unknown[]} */
768
+ const roots = parseK8sYamlDocumentObjectRoots(body)
769
+ /** @type {Record<string, unknown>[]} */
770
+ const out = []
771
+ for (const r of roots) {
772
+ if (r !== null && typeof r === 'object' && !Array.isArray(r)) {
773
+ out.push(/** @type {Record<string, unknown>} */ (r))
774
+ }
673
775
  }
776
+ return out
674
777
  }
675
778
 
676
779
  /**
677
- * Чи перший YAML-документ (до `---`) **HttpBackendGroup** з API **alb.yc.io/v1alpha1** (Yandex ALB).
678
- * Для таких файлів **check-k8s** не вимагає modeline `# yaml-language-server: $schema=…` і забороняє його.
679
- * @param {string} yamlBody вміст файлу або фрагмент після modeline
680
- * @returns {boolean} true, якщо `apiVersion`/`kind` першого документа збігаються з винятком
780
+ * Збирає дескриптори ресурсів з **`resources` / `bases` / `components` / `crds`** для одного дерева kustomization.
781
+ * Повторний вхід у той самий **`kustomization.yaml`** дає порожній внесок (як у **`collectKustomizeManagedRelPaths`**).
782
+ * @param {string} kustAbs абсолютний шлях до **kustomization.yaml**
783
+ * @param {string} rootNorm нормалізований абсолютний корінь репозиторію
784
+ * @param {Set<string>} visitedKustomization нормалізовані абсолютні шляхи відвіданих **kustomization.yaml**
785
+ * @returns {Promise<KustomizeResourceDescriptor[]>} плоский список дескрипторів із дерева **resources** / **bases** / **components** / **crds**.
681
786
  */
682
- export function k8sYamlFirstDocIsAlbYcHttpBackendGroup(yamlBody) {
683
- const first = firstYamlDocument(yamlBody)
684
- const { apiVersion, kind } = extractApiVersionAndKind(first)
685
- return apiVersion === 'alb.yc.io/v1alpha1' && kind === 'HttpBackendGroup'
686
- }
787
+ export async function collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visitedKustomization) {
788
+ const normKust = resolve(kustAbs)
789
+ if (visitedKustomization.has(normKust)) {
790
+ return []
791
+ }
792
+ visitedKustomization.add(normKust)
687
793
 
688
- /**
689
- * Чи вміст overlay **`ru/kustomization.yaml`** містить Kustomize patch видалення **HealthCheckPolicy**.
690
- * @param {string} raw повний текст файлу
691
- * @returns {boolean} true, якщо є `$patch: delete` і блоки kind/metadata для HealthCheckPolicy
692
- */
693
- export function ruKustomizationHasHealthCheckDeletePatch(raw) {
694
- if (!/\$patch:\s*delete/u.test(raw)) return false
695
- if (!/kind:\s*HealthCheckPolicy/u.test(raw)) return false
696
- if (!/metadata:/u.test(raw)) return false
697
- if (!/name:\s*\S+/u.test(raw)) return false
698
- return true
699
- }
794
+ let raw
795
+ try {
796
+ raw = await readFile(normKust, 'utf8')
797
+ } catch {
798
+ return []
799
+ }
800
+ const lines = toLines(raw)
801
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
700
802
 
701
- /**
702
- * Чи абсолютний шлях лежить усередині кореня репозиторію (без виходу через `..`).
703
- * @param {string} rootAbs абсолютний корінь
803
+ /** @type {import('yaml').Document[] | undefined} */
804
+ let docs
805
+ try {
806
+ docs = parseAllDocuments(body)
807
+ } catch {
808
+ return []
809
+ }
810
+ const first = docs[0]?.toJSON()
811
+ if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
812
+ return []
813
+ }
814
+ const rec = /** @type {Record<string, unknown>} */ (first)
815
+ const kustNs = typeof rec.namespace === 'string' && rec.namespace.trim() !== '' ? rec.namespace.trim() : ''
816
+ const kustDir = dirname(normKust)
817
+ const pathRefs = resourcePathRefsFromKustomizationObject(first)
818
+
819
+ /** @type {KustomizeResourceDescriptor[]} */
820
+ const out = []
821
+
822
+ /**
823
+ * @param {string} ref шлях з resources/bases/…
824
+ * @returns {Promise<void>}
825
+ */
826
+ async function handleResourceDescriptorPathRef(ref) {
827
+ if (typeof ref !== 'string' || ref.includes('://')) {
828
+ return
829
+ }
830
+ const resolved = resolve(kustDir, ref)
831
+ if (!resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
832
+ return
833
+ }
834
+ /** @type {import('node:fs').Stats | undefined} */
835
+ let st
836
+ try {
837
+ st = await stat(resolved)
838
+ } catch {
839
+ st = undefined
840
+ }
841
+ if (st === undefined) {
842
+ return
843
+ }
844
+ if (st.isFile() && YAML_EXTENSION_RE.test(resolved)) {
845
+ const roots = await readK8sYamlDocumentRootsForInventory(resolved)
846
+ for (const o of roots) {
847
+ const d = kustomizeResourceDescriptorFromManifest(o, kustNs)
848
+ if (d !== null) {
849
+ out.push(d)
850
+ }
851
+ }
852
+ return
853
+ }
854
+ if (!st.isDirectory()) {
855
+ return
856
+ }
857
+ const childK = existsSync(join(resolved, 'kustomization.yaml')) ? join(resolved, 'kustomization.yaml') : null
858
+ if (childK !== null) {
859
+ const sub = await collectResourceDescriptorsForKustomizationWalk(childK, rootNorm, visitedKustomization)
860
+ out.push(...sub)
861
+ }
862
+ }
863
+
864
+ for (const ref of pathRefs) {
865
+ await handleResourceDescriptorPathRef(ref)
866
+ }
867
+
868
+ return out
869
+ }
870
+
871
+ /**
872
+ * Витягує записи з явним **target** з **patches** / **patchesJson6902**.
873
+ * @param {unknown} obj перший документ Kustomization
874
+ * @returns {Array<{ section: string, index: number, target: unknown }>} пари **section** + індекс (1-based) і **target** з YAML.
875
+ */
876
+ function extractExplicitPatchTargetsFromKustomization(obj) {
877
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
878
+ return []
879
+ }
880
+ const rec = /** @type {Record<string, unknown>} */ (obj)
881
+ /** @type {Array<{ section: string, index: number, target: unknown }>} */
882
+ const out = []
883
+ /**
884
+ * @param {string} section ім’я поля
885
+ * @param {unknown} arr масив з YAML
886
+ * @returns {void}
887
+ */
888
+ const push = (section, arr) => {
889
+ if (!Array.isArray(arr)) {
890
+ return
891
+ }
892
+ let i = 0
893
+ for (const item of arr) {
894
+ i++
895
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
896
+ const it = /** @type {Record<string, unknown>} */ (item)
897
+ if ('target' in it) {
898
+ out.push({ section, index: i, target: it.target })
899
+ }
900
+ }
901
+ }
902
+ }
903
+ push('patches', rec.patches)
904
+ push('patchesJson6902', rec.patchesJson6902)
905
+ return out
906
+ }
907
+
908
+ /**
909
+ * Людинозчитуваний опис **target** для повідомлення про помилку.
910
+ * @param {unknown} target об’єкт **target**
911
+ * @returns {string} короткий рядок
912
+ */
913
+ function formatKustomizePatchTargetForMessage(target) {
914
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) {
915
+ return String(target)
916
+ }
917
+ const t = /** @type {Record<string, unknown>} */ (target)
918
+ const parts = []
919
+ const g = t.group
920
+ const v = t.version
921
+ const k = t.kind
922
+ const n = t.name
923
+ const ns = t.namespace
924
+ if (typeof g === 'string' && g.trim() !== '') {
925
+ parts.push(`group=${g.trim()}`)
926
+ }
927
+ if (typeof v === 'string' && v.trim() !== '') {
928
+ parts.push(`version=${v.trim()}`)
929
+ }
930
+ if (typeof k === 'string' && k.trim() !== '') {
931
+ parts.push(`kind=${k.trim()}`)
932
+ }
933
+ if (typeof n === 'string' && n.trim() !== '') {
934
+ parts.push(`name=${n.trim()}`)
935
+ }
936
+ if (typeof ns === 'string' && ns.trim() !== '') {
937
+ parts.push(`namespace=${ns.trim()}`)
938
+ }
939
+ return parts.length > 0 ? parts.join(', ') : JSON.stringify(t)
940
+ }
941
+
942
+ /**
943
+ * Явні **patches[].target** / **patchesJson6902[].target** — ресурс має бути в інвентарі.
944
+ * @param {string} rel відносний шлях до kustomization.yaml
945
+ * @param {Record<string, unknown>} first корінь Kustomization
946
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар resources/bases/…
947
+ * @param {(msg: string) => void} fail реєстрація помилки
948
+ * @returns {void}
949
+ */
950
+ function failIfExplicitPatchTargetsNotInCatalog(rel, first, catalog, fail) {
951
+ for (const { section, index, target } of extractExplicitPatchTargetsFromKustomization(first)) {
952
+ if (
953
+ shouldValidateKustomizePatchTarget(target) &&
954
+ !kustomizeResourceCatalogMatchesPatchTarget(catalog, target)
955
+ ) {
956
+ fail(
957
+ `${rel}: ${section}[${index}].target — немає відповідного ресурсу в resources/bases/components/crds (рекурсивно): ${formatKustomizePatchTargetForMessage(target)}`
958
+ )
959
+ }
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Документи з YAML-файлу мають мати дескриптор у **catalog** (інвентар resources).
965
+ * @param {string} rel відносний шлях до kustomization.yaml
966
+ * @param {string} resolvedAbs абсолютний шлях до patch-файлу
967
+ * @param {string} root корінь репо
968
+ * @param {string} relPatchFallback якщо **relative** дає порожньо
969
+ * @param {string} violationIntro префікс повідомлення (`patches[1] path` або `patchesStrategicMerge[2]`)
970
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар
971
+ * @param {string} kustNs default namespace
972
+ * @param {(msg: string) => void} fail реєстрація помилки
973
+ * @returns {Promise<void>}
974
+ */
975
+ async function failIfYamlFileRootsMissingFromCatalog(
976
+ rel,
977
+ resolvedAbs,
978
+ root,
979
+ relPatchFallback,
980
+ violationIntro,
981
+ catalog,
982
+ kustNs,
983
+ fail
984
+ ) {
985
+ const roots = await readK8sYamlDocumentRootsForInventory(resolvedAbs)
986
+ let docIdx = 0
987
+ for (const o of roots) {
988
+ docIdx++
989
+ const d = kustomizeResourceDescriptorFromManifest(o, kustNs)
990
+ if (d !== null && !catalog.some(c => kustomizeResourceDescriptorsIdentityEqual(c, d))) {
991
+ const relPatch = (relative(root, resolvedAbs) || relPatchFallback).replaceAll('\\', '/')
992
+ fail(
993
+ `${rel}: ${violationIntro} «${relPatch}» документ ${docIdx} — у каталозі resources немає ресурсу ${d.kind}/${d.name} (namespace=${d.namespace || '(порожньо)'}, apiVersion group/version=${d.group || 'core'}/${d.version})`
994
+ )
995
+ }
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * Вирішує відносний шлях до існуючого **.yaml** під root і перевіряє, що це файл.
1001
+ * @param {string} kustDir каталог kustomization
1002
+ * @param {string} pathStr відносний шлях
1003
+ * @param {string} rootNorm нормалізований корінь репо
1004
+ * @returns {Promise<string | null>} абсолютний шлях або null
1005
+ */
1006
+ async function resolveExistingYamlFileUnderRoot(kustDir, pathStr, rootNorm) {
1007
+ const resolved = resolve(kustDir, pathStr)
1008
+ if (!resolvedFilePathIsUnderRoot(rootNorm, resolved) || !existsSync(resolved)) {
1009
+ return null
1010
+ }
1011
+ /** @type {import('node:fs').Stats | null} */
1012
+ let st = null
1013
+ try {
1014
+ st = await stat(resolved)
1015
+ } catch {
1016
+ st = null
1017
+ }
1018
+ if (st === null || !st.isFile() || !YAML_EXTENSION_RE.test(resolved)) {
1019
+ return null
1020
+ }
1021
+ return resolved
1022
+ }
1023
+
1024
+ /**
1025
+ * Один елемент **patches[]** лише з **path** (без **target**, без inline patch): корені файлу проти інвентарю.
1026
+ * @param {string} rel відносний шлях до kustomization.yaml
1027
+ * @param {unknown} p елемент **patches**
1028
+ * @param {number} pIdx 1-based індекс у масиві
1029
+ * @param {string} kustDir каталог kustomization.yaml
1030
+ * @param {string} rootNorm нормалізований корінь репо
1031
+ * @param {string} root корінь репо
1032
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар
1033
+ * @param {string} kustNs default namespace з kustomization
1034
+ * @param {(msg: string) => void} fail реєстрація помилки
1035
+ * @returns {Promise<void>}
1036
+ */
1037
+ async function failIfOnePathOnlyPatchNotInCatalog(rel, p, pIdx, kustDir, rootNorm, root, catalog, kustNs, fail) {
1038
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) {
1039
+ return
1040
+ }
1041
+ const pr = /** @type {Record<string, unknown>} */ (p)
1042
+ const hasTargetKey = 'target' in pr && pr.target !== undefined && pr.target !== null
1043
+ const pathStr = typeof pr.path === 'string' ? pr.path.trim() : ''
1044
+ const inlinePatch = typeof pr.patch === 'string' && pr.patch.trim() !== ''
1045
+ if (hasTargetKey || pathStr === '' || inlinePatch || pathStr.includes('://')) {
1046
+ return
1047
+ }
1048
+ const resolved = await resolveExistingYamlFileUnderRoot(kustDir, pathStr, rootNorm)
1049
+ if (resolved === null) {
1050
+ return
1051
+ }
1052
+ await failIfYamlFileRootsMissingFromCatalog(
1053
+ rel,
1054
+ resolved,
1055
+ root,
1056
+ pathStr,
1057
+ `patches[${pIdx}] path`,
1058
+ catalog,
1059
+ kustNs,
1060
+ fail
1061
+ )
1062
+ }
1063
+
1064
+ /**
1065
+ * **patches[]** лише з **path** (без **target**, без inline patch) — документи у файлі мають збігатися з інвентарем.
1066
+ * @param {string} rel відносний шлях до kustomization.yaml
1067
+ * @param {unknown} patches поле **patches**
1068
+ * @param {string} kustDir каталог kustomization.yaml
1069
+ * @param {string} rootNorm нормалізований корінь репо
1070
+ * @param {string} root корінь репо
1071
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар
1072
+ * @param {string} kustNs default namespace з kustomization
1073
+ * @param {(msg: string) => void} fail реєстрація помилки
1074
+ * @returns {Promise<void>}
1075
+ */
1076
+ async function failIfPathOnlyPatchesNotInCatalog(rel, patches, kustDir, rootNorm, root, catalog, kustNs, fail) {
1077
+ if (!Array.isArray(patches)) {
1078
+ return
1079
+ }
1080
+ let pIdx = 0
1081
+ for (const p of patches) {
1082
+ pIdx++
1083
+ await failIfOnePathOnlyPatchNotInCatalog(rel, p, pIdx, kustDir, rootNorm, root, catalog, kustNs, fail)
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * **patchesStrategicMerge** — кожен документ у файлі має збігатися з інвентарем.
1089
+ * @param {string} rel відносний шлях до kustomization.yaml
1090
+ * @param {unknown} sm поле **patchesStrategicMerge**
1091
+ * @param {string} kustDir каталог kustomization.yaml
1092
+ * @param {string} rootNorm нормалізований корінь репо
1093
+ * @param {string} root корінь репо
1094
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар
1095
+ * @param {string} kustNs default namespace з kustomization
1096
+ * @param {(msg: string) => void} fail реєстрація помилки
1097
+ * @returns {Promise<void>}
1098
+ */
1099
+ async function failIfStrategicMergePatchesNotInCatalog(rel, sm, kustDir, rootNorm, root, catalog, kustNs, fail) {
1100
+ if (!Array.isArray(sm)) {
1101
+ return
1102
+ }
1103
+ let smIdx = 0
1104
+ for (const ref of sm) {
1105
+ smIdx++
1106
+ if (typeof ref === 'string' && ref.trim() !== '' && !ref.includes('://')) {
1107
+ const resolved = await resolveExistingYamlFileUnderRoot(kustDir, ref.trim(), rootNorm)
1108
+ if (resolved !== null) {
1109
+ await failIfYamlFileRootsMissingFromCatalog(
1110
+ rel,
1111
+ resolved,
1112
+ root,
1113
+ ref,
1114
+ `patchesStrategicMerge[${smIdx}]`,
1115
+ catalog,
1116
+ kustNs,
1117
+ fail
1118
+ )
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ /**
1125
+ * Один **`kustomization.yaml`**: patch **target**, **path** без target, **patchesStrategicMerge**.
1126
+ * @param {string} root корінь репозиторію
1127
+ * @param {string} kustAbs абсолютний шлях до файлу
1128
+ * @param {string} rootNorm нормалізований корінь
1129
+ * @param {(msg: string) => void} fail реєстрація помилки
1130
+ * @returns {Promise<void>}
1131
+ */
1132
+ async function validatePatchTargetsOneKustomizationFile(root, kustAbs, rootNorm, fail) {
1133
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
1134
+ let raw
1135
+ try {
1136
+ raw = await readFile(kustAbs, 'utf8')
1137
+ } catch (error) {
1138
+ const msg = error instanceof Error ? error.message : String(error)
1139
+ fail(`${rel}: не вдалося прочитати для перевірки patch target (${msg})`)
1140
+ return
1141
+ }
1142
+ const lines = toLines(raw)
1143
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
1144
+ /** @type {import('yaml').Document[] | null} */
1145
+ let docs = null
1146
+ try {
1147
+ docs = parseAllDocuments(body)
1148
+ } catch {
1149
+ fail(`${rel}: не вдалося розпарсити YAML для перевірки patch target`)
1150
+ return
1151
+ }
1152
+ const first = docs[0]?.toJSON()
1153
+ if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
1154
+ return
1155
+ }
1156
+ const rec = /** @type {Record<string, unknown>} */ (first)
1157
+ if (rec.kind !== 'Kustomization') {
1158
+ return
1159
+ }
1160
+ const visited = new Set()
1161
+ const catalog = await collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visited)
1162
+ const kustDir = dirname(resolve(kustAbs))
1163
+ const kustNs = typeof rec.namespace === 'string' && rec.namespace.trim() !== '' ? rec.namespace.trim() : ''
1164
+ failIfExplicitPatchTargetsNotInCatalog(rel, first, catalog, fail)
1165
+ await failIfPathOnlyPatchesNotInCatalog(rel, rec.patches, kustDir, rootNorm, root, catalog, kustNs, fail)
1166
+ await failIfStrategicMergePatchesNotInCatalog(rel, rec.patchesStrategicMerge, kustDir, rootNorm, root, catalog, kustNs, fail)
1167
+ }
1168
+
1169
+ /**
1170
+ * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: **target** patch і strategic-merge посилання не вказують на ресурс поза інвентарем **resources** / **bases** / **components** / **crds**.
1171
+ * @param {string} root корінь репозиторію
1172
+ * @param {string[]} yamlFilesAbs абсолютні шляхи до yaml під k8s
1173
+ * @param {(msg: string) => void} fail реєстрація помилки
1174
+ * @returns {Promise<void>}
1175
+ */
1176
+ async function validateKustomizationPatchTargetsResolved(root, yamlFilesAbs, fail) {
1177
+ const rootNorm = resolve(root)
1178
+ for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
1179
+ await validatePatchTargetsOneKustomizationFile(root, kustAbs, rootNorm, fail)
1180
+ }
1181
+ }
1182
+
1183
+ /**
1184
+ * Чи це **`k8s/base/kustomization.yaml`** (перевірка обов’язкового непорожнього **`namespace:`**).
1185
+ * @param {string} rel шлях від кореня репозиторію
1186
+ * @returns {boolean} true для шляху виду `…/k8s/base/kustomization.yaml`
1187
+ */
1188
+ export function isBaseKustomizationPath(rel) {
1189
+ const n = rel.replaceAll('\\', '/')
1190
+ return K8S_BASE_KUSTOMIZATION_PATH_RE.test(n)
1191
+ }
1192
+
1193
+ /**
1194
+ * Чи є в Kustomization для **`base`** завжди обов’язкове непорожнє поле **`namespace:`** (k8s.mdc).
1195
+ * @param {unknown} obj перший документ YAML
1196
+ * @returns {string | null} текст порушення або null, якщо ок
1197
+ */
1198
+ export function baseKustomizationNamespaceViolation(obj) {
1199
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
1200
+ return 'у base/kustomization.yaml завжди має бути непорожній namespace: (див. k8s.mdc)'
1201
+ }
1202
+ const rec = /** @type {Record<string, unknown>} */ (obj)
1203
+ const ns = rec.namespace
1204
+ if (typeof ns === 'string' && ns.trim() !== '') {
1205
+ return null
1206
+ }
1207
+ return 'у base/kustomization.yaml завжди додай непорожній namespace: (наприклад namespace: dev; див. k8s.mdc)'
1208
+ }
1209
+
1210
+ /**
1211
+ * Збирає всі `*.yaml` та `*.yml` під деревом від кореня cwd, якщо шлях містить сегмент `k8s` (для `.yml` далі — помилка перейменування).
1212
+ * @param {string} root корінь репозиторію (cwd)
1213
+ * @returns {Promise<string[]>} відсортовані абсолютні шляхи до файлів
1214
+ */
1215
+ async function findK8sYamlFiles(root) {
1216
+ /** @type {string[]} */
1217
+ const out = []
1218
+ await walkDir(root, p => {
1219
+ if (!pathHasK8sSegment(p)) return
1220
+ if (!YAML_EXTENSION_RE.test(p)) return
1221
+ out.push(p)
1222
+ })
1223
+
1224
+ return out.toSorted((a, b) => a.localeCompare(b))
1225
+ }
1226
+
1227
+ /**
1228
+ * Тіло YAML для політик (Ingress, BackendConfig тощо): якщо перший рядок — modeline `$schema`, береться вміст після нього.
1229
+ * @param {string[]} lines рядки файлу
1230
+ * @returns {string} фрагмент для `parseAllDocuments`
1231
+ */
1232
+ function k8sYamlBodyForDocumentParse(lines) {
1233
+ if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
1234
+ return yamlBodyAfterModeline(lines)
1235
+ }
1236
+ return lines.join('\n')
1237
+ }
1238
+
1239
+ /**
1240
+ * Оновлює прапорці наявності **BackendConfig** / інших **kind** у документі.
1241
+ * @param {unknown} kind значення **kind**
1242
+ * @param {{ hasBc: boolean, hasOther: boolean }} acc накопичувач
1243
+ * @returns {void}
1244
+ */
1245
+ function updateBackendConfigKindFlags(kind, acc) {
1246
+ if (kind === 'BackendConfig') {
1247
+ acc.hasBc = true
1248
+ return
1249
+ }
1250
+ if (kind !== undefined && kind !== null && String(kind).trim() !== '') {
1251
+ acc.hasOther = true
1252
+ }
1253
+ }
1254
+
1255
+ /**
1256
+ * Чи всі нетривіальні документи у тілі — **`kind: BackendConfig`**, чи є змішування з іншими kind.
1257
+ * @param {string} body YAML без обов’язкового modeline (див. `k8sYamlBodyForDocumentParse`)
1258
+ * @returns {'none' | 'only' | 'mixed' | 'unparsed'} unparsed — не вдалося розпарсити YAML
1259
+ */
1260
+ export function classifyBackendConfigManifestPresence(body) {
1261
+ /** @type {import('yaml').Document[]} */
1262
+ let docs
1263
+ try {
1264
+ docs = parseAllDocuments(body)
1265
+ } catch {
1266
+ return 'unparsed'
1267
+ }
1268
+
1269
+ const acc = { hasBc: false, hasOther: false }
1270
+ for (const doc of docs) {
1271
+ if (doc.errors.length === 0) {
1272
+ const obj = doc.toJSON()
1273
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1274
+ updateBackendConfigKindFlags(obj.kind, acc)
1275
+ }
1276
+ }
1277
+ }
1278
+
1279
+ if (!acc.hasBc) {
1280
+ return 'none'
1281
+ }
1282
+ if (acc.hasOther) {
1283
+ return 'mixed'
1284
+ }
1285
+ return 'only'
1286
+ }
1287
+
1288
+ /**
1289
+ * Видаляє під **`k8s`** YAML-файли, що містять **лише** ресурси **BackendConfig**; змішані файли — `fail`.
1290
+ * @param {string} root корінь репозиторію
1291
+ * @param {(msg: string) => void} fail реєстрація порушення
1292
+ * @param {(msg: string) => void} pass реєстрація успіху
1293
+ * @returns {Promise<void>}
1294
+ */
1295
+ async function removeBackendConfigOnlyK8sYamlFiles(root, fail, pass) {
1296
+ const yamlFiles = await findK8sYamlFiles(root)
1297
+ for (const abs of yamlFiles) {
1298
+ const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
1299
+ try {
1300
+ const raw = await readFile(abs, 'utf8')
1301
+ const lines = toLines(raw)
1302
+ const body = k8sYamlBodyForDocumentParse(lines)
1303
+ const bcPresence = classifyBackendConfigManifestPresence(body)
1304
+
1305
+ if (bcPresence === 'mixed') {
1306
+ fail(
1307
+ `${rel}: у файлі разом BackendConfig та інші kind — винеси BackendConfig окремо або прибери вручну; автоматичне видалення не застосовується (див. k8s.mdc)`
1308
+ )
1309
+ } else if (bcPresence === 'only') {
1310
+ try {
1311
+ await unlink(abs)
1312
+ pass(`${rel}: видалено (лише kind: BackendConfig; див. k8s.mdc)`)
1313
+ } catch (error) {
1314
+ fail(`${rel}: не вдалося видалити BackendConfig-файл (${error.message})`)
1315
+ }
1316
+ }
1317
+ } catch (error) {
1318
+ fail(`${rel}: не вдалося прочитати для перевірки BackendConfig (${error.message})`)
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+ /**
1324
+ * Прибирає BOM і ділить на рядки.
1325
+ * @param {string} content вміст файлу
1326
+ * @returns {string[]} рядки без BOM на початку
1327
+ */
1328
+ function toLines(content) {
1329
+ const body = content.startsWith('\uFEFF') ? content.slice(1) : content
1330
+ return body.split(YAML_LINE_SPLIT_RE)
1331
+ }
1332
+
1333
+ /**
1334
+ * Вміст після першого рядка (modeline), без провідних порожніх рядків.
1335
+ * @param {string[]} lines рядки файлу
1336
+ * @returns {string} тіло для парсингу першого YAML-документа
1337
+ */
1338
+ function yamlBodyAfterModeline(lines) {
1339
+ let i = 1
1340
+ while (i < lines.length && lines[i].trim() === '') i++
1341
+ return lines.slice(i).join('\n')
1342
+ }
1343
+
1344
+ /**
1345
+ * Читає k8s YAML і повертає фрагмент після modeline `$schema`, якщо перший рядок — modeline.
1346
+ * Потрібно для парної перевірки **`svc.yaml`** / **`svc-hl.yaml`**.
1347
+ * @param {string} abs абсолютний шлях до файлу
1348
+ * @returns {Promise<string>} тіло для `parseAllDocuments`
1349
+ */
1350
+ async function readK8sYamlBodyAfterModelineForSvcPair(abs) {
1351
+ const raw = await readFile(abs, 'utf8')
1352
+ const lines = toLines(raw)
1353
+ if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
1354
+ return yamlBodyAfterModeline(lines)
1355
+ }
1356
+ return lines.join('\n')
1357
+ }
1358
+
1359
+ /**
1360
+ * Розбирає YAML на корені документів (ігнорує зламані документи).
1361
+ * @param {string} body фрагмент YAML
1362
+ * @returns {unknown[]} масив успішно розібраних коренів YAML-документів
1363
+ */
1364
+ function parseK8sYamlDocumentObjectRoots(body) {
1365
+ try {
1366
+ return parseAllDocuments(body)
1367
+ .filter(d => d.errors.length === 0)
1368
+ .map(d => d.toJSON())
1369
+ .filter(x => x !== null && x !== undefined && typeof x === 'object' && !Array.isArray(x))
1370
+ } catch {
1371
+ return []
1372
+ }
1373
+ }
1374
+
1375
+ /**
1376
+ * Перший YAML-документ (до наступного `---` на окремому рядку).
1377
+ * @param {string} body фрагмент YAML
1378
+ * @returns {string} перший документ без зайвих пробілів по краях
1379
+ */
1380
+ function firstYamlDocument(body) {
1381
+ const lines = body.split(YAML_LINE_SPLIT_RE)
1382
+ const out = []
1383
+ for (const line of lines) {
1384
+ if (YAML_DOC_SEPARATOR_LINE_RE.test(line)) {
1385
+ break
1386
+ }
1387
+ out.push(line)
1388
+ }
1389
+ return out.join('\n').trim()
1390
+ }
1391
+
1392
+ /**
1393
+ * Витягує `apiVersion` та `kind` з тексту документа (без повного YAML-парсера).
1394
+ * @param {string} doc фрагмент YAML одного документа
1395
+ * @returns {{ apiVersion?: string, kind?: string }} знайдені поля або властивості відсутні
1396
+ */
1397
+ function extractApiVersionAndKind(doc) {
1398
+ /** @type {string | undefined} */
1399
+ let apiVersion
1400
+ /** @type {string | undefined} */
1401
+ let kind
1402
+ for (const line of doc.split(YAML_LINE_SPLIT_RE)) {
1403
+ if (apiVersion === undefined) {
1404
+ const av = line.match(API_VERSION_FIELD_RE)
1405
+ if (av) {
1406
+ apiVersion = trimYamlScalarQuotes(av[1])
1407
+ }
1408
+ }
1409
+ if (kind === undefined) {
1410
+ const k = line.match(KIND_FIELD_RE)
1411
+ if (k) {
1412
+ kind = trimYamlScalarQuotes(k[1])
1413
+ }
1414
+ }
1415
+ if (apiVersion !== undefined && kind !== undefined) {
1416
+ break
1417
+ }
1418
+ }
1419
+ return { apiVersion, kind }
1420
+ }
1421
+
1422
+ /**
1423
+ * Чи перший YAML-документ (до `---`) — **HttpBackendGroup** з API **alb.yc.io/v1alpha1** (Yandex ALB).
1424
+ * Для таких файлів **check-k8s** не вимагає modeline `# yaml-language-server: $schema=…` і забороняє його.
1425
+ * @param {string} yamlBody вміст файлу або фрагмент після modeline
1426
+ * @returns {boolean} true, якщо `apiVersion`/`kind` першого документа збігаються з винятком
1427
+ */
1428
+ export function k8sYamlFirstDocIsAlbYcHttpBackendGroup(yamlBody) {
1429
+ const first = firstYamlDocument(yamlBody)
1430
+ const { apiVersion, kind } = extractApiVersionAndKind(first)
1431
+ return apiVersion === 'alb.yc.io/v1alpha1' && kind === 'HttpBackendGroup'
1432
+ }
1433
+
1434
+ /**
1435
+ * Чи вміст overlay **`ru/kustomization.yaml`** містить Kustomize patch видалення **HealthCheckPolicy**.
1436
+ * @param {string} raw повний текст файлу
1437
+ * @returns {boolean} true, якщо є `$patch: delete` і блоки kind/metadata для HealthCheckPolicy
1438
+ */
1439
+ export function ruKustomizationHasHealthCheckDeletePatch(raw) {
1440
+ if (!HEALTHCHECK_DELETE_RE.test(raw)) return false
1441
+ if (!HEALTHCHECK_KIND_RE.test(raw)) return false
1442
+ if (!METADATA_LINE_RE.test(raw)) return false
1443
+ if (!NAME_NON_EMPTY_RE.test(raw)) return false
1444
+ return true
1445
+ }
1446
+
1447
+ /**
1448
+ * Чи абсолютний шлях лежить усередині кореня репозиторію (без виходу через `..`).
1449
+ * @param {string} rootAbs абсолютний корінь
704
1450
  * @param {string} fileAbs абсолютний шлях до файлу
705
1451
  * @returns {boolean} true, якщо `fileAbs` усередині `rootAbs`
706
1452
  */
@@ -810,6 +1556,173 @@ export function json6902PathsWithRemoveAndAddOnSamePath(ops) {
810
1556
  return out.toSorted((a, b) => a.localeCompare(b))
811
1557
  }
812
1558
 
1559
+ /**
1560
+ * Реєструє порушення, якщо в JSON6902-операціях є **remove** і **add** на один **path**.
1561
+ * @param {string} rel відносний шлях до kustomization.yaml
1562
+ * @param {string} label фрагмент повідомлення (наприклад `patches[1] inline JSON6902`)
1563
+ * @param {string} patchText текст patch
1564
+ * @param {(msg: string) => void} fail реєстрація порушення
1565
+ * @returns {void}
1566
+ */
1567
+ function failIfJson6902RemoveAddConflictOnSamePath(rel, label, patchText, fail) {
1568
+ const ops = collectJson6902OperationsFromPatchText(patchText)
1569
+ const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
1570
+ if (bad.length > 0) {
1571
+ fail(`${rel}: ${label}: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`)
1572
+ }
1573
+ }
1574
+
1575
+ /**
1576
+ * Зовнішній patch-файл (масив JSON6902): remove+add на один path.
1577
+ * @param {string} rel відносний шлях до kustomization.yaml
1578
+ * @param {string} resolved абсолютний шлях до файлу patch
1579
+ * @param {string} root корінь репо
1580
+ * @param {string} patchRef відносне посилання з kustomization
1581
+ * @param {(msg: string) => void} fail реєстрація порушення
1582
+ * @returns {Promise<void>}
1583
+ */
1584
+ async function auditJson6902PatchExternalFile(rel, resolved, root, patchRef, fail) {
1585
+ /** @type {import('node:fs').Stats | null} */
1586
+ let st = null
1587
+ try {
1588
+ st = await stat(resolved)
1589
+ } catch {
1590
+ st = null
1591
+ }
1592
+ if (st === null || !st.isFile()) {
1593
+ return
1594
+ }
1595
+ let pRaw
1596
+ try {
1597
+ pRaw = await readFile(resolved, 'utf8')
1598
+ } catch {
1599
+ return
1600
+ }
1601
+ const ops = collectJson6902OperationsFromPatchText(pRaw)
1602
+ if (ops.length === 0) {
1603
+ return
1604
+ }
1605
+ const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
1606
+ if (bad.length === 0) {
1607
+ return
1608
+ }
1609
+ const relPatch = (relative(root, resolved) || patchRef).replaceAll('\\', '/')
1610
+ fail(`${rel}: patch-файл «${relPatch}»: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`)
1611
+ }
1612
+
1613
+ /**
1614
+ * Один елемент **`patches[]`**: inline JSON6902 або зовнішній patch-файл.
1615
+ * @param {string} rel відносний шлях до kustomization.yaml
1616
+ * @param {Record<string, unknown>} pr об’єкт patch
1617
+ * @param {number} patchIdx 1-based індекс у масиві
1618
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
1619
+ * @param {string} rootNorm нормалізований корінь репо
1620
+ * @param {string} root корінь репо
1621
+ * @param {(msg: string) => void} fail реєстрація порушення
1622
+ * @returns {Promise<void>}
1623
+ */
1624
+ async function auditOneKustomizationJson6902Patch(
1625
+ rel,
1626
+ pr,
1627
+ patchIdx,
1628
+ kustAbs,
1629
+ rootNorm,
1630
+ root,
1631
+ fail
1632
+ ) {
1633
+ if (typeof pr.patch === 'string' && pr.patch.trim() !== '') {
1634
+ failIfJson6902RemoveAddConflictOnSamePath(rel, `patches[${patchIdx}] inline JSON6902`, pr.patch, fail)
1635
+ }
1636
+ if (typeof pr.path !== 'string' || pr.path.trim() === '') {
1637
+ return
1638
+ }
1639
+ const patchRef = pr.path.trim()
1640
+ const resolved = resolve(dirname(kustAbs), patchRef)
1641
+ if (!resolvedFilePathIsUnderRoot(rootNorm, resolved) || !existsSync(resolved)) {
1642
+ return
1643
+ }
1644
+ await auditJson6902PatchExternalFile(rel, resolved, root, patchRef, fail)
1645
+ }
1646
+
1647
+ /**
1648
+ * Усі **`patches[]`** у Kustomization: inline та зовнішні файли.
1649
+ * @param {string} rel відносний шлях до kustomization.yaml
1650
+ * @param {unknown} patches поле **patches**
1651
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
1652
+ * @param {string} rootNorm нормалізований корінь репо
1653
+ * @param {string} root корінь репо
1654
+ * @param {(msg: string) => void} fail реєстрація порушення
1655
+ * @returns {Promise<void>}
1656
+ */
1657
+ async function auditKustomizationPatchesJson6902(rel, patches, kustAbs, rootNorm, root, fail) {
1658
+ if (!Array.isArray(patches)) {
1659
+ return
1660
+ }
1661
+ let patchIdx = 0
1662
+ for (const p of patches) {
1663
+ patchIdx++
1664
+ if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
1665
+ const pr = /** @type {Record<string, unknown>} */ (p)
1666
+ await auditOneKustomizationJson6902Patch(rel, pr, patchIdx, kustAbs, rootNorm, root, fail)
1667
+ }
1668
+ }
1669
+ }
1670
+
1671
+ /**
1672
+ * Один YAML-документ: якщо це Kustomization — перевірка **patches** на JSON6902 remove+add.
1673
+ * @param {string} rel відносний шлях до kustomization.yaml
1674
+ * @param {unknown} rootObj корінь документа
1675
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
1676
+ * @param {string} rootNorm нормалізований корінь репо
1677
+ * @param {string} root корінь репо
1678
+ * @param {(msg: string) => void} fail реєстрація порушення
1679
+ * @returns {Promise<void>}
1680
+ */
1681
+ async function auditJson6902ForKustomizationYamlDoc(rel, rootObj, kustAbs, rootNorm, root, fail) {
1682
+ const rec = /** @type {Record<string, unknown>} */ (rootObj)
1683
+ if (rec.kind !== 'Kustomization') {
1684
+ return
1685
+ }
1686
+ await auditKustomizationPatchesJson6902(rel, rec.patches, kustAbs, rootNorm, root, fail)
1687
+ }
1688
+
1689
+ /**
1690
+ * Один **`kustomization.yaml`**: JSON6902 remove+add на одному path.
1691
+ * @param {string} root корінь репозиторію
1692
+ * @param {string} rootNorm нормалізований корінь
1693
+ * @param {string} kustAbs абсолютний шлях до файлу
1694
+ * @param {(msg: string) => void} fail реєстрація порушення
1695
+ * @returns {Promise<void>}
1696
+ */
1697
+ async function auditJson6902OneKustomizationYamlFile(root, rootNorm, kustAbs, fail) {
1698
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
1699
+ let raw
1700
+ try {
1701
+ raw = await readFile(kustAbs, 'utf8')
1702
+ } catch (error) {
1703
+ const msg = error instanceof Error ? error.message : String(error)
1704
+ fail(`${rel}: не вдалося прочитати для перевірки JSON6902 (${msg})`)
1705
+ return
1706
+ }
1707
+ const lines = toLines(raw)
1708
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
1709
+ /** @type {import('yaml').Document[] | null} */
1710
+ let docs = null
1711
+ try {
1712
+ docs = parseAllDocuments(body)
1713
+ } catch {
1714
+ return
1715
+ }
1716
+ for (const doc of docs) {
1717
+ if (doc.errors.length === 0) {
1718
+ const rootObj = doc.toJSON()
1719
+ if (rootObj !== null && typeof rootObj === 'object' && !Array.isArray(rootObj)) {
1720
+ await auditJson6902ForKustomizationYamlDoc(rel, rootObj, kustAbs, rootNorm, root, fail)
1721
+ }
1722
+ }
1723
+ }
1724
+ }
1725
+
813
1726
  /**
814
1727
  * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: у inline **`patch`** і у зовнішніх patch-файлах не має бути **remove** і **add** на той самий **path**.
815
1728
  * @param {string} root корінь репозиторію
@@ -819,97 +1732,26 @@ export function json6902PathsWithRemoveAndAddOnSamePath(ops) {
819
1732
  */
820
1733
  async function validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFilesAbs, fail) {
821
1734
  const rootNorm = resolve(root)
822
- for (const kustAbs of yamlFilesAbs) {
823
- if (basename(kustAbs).toLowerCase() === 'kustomization.yaml') {
824
- const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
825
- /** @type {string | undefined} */
826
- let raw
827
- let readOk = false
828
- try {
829
- raw = await readFile(kustAbs, 'utf8')
830
- readOk = true
831
- } catch (error) {
832
- const msg = error instanceof Error ? error.message : String(error)
833
- fail(`${rel}: не вдалося прочитати для перевірки JSON6902 (${msg})`)
834
- }
835
- if (readOk && raw !== undefined) {
836
- const lines = toLines(raw)
837
- const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
838
- /** @type {import('yaml').Document[] | null} */
839
- let docs = null
840
- try {
841
- docs = parseAllDocuments(body)
842
- } catch {
843
- docs = null
844
- }
845
- if (docs !== null) {
846
- for (const doc of docs) {
847
- if (doc.errors.length === 0) {
848
- const rootObj = doc.toJSON()
849
- if (rootObj !== null && typeof rootObj === 'object' && !Array.isArray(rootObj)) {
850
- const rec = /** @type {Record<string, unknown>} */ (rootObj)
851
- if (rec.kind === 'Kustomization') {
852
- const patches = rec.patches
853
- if (Array.isArray(patches)) {
854
- let patchIdx = 0
855
- for (const p of patches) {
856
- patchIdx++
857
- if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
858
- const pr = /** @type {Record<string, unknown>} */ (p)
859
- if (typeof pr.patch === 'string' && pr.patch.trim() !== '') {
860
- const ops = collectJson6902OperationsFromPatchText(pr.patch)
861
- const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
862
- if (bad.length > 0) {
863
- fail(
864
- `${rel}: patches[${patchIdx}] inline JSON6902: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
865
- )
866
- }
867
- }
868
- if (typeof pr.path === 'string' && pr.path.trim() !== '') {
869
- const patchRef = pr.path.trim()
870
- const resolved = resolve(dirname(kustAbs), patchRef)
871
- if (resolvedFilePathIsUnderRoot(rootNorm, resolved) && existsSync(resolved)) {
872
- /** @type {import('node:fs').Stats | null} */
873
- let st = null
874
- try {
875
- st = await stat(resolved)
876
- } catch {
877
- st = null
878
- }
879
- if (st !== null && st.isFile()) {
880
- /** @type {string | undefined} */
881
- let pRaw
882
- try {
883
- pRaw = await readFile(resolved, 'utf8')
884
- } catch {
885
- pRaw = undefined
886
- }
887
- if (pRaw !== undefined) {
888
- const ops = collectJson6902OperationsFromPatchText(pRaw)
889
- if (ops.length > 0) {
890
- const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
891
- if (bad.length > 0) {
892
- const relPatch = (relative(root, resolved) || patchRef).replaceAll('\\', '/')
893
- fail(
894
- `${rel}: patch-файл «${relPatch}»: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
895
- )
896
- }
897
- }
898
- }
899
- }
900
- }
901
- }
902
- }
903
- }
904
- }
905
- }
906
- }
907
- }
908
- }
909
- }
910
- }
911
- }
1735
+ for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
1736
+ await auditJson6902OneKustomizationYamlFile(root, rootNorm, kustAbs, fail)
1737
+ }
1738
+ }
1739
+
1740
+ /**
1741
+ * Заборонений **kind: Ingress** у документі.
1742
+ * @param {string} rel відносний шлях до файлу
1743
+ * @param {number} docIndex 1-based індекс документа
1744
+ * @param {Record<string, unknown>} rec корінь маніфесту
1745
+ * @param {(msg: string) => void} fail реєстрація помилки
1746
+ * @returns {void}
1747
+ */
1748
+ function failIfIngressInDocument(rel, docIndex, rec, fail) {
1749
+ if (rec.kind !== 'Ingress') {
1750
+ return
912
1751
  }
1752
+ fail(
1753
+ `${rel}: знайдено kind: Ingress (документ ${docIndex}) — заміни на Gateway API: HTTPRoute (hr.yaml), HealthCheckPolicy (hc.yaml) (див. k8s.mdc)`
1754
+ )
913
1755
  }
914
1756
 
915
1757
  /**
@@ -932,17 +1774,33 @@ function scanIngressInYamlDocuments(rel, body, fail) {
932
1774
  if (doc.errors.length === 0) {
933
1775
  const obj = doc.toJSON()
934
1776
  if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
935
- const rec = /** @type {Record<string, unknown>} */ (obj)
936
- if (rec.kind === 'Ingress') {
937
- fail(
938
- `${rel}: знайдено kind: Ingress (документ ${di + 1}) — заміни на Gateway API: HTTPRoute (hr.yaml), HealthCheckPolicy (hc.yaml) (див. k8s.mdc)`
939
- )
940
- }
1777
+ failIfIngressInDocument(rel, di + 1, /** @type {Record<string, unknown>} */ (obj), fail)
941
1778
  }
942
1779
  }
943
1780
  }
944
1781
  }
945
1782
 
1783
+ /**
1784
+ * Перевірка поля **resources** для одного контейнера **Deployment**.
1785
+ * @param {unknown} c елемент **containers[]**
1786
+ * @param {string} label підпис у повідомленні
1787
+ * @returns {string | null} текст порушення або null
1788
+ */
1789
+ function deploymentContainerResourcesViolation(c, label) {
1790
+ if (c === null || c === undefined || typeof c !== 'object' || Array.isArray(c)) {
1791
+ return null
1792
+ }
1793
+ const cont = /** @type {Record<string, unknown>} */ (c)
1794
+ if (!('resources' in cont)) {
1795
+ return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
1796
+ }
1797
+ const r = cont.resources
1798
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
1799
+ return `контейнер "${label}": resources має бути записом у YAML (наприклад порожній: resources: {})`
1800
+ }
1801
+ return null
1802
+ }
1803
+
946
1804
  /**
947
1805
  * Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
948
1806
  * @param {unknown} manifest корінь YAML-документа як запис JavaScript
@@ -968,15 +1826,9 @@ export function deploymentResourcesViolation(manifest) {
968
1826
  typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
969
1827
  ? c.name
970
1828
  : `#${i + 1}`
971
- if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
972
- const cont = /** @type {Record<string, unknown>} */ (c)
973
- if (!('resources' in cont)) {
974
- return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
975
- }
976
- const r = cont.resources
977
- if (r === null || typeof r !== 'object' || Array.isArray(r)) {
978
- return `контейнер "${label}": resources має бути записом у YAML (наприклад порожній: resources: {})`
979
- }
1829
+ const v = deploymentContainerResourcesViolation(c, label)
1830
+ if (v !== null) {
1831
+ return v
980
1832
  }
981
1833
  }
982
1834
 
@@ -1000,31 +1852,48 @@ function stripImageDigest(image) {
1000
1852
  */
1001
1853
  function isHasuraGraphqlEngineImageRef(image) {
1002
1854
  const s = stripImageDigest(image)
1003
- return /(^|\/)hasura\/graphql-engine(?:[:]|$)/u.test(s)
1855
+ return HASURA_GRAPHQL_ENGINE_RE.test(s)
1004
1856
  }
1005
1857
 
1006
1858
  /**
1007
- * Перевіряє пін образу Hasura у одному списку контейнерів Pod spec.
1859
+ * Перевірка образу Hasura для одного контейнера у списку **containers** / **initContainers**.
1008
1860
  * @param {string} list ім’я поля для повідомлення (`containers` / `initContainers`)
1009
- * @param {unknown} containers значення з маніфесту
1861
+ * @param {unknown} c елемент масиву
1862
+ * @param {number} i індекс
1863
+ * @returns {string | null} текст порушення або null
1864
+ */
1865
+ function hasuraGraphqlEngineViolationForOneContainer(list, c, i) {
1866
+ const label =
1867
+ typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
1868
+ ? c.name
1869
+ : `#${i + 1}`
1870
+ if (c === null || c === undefined || typeof c !== 'object' || Array.isArray(c)) {
1871
+ return null
1872
+ }
1873
+ const cont = /** @type {Record<string, unknown>} */ (c)
1874
+ const image = cont.image
1875
+ if (typeof image !== 'string' || image.trim() === '' || !isHasuraGraphqlEngineImageRef(image)) {
1876
+ return null
1877
+ }
1878
+ const normalized = stripImageDigest(image)
1879
+ if (!HASURA_GRAPHQL_ENGINE_ALLOWED_IMAGES.has(normalized)) {
1880
+ return `${list} "${label}": образ hasura/graphql-engine має бути ${HASURA_GRAPHQL_ENGINE_IMAGE} (зараз: ${image}) (див. k8s.mdc)`
1881
+ }
1882
+ return null
1883
+ }
1884
+
1885
+ /**
1886
+ * Перевіряє масив **containers** / **initContainers** на зафіксований образ Hasura.
1887
+ * @param {string} list **containers** або **initContainers** (для тексту помилки)
1888
+ * @param {unknown} containers значення поля з маніфесту
1010
1889
  * @returns {string | null} текст порушення або null
1011
1890
  */
1012
1891
  function hasuraGraphqlEngineViolationInContainerList(list, containers) {
1013
1892
  if (!Array.isArray(containers)) return null
1014
1893
  for (const [i, c] of containers.entries()) {
1015
- const label =
1016
- typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
1017
- ? c.name
1018
- : `#${i + 1}`
1019
- if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
1020
- const cont = /** @type {Record<string, unknown>} */ (c)
1021
- const image = cont.image
1022
- if (typeof image === 'string' && image.trim() !== '' && isHasuraGraphqlEngineImageRef(image)) {
1023
- const normalized = stripImageDigest(image)
1024
- if (!HASURA_GRAPHQL_ENGINE_ALLOWED_IMAGES.has(normalized)) {
1025
- return `${list} "${label}": образ hasura/graphql-engine має бути ${HASURA_GRAPHQL_ENGINE_IMAGE} (зараз: ${image}) (див. k8s.mdc)`
1026
- }
1027
- }
1894
+ const v = hasuraGraphqlEngineViolationForOneContainer(list, c, i)
1895
+ if (v !== null) {
1896
+ return v
1028
1897
  }
1029
1898
  }
1030
1899
  return null
@@ -1230,6 +2099,35 @@ export function collectGatewayApiRouteBackendServiceNames(spec) {
1230
2099
  return out
1231
2100
  }
1232
2101
 
2102
+ /**
2103
+ * Один документ: маршрут Gateway API має посилатися на **Service** з суфіксом **`-hl`**.
2104
+ * @param {string} rel відносний шлях до файлу
2105
+ * @param {number} docIndex 1-based індекс документа
2106
+ * @param {Record<string, unknown>} rec корінь маніфесту
2107
+ * @param {(msg: string) => void} fail callback помилки
2108
+ * @returns {void}
2109
+ */
2110
+ function failIfGatewayRouteUsesNonHeadlessService(rel, docIndex, rec, fail) {
2111
+ const av = rec.apiVersion
2112
+ const kind = rec.kind
2113
+ if (
2114
+ typeof av !== 'string' ||
2115
+ !av.startsWith(GATEWAY_API_GROUP_PREFIX) ||
2116
+ typeof kind !== 'string' ||
2117
+ !GATEWAY_API_ROUTE_KINDS.has(kind)
2118
+ ) {
2119
+ return
2120
+ }
2121
+ const names = collectGatewayApiRouteBackendServiceNames(rec.spec)
2122
+ for (const svcName of names) {
2123
+ if (!svcName.endsWith(SVC_HL_NAME_SUFFIX)) {
2124
+ fail(
2125
+ `${rel}: Gateway API ${kind} (документ ${docIndex}): backendRef до Service має вказувати headless-сервіс з суфіксом «${SVC_HL_NAME_SUFFIX}» у name (зараз: «${svcName}»; див. k8s.mdc)`
2126
+ )
2127
+ }
2128
+ }
2129
+ }
2130
+
1233
2131
  /**
1234
2132
  * Реєструє порушення: маршрути Gateway API мають посилатися на **Service** з суфіксом **`-hl`**.
1235
2133
  * @param {string} rel відносний шлях до файлу
@@ -1237,40 +2135,154 @@ export function collectGatewayApiRouteBackendServiceNames(spec) {
1237
2135
  * @param {(msg: string) => void} fail callback помилки
1238
2136
  * @returns {void}
1239
2137
  */
1240
- function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
1241
- /** @type {import('yaml').Document[]} */
1242
- let docs
2138
+ function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
2139
+ /** @type {import('yaml').Document[]} */
2140
+ let docs
2141
+ try {
2142
+ docs = parseAllDocuments(body)
2143
+ } catch {
2144
+ return
2145
+ }
2146
+
2147
+ for (const [di, doc] of docs.entries()) {
2148
+ if (doc.errors.length === 0) {
2149
+ const obj = doc.toJSON()
2150
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
2151
+ failIfGatewayRouteUsesNonHeadlessService(rel, di + 1, /** @type {Record<string, unknown>} */ (obj), fail)
2152
+ }
2153
+ }
2154
+ }
2155
+ }
2156
+
2157
+ /**
2158
+ * Збирає **`metadata.name`** для **kind: Service** у коренях документів; при помилці викликає **fail** і повертає false.
2159
+ * @param {Record<string, unknown>[]} roots корені YAML-документів
2160
+ * @param {string} relForMsg відносний шлях до файлу для повідомлення
2161
+ * @param {string} fileLabel **svc.yaml** / **svc-hl.yaml**
2162
+ * @param {string[]} names накопичувач імен
2163
+ * @param {(msg: string) => void} fail реєстрація помилки
2164
+ * @returns {boolean} false, якщо зафіксовано порушення
2165
+ */
2166
+ function appendServiceNamesFromSvcRoots(roots, relForMsg, fileLabel, names, fail) {
2167
+ for (const [i, rootObj] of roots.entries()) {
2168
+ const r = /** @type {Record<string, unknown>} */ (rootObj)
2169
+ if (r.kind === 'Service') {
2170
+ const meta = r.metadata
2171
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
2172
+ fail(`${relForMsg}: ${fileLabel} (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
2173
+ return false
2174
+ }
2175
+ const nm = /** @type {Record<string, unknown>} */ (meta).name
2176
+ if (typeof nm !== 'string') {
2177
+ fail(`${relForMsg}: ${fileLabel} (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
2178
+ return false
2179
+ }
2180
+ names.push(nm)
2181
+ }
2182
+ }
2183
+ return true
2184
+ }
2185
+
2186
+ /**
2187
+ * Узгодженість імен **Service** між **svc.yaml** та **svc-hl.yaml**.
2188
+ * @param {string} relSvc відносний шлях до **svc.yaml**
2189
+ * @param {string} relHl відносний шлях до **svc-hl.yaml**
2190
+ * @param {string[]} svcNames імена з **svc.yaml**
2191
+ * @param {string[]} hlNames імена з **svc-hl.yaml**
2192
+ * @param {(msg: string) => void} fail реєстрація помилки
2193
+ * @returns {void}
2194
+ */
2195
+ function validateSvcHlServiceNamePairing(relSvc, relHl, svcNames, hlNames, fail) {
2196
+ if (svcNames.length === 0) {
2197
+ fail(`${relSvc}: svc.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
2198
+ return
2199
+ }
2200
+ if (hlNames.length === 0) {
2201
+ fail(`${relHl}: svc-hl.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
2202
+ return
2203
+ }
2204
+ const hlSet = new Set(hlNames)
2205
+ for (const n of svcNames) {
2206
+ const expectHl = `${n}${SVC_HL_NAME_SUFFIX}`
2207
+ if (!hlSet.has(expectHl)) {
2208
+ fail(
2209
+ `${relSvc}: для Service «${n}» у svc.yaml у svc-hl.yaml має бути Service з metadata.name «${expectHl}» (див. k8s.mdc)`
2210
+ )
2211
+ }
2212
+ }
2213
+ for (const h of hlNames) {
2214
+ if (h.endsWith(SVC_HL_NAME_SUFFIX)) {
2215
+ const base = h.slice(0, -SVC_HL_NAME_SUFFIX.length)
2216
+ if (!svcNames.includes(base)) {
2217
+ fail(
2218
+ `${relHl}: Service «${h}» у svc-hl.yaml не відповідає жодному Service у svc.yaml (очікується базове ім’я «${base}»; див. k8s.mdc)`
2219
+ )
2220
+ }
2221
+ } else {
2222
+ fail(
2223
+ `${relHl}: Service «${h}» у svc-hl.yaml: metadata.name має закінчуватися на «${SVC_HL_NAME_SUFFIX}» (див. k8s.mdc)`
2224
+ )
2225
+ }
2226
+ }
2227
+ }
2228
+
2229
+ /**
2230
+ * **svc-hl.yaml** без **svc.yaml** у тому самому каталозі.
2231
+ * @param {string} root корінь репозиторію
2232
+ * @param {string[]} yamlFiles абсолютні шляхи
2233
+ * @param {Set<string>} absSet той самий набір шляхів
2234
+ * @param {(msg: string) => void} fail реєстрація помилки
2235
+ * @returns {void}
2236
+ */
2237
+ function failIfSvcHlWithoutSiblingSvc(root, yamlFiles, absSet, fail) {
2238
+ for (const abs of yamlFiles.filter(p => basename(p).toLowerCase() === 'svc-hl.yaml')) {
2239
+ const svcAbs = join(dirname(abs), 'svc.yaml')
2240
+ if (!absSet.has(svcAbs)) {
2241
+ const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
2242
+ fail(`${rel}: svc-hl.yaml потребує svc.yaml у тому самому каталозі (див. k8s.mdc)`)
2243
+ }
2244
+ }
2245
+ }
2246
+
2247
+ /**
2248
+ * Одна пара **svc.yaml** / **svc-hl.yaml**: читання, імена **Service**, узгодженість.
2249
+ * @param {string} root корінь репозиторію
2250
+ * @param {Set<string>} absSet наявні yaml під k8s
2251
+ * @param {string} svcAbs абсолютний шлях до **svc.yaml**
2252
+ * @param {(msg: string) => void} fail реєстрація помилки
2253
+ * @returns {Promise<void>}
2254
+ */
2255
+ async function validateOneSvcYamlHlPair(root, absSet, svcAbs, fail) {
2256
+ const rel = (relative(root, svcAbs) || svcAbs).replaceAll('\\', '/')
2257
+ const hlAbs = join(dirname(svcAbs), 'svc-hl.yaml')
2258
+ if (!absSet.has(hlAbs)) {
2259
+ fail(`${rel}: поруч обов’язковий svc-hl.yaml (headless-копія з суфіксом -hl у metadata.name; див. k8s.mdc)`)
2260
+ return
2261
+ }
2262
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
2263
+ let svcBody
2264
+ let hlBody
1243
2265
  try {
1244
- docs = parseAllDocuments(body)
1245
- } catch {
2266
+ svcBody = await readK8sYamlBodyAfterModelineForSvcPair(svcAbs)
2267
+ hlBody = await readK8sYamlBodyAfterModelineForSvcPair(hlAbs)
2268
+ } catch (error) {
2269
+ const msg = error instanceof Error ? error.message : String(error)
2270
+ fail(`${rel}: не вдалося прочитати svc.yaml / svc-hl.yaml (${msg})`)
1246
2271
  return
1247
2272
  }
1248
-
1249
- for (const [di, doc] of docs.entries()) {
1250
- if (doc.errors.length === 0) {
1251
- const obj = doc.toJSON()
1252
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1253
- const rec = /** @type {Record<string, unknown>} */ (obj)
1254
- const av = rec.apiVersion
1255
- const kind = rec.kind
1256
- if (
1257
- typeof av === 'string' &&
1258
- av.startsWith(GATEWAY_API_GROUP_PREFIX) &&
1259
- typeof kind === 'string' &&
1260
- GATEWAY_API_ROUTE_KINDS.has(kind)
1261
- ) {
1262
- const names = collectGatewayApiRouteBackendServiceNames(rec.spec)
1263
- for (const svcName of names) {
1264
- if (!svcName.endsWith(SVC_HL_NAME_SUFFIX)) {
1265
- fail(
1266
- `${rel}: Gateway API ${kind} (документ ${di + 1}): backendRef до Service має вказувати headless-сервіс з суфіксом «${SVC_HL_NAME_SUFFIX}» у name (зараз: «${svcName}»; див. k8s.mdc)`
1267
- )
1268
- }
1269
- }
1270
- }
1271
- }
1272
- }
2273
+ const svcRoots = parseK8sYamlDocumentObjectRoots(svcBody)
2274
+ const hlRoots = parseK8sYamlDocumentObjectRoots(hlBody)
2275
+ /** @type {string[]} */
2276
+ const svcNames = []
2277
+ if (!appendServiceNamesFromSvcRoots(svcRoots, rel, 'svc.yaml', svcNames, fail)) {
2278
+ return
1273
2279
  }
2280
+ /** @type {string[]} */
2281
+ const hlNames = []
2282
+ if (!appendServiceNamesFromSvcRoots(hlRoots, hlRel, 'svc-hl.yaml', hlNames, fail)) {
2283
+ return
2284
+ }
2285
+ validateSvcHlServiceNamePairing(rel, hlRel, svcNames, hlNames, fail)
1274
2286
  }
1275
2287
 
1276
2288
  /**
@@ -1282,117 +2294,9 @@ function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
1282
2294
  */
1283
2295
  async function validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail) {
1284
2296
  const absSet = new Set(yamlFiles)
1285
-
1286
- for (const abs of yamlFiles) {
1287
- if (basename(abs).toLowerCase() === 'svc-hl.yaml') {
1288
- const svcAbs = join(dirname(abs), 'svc.yaml')
1289
- if (!absSet.has(svcAbs)) {
1290
- const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
1291
- fail(`${rel}: svc-hl.yaml потребує svc.yaml у тому самому каталозі (див. k8s.mdc)`)
1292
- }
1293
- }
1294
- }
1295
-
1296
- for (const svcAbs of yamlFiles) {
1297
- if (basename(svcAbs).toLowerCase() === 'svc.yaml') {
1298
- const rel = (relative(root, svcAbs) || svcAbs).replaceAll('\\', '/')
1299
- const hlAbs = join(dirname(svcAbs), 'svc-hl.yaml')
1300
- if (absSet.has(hlAbs)) {
1301
- /** @type {string | undefined} */
1302
- let svcBody
1303
- /** @type {string | undefined} */
1304
- let hlBody
1305
- try {
1306
- svcBody = await readK8sYamlBodyAfterModelineForSvcPair(svcAbs)
1307
- hlBody = await readK8sYamlBodyAfterModelineForSvcPair(hlAbs)
1308
- } catch (error) {
1309
- const msg = error instanceof Error ? error.message : String(error)
1310
- fail(`${rel}: не вдалося прочитати svc.yaml / svc-hl.yaml (${msg})`)
1311
- }
1312
- if (svcBody !== undefined && hlBody !== undefined) {
1313
- const svcRoots = parseK8sYamlDocumentObjectRoots(svcBody)
1314
- const hlRoots = parseK8sYamlDocumentObjectRoots(hlBody)
1315
-
1316
- /** @type {string[]} */
1317
- const svcNames = []
1318
- for (const [i, rootObj] of svcRoots.entries()) {
1319
- const r = /** @type {Record<string, unknown>} */ (rootObj)
1320
- if (r.kind === 'Service') {
1321
- const meta = r.metadata
1322
- if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
1323
- const nm = /** @type {Record<string, unknown>} */ (meta).name
1324
- if (typeof nm === 'string') {
1325
- svcNames.push(nm)
1326
- } else {
1327
- fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
1328
- }
1329
- } else {
1330
- fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
1331
- }
1332
- }
1333
- }
1334
-
1335
- if (svcNames.length === 0) {
1336
- fail(`${rel}: svc.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
1337
- } else {
1338
- /** @type {string[]} */
1339
- const hlNames = []
1340
- for (const [i, rootObj] of hlRoots.entries()) {
1341
- const r = /** @type {Record<string, unknown>} */ (rootObj)
1342
- if (r.kind === 'Service') {
1343
- const meta = r.metadata
1344
- if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
1345
- const nm = /** @type {Record<string, unknown>} */ (meta).name
1346
- if (typeof nm === 'string') {
1347
- hlNames.push(nm)
1348
- } else {
1349
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1350
- fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
1351
- }
1352
- } else {
1353
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1354
- fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
1355
- }
1356
- }
1357
- }
1358
-
1359
- if (hlNames.length === 0) {
1360
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1361
- fail(`${hlRel}: svc-hl.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
1362
- } else {
1363
- const hlSet = new Set(hlNames)
1364
- for (const n of svcNames) {
1365
- const expectHl = `${n}${SVC_HL_NAME_SUFFIX}`
1366
- if (!hlSet.has(expectHl)) {
1367
- fail(
1368
- `${rel}: для Service «${n}» у svc.yaml у svc-hl.yaml має бути Service з metadata.name «${expectHl}» (див. k8s.mdc)`
1369
- )
1370
- }
1371
- }
1372
-
1373
- for (const h of hlNames) {
1374
- if (h.endsWith(SVC_HL_NAME_SUFFIX)) {
1375
- const base = h.slice(0, -SVC_HL_NAME_SUFFIX.length)
1376
- if (!svcNames.includes(base)) {
1377
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1378
- fail(
1379
- `${hlRel}: Service «${h}» у svc-hl.yaml не відповідає жодному Service у svc.yaml (очікується базове ім’я «${base}»; див. k8s.mdc)`
1380
- )
1381
- }
1382
- } else {
1383
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1384
- fail(
1385
- `${hlRel}: Service «${h}» у svc-hl.yaml: metadata.name має закінчуватися на «${SVC_HL_NAME_SUFFIX}» (див. k8s.mdc)`
1386
- )
1387
- }
1388
- }
1389
- }
1390
- }
1391
- }
1392
- } else {
1393
- fail(`${rel}: поруч обов’язковий svc-hl.yaml (headless-копія з суфіксом -hl у metadata.name; див. k8s.mdc)`)
1394
- }
1395
- }
2297
+ failIfSvcHlWithoutSiblingSvc(root, yamlFiles, absSet, fail)
2298
+ for (const svcAbs of yamlFiles.filter(p => basename(p).toLowerCase() === 'svc.yaml')) {
2299
+ await validateOneSvcYamlHlPair(root, absSet, svcAbs, fail)
1396
2300
  }
1397
2301
  }
1398
2302
 
@@ -1460,7 +2364,90 @@ function isKustomizationFileName(baseLower) {
1460
2364
  export function isK8sBaseManifestYamlPath(rel, baseLower) {
1461
2365
  if (isKustomizationFileName(baseLower)) return false
1462
2366
  const n = rel.replaceAll('\\', '/')
1463
- return /(^|\/)k8s\/base\//u.test(n)
2367
+ return K8S_BASE_SEGMENT_RE.test(n)
2368
+ }
2369
+
2370
+ /**
2371
+ * Правила **metadata.namespace** для одного документа.
2372
+ * @param {string} rel відносний шлях
2373
+ * @param {number} docIndex 1-based
2374
+ * @param {unknown} obj корінь документа
2375
+ * @param {boolean} skipMetaNs пропуск для **kustomization.yaml**
2376
+ * @param {boolean} inBaseManifest файл у **k8s/base/**
2377
+ * @param {boolean} kustomizeManaged файл у графі kustomization
2378
+ * @param {(msg: string) => void} fail реєстрація помилки
2379
+ * @returns {void}
2380
+ */
2381
+ function failIfK8sPolicyNamespaceRulesViolated(
2382
+ rel,
2383
+ docIndex,
2384
+ obj,
2385
+ skipMetaNs,
2386
+ inBaseManifest,
2387
+ kustomizeManaged,
2388
+ fail
2389
+ ) {
2390
+ if (skipMetaNs) {
2391
+ return
2392
+ }
2393
+ if (inBaseManifest) {
2394
+ const req = metadataNamespaceRequiredViolation(obj, true)
2395
+ if (req !== null) {
2396
+ fail(`${rel}: документ ${docIndex}: ${req}`)
2397
+ }
2398
+ return
2399
+ }
2400
+ if (kustomizeManaged) {
2401
+ const ns = metadataNamespaceForbiddenViolation(obj)
2402
+ if (ns !== null) {
2403
+ fail(`${rel}: документ ${docIndex}: ${ns}`)
2404
+ }
2405
+ return
2406
+ }
2407
+ const req = metadataNamespaceRequiredViolation(obj, false)
2408
+ if (req !== null) {
2409
+ fail(`${rel}: документ ${docIndex}: ${req}`)
2410
+ }
2411
+ }
2412
+
2413
+ /**
2414
+ * Deployment / Service / HealthCheckPolicy — політики для одного документа.
2415
+ * @param {string} rel відносний шлях
2416
+ * @param {string} baseLower basename (нижній регістр)
2417
+ * @param {number} docIndex 1-based
2418
+ * @param {unknown} obj корінь документа
2419
+ * @param {(msg: string) => void} fail реєстрація помилки
2420
+ * @returns {void}
2421
+ */
2422
+ function failIfK8sPolicyResourceRulesViolated(rel, baseLower, docIndex, obj, fail) {
2423
+ const resV = deploymentResourcesViolation(obj)
2424
+ if (resV !== null) {
2425
+ fail(`${rel}: Deployment (документ ${docIndex}): ${resV}`)
2426
+ }
2427
+ const hasuraV = deploymentHasuraGraphqlEngineImageViolation(obj)
2428
+ if (hasuraV !== null) {
2429
+ fail(`${rel}: Deployment (документ ${docIndex}): ${hasuraV}`)
2430
+ }
2431
+ const svcGcpV = serviceForbiddenGcpAnnotationsViolation(obj)
2432
+ if (svcGcpV !== null) {
2433
+ fail(`${rel}: Service (документ ${docIndex}): ${svcGcpV}`)
2434
+ }
2435
+ if (baseLower === 'svc.yaml') {
2436
+ const svcT = serviceSvcYamlClusterIpTypeViolation(obj)
2437
+ if (svcT !== null) {
2438
+ fail(`${rel}: Service (документ ${docIndex}): ${svcT}`)
2439
+ }
2440
+ }
2441
+ if (baseLower === 'svc-hl.yaml') {
2442
+ const svcH = serviceSvcHlYamlHeadlessViolation(obj)
2443
+ if (svcH !== null) {
2444
+ fail(`${rel}: Service (документ ${docIndex}): ${svcH}`)
2445
+ }
2446
+ }
2447
+ const hcpHl = healthCheckPolicyTargetRefHeadlessServiceViolation(obj)
2448
+ if (hcpHl !== null) {
2449
+ fail(`${rel}: документ ${docIndex}: ${hcpHl}`)
2450
+ }
1464
2451
  }
1465
2452
 
1466
2453
  /**
@@ -1492,52 +2479,16 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
1492
2479
  fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
1493
2480
  } else {
1494
2481
  const obj = doc.toJSON()
1495
- if (!skipMetaNs) {
1496
- if (inBaseManifest) {
1497
- const req = metadataNamespaceRequiredViolation(obj, true)
1498
- if (req !== null) {
1499
- fail(`${rel}: документ ${di + 1}: ${req}`)
1500
- }
1501
- } else if (kustomizeManaged) {
1502
- const ns = metadataNamespaceForbiddenViolation(obj)
1503
- if (ns !== null) {
1504
- fail(`${rel}: документ ${di + 1}: ${ns}`)
1505
- }
1506
- } else {
1507
- const req = metadataNamespaceRequiredViolation(obj, false)
1508
- if (req !== null) {
1509
- fail(`${rel}: документ ${di + 1}: ${req}`)
1510
- }
1511
- }
1512
- }
1513
- const resV = deploymentResourcesViolation(obj)
1514
- if (resV !== null) {
1515
- fail(`${rel}: Deployment (документ ${di + 1}): ${resV}`)
1516
- }
1517
- const hasuraV = deploymentHasuraGraphqlEngineImageViolation(obj)
1518
- if (hasuraV !== null) {
1519
- fail(`${rel}: Deployment (документ ${di + 1}): ${hasuraV}`)
1520
- }
1521
- const svcGcpV = serviceForbiddenGcpAnnotationsViolation(obj)
1522
- if (svcGcpV !== null) {
1523
- fail(`${rel}: Service (документ ${di + 1}): ${svcGcpV}`)
1524
- }
1525
- if (baseLower === 'svc.yaml') {
1526
- const svcT = serviceSvcYamlClusterIpTypeViolation(obj)
1527
- if (svcT !== null) {
1528
- fail(`${rel}: Service (документ ${di + 1}): ${svcT}`)
1529
- }
1530
- }
1531
- if (baseLower === 'svc-hl.yaml') {
1532
- const svcH = serviceSvcHlYamlHeadlessViolation(obj)
1533
- if (svcH !== null) {
1534
- fail(`${rel}: Service (документ ${di + 1}): ${svcH}`)
1535
- }
1536
- }
1537
- const hcpHl = healthCheckPolicyTargetRefHeadlessServiceViolation(obj)
1538
- if (hcpHl !== null) {
1539
- fail(`${rel}: документ ${di + 1}: ${hcpHl}`)
1540
- }
2482
+ failIfK8sPolicyNamespaceRulesViolated(
2483
+ rel,
2484
+ di + 1,
2485
+ obj,
2486
+ skipMetaNs,
2487
+ inBaseManifest,
2488
+ kustomizeManaged,
2489
+ fail
2490
+ )
2491
+ failIfK8sPolicyResourceRulesViolated(rel, baseLower, di + 1, obj, fail)
1541
2492
  }
1542
2493
  }
1543
2494
  }
@@ -1548,31 +2499,24 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
1548
2499
  * @returns {string} рядок для шаблону імені файлу схеми
1549
2500
  */
1550
2501
  function kindToSchemaFilePart(kind) {
1551
- return kind.replaceAll(/[^a-zA-Z0-9]/gu, '').toLowerCase()
2502
+ let out = ''
2503
+ for (const ch of kind) {
2504
+ const c = ch.codePointAt(0)
2505
+ if (c !== undefined && ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122))) {
2506
+ out += ch
2507
+ }
2508
+ }
2509
+ return out.toLowerCase()
1552
2510
  }
1553
2511
 
1554
2512
  /**
1555
- * Очікуваний $schema для маніфесту згідно з k8s.mdc.
1556
- * @param {string} filePath шлях до файлу (для імені kustomization)
1557
- * @param {string} doc перший YAML-документ після modeline
1558
- * @returns {{ expected: string | null, reason: string }} reason для повідомлень про помилку
2513
+ * Очікуваний URL схеми за **apiVersion/kind** (не **kustomization.yaml**).
2514
+ * @param {string} doc текст першого документа
2515
+ * @param {string} apiVersion значення **apiVersion** з маніфесту
2516
+ * @param {string} kind значення **kind** з маніфесту
2517
+ * @returns {{ expected: string | null, reason: string }} очікуваний URL і пояснення для повідомлень
1559
2518
  */
1560
- export function expectedSchemaUrl(filePath, doc) {
1561
- const base = basename(filePath)
1562
- const baseLower = base.toLowerCase()
1563
-
1564
- if (baseLower === 'kustomization.yaml') {
1565
- return { expected: KUSTOMIZATION_SCHEMA, reason: 'kustomization (ім’я файлу)' }
1566
- }
1567
-
1568
- const { apiVersion, kind } = extractApiVersionAndKind(doc)
1569
- if (!apiVersion || !kind) {
1570
- return {
1571
- expected: null,
1572
- reason: 'не знайдено apiVersion/kind у першому документі (потрібні для перевірки $schema)'
1573
- }
1574
- }
1575
-
2519
+ function expectedSchemaUrlForTypedManifest(doc, apiVersion, kind) {
1576
2520
  const manifestType = extractTopLevelManifestType(doc)
1577
2521
  const explicit = lookupExplicitK8sSchema(apiVersion, kind, manifestType)
1578
2522
  if (explicit) {
@@ -1608,13 +2552,122 @@ export function expectedSchemaUrl(filePath, doc) {
1608
2552
  return { expected: url, reason: 'CRD / група поза yannh (datree CRDs-catalog)' }
1609
2553
  }
1610
2554
 
2555
+ /**
2556
+ * Очікуваний $schema для маніфесту згідно з k8s.mdc.
2557
+ * @param {string} filePath шлях до файлу (для імені kustomization)
2558
+ * @param {string} doc перший YAML-документ після modeline
2559
+ * @returns {{ expected: string | null, reason: string }} reason — для повідомлень про помилку
2560
+ */
2561
+ export function expectedSchemaUrl(filePath, doc) {
2562
+ const base = basename(filePath)
2563
+ const baseLower = base.toLowerCase()
2564
+
2565
+ if (baseLower === 'kustomization.yaml') {
2566
+ return { expected: KUSTOMIZATION_SCHEMA, reason: 'kustomization (ім’я файлу)' }
2567
+ }
2568
+
2569
+ const { apiVersion, kind } = extractApiVersionAndKind(doc)
2570
+ if (!apiVersion || !kind) {
2571
+ return {
2572
+ expected: null,
2573
+ reason: 'не знайдено apiVersion/kind у першому документі (потрібні для перевірки $schema)'
2574
+ }
2575
+ }
2576
+
2577
+ return expectedSchemaUrlForTypedManifest(doc, apiVersion, kind)
2578
+ }
2579
+
1611
2580
  /**
1612
2581
  * Підраховує рядки з modeline $schema у файлі.
1613
2582
  * @param {string[]} lines рядки файлу
1614
2583
  * @returns {number} скільки рядків містять modeline `$schema`
1615
2584
  */
1616
2585
  function countSchemaModelines(lines) {
1617
- return lines.filter(l => /^\s*#\s*yaml-language-server:\s*\$schema=\S+/u.test(l.trim())).length
2586
+ return lines.filter(l => OXLINT_SCHEMA_MODELINE_RE.test(l.trim())).length
2587
+ }
2588
+
2589
+ /**
2590
+ * Політики маніфестів і Gateway backendRefs після розбору тіла.
2591
+ * @param {string} rel відносний шлях
2592
+ * @param {string} baseLower basename (нижній регістр)
2593
+ * @param {string} body YAML після modeline
2594
+ * @param {(msg: string) => void} fail реєстрація помилки
2595
+ * @param {Set<string>} kustomizeManagedRel kustomize-managed шляхи
2596
+ * @returns {void}
2597
+ */
2598
+ function runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel) {
2599
+ const kustomizeManaged = kustomizeManagedRel.has(rel)
2600
+ validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
2601
+ scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
2602
+ }
2603
+
2604
+ /**
2605
+ * Файл з першим документом **HttpBackendGroup** (ALB Yandex): без modeline **$schema**.
2606
+ * @param {string} rel відносний шлях
2607
+ * @param {string} baseLower basename
2608
+ * @param {string[]} lines рядки файлу
2609
+ * @param {(msg: string) => void} fail реєстрація помилки
2610
+ * @param {(msg: string) => void} pass реєстрація успіху
2611
+ * @param {Set<string>} kustomizeManagedRel kustomize-managed шляхи
2612
+ * @returns {void}
2613
+ */
2614
+ function checkK8sYamlHttpBackendGroupFile(rel, baseLower, lines, fail, pass, kustomizeManagedRel) {
2615
+ const body = lines.join('\n')
2616
+ scanIngressInYamlDocuments(rel, body, fail)
2617
+ pass(`${rel}: HttpBackendGroup (alb.yc.io/v1alpha1) — modeline $schema не застосовується (k8s.mdc)`)
2618
+ runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel)
2619
+ }
2620
+
2621
+ /**
2622
+ * Стандартний файл: перший рядок — modeline **$schema**, далі перевірка URL і політики.
2623
+ * @param {string} abs абсолютний шлях
2624
+ * @param {string} rel відносний шлях
2625
+ * @param {string} baseLower basename
2626
+ * @param {string[]} lines рядки файлу
2627
+ * @param {(msg: string) => void} fail реєстрація помилки
2628
+ * @param {(msg: string) => void} pass реєстрація успіху
2629
+ * @param {Set<string>} kustomizeManagedRel kustomize-managed шляхи
2630
+ * @returns {void}
2631
+ */
2632
+ function checkK8sYamlFileWithSchemaModeline(abs, rel, baseLower, lines, fail, pass, kustomizeManagedRel) {
2633
+ const match = lines[0].match(MODELINE_RE)
2634
+ if (!match) {
2635
+ fail(`${rel}: некоректний modeline $schema у першому рядку`)
2636
+ return
2637
+ }
2638
+ const schemaUrl = match[1]
2639
+ if (countSchemaModelines(lines) > 1) {
2640
+ fail(`${rel}: кілька рядків yaml-language-server $schema — лиш один modeline на файл (див. k8s.mdc)`)
2641
+ return
2642
+ }
2643
+
2644
+ const body = yamlBodyAfterModeline(lines)
2645
+
2646
+ scanIngressInYamlDocuments(rel, body, fail)
2647
+
2648
+ if (schemaUrl.startsWith('file:')) {
2649
+ pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
2650
+ } else if (HTTPS_SCHEMA_RE.test(schemaUrl)) {
2651
+ const doc = firstYamlDocument(body)
2652
+ const { expected, reason } = expectedSchemaUrl(abs, doc)
2653
+
2654
+ if (expected === null) {
2655
+ fail(`${rel}: ${reason}`)
2656
+ return
2657
+ }
2658
+
2659
+ if (schemaUrl !== expected) {
2660
+ fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
2661
+ return
2662
+ }
2663
+
2664
+ pass(`${rel}: $schema узгоджено (${reason})`)
2665
+ } else {
2666
+ fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
2667
+ return
2668
+ }
2669
+
2670
+ runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel)
1618
2671
  }
1619
2672
 
1620
2673
  /**
@@ -1667,12 +2720,7 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
1667
2720
  )
1668
2721
  return
1669
2722
  }
1670
- const body = lines.join('\n')
1671
- scanIngressInYamlDocuments(rel, body, fail)
1672
- pass(`${rel}: HttpBackendGroup (alb.yc.io/v1alpha1) — modeline $schema не застосовується (k8s.mdc)`)
1673
- const kustomizeManaged = kustomizeManagedRel.has(rel)
1674
- validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
1675
- scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
2723
+ checkK8sYamlHttpBackendGroupFile(rel, baseLower, lines, fail, pass, kustomizeManagedRel)
1676
2724
  return
1677
2725
  }
1678
2726
 
@@ -1681,43 +2729,7 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
1681
2729
  return
1682
2730
  }
1683
2731
 
1684
- const m = /** @type {RegExpMatchArray} */ (lines[0].match(MODELINE_RE))
1685
- const schemaUrl = m[1]
1686
- if (countSchemaModelines(lines) > 1) {
1687
- fail(`${rel}: кілька рядків yaml-language-server $schema — лиш один modeline на файл (див. k8s.mdc)`)
1688
- return
1689
- }
1690
-
1691
- const body = yamlBodyAfterModeline(lines)
1692
-
1693
- scanIngressInYamlDocuments(rel, body, fail)
1694
-
1695
- if (schemaUrl.startsWith('file:')) {
1696
- pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
1697
- } else if (/^https:/iu.test(schemaUrl)) {
1698
- const doc = firstYamlDocument(body)
1699
- const { expected, reason } = expectedSchemaUrl(abs, doc)
1700
-
1701
- if (expected === null) {
1702
- fail(`${rel}: ${reason}`)
1703
- return
1704
- }
1705
-
1706
- if (schemaUrl !== expected) {
1707
- fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
1708
- return
1709
- }
1710
-
1711
- pass(`${rel}: $schema узгоджено (${reason})`)
1712
- } else {
1713
- fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
1714
- return
1715
- }
1716
-
1717
- const kustomizeManaged = kustomizeManagedRel.has(rel)
1718
- validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
1719
-
1720
- scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
2732
+ checkK8sYamlFileWithSchemaModeline(abs, rel, baseLower, lines, fail, pass, kustomizeManagedRel)
1721
2733
  }
1722
2734
 
1723
2735
  /**
@@ -1736,6 +2748,38 @@ function assertNoForbiddenK8sDevPaths(yamlFiles, root, fail) {
1736
2748
  }
1737
2749
  }
1738
2750
 
2751
+ /**
2752
+ * Один файл **k8s/base/kustomization.yaml**: непорожній **namespace:**.
2753
+ * @param {string} root корінь репозиторію
2754
+ * @param {string} abs абсолютний шлях до файлу
2755
+ * @param {(msg: string) => void} fail реєстрація порушення
2756
+ * @returns {Promise<void>}
2757
+ */
2758
+ async function verifyBaseKustomizationNamespaceOnFile(root, abs, fail) {
2759
+ const rel = relative(root, abs).replaceAll('\\', '/')
2760
+ try {
2761
+ const raw = await readFile(abs, 'utf8')
2762
+ const lines = toLines(raw)
2763
+ const body = yamlBodyAfterModeline(lines)
2764
+ /** @type {import('yaml').Document[] | undefined} */
2765
+ let docs
2766
+ try {
2767
+ docs = parseAllDocuments(body)
2768
+ } catch {
2769
+ fail(`${rel}: не вдалося розпарсити YAML для перевірки namespace у base (див. k8s.mdc)`)
2770
+ return
2771
+ }
2772
+ const first = docs[0]?.toJSON()
2773
+ const v = baseKustomizationNamespaceViolation(first)
2774
+ if (v) {
2775
+ fail(`${rel}: ${v}`)
2776
+ }
2777
+ } catch (error) {
2778
+ const msg = error instanceof Error ? error.message : String(error)
2779
+ fail(`${rel}: не вдалося прочитати (${msg})`)
2780
+ }
2781
+ }
2782
+
1739
2783
  /**
1740
2784
  * Якщо є **`k8s/base/kustomization.yaml`**, у ньому **завжди** має бути непорожній **`namespace:`**.
1741
2785
  * @param {string} root корінь репозиторію
@@ -1747,28 +2791,7 @@ async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
1747
2791
  for (const abs of yamlFiles) {
1748
2792
  const rel = relative(root, abs).replaceAll('\\', '/')
1749
2793
  if (isBaseKustomizationPath(rel)) {
1750
- try {
1751
- const raw = await readFile(abs, 'utf8')
1752
- const lines = toLines(raw)
1753
- const body = yamlBodyAfterModeline(lines)
1754
- /** @type {import('yaml').Document[] | undefined} */
1755
- let docs
1756
- try {
1757
- docs = parseAllDocuments(body)
1758
- } catch {
1759
- fail(`${rel}: не вдалося розпарсити YAML для перевірки namespace у base (див. k8s.mdc)`)
1760
- }
1761
- if (docs !== undefined) {
1762
- const first = docs[0]?.toJSON()
1763
- const v = baseKustomizationNamespaceViolation(first)
1764
- if (v) {
1765
- fail(`${rel}: ${v}`)
1766
- }
1767
- }
1768
- } catch (error) {
1769
- const msg = error instanceof Error ? error.message : String(error)
1770
- fail(`${rel}: не вдалося прочитати (${msg})`)
1771
- }
2794
+ await verifyBaseKustomizationNamespaceOnFile(root, abs, fail)
1772
2795
  }
1773
2796
  }
1774
2797
  }
@@ -1808,6 +2831,8 @@ export async function check() {
1808
2831
 
1809
2832
  await validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFiles, fail)
1810
2833
 
2834
+ await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
2835
+
1811
2836
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
1812
2837
 
1813
2838
  return reporter.getExitCode()