@nitra/cursor 1.8.104 → 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.
@@ -142,15 +142,38 @@ const EXPLICIT_K8S_SCHEMAS = new Map([
142
142
  ]
143
143
  ])
144
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
+
145
161
  /**
146
162
  * Витягує кореневе поле **`type:`** з документа (без повного YAML-парсера).
147
163
  * @param {string} doc фрагмент YAML одного документа
148
164
  * @returns {string | undefined} значення без лапок або undefined, якщо поля немає
149
165
  */
150
166
  function extractTopLevelManifestType(doc) {
151
- const m = doc.match(/^\s*type:\s*(\S+)\s*$/mu)
152
- const raw = m?.[1]?.replaceAll(/^["']|["']$/gu, '')
153
- 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
+ }
154
177
  }
155
178
 
156
179
  /**
@@ -198,6 +221,22 @@ const YANNH_GROUPS = new Set([
198
221
  ])
199
222
 
200
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
201
240
 
202
241
  /**
203
242
  * Чи містить шлях сегмент директорії `k8s` (рівно ця назва компонента).
@@ -205,7 +244,7 @@ const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
205
244
  * @returns {boolean} true, якщо серед компонентів шляху є каталог `k8s`
206
245
  */
207
246
  export function pathHasK8sSegment(filePath) {
208
- const parts = filePath.split(/[/\\]/u)
247
+ const parts = filePath.split(PATH_SPLIT_RE)
209
248
  return parts.includes('k8s')
210
249
  }
211
250
 
@@ -347,6 +386,45 @@ export function kustomizationSvcYamlMissingSvcHlViolation(kustomizationDir, path
347
386
  return null
348
387
  }
349
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
+
350
428
  /**
351
429
  * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: разом із **`svc.yaml`** має бути **`svc-hl.yaml`** у полях шляхів.
352
430
  * @param {string} root корінь репозиторію
@@ -355,39 +433,8 @@ export function kustomizationSvcYamlMissingSvcHlViolation(kustomizationDir, path
355
433
  * @returns {Promise<void>}
356
434
  */
357
435
  async function validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail) {
358
- for (const kustAbs of yamlFiles) {
359
- if (basename(kustAbs).toLowerCase() === 'kustomization.yaml') {
360
- const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
361
- let raw
362
- try {
363
- raw = await readFile(kustAbs, 'utf8')
364
- } catch (error) {
365
- const msg = error instanceof Error ? error.message : String(error)
366
- fail(`${rel}: не вдалося прочитати для перевірки svc.yaml/svc-hl.yaml у kustomization (${msg})`)
367
- }
368
- if (raw !== undefined) {
369
- const lines = toLines(raw)
370
- const body = yamlBodyAfterModeline(lines)
371
- /** @type {import('yaml').Document[] | undefined} */
372
- let docs
373
- try {
374
- docs = parseAllDocuments(body)
375
- } catch {
376
- fail(`${rel}: не вдалося розпарсити YAML для перевірки svc.yaml/svc-hl.yaml у kustomization (див. k8s.mdc)`)
377
- }
378
- if (docs !== undefined) {
379
- const first = docs[0]?.toJSON()
380
- if (first !== null && first !== undefined && typeof first === 'object' && !Array.isArray(first)) {
381
- const pathRefs = pathsFromKustomizationObject(first)
382
- const kustDir = dirname(kustAbs)
383
- const v = kustomizationSvcYamlMissingSvcHlViolation(kustDir, pathRefs)
384
- if (v !== null) {
385
- fail(`${rel}: ${v}`)
386
- }
387
- }
388
- }
389
- }
390
- }
436
+ for (const kustAbs of yamlFiles.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
437
+ await validateOneKustomizationSvcHlWithSvc(root, kustAbs, fail)
391
438
  }
392
439
  }
393
440
 
@@ -441,31 +488,44 @@ export async function collectKustomizeManagedRelPaths(root, yamlFilesAbs) {
441
488
  const kustDir = dirname(normKust)
442
489
  const pathRefs = pathsFromKustomizationObject(first)
443
490
 
444
- for (const ref of pathRefs) {
445
- if (!ref.includes('://')) {
446
- const resolved = resolve(kustDir, ref)
447
- let st
448
- try {
449
- st = await stat(resolved)
450
- } catch {
451
- st = undefined
452
- }
453
- if (st) {
454
- if (st.isFile()) {
455
- if (/\.ya?ml$/iu.test(resolved)) {
456
- const pr = posixRelFromAbs(root, resolved)
457
- if (pr !== null) managed.add(pr)
458
- }
459
- } else if (st.isDirectory()) {
460
- const childK = existsSync(join(resolved, 'kustomization.yaml'))
461
- ? join(resolved, 'kustomization.yaml')
462
- : null
463
- if (childK !== null) {
464
- await walkKustomization(childK)
465
- }
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)
466
514
  }
467
515
  }
516
+ return
517
+ }
518
+ if (!st.isDirectory()) {
519
+ return
468
520
  }
521
+ const childK = existsSync(join(resolved, 'kustomization.yaml')) ? join(resolved, 'kustomization.yaml') : null
522
+ if (childK !== null) {
523
+ await walkKustomization(childK)
524
+ }
525
+ }
526
+
527
+ for (const ref of pathRefs) {
528
+ await handleKustomizeManagedPathRef(ref)
469
529
  }
470
530
  }
471
531
 
@@ -628,6 +688,32 @@ export function kustomizeResourceDescriptorsIdentityEqual(a, b) {
628
688
  )
629
689
  }
630
690
 
691
+ /**
692
+ * Непорожнє **`metadata.name`**, якщо задано коректно.
693
+ * @param {unknown} meta значення **metadata**
694
+ * @returns {string} ім’я або порожній рядок
695
+ */
696
+ function metadataNameTrimmed(meta) {
697
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
698
+ return ''
699
+ }
700
+ const n = /** @type {Record<string, unknown>} */ (meta).name
701
+ return typeof n === 'string' && n.trim() !== '' ? n.trim() : ''
702
+ }
703
+
704
+ /**
705
+ * Непорожній **`metadata.namespace`**, якщо задано коректно.
706
+ * @param {unknown} meta значення **metadata**
707
+ * @returns {string} namespace або порожній рядок
708
+ */
709
+ function metadataNamespaceTrimmed(meta) {
710
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
711
+ return ''
712
+ }
713
+ const ns = /** @type {Record<string, unknown>} */ (meta).namespace
714
+ return typeof ns === 'string' && ns.trim() !== '' ? ns.trim() : ''
715
+ }
716
+
631
717
  /**
632
718
  * Будує дескриптор з маніфесту (пропускає **Kustomization** та об’єкти без **metadata.name**).
633
719
  * @param {Record<string, unknown>} obj корінь документа
@@ -647,28 +733,14 @@ export function kustomizeResourceDescriptorFromManifest(obj, kustomizationDefaul
647
733
  return null
648
734
  }
649
735
  const meta = obj.metadata
650
- let name = ''
651
- if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
652
- const m = /** @type {Record<string, unknown>} */ (meta)
653
- const n = m.name
654
- if (typeof n === 'string' && n.trim() !== '') {
655
- name = n.trim()
656
- }
657
- }
736
+ const name = metadataNameTrimmed(meta)
658
737
  if (name === '') {
659
738
  return null
660
739
  }
661
740
  const { group, version } = splitK8sApiVersion(obj.apiVersion)
662
741
  let namespace = ''
663
742
  if (!isClusterScopedKubernetesKind(kind)) {
664
- let metaNs = ''
665
- if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
666
- const m = /** @type {Record<string, unknown>} */ (meta)
667
- const ns = m.namespace
668
- if (typeof ns === 'string' && ns.trim() !== '') {
669
- metaNs = ns.trim()
670
- }
671
- }
743
+ const metaNs = metadataNamespaceTrimmed(meta)
672
744
  const def =
673
745
  typeof kustomizationDefaultNs === 'string' && kustomizationDefaultNs.trim() !== ''
674
746
  ? kustomizationDefaultNs.trim()
@@ -691,8 +763,7 @@ async function readK8sYamlDocumentRootsForInventory(abs) {
691
763
  return []
692
764
  }
693
765
  const lines = toLines(raw)
694
- const body =
695
- lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
766
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
696
767
  /** @type {unknown[]} */
697
768
  const roots = parseK8sYamlDocumentObjectRoots(body)
698
769
  /** @type {Record<string, unknown>[]} */
@@ -727,8 +798,7 @@ export async function collectResourceDescriptorsForKustomizationWalk(kustAbs, ro
727
798
  return []
728
799
  }
729
800
  const lines = toLines(raw)
730
- const body =
731
- lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
801
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
732
802
 
733
803
  /** @type {import('yaml').Document[] | undefined} */
734
804
  let docs
@@ -749,44 +819,52 @@ export async function collectResourceDescriptorsForKustomizationWalk(kustAbs, ro
749
819
  /** @type {KustomizeResourceDescriptor[]} */
750
820
  const out = []
751
821
 
752
- for (const ref of pathRefs) {
753
- if (typeof ref === 'string' && !ref.includes('://')) {
754
- const resolved = resolve(kustDir, ref)
755
- if (resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
756
- /** @type {import('node:fs').Stats | undefined} */
757
- let st
758
- try {
759
- st = await stat(resolved)
760
- } catch {
761
- st = undefined
762
- }
763
- if (st !== undefined) {
764
- if (st.isFile() && /\.ya?ml$/iu.test(resolved)) {
765
- const roots = await readK8sYamlDocumentRootsForInventory(resolved)
766
- for (const o of roots) {
767
- const d = kustomizeResourceDescriptorFromManifest(o, kustNs)
768
- if (d !== null) {
769
- out.push(d)
770
- }
771
- }
772
- } else if (st.isDirectory()) {
773
- const childK = existsSync(join(resolved, 'kustomization.yaml'))
774
- ? join(resolved, 'kustomization.yaml')
775
- : null
776
- if (childK !== null) {
777
- const sub = await collectResourceDescriptorsForKustomizationWalk(
778
- childK,
779
- rootNorm,
780
- visitedKustomization
781
- )
782
- out.push(...sub)
783
- }
784
- }
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)
785
850
  }
786
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)
787
861
  }
788
862
  }
789
863
 
864
+ for (const ref of pathRefs) {
865
+ await handleResourceDescriptorPathRef(ref)
866
+ }
867
+
790
868
  return out
791
869
  }
792
870
 
@@ -861,6 +939,233 @@ function formatKustomizePatchTargetForMessage(target) {
861
939
  return parts.length > 0 ? parts.join(', ') : JSON.stringify(t)
862
940
  }
863
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
+
864
1169
  /**
865
1170
  * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: **target** patch і strategic-merge посилання не вказують на ресурс поза інвентарем **resources** / **bases** / **components** / **crds**.
866
1171
  * @param {string} root корінь репозиторію
@@ -870,136 +1175,8 @@ function formatKustomizePatchTargetForMessage(target) {
870
1175
  */
871
1176
  async function validateKustomizationPatchTargetsResolved(root, yamlFilesAbs, fail) {
872
1177
  const rootNorm = resolve(root)
873
- for (const kustAbs of yamlFilesAbs) {
874
- if (basename(kustAbs).toLowerCase() === 'kustomization.yaml') {
875
- const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
876
- /** @type {string | undefined} */
877
- let raw
878
- let readOk = false
879
- try {
880
- raw = await readFile(kustAbs, 'utf8')
881
- readOk = true
882
- } catch (error) {
883
- const msg = error instanceof Error ? error.message : String(error)
884
- fail(`${rel}: не вдалося прочитати для перевірки patch target (${msg})`)
885
- }
886
- if (readOk && raw !== undefined) {
887
- const lines = toLines(raw)
888
- const body =
889
- lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
890
- /** @type {import('yaml').Document[] | null} */
891
- let docs = null
892
- try {
893
- docs = parseAllDocuments(body)
894
- } catch {
895
- fail(`${rel}: не вдалося розпарсити YAML для перевірки patch target`)
896
- }
897
- if (docs !== null) {
898
- const first = docs[0]?.toJSON()
899
- if (first !== null && first !== undefined && typeof first === 'object' && !Array.isArray(first)) {
900
- const rec = /** @type {Record<string, unknown>} */ (first)
901
- if (rec.kind === 'Kustomization') {
902
- const visited = new Set()
903
- const catalog = await collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visited)
904
- const kustDir = dirname(resolve(kustAbs))
905
- const kustNs =
906
- typeof rec.namespace === 'string' && rec.namespace.trim() !== '' ? rec.namespace.trim() : ''
907
-
908
- for (const { section, index, target } of extractExplicitPatchTargetsFromKustomization(first)) {
909
- if (
910
- shouldValidateKustomizePatchTarget(target) &&
911
- !kustomizeResourceCatalogMatchesPatchTarget(catalog, target)
912
- ) {
913
- fail(
914
- `${rel}: ${section}[${index}].target — немає відповідного ресурсу в resources/bases/components/crds (рекурсивно): ${formatKustomizePatchTargetForMessage(target)}`
915
- )
916
- }
917
- }
918
-
919
- const patchesOnlyPath = rec.patches
920
- if (Array.isArray(patchesOnlyPath)) {
921
- let pIdx = 0
922
- for (const p of patchesOnlyPath) {
923
- pIdx++
924
- if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
925
- const pr = /** @type {Record<string, unknown>} */ (p)
926
- const hasTargetKey = 'target' in pr && pr.target !== undefined && pr.target !== null
927
- const pathStr = typeof pr.path === 'string' ? pr.path.trim() : ''
928
- const inlinePatch = typeof pr.patch === 'string' && pr.patch.trim() !== ''
929
- if (!hasTargetKey && pathStr !== '' && !inlinePatch && !pathStr.includes('://')) {
930
- const resolved = resolve(kustDir, pathStr)
931
- if (resolvedFilePathIsUnderRoot(rootNorm, resolved) && existsSync(resolved)) {
932
- /** @type {import('node:fs').Stats | null} */
933
- let st = null
934
- try {
935
- st = await stat(resolved)
936
- } catch {
937
- st = null
938
- }
939
- if (st !== null && st.isFile() && /\.ya?ml$/iu.test(resolved)) {
940
- const roots = await readK8sYamlDocumentRootsForInventory(resolved)
941
- let docIdx = 0
942
- for (const o of roots) {
943
- docIdx++
944
- const d = kustomizeResourceDescriptorFromManifest(o, kustNs)
945
- if (
946
- d !== null &&
947
- !catalog.some(c => kustomizeResourceDescriptorsIdentityEqual(c, d))
948
- ) {
949
- const relPatch = (relative(root, resolved) || pathStr).replaceAll('\\', '/')
950
- fail(
951
- `${rel}: patches[${pIdx}] path «${relPatch}» документ ${docIdx} — у каталозі resources немає ресурсу ${d.kind}/${d.name} (namespace=${d.namespace || '(порожньо)'}, apiVersion group/version=${d.group || 'core'}/${d.version})`
952
- )
953
- }
954
- }
955
- }
956
- }
957
- }
958
- }
959
- }
960
- }
961
-
962
- const sm = rec.patchesStrategicMerge
963
- if (Array.isArray(sm)) {
964
- let smIdx = 0
965
- for (const ref of sm) {
966
- smIdx++
967
- if (typeof ref === 'string' && ref.trim() !== '' && !ref.includes('://')) {
968
- const resolved = resolve(kustDir, ref.trim())
969
- if (resolvedFilePathIsUnderRoot(rootNorm, resolved) && existsSync(resolved)) {
970
- /** @type {import('node:fs').Stats | null} */
971
- let st = null
972
- try {
973
- st = await stat(resolved)
974
- } catch {
975
- st = null
976
- }
977
- if (st !== null && st.isFile() && /\.ya?ml$/iu.test(resolved)) {
978
- const roots = await readK8sYamlDocumentRootsForInventory(resolved)
979
- let docIdx = 0
980
- for (const o of roots) {
981
- docIdx++
982
- const d = kustomizeResourceDescriptorFromManifest(o, kustNs)
983
- if (
984
- d !== null &&
985
- !catalog.some(c => kustomizeResourceDescriptorsIdentityEqual(c, d))
986
- ) {
987
- const relPatch = (relative(root, resolved) || ref).replaceAll('\\', '/')
988
- fail(
989
- `${rel}: patchesStrategicMerge[${smIdx}] «${relPatch}» документ ${docIdx} — у каталозі resources немає ресурсу ${d.kind}/${d.name} (namespace=${d.namespace || '(порожньо)'}, apiVersion group/version=${d.group || 'core'}/${d.version})`
990
- )
991
- }
992
- }
993
- }
994
- }
995
- }
996
- }
997
- }
998
- }
999
- }
1000
- }
1001
- }
1002
- }
1178
+ for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
1179
+ await validatePatchTargetsOneKustomizationFile(root, kustAbs, rootNorm, fail)
1003
1180
  }
1004
1181
  }
1005
1182
 
@@ -1010,7 +1187,7 @@ async function validateKustomizationPatchTargetsResolved(root, yamlFilesAbs, fai
1010
1187
  */
1011
1188
  export function isBaseKustomizationPath(rel) {
1012
1189
  const n = rel.replaceAll('\\', '/')
1013
- return /(^|\/)k8s\/base\/kustomization\.yaml$/u.test(n)
1190
+ return K8S_BASE_KUSTOMIZATION_PATH_RE.test(n)
1014
1191
  }
1015
1192
 
1016
1193
  /**
@@ -1040,10 +1217,10 @@ async function findK8sYamlFiles(root) {
1040
1217
  const out = []
1041
1218
  await walkDir(root, p => {
1042
1219
  if (!pathHasK8sSegment(p)) return
1043
- if (!/\.ya?ml$/iu.test(p)) return
1220
+ if (!YAML_EXTENSION_RE.test(p)) return
1044
1221
  out.push(p)
1045
1222
  })
1046
-
1223
+
1047
1224
  return out.toSorted((a, b) => a.localeCompare(b))
1048
1225
  }
1049
1226
 
@@ -1059,6 +1236,22 @@ function k8sYamlBodyForDocumentParse(lines) {
1059
1236
  return lines.join('\n')
1060
1237
  }
1061
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
+
1062
1255
  /**
1063
1256
  * Чи всі нетривіальні документи у тілі — **`kind: BackendConfig`**, чи є змішування з іншими kind.
1064
1257
  * @param {string} body YAML без обов’язкового modeline (див. `k8sYamlBodyForDocumentParse`)
@@ -1073,24 +1266,22 @@ export function classifyBackendConfigManifestPresence(body) {
1073
1266
  return 'unparsed'
1074
1267
  }
1075
1268
 
1076
- let hasBc = false
1077
- let hasOther = false
1269
+ const acc = { hasBc: false, hasOther: false }
1078
1270
  for (const doc of docs) {
1079
1271
  if (doc.errors.length === 0) {
1080
1272
  const obj = doc.toJSON()
1081
1273
  if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1082
- const kind = obj.kind
1083
- if (kind === 'BackendConfig') {
1084
- hasBc = true
1085
- } else if (kind !== undefined && kind !== null && String(kind).trim() !== '') {
1086
- hasOther = true
1087
- }
1274
+ updateBackendConfigKindFlags(obj.kind, acc)
1088
1275
  }
1089
1276
  }
1090
1277
  }
1091
1278
 
1092
- if (!hasBc) return 'none'
1093
- if (hasOther) return 'mixed'
1279
+ if (!acc.hasBc) {
1280
+ return 'none'
1281
+ }
1282
+ if (acc.hasOther) {
1283
+ return 'mixed'
1284
+ }
1094
1285
  return 'only'
1095
1286
  }
1096
1287
 
@@ -1136,7 +1327,7 @@ async function removeBackendConfigOnlyK8sYamlFiles(root, fail, pass) {
1136
1327
  */
1137
1328
  function toLines(content) {
1138
1329
  const body = content.startsWith('\uFEFF') ? content.slice(1) : content
1139
- return body.split(/\r?\n/u)
1330
+ return body.split(YAML_LINE_SPLIT_RE)
1140
1331
  }
1141
1332
 
1142
1333
  /**
@@ -1187,8 +1378,15 @@ function parseK8sYamlDocumentObjectRoots(body) {
1187
1378
  * @returns {string} перший документ без зайвих пробілів по краях
1188
1379
  */
1189
1380
  function firstYamlDocument(body) {
1190
- const parts = body.split(/^---\s*$/mu)
1191
- return (parts[0] ?? body).trim()
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()
1192
1390
  }
1193
1391
 
1194
1392
  /**
@@ -1197,12 +1395,28 @@ function firstYamlDocument(body) {
1197
1395
  * @returns {{ apiVersion?: string, kind?: string }} знайдені поля або властивості відсутні
1198
1396
  */
1199
1397
  function extractApiVersionAndKind(doc) {
1200
- const av = doc.match(/^\s*apiVersion:\s*(\S+)\s*$/mu)
1201
- const k = doc.match(/^\s*kind:\s*(\S+)\s*$/mu)
1202
- return {
1203
- apiVersion: av?.[1]?.replaceAll(/^["']|["']$/gu, ''),
1204
- kind: k?.[1]?.replaceAll(/^["']|["']$/gu, '')
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
+ }
1205
1418
  }
1419
+ return { apiVersion, kind }
1206
1420
  }
1207
1421
 
1208
1422
  /**
@@ -1223,10 +1437,10 @@ export function k8sYamlFirstDocIsAlbYcHttpBackendGroup(yamlBody) {
1223
1437
  * @returns {boolean} true, якщо є `$patch: delete` і блоки kind/metadata для HealthCheckPolicy
1224
1438
  */
1225
1439
  export function ruKustomizationHasHealthCheckDeletePatch(raw) {
1226
- if (!/\$patch:\s*delete/u.test(raw)) return false
1227
- if (!/kind:\s*HealthCheckPolicy/u.test(raw)) return false
1228
- if (!/metadata:/u.test(raw)) return false
1229
- if (!/name:\s*\S+/u.test(raw)) return false
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
1230
1444
  return true
1231
1445
  }
1232
1446
 
@@ -1343,137 +1557,249 @@ export function json6902PathsWithRemoveAndAddOnSamePath(ops) {
1343
1557
  }
1344
1558
 
1345
1559
  /**
1346
- * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: у inline **`patch`** і у зовнішніх patch-файлах не має бути **remove** і **add** на той самий **path**.
1347
- * @param {string} root корінь репозиторію
1348
- * @param {string[]} yamlFilesAbs абсолютні шляхи до yaml під k8s
1560
+ * Реєструє порушення, якщо в JSON6902-операціях є **remove** і **add** на один **path**.
1561
+ * @param {string} rel відносний шлях до kustomization.yaml
1562
+ * @param {string} label фрагмент повідомлення (наприклад `patches[1] inline JSON6902`)
1563
+ * @param {string} patchText текст patch
1349
1564
  * @param {(msg: string) => void} fail реєстрація порушення
1350
- * @returns {Promise<void>}
1565
+ * @returns {void}
1351
1566
  */
1352
- async function validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFilesAbs, fail) {
1353
- const rootNorm = resolve(root)
1354
- for (const kustAbs of yamlFilesAbs) {
1355
- if (basename(kustAbs).toLowerCase() === 'kustomization.yaml') {
1356
- const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
1357
- /** @type {string | undefined} */
1358
- let raw
1359
- let readOk = false
1360
- try {
1361
- raw = await readFile(kustAbs, 'utf8')
1362
- readOk = true
1363
- } catch (error) {
1364
- const msg = error instanceof Error ? error.message : String(error)
1365
- fail(`${rel}: не вдалося прочитати для перевірки JSON6902 (${msg})`)
1366
- }
1367
- if (readOk && raw !== undefined) {
1368
- const lines = toLines(raw)
1369
- const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
1370
- /** @type {import('yaml').Document[] | null} */
1371
- let docs = null
1372
- try {
1373
- docs = parseAllDocuments(body)
1374
- } catch {
1375
- docs = null
1376
- }
1377
- if (docs !== null) {
1378
- for (const doc of docs) {
1379
- if (doc.errors.length === 0) {
1380
- const rootObj = doc.toJSON()
1381
- if (rootObj !== null && typeof rootObj === 'object' && !Array.isArray(rootObj)) {
1382
- const rec = /** @type {Record<string, unknown>} */ (rootObj)
1383
- if (rec.kind === 'Kustomization') {
1384
- const patches = rec.patches
1385
- if (Array.isArray(patches)) {
1386
- let patchIdx = 0
1387
- for (const p of patches) {
1388
- patchIdx++
1389
- if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
1390
- const pr = /** @type {Record<string, unknown>} */ (p)
1391
- if (typeof pr.patch === 'string' && pr.patch.trim() !== '') {
1392
- const ops = collectJson6902OperationsFromPatchText(pr.patch)
1393
- const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
1394
- if (bad.length > 0) {
1395
- fail(
1396
- `${rel}: patches[${patchIdx}] inline JSON6902: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
1397
- )
1398
- }
1399
- }
1400
- if (typeof pr.path === 'string' && pr.path.trim() !== '') {
1401
- const patchRef = pr.path.trim()
1402
- const resolved = resolve(dirname(kustAbs), patchRef)
1403
- if (resolvedFilePathIsUnderRoot(rootNorm, resolved) && existsSync(resolved)) {
1404
- /** @type {import('node:fs').Stats | null} */
1405
- let st = null
1406
- try {
1407
- st = await stat(resolved)
1408
- } catch {
1409
- st = null
1410
- }
1411
- if (st !== null && st.isFile()) {
1412
- /** @type {string | undefined} */
1413
- let pRaw
1414
- try {
1415
- pRaw = await readFile(resolved, 'utf8')
1416
- } catch {
1417
- pRaw = undefined
1418
- }
1419
- if (pRaw !== undefined) {
1420
- const ops = collectJson6902OperationsFromPatchText(pRaw)
1421
- if (ops.length > 0) {
1422
- const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
1423
- if (bad.length > 0) {
1424
- const relPatch = (relative(root, resolved) || patchRef).replaceAll('\\', '/')
1425
- fail(
1426
- `${rel}: patch-файл «${relPatch}»: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
1427
- )
1428
- }
1429
- }
1430
- }
1431
- }
1432
- }
1433
- }
1434
- }
1435
- }
1436
- }
1437
- }
1438
- }
1439
- }
1440
- }
1441
- }
1442
- }
1443
- }
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(', ')}`)
1444
1572
  }
1445
1573
  }
1446
1574
 
1447
1575
  /**
1448
- * Шукає **Ingress** у розібраних документах; реєструє порушення.
1449
- * @param {string} rel відносний шлях до файлу
1450
- * @param {string} body YAML після modeline
1451
- * @param {(msg: string) => void} fail callback для помилки (Ingress)
1452
- * @returns {void}
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>}
1453
1583
  */
1454
- function scanIngressInYamlDocuments(rel, body, fail) {
1455
- /** @type {import('yaml').Document[]} */
1456
- let docs
1584
+ async function auditJson6902PatchExternalFile(rel, resolved, root, patchRef, fail) {
1585
+ /** @type {import('node:fs').Stats | null} */
1586
+ let st = null
1457
1587
  try {
1458
- docs = parseAllDocuments(body)
1588
+ st = await stat(resolved)
1459
1589
  } catch {
1590
+ st = null
1591
+ }
1592
+ if (st === null || !st.isFile()) {
1460
1593
  return
1461
1594
  }
1462
-
1463
- for (const [di, doc] of docs.entries()) {
1464
- if (doc.errors.length === 0) {
1465
- const obj = doc.toJSON()
1466
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1467
- const rec = /** @type {Record<string, unknown>} */ (obj)
1468
- if (rec.kind === 'Ingress') {
1469
- fail(
1470
- `${rel}: знайдено kind: Ingress (документ ${di + 1}) — заміни на Gateway API: HTTPRoute (hr.yaml), HealthCheckPolicy (hc.yaml) (див. k8s.mdc)`
1471
- )
1472
- }
1473
- }
1474
- }
1595
+ let pRaw
1596
+ try {
1597
+ pRaw = await readFile(resolved, 'utf8')
1598
+ } catch {
1599
+ return
1475
1600
  }
1476
- }
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
+
1726
+ /**
1727
+ * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: у inline **`patch`** і у зовнішніх patch-файлах не має бути **remove** і **add** на той самий **path**.
1728
+ * @param {string} root корінь репозиторію
1729
+ * @param {string[]} yamlFilesAbs абсолютні шляхи до yaml під k8s
1730
+ * @param {(msg: string) => void} fail реєстрація порушення
1731
+ * @returns {Promise<void>}
1732
+ */
1733
+ async function validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFilesAbs, fail) {
1734
+ const rootNorm = resolve(root)
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
1751
+ }
1752
+ fail(
1753
+ `${rel}: знайдено kind: Ingress (документ ${docIndex}) — заміни на Gateway API: HTTPRoute (hr.yaml), HealthCheckPolicy (hc.yaml) (див. k8s.mdc)`
1754
+ )
1755
+ }
1756
+
1757
+ /**
1758
+ * Шукає **Ingress** у розібраних документах; реєструє порушення.
1759
+ * @param {string} rel відносний шлях до файлу
1760
+ * @param {string} body YAML після modeline
1761
+ * @param {(msg: string) => void} fail callback для помилки (Ingress)
1762
+ * @returns {void}
1763
+ */
1764
+ function scanIngressInYamlDocuments(rel, body, fail) {
1765
+ /** @type {import('yaml').Document[]} */
1766
+ let docs
1767
+ try {
1768
+ docs = parseAllDocuments(body)
1769
+ } catch {
1770
+ return
1771
+ }
1772
+
1773
+ for (const [di, doc] of docs.entries()) {
1774
+ if (doc.errors.length === 0) {
1775
+ const obj = doc.toJSON()
1776
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1777
+ failIfIngressInDocument(rel, di + 1, /** @type {Record<string, unknown>} */ (obj), fail)
1778
+ }
1779
+ }
1780
+ }
1781
+ }
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
+ }
1477
1803
 
1478
1804
  /**
1479
1805
  * Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
@@ -1500,15 +1826,9 @@ export function deploymentResourcesViolation(manifest) {
1500
1826
  typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
1501
1827
  ? c.name
1502
1828
  : `#${i + 1}`
1503
- if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
1504
- const cont = /** @type {Record<string, unknown>} */ (c)
1505
- if (!('resources' in cont)) {
1506
- return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
1507
- }
1508
- const r = cont.resources
1509
- if (r === null || typeof r !== 'object' || Array.isArray(r)) {
1510
- return `контейнер "${label}": resources має бути записом у YAML (наприклад порожній: resources: {})`
1511
- }
1829
+ const v = deploymentContainerResourcesViolation(c, label)
1830
+ if (v !== null) {
1831
+ return v
1512
1832
  }
1513
1833
  }
1514
1834
 
@@ -1532,31 +1852,48 @@ function stripImageDigest(image) {
1532
1852
  */
1533
1853
  function isHasuraGraphqlEngineImageRef(image) {
1534
1854
  const s = stripImageDigest(image)
1535
- return /(^|\/)hasura\/graphql-engine(?:[:]|$)/u.test(s)
1855
+ return HASURA_GRAPHQL_ENGINE_RE.test(s)
1536
1856
  }
1537
1857
 
1538
1858
  /**
1539
- * Перевіряє пін образу Hasura у одному списку контейнерів Pod spec.
1859
+ * Перевірка образу Hasura для одного контейнера у списку **containers** / **initContainers**.
1540
1860
  * @param {string} list ім’я поля для повідомлення (`containers` / `initContainers`)
1541
- * @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 значення поля з маніфесту
1542
1889
  * @returns {string | null} текст порушення або null
1543
1890
  */
1544
1891
  function hasuraGraphqlEngineViolationInContainerList(list, containers) {
1545
1892
  if (!Array.isArray(containers)) return null
1546
1893
  for (const [i, c] of containers.entries()) {
1547
- const label =
1548
- typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
1549
- ? c.name
1550
- : `#${i + 1}`
1551
- if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
1552
- const cont = /** @type {Record<string, unknown>} */ (c)
1553
- const image = cont.image
1554
- if (typeof image === 'string' && image.trim() !== '' && isHasuraGraphqlEngineImageRef(image)) {
1555
- const normalized = stripImageDigest(image)
1556
- if (!HASURA_GRAPHQL_ENGINE_ALLOWED_IMAGES.has(normalized)) {
1557
- return `${list} "${label}": образ hasura/graphql-engine має бути ${HASURA_GRAPHQL_ENGINE_IMAGE} (зараз: ${image}) (див. k8s.mdc)`
1558
- }
1559
- }
1894
+ const v = hasuraGraphqlEngineViolationForOneContainer(list, c, i)
1895
+ if (v !== null) {
1896
+ return v
1560
1897
  }
1561
1898
  }
1562
1899
  return null
@@ -1762,6 +2099,35 @@ export function collectGatewayApiRouteBackendServiceNames(spec) {
1762
2099
  return out
1763
2100
  }
1764
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
+
1765
2131
  /**
1766
2132
  * Реєструє порушення: маршрути Gateway API мають посилатися на **Service** з суфіксом **`-hl`**.
1767
2133
  * @param {string} rel відносний шлях до файлу
@@ -1782,27 +2148,141 @@ function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
1782
2148
  if (doc.errors.length === 0) {
1783
2149
  const obj = doc.toJSON()
1784
2150
  if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1785
- const rec = /** @type {Record<string, unknown>} */ (obj)
1786
- const av = rec.apiVersion
1787
- const kind = rec.kind
1788
- if (
1789
- typeof av === 'string' &&
1790
- av.startsWith(GATEWAY_API_GROUP_PREFIX) &&
1791
- typeof kind === 'string' &&
1792
- GATEWAY_API_ROUTE_KINDS.has(kind)
1793
- ) {
1794
- const names = collectGatewayApiRouteBackendServiceNames(rec.spec)
1795
- for (const svcName of names) {
1796
- if (!svcName.endsWith(SVC_HL_NAME_SUFFIX)) {
1797
- fail(
1798
- `${rel}: Gateway API ${kind} (документ ${di + 1}): backendRef до Service має вказувати headless-сервіс з суфіксом «${SVC_HL_NAME_SUFFIX}» у name (зараз: «${svcName}»; див. k8s.mdc)`
1799
- )
1800
- }
1801
- }
1802
- }
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
1803
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)
1804
2181
  }
1805
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
2265
+ try {
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})`)
2271
+ return
2272
+ }
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
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)
1806
2286
  }
1807
2287
 
1808
2288
  /**
@@ -1814,117 +2294,9 @@ function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
1814
2294
  */
1815
2295
  async function validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail) {
1816
2296
  const absSet = new Set(yamlFiles)
1817
-
1818
- for (const abs of yamlFiles) {
1819
- if (basename(abs).toLowerCase() === 'svc-hl.yaml') {
1820
- const svcAbs = join(dirname(abs), 'svc.yaml')
1821
- if (!absSet.has(svcAbs)) {
1822
- const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
1823
- fail(`${rel}: svc-hl.yaml потребує svc.yaml у тому самому каталозі (див. k8s.mdc)`)
1824
- }
1825
- }
1826
- }
1827
-
1828
- for (const svcAbs of yamlFiles) {
1829
- if (basename(svcAbs).toLowerCase() === 'svc.yaml') {
1830
- const rel = (relative(root, svcAbs) || svcAbs).replaceAll('\\', '/')
1831
- const hlAbs = join(dirname(svcAbs), 'svc-hl.yaml')
1832
- if (absSet.has(hlAbs)) {
1833
- /** @type {string | undefined} */
1834
- let svcBody
1835
- /** @type {string | undefined} */
1836
- let hlBody
1837
- try {
1838
- svcBody = await readK8sYamlBodyAfterModelineForSvcPair(svcAbs)
1839
- hlBody = await readK8sYamlBodyAfterModelineForSvcPair(hlAbs)
1840
- } catch (error) {
1841
- const msg = error instanceof Error ? error.message : String(error)
1842
- fail(`${rel}: не вдалося прочитати svc.yaml / svc-hl.yaml (${msg})`)
1843
- }
1844
- if (svcBody !== undefined && hlBody !== undefined) {
1845
- const svcRoots = parseK8sYamlDocumentObjectRoots(svcBody)
1846
- const hlRoots = parseK8sYamlDocumentObjectRoots(hlBody)
1847
-
1848
- /** @type {string[]} */
1849
- const svcNames = []
1850
- for (const [i, rootObj] of svcRoots.entries()) {
1851
- const r = /** @type {Record<string, unknown>} */ (rootObj)
1852
- if (r.kind === 'Service') {
1853
- const meta = r.metadata
1854
- if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
1855
- const nm = /** @type {Record<string, unknown>} */ (meta).name
1856
- if (typeof nm === 'string') {
1857
- svcNames.push(nm)
1858
- } else {
1859
- fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
1860
- }
1861
- } else {
1862
- fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
1863
- }
1864
- }
1865
- }
1866
-
1867
- if (svcNames.length === 0) {
1868
- fail(`${rel}: svc.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
1869
- } else {
1870
- /** @type {string[]} */
1871
- const hlNames = []
1872
- for (const [i, rootObj] of hlRoots.entries()) {
1873
- const r = /** @type {Record<string, unknown>} */ (rootObj)
1874
- if (r.kind === 'Service') {
1875
- const meta = r.metadata
1876
- if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
1877
- const nm = /** @type {Record<string, unknown>} */ (meta).name
1878
- if (typeof nm === 'string') {
1879
- hlNames.push(nm)
1880
- } else {
1881
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1882
- fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
1883
- }
1884
- } else {
1885
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1886
- fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
1887
- }
1888
- }
1889
- }
1890
-
1891
- if (hlNames.length === 0) {
1892
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1893
- fail(`${hlRel}: svc-hl.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
1894
- } else {
1895
- const hlSet = new Set(hlNames)
1896
- for (const n of svcNames) {
1897
- const expectHl = `${n}${SVC_HL_NAME_SUFFIX}`
1898
- if (!hlSet.has(expectHl)) {
1899
- fail(
1900
- `${rel}: для Service «${n}» у svc.yaml у svc-hl.yaml має бути Service з metadata.name «${expectHl}» (див. k8s.mdc)`
1901
- )
1902
- }
1903
- }
1904
-
1905
- for (const h of hlNames) {
1906
- if (h.endsWith(SVC_HL_NAME_SUFFIX)) {
1907
- const base = h.slice(0, -SVC_HL_NAME_SUFFIX.length)
1908
- if (!svcNames.includes(base)) {
1909
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1910
- fail(
1911
- `${hlRel}: Service «${h}» у svc-hl.yaml не відповідає жодному Service у svc.yaml (очікується базове ім’я «${base}»; див. k8s.mdc)`
1912
- )
1913
- }
1914
- } else {
1915
- const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1916
- fail(
1917
- `${hlRel}: Service «${h}» у svc-hl.yaml: metadata.name має закінчуватися на «${SVC_HL_NAME_SUFFIX}» (див. k8s.mdc)`
1918
- )
1919
- }
1920
- }
1921
- }
1922
- }
1923
- }
1924
- } else {
1925
- fail(`${rel}: поруч обов’язковий svc-hl.yaml (headless-копія з суфіксом -hl у metadata.name; див. k8s.mdc)`)
1926
- }
1927
- }
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)
1928
2300
  }
1929
2301
  }
1930
2302
 
@@ -1992,7 +2364,90 @@ function isKustomizationFileName(baseLower) {
1992
2364
  export function isK8sBaseManifestYamlPath(rel, baseLower) {
1993
2365
  if (isKustomizationFileName(baseLower)) return false
1994
2366
  const n = rel.replaceAll('\\', '/')
1995
- 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
+ }
1996
2451
  }
1997
2452
 
1998
2453
  /**
@@ -2024,52 +2479,16 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
2024
2479
  fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
2025
2480
  } else {
2026
2481
  const obj = doc.toJSON()
2027
- if (!skipMetaNs) {
2028
- if (inBaseManifest) {
2029
- const req = metadataNamespaceRequiredViolation(obj, true)
2030
- if (req !== null) {
2031
- fail(`${rel}: документ ${di + 1}: ${req}`)
2032
- }
2033
- } else if (kustomizeManaged) {
2034
- const ns = metadataNamespaceForbiddenViolation(obj)
2035
- if (ns !== null) {
2036
- fail(`${rel}: документ ${di + 1}: ${ns}`)
2037
- }
2038
- } else {
2039
- const req = metadataNamespaceRequiredViolation(obj, false)
2040
- if (req !== null) {
2041
- fail(`${rel}: документ ${di + 1}: ${req}`)
2042
- }
2043
- }
2044
- }
2045
- const resV = deploymentResourcesViolation(obj)
2046
- if (resV !== null) {
2047
- fail(`${rel}: Deployment (документ ${di + 1}): ${resV}`)
2048
- }
2049
- const hasuraV = deploymentHasuraGraphqlEngineImageViolation(obj)
2050
- if (hasuraV !== null) {
2051
- fail(`${rel}: Deployment (документ ${di + 1}): ${hasuraV}`)
2052
- }
2053
- const svcGcpV = serviceForbiddenGcpAnnotationsViolation(obj)
2054
- if (svcGcpV !== null) {
2055
- fail(`${rel}: Service (документ ${di + 1}): ${svcGcpV}`)
2056
- }
2057
- if (baseLower === 'svc.yaml') {
2058
- const svcT = serviceSvcYamlClusterIpTypeViolation(obj)
2059
- if (svcT !== null) {
2060
- fail(`${rel}: Service (документ ${di + 1}): ${svcT}`)
2061
- }
2062
- }
2063
- if (baseLower === 'svc-hl.yaml') {
2064
- const svcH = serviceSvcHlYamlHeadlessViolation(obj)
2065
- if (svcH !== null) {
2066
- fail(`${rel}: Service (документ ${di + 1}): ${svcH}`)
2067
- }
2068
- }
2069
- const hcpHl = healthCheckPolicyTargetRefHeadlessServiceViolation(obj)
2070
- if (hcpHl !== null) {
2071
- fail(`${rel}: документ ${di + 1}: ${hcpHl}`)
2072
- }
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)
2073
2492
  }
2074
2493
  }
2075
2494
  }
@@ -2080,31 +2499,24 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
2080
2499
  * @returns {string} рядок для шаблону імені файлу схеми
2081
2500
  */
2082
2501
  function kindToSchemaFilePart(kind) {
2083
- 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()
2084
2510
  }
2085
2511
 
2086
2512
  /**
2087
- * Очікуваний $schema для маніфесту згідно з k8s.mdc.
2088
- * @param {string} filePath шлях до файлу (для імені kustomization)
2089
- * @param {string} doc перший YAML-документ після modeline
2090
- * @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 і пояснення для повідомлень
2091
2518
  */
2092
- export function expectedSchemaUrl(filePath, doc) {
2093
- const base = basename(filePath)
2094
- const baseLower = base.toLowerCase()
2095
-
2096
- if (baseLower === 'kustomization.yaml') {
2097
- return { expected: KUSTOMIZATION_SCHEMA, reason: 'kustomization (ім’я файлу)' }
2098
- }
2099
-
2100
- const { apiVersion, kind } = extractApiVersionAndKind(doc)
2101
- if (!apiVersion || !kind) {
2102
- return {
2103
- expected: null,
2104
- reason: 'не знайдено apiVersion/kind у першому документі (потрібні для перевірки $schema)'
2105
- }
2106
- }
2107
-
2519
+ function expectedSchemaUrlForTypedManifest(doc, apiVersion, kind) {
2108
2520
  const manifestType = extractTopLevelManifestType(doc)
2109
2521
  const explicit = lookupExplicitK8sSchema(apiVersion, kind, manifestType)
2110
2522
  if (explicit) {
@@ -2140,13 +2552,122 @@ export function expectedSchemaUrl(filePath, doc) {
2140
2552
  return { expected: url, reason: 'CRD / група поза yannh (datree CRDs-catalog)' }
2141
2553
  }
2142
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
+
2143
2580
  /**
2144
2581
  * Підраховує рядки з modeline $schema у файлі.
2145
2582
  * @param {string[]} lines рядки файлу
2146
2583
  * @returns {number} скільки рядків містять modeline `$schema`
2147
2584
  */
2148
2585
  function countSchemaModelines(lines) {
2149
- 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)
2150
2671
  }
2151
2672
 
2152
2673
  /**
@@ -2199,12 +2720,7 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
2199
2720
  )
2200
2721
  return
2201
2722
  }
2202
- const body = lines.join('\n')
2203
- scanIngressInYamlDocuments(rel, body, fail)
2204
- pass(`${rel}: HttpBackendGroup (alb.yc.io/v1alpha1) — modeline $schema не застосовується (k8s.mdc)`)
2205
- const kustomizeManaged = kustomizeManagedRel.has(rel)
2206
- validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
2207
- scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
2723
+ checkK8sYamlHttpBackendGroupFile(rel, baseLower, lines, fail, pass, kustomizeManagedRel)
2208
2724
  return
2209
2725
  }
2210
2726
 
@@ -2213,43 +2729,7 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
2213
2729
  return
2214
2730
  }
2215
2731
 
2216
- const m = /** @type {RegExpMatchArray} */ (lines[0].match(MODELINE_RE))
2217
- const schemaUrl = m[1]
2218
- if (countSchemaModelines(lines) > 1) {
2219
- fail(`${rel}: кілька рядків yaml-language-server $schema — лиш один modeline на файл (див. k8s.mdc)`)
2220
- return
2221
- }
2222
-
2223
- const body = yamlBodyAfterModeline(lines)
2224
-
2225
- scanIngressInYamlDocuments(rel, body, fail)
2226
-
2227
- if (schemaUrl.startsWith('file:')) {
2228
- pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
2229
- } else if (/^https:/iu.test(schemaUrl)) {
2230
- const doc = firstYamlDocument(body)
2231
- const { expected, reason } = expectedSchemaUrl(abs, doc)
2232
-
2233
- if (expected === null) {
2234
- fail(`${rel}: ${reason}`)
2235
- return
2236
- }
2237
-
2238
- if (schemaUrl !== expected) {
2239
- fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
2240
- return
2241
- }
2242
-
2243
- pass(`${rel}: $schema узгоджено (${reason})`)
2244
- } else {
2245
- fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
2246
- return
2247
- }
2248
-
2249
- const kustomizeManaged = kustomizeManagedRel.has(rel)
2250
- validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
2251
-
2252
- scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
2732
+ checkK8sYamlFileWithSchemaModeline(abs, rel, baseLower, lines, fail, pass, kustomizeManagedRel)
2253
2733
  }
2254
2734
 
2255
2735
  /**
@@ -2268,6 +2748,38 @@ function assertNoForbiddenK8sDevPaths(yamlFiles, root, fail) {
2268
2748
  }
2269
2749
  }
2270
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
+
2271
2783
  /**
2272
2784
  * Якщо є **`k8s/base/kustomization.yaml`**, у ньому **завжди** має бути непорожній **`namespace:`**.
2273
2785
  * @param {string} root корінь репозиторію
@@ -2279,28 +2791,7 @@ async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
2279
2791
  for (const abs of yamlFiles) {
2280
2792
  const rel = relative(root, abs).replaceAll('\\', '/')
2281
2793
  if (isBaseKustomizationPath(rel)) {
2282
- try {
2283
- const raw = await readFile(abs, 'utf8')
2284
- const lines = toLines(raw)
2285
- const body = yamlBodyAfterModeline(lines)
2286
- /** @type {import('yaml').Document[] | undefined} */
2287
- let docs
2288
- try {
2289
- docs = parseAllDocuments(body)
2290
- } catch {
2291
- fail(`${rel}: не вдалося розпарсити YAML для перевірки namespace у base (див. k8s.mdc)`)
2292
- }
2293
- if (docs !== undefined) {
2294
- const first = docs[0]?.toJSON()
2295
- const v = baseKustomizationNamespaceViolation(first)
2296
- if (v) {
2297
- fail(`${rel}: ${v}`)
2298
- }
2299
- }
2300
- } catch (error) {
2301
- const msg = error instanceof Error ? error.message : String(error)
2302
- fail(`${rel}: не вдалося прочитати (${msg})`)
2303
- }
2794
+ await verifyBaseKustomizationNamespaceOnFile(root, abs, fail)
2304
2795
  }
2305
2796
  }
2306
2797
  }