@nitra/cursor 1.8.104 → 1.8.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,239 @@ 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 (shouldValidateKustomizePatchTarget(target) && !kustomizeResourceCatalogMatchesPatchTarget(catalog, target)) {
953
+ fail(
954
+ `${rel}: ${section}[${index}].target — немає відповідного ресурсу в resources/bases/components/crds (рекурсивно): ${formatKustomizePatchTargetForMessage(target)}`
955
+ )
956
+ }
957
+ }
958
+ }
959
+
960
+ /**
961
+ * Документи з YAML-файлу мають мати дескриптор у **catalog** (інвентар resources).
962
+ * @param {string} rel відносний шлях до kustomization.yaml
963
+ * @param {string} resolvedAbs абсолютний шлях до patch-файлу
964
+ * @param {string} root корінь репо
965
+ * @param {string} relPatchFallback якщо **relative** дає порожньо
966
+ * @param {string} violationIntro префікс повідомлення (`patches[1] path` або `patchesStrategicMerge[2]`)
967
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар
968
+ * @param {string} kustNs default namespace
969
+ * @param {(msg: string) => void} fail реєстрація помилки
970
+ * @returns {Promise<void>}
971
+ */
972
+ async function failIfYamlFileRootsMissingFromCatalog(
973
+ rel,
974
+ resolvedAbs,
975
+ root,
976
+ relPatchFallback,
977
+ violationIntro,
978
+ catalog,
979
+ kustNs,
980
+ fail
981
+ ) {
982
+ const roots = await readK8sYamlDocumentRootsForInventory(resolvedAbs)
983
+ let docIdx = 0
984
+ for (const o of roots) {
985
+ docIdx++
986
+ const d = kustomizeResourceDescriptorFromManifest(o, kustNs)
987
+ if (d !== null && !catalog.some(c => kustomizeResourceDescriptorsIdentityEqual(c, d))) {
988
+ const relPatch = (relative(root, resolvedAbs) || relPatchFallback).replaceAll('\\', '/')
989
+ fail(
990
+ `${rel}: ${violationIntro} «${relPatch}» документ ${docIdx} — у каталозі resources немає ресурсу ${d.kind}/${d.name} (namespace=${d.namespace || '(порожньо)'}, apiVersion group/version=${d.group || 'core'}/${d.version})`
991
+ )
992
+ }
993
+ }
994
+ }
995
+
996
+ /**
997
+ * Вирішує відносний шлях до існуючого **.yaml** під root і перевіряє, що це файл.
998
+ * @param {string} kustDir каталог kustomization
999
+ * @param {string} pathStr відносний шлях
1000
+ * @param {string} rootNorm нормалізований корінь репо
1001
+ * @returns {Promise<string | null>} абсолютний шлях або null
1002
+ */
1003
+ async function resolveExistingYamlFileUnderRoot(kustDir, pathStr, rootNorm) {
1004
+ const resolved = resolve(kustDir, pathStr)
1005
+ if (!resolvedFilePathIsUnderRoot(rootNorm, resolved) || !existsSync(resolved)) {
1006
+ return null
1007
+ }
1008
+ /** @type {import('node:fs').Stats | null} */
1009
+ let st = null
1010
+ try {
1011
+ st = await stat(resolved)
1012
+ } catch {
1013
+ st = null
1014
+ }
1015
+ if (st === null || !st.isFile() || !YAML_EXTENSION_RE.test(resolved)) {
1016
+ return null
1017
+ }
1018
+ return resolved
1019
+ }
1020
+
1021
+ /**
1022
+ * Один елемент **patches[]** лише з **path** (без **target**, без inline patch): корені файлу проти інвентарю.
1023
+ * @param {string} rel відносний шлях до kustomization.yaml
1024
+ * @param {unknown} p елемент **patches**
1025
+ * @param {number} pIdx 1-based індекс у масиві
1026
+ * @param {string} kustDir каталог kustomization.yaml
1027
+ * @param {string} rootNorm нормалізований корінь репо
1028
+ * @param {string} root корінь репо
1029
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар
1030
+ * @param {string} kustNs default namespace з kustomization
1031
+ * @param {(msg: string) => void} fail реєстрація помилки
1032
+ * @returns {Promise<void>}
1033
+ */
1034
+ async function failIfOnePathOnlyPatchNotInCatalog(rel, p, pIdx, kustDir, rootNorm, root, catalog, kustNs, fail) {
1035
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) {
1036
+ return
1037
+ }
1038
+ const pr = /** @type {Record<string, unknown>} */ (p)
1039
+ const hasTargetKey = 'target' in pr && pr.target !== undefined && pr.target !== null
1040
+ const pathStr = typeof pr.path === 'string' ? pr.path.trim() : ''
1041
+ const inlinePatch = typeof pr.patch === 'string' && pr.patch.trim() !== ''
1042
+ if (hasTargetKey || pathStr === '' || inlinePatch || pathStr.includes('://')) {
1043
+ return
1044
+ }
1045
+ const resolved = await resolveExistingYamlFileUnderRoot(kustDir, pathStr, rootNorm)
1046
+ if (resolved === null) {
1047
+ return
1048
+ }
1049
+ await failIfYamlFileRootsMissingFromCatalog(
1050
+ rel,
1051
+ resolved,
1052
+ root,
1053
+ pathStr,
1054
+ `patches[${pIdx}] path`,
1055
+ catalog,
1056
+ kustNs,
1057
+ fail
1058
+ )
1059
+ }
1060
+
1061
+ /**
1062
+ * **patches[]** лише з **path** (без **target**, без inline patch) — документи у файлі мають збігатися з інвентарем.
1063
+ * @param {string} rel відносний шлях до kustomization.yaml
1064
+ * @param {unknown} patches поле **patches**
1065
+ * @param {string} kustDir каталог kustomization.yaml
1066
+ * @param {string} rootNorm нормалізований корінь репо
1067
+ * @param {string} root корінь репо
1068
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар
1069
+ * @param {string} kustNs default namespace з kustomization
1070
+ * @param {(msg: string) => void} fail реєстрація помилки
1071
+ * @returns {Promise<void>}
1072
+ */
1073
+ async function failIfPathOnlyPatchesNotInCatalog(rel, patches, kustDir, rootNorm, root, catalog, kustNs, fail) {
1074
+ if (!Array.isArray(patches)) {
1075
+ return
1076
+ }
1077
+ let pIdx = 0
1078
+ for (const p of patches) {
1079
+ pIdx++
1080
+ await failIfOnePathOnlyPatchNotInCatalog(rel, p, pIdx, kustDir, rootNorm, root, catalog, kustNs, fail)
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * **patchesStrategicMerge** — кожен документ у файлі має збігатися з інвентарем.
1086
+ * @param {string} rel відносний шлях до kustomization.yaml
1087
+ * @param {unknown} sm поле **patchesStrategicMerge**
1088
+ * @param {string} kustDir каталог kustomization.yaml
1089
+ * @param {string} rootNorm нормалізований корінь репо
1090
+ * @param {string} root корінь репо
1091
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар
1092
+ * @param {string} kustNs default namespace з kustomization
1093
+ * @param {(msg: string) => void} fail реєстрація помилки
1094
+ * @returns {Promise<void>}
1095
+ */
1096
+ async function failIfStrategicMergePatchesNotInCatalog(rel, sm, kustDir, rootNorm, root, catalog, kustNs, fail) {
1097
+ if (!Array.isArray(sm)) {
1098
+ return
1099
+ }
1100
+ let smIdx = 0
1101
+ for (const ref of sm) {
1102
+ smIdx++
1103
+ if (typeof ref === 'string' && ref.trim() !== '' && !ref.includes('://')) {
1104
+ const resolved = await resolveExistingYamlFileUnderRoot(kustDir, ref.trim(), rootNorm)
1105
+ if (resolved !== null) {
1106
+ await failIfYamlFileRootsMissingFromCatalog(
1107
+ rel,
1108
+ resolved,
1109
+ root,
1110
+ ref,
1111
+ `patchesStrategicMerge[${smIdx}]`,
1112
+ catalog,
1113
+ kustNs,
1114
+ fail
1115
+ )
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ /**
1122
+ * Один **`kustomization.yaml`**: patch **target**, **path** без target, **patchesStrategicMerge**.
1123
+ * @param {string} root корінь репозиторію
1124
+ * @param {string} kustAbs абсолютний шлях до файлу
1125
+ * @param {string} rootNorm нормалізований корінь
1126
+ * @param {(msg: string) => void} fail реєстрація помилки
1127
+ * @returns {Promise<void>}
1128
+ */
1129
+ async function validatePatchTargetsOneKustomizationFile(root, kustAbs, rootNorm, fail) {
1130
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
1131
+ let raw
1132
+ try {
1133
+ raw = await readFile(kustAbs, 'utf8')
1134
+ } catch (error) {
1135
+ const msg = error instanceof Error ? error.message : String(error)
1136
+ fail(`${rel}: не вдалося прочитати для перевірки patch target (${msg})`)
1137
+ return
1138
+ }
1139
+ const lines = toLines(raw)
1140
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
1141
+ /** @type {import('yaml').Document[] | null} */
1142
+ let docs = null
1143
+ try {
1144
+ docs = parseAllDocuments(body)
1145
+ } catch {
1146
+ fail(`${rel}: не вдалося розпарсити YAML для перевірки patch target`)
1147
+ return
1148
+ }
1149
+ const first = docs[0]?.toJSON()
1150
+ if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
1151
+ return
1152
+ }
1153
+ const rec = /** @type {Record<string, unknown>} */ (first)
1154
+ if (rec.kind !== 'Kustomization') {
1155
+ return
1156
+ }
1157
+ const visited = new Set()
1158
+ const catalog = await collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visited)
1159
+ const kustDir = dirname(resolve(kustAbs))
1160
+ const kustNs = typeof rec.namespace === 'string' && rec.namespace.trim() !== '' ? rec.namespace.trim() : ''
1161
+ failIfExplicitPatchTargetsNotInCatalog(rel, first, catalog, fail)
1162
+ await failIfPathOnlyPatchesNotInCatalog(rel, rec.patches, kustDir, rootNorm, root, catalog, kustNs, fail)
1163
+ await failIfStrategicMergePatchesNotInCatalog(
1164
+ rel,
1165
+ rec.patchesStrategicMerge,
1166
+ kustDir,
1167
+ rootNorm,
1168
+ root,
1169
+ catalog,
1170
+ kustNs,
1171
+ fail
1172
+ )
1173
+ }
1174
+
864
1175
  /**
865
1176
  * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: **target** patch і strategic-merge посилання не вказують на ресурс поза інвентарем **resources** / **bases** / **components** / **crds**.
866
1177
  * @param {string} root корінь репозиторію
@@ -870,136 +1181,8 @@ function formatKustomizePatchTargetForMessage(target) {
870
1181
  */
871
1182
  async function validateKustomizationPatchTargetsResolved(root, yamlFilesAbs, fail) {
872
1183
  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
- }
1184
+ for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
1185
+ await validatePatchTargetsOneKustomizationFile(root, kustAbs, rootNorm, fail)
1003
1186
  }
1004
1187
  }
1005
1188
 
@@ -1010,7 +1193,7 @@ async function validateKustomizationPatchTargetsResolved(root, yamlFilesAbs, fai
1010
1193
  */
1011
1194
  export function isBaseKustomizationPath(rel) {
1012
1195
  const n = rel.replaceAll('\\', '/')
1013
- return /(^|\/)k8s\/base\/kustomization\.yaml$/u.test(n)
1196
+ return K8S_BASE_KUSTOMIZATION_PATH_RE.test(n)
1014
1197
  }
1015
1198
 
1016
1199
  /**
@@ -1040,10 +1223,10 @@ async function findK8sYamlFiles(root) {
1040
1223
  const out = []
1041
1224
  await walkDir(root, p => {
1042
1225
  if (!pathHasK8sSegment(p)) return
1043
- if (!/\.ya?ml$/iu.test(p)) return
1226
+ if (!YAML_EXTENSION_RE.test(p)) return
1044
1227
  out.push(p)
1045
1228
  })
1046
-
1229
+
1047
1230
  return out.toSorted((a, b) => a.localeCompare(b))
1048
1231
  }
1049
1232
 
@@ -1059,6 +1242,22 @@ function k8sYamlBodyForDocumentParse(lines) {
1059
1242
  return lines.join('\n')
1060
1243
  }
1061
1244
 
1245
+ /**
1246
+ * Оновлює прапорці наявності **BackendConfig** / інших **kind** у документі.
1247
+ * @param {unknown} kind значення **kind**
1248
+ * @param {{ hasBc: boolean, hasOther: boolean }} acc накопичувач
1249
+ * @returns {void}
1250
+ */
1251
+ function updateBackendConfigKindFlags(kind, acc) {
1252
+ if (kind === 'BackendConfig') {
1253
+ acc.hasBc = true
1254
+ return
1255
+ }
1256
+ if (kind !== undefined && kind !== null && String(kind).trim() !== '') {
1257
+ acc.hasOther = true
1258
+ }
1259
+ }
1260
+
1062
1261
  /**
1063
1262
  * Чи всі нетривіальні документи у тілі — **`kind: BackendConfig`**, чи є змішування з іншими kind.
1064
1263
  * @param {string} body YAML без обов’язкового modeline (див. `k8sYamlBodyForDocumentParse`)
@@ -1073,24 +1272,22 @@ export function classifyBackendConfigManifestPresence(body) {
1073
1272
  return 'unparsed'
1074
1273
  }
1075
1274
 
1076
- let hasBc = false
1077
- let hasOther = false
1275
+ const acc = { hasBc: false, hasOther: false }
1078
1276
  for (const doc of docs) {
1079
1277
  if (doc.errors.length === 0) {
1080
1278
  const obj = doc.toJSON()
1081
1279
  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
- }
1280
+ updateBackendConfigKindFlags(obj.kind, acc)
1088
1281
  }
1089
1282
  }
1090
1283
  }
1091
1284
 
1092
- if (!hasBc) return 'none'
1093
- if (hasOther) return 'mixed'
1285
+ if (!acc.hasBc) {
1286
+ return 'none'
1287
+ }
1288
+ if (acc.hasOther) {
1289
+ return 'mixed'
1290
+ }
1094
1291
  return 'only'
1095
1292
  }
1096
1293
 
@@ -1136,7 +1333,7 @@ async function removeBackendConfigOnlyK8sYamlFiles(root, fail, pass) {
1136
1333
  */
1137
1334
  function toLines(content) {
1138
1335
  const body = content.startsWith('\uFEFF') ? content.slice(1) : content
1139
- return body.split(/\r?\n/u)
1336
+ return body.split(YAML_LINE_SPLIT_RE)
1140
1337
  }
1141
1338
 
1142
1339
  /**
@@ -1187,8 +1384,15 @@ function parseK8sYamlDocumentObjectRoots(body) {
1187
1384
  * @returns {string} перший документ без зайвих пробілів по краях
1188
1385
  */
1189
1386
  function firstYamlDocument(body) {
1190
- const parts = body.split(/^---\s*$/mu)
1191
- return (parts[0] ?? body).trim()
1387
+ const lines = body.split(YAML_LINE_SPLIT_RE)
1388
+ const out = []
1389
+ for (const line of lines) {
1390
+ if (YAML_DOC_SEPARATOR_LINE_RE.test(line)) {
1391
+ break
1392
+ }
1393
+ out.push(line)
1394
+ }
1395
+ return out.join('\n').trim()
1192
1396
  }
1193
1397
 
1194
1398
  /**
@@ -1197,12 +1401,28 @@ function firstYamlDocument(body) {
1197
1401
  * @returns {{ apiVersion?: string, kind?: string }} знайдені поля або властивості відсутні
1198
1402
  */
1199
1403
  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, '')
1404
+ /** @type {string | undefined} */
1405
+ let apiVersion
1406
+ /** @type {string | undefined} */
1407
+ let kind
1408
+ for (const line of doc.split(YAML_LINE_SPLIT_RE)) {
1409
+ if (apiVersion === undefined) {
1410
+ const av = line.match(API_VERSION_FIELD_RE)
1411
+ if (av) {
1412
+ apiVersion = trimYamlScalarQuotes(av[1])
1413
+ }
1414
+ }
1415
+ if (kind === undefined) {
1416
+ const k = line.match(KIND_FIELD_RE)
1417
+ if (k) {
1418
+ kind = trimYamlScalarQuotes(k[1])
1419
+ }
1420
+ }
1421
+ if (apiVersion !== undefined && kind !== undefined) {
1422
+ break
1423
+ }
1205
1424
  }
1425
+ return { apiVersion, kind }
1206
1426
  }
1207
1427
 
1208
1428
  /**
@@ -1223,10 +1443,10 @@ export function k8sYamlFirstDocIsAlbYcHttpBackendGroup(yamlBody) {
1223
1443
  * @returns {boolean} true, якщо є `$patch: delete` і блоки kind/metadata для HealthCheckPolicy
1224
1444
  */
1225
1445
  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
1446
+ if (!HEALTHCHECK_DELETE_RE.test(raw)) return false
1447
+ if (!HEALTHCHECK_KIND_RE.test(raw)) return false
1448
+ if (!METADATA_LINE_RE.test(raw)) return false
1449
+ if (!NAME_NON_EMPTY_RE.test(raw)) return false
1230
1450
  return true
1231
1451
  }
1232
1452
 
@@ -1343,136 +1563,242 @@ export function json6902PathsWithRemoveAndAddOnSamePath(ops) {
1343
1563
  }
1344
1564
 
1345
1565
  /**
1346
- * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: у inline **`patch`** і у зовнішніх patch-файлах не має бути **remove** і **add** на той самий **path**.
1347
- * @param {string} root корінь репозиторію
1348
- * @param {string[]} yamlFilesAbs абсолютні шляхи до yaml під k8s
1566
+ * Реєструє порушення, якщо в JSON6902-операціях є **remove** і **add** на один **path**.
1567
+ * @param {string} rel відносний шлях до kustomization.yaml
1568
+ * @param {string} label фрагмент повідомлення (наприклад `patches[1] inline JSON6902`)
1569
+ * @param {string} patchText текст patch
1349
1570
  * @param {(msg: string) => void} fail реєстрація порушення
1350
- * @returns {Promise<void>}
1571
+ * @returns {void}
1351
1572
  */
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
- }
1573
+ function failIfJson6902RemoveAddConflictOnSamePath(rel, label, patchText, fail) {
1574
+ const ops = collectJson6902OperationsFromPatchText(patchText)
1575
+ const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
1576
+ if (bad.length > 0) {
1577
+ fail(`${rel}: ${label}: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`)
1444
1578
  }
1445
1579
  }
1446
1580
 
1447
1581
  /**
1448
- * Шукає **Ingress** у розібраних документах; реєструє порушення.
1449
- * @param {string} rel відносний шлях до файлу
1450
- * @param {string} body YAML після modeline
1451
- * @param {(msg: string) => void} fail callback для помилки (Ingress)
1452
- * @returns {void}
1582
+ * Зовнішній patch-файл (масив JSON6902): remove+add на один path.
1583
+ * @param {string} rel відносний шлях до kustomization.yaml
1584
+ * @param {string} resolved абсолютний шлях до файлу patch
1585
+ * @param {string} root корінь репо
1586
+ * @param {string} patchRef відносне посилання з kustomization
1587
+ * @param {(msg: string) => void} fail реєстрація порушення
1588
+ * @returns {Promise<void>}
1453
1589
  */
1454
- function scanIngressInYamlDocuments(rel, body, fail) {
1455
- /** @type {import('yaml').Document[]} */
1456
- let docs
1590
+ async function auditJson6902PatchExternalFile(rel, resolved, root, patchRef, fail) {
1591
+ /** @type {import('node:fs').Stats | null} */
1592
+ let st = null
1457
1593
  try {
1458
- docs = parseAllDocuments(body)
1594
+ st = await stat(resolved)
1459
1595
  } catch {
1596
+ st = null
1597
+ }
1598
+ if (st === null || !st.isFile()) {
1460
1599
  return
1461
1600
  }
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
- }
1601
+ let pRaw
1602
+ try {
1603
+ pRaw = await readFile(resolved, 'utf8')
1604
+ } catch {
1605
+ return
1475
1606
  }
1607
+ const ops = collectJson6902OperationsFromPatchText(pRaw)
1608
+ if (ops.length === 0) {
1609
+ return
1610
+ }
1611
+ const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
1612
+ if (bad.length === 0) {
1613
+ return
1614
+ }
1615
+ const relPatch = (relative(root, resolved) || patchRef).replaceAll('\\', '/')
1616
+ fail(
1617
+ `${rel}: patch-файл «${relPatch}»: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
1618
+ )
1619
+ }
1620
+
1621
+ /**
1622
+ * Один елемент **`patches[]`**: inline JSON6902 або зовнішній patch-файл.
1623
+ * @param {string} rel відносний шлях до kustomization.yaml
1624
+ * @param {Record<string, unknown>} pr об’єкт patch
1625
+ * @param {number} patchIdx 1-based індекс у масиві
1626
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
1627
+ * @param {string} rootNorm нормалізований корінь репо
1628
+ * @param {string} root корінь репо
1629
+ * @param {(msg: string) => void} fail реєстрація порушення
1630
+ * @returns {Promise<void>}
1631
+ */
1632
+ async function auditOneKustomizationJson6902Patch(rel, pr, patchIdx, kustAbs, rootNorm, root, fail) {
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
1476
1802
  }
1477
1803
 
1478
1804
  /**
@@ -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
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
1803
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,82 @@ 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(rel, docIndex, obj, skipMetaNs, inBaseManifest, kustomizeManaged, fail) {
2382
+ if (skipMetaNs) {
2383
+ return
2384
+ }
2385
+ if (inBaseManifest) {
2386
+ const req = metadataNamespaceRequiredViolation(obj, true)
2387
+ if (req !== null) {
2388
+ fail(`${rel}: документ ${docIndex}: ${req}`)
2389
+ }
2390
+ return
2391
+ }
2392
+ if (kustomizeManaged) {
2393
+ const ns = metadataNamespaceForbiddenViolation(obj)
2394
+ if (ns !== null) {
2395
+ fail(`${rel}: документ ${docIndex}: ${ns}`)
2396
+ }
2397
+ return
2398
+ }
2399
+ const req = metadataNamespaceRequiredViolation(obj, false)
2400
+ if (req !== null) {
2401
+ fail(`${rel}: документ ${docIndex}: ${req}`)
2402
+ }
2403
+ }
2404
+
2405
+ /**
2406
+ * Deployment / Service / HealthCheckPolicy — політики для одного документа.
2407
+ * @param {string} rel відносний шлях
2408
+ * @param {string} baseLower basename (нижній регістр)
2409
+ * @param {number} docIndex 1-based
2410
+ * @param {unknown} obj корінь документа
2411
+ * @param {(msg: string) => void} fail реєстрація помилки
2412
+ * @returns {void}
2413
+ */
2414
+ function failIfK8sPolicyResourceRulesViolated(rel, baseLower, docIndex, obj, fail) {
2415
+ const resV = deploymentResourcesViolation(obj)
2416
+ if (resV !== null) {
2417
+ fail(`${rel}: Deployment (документ ${docIndex}): ${resV}`)
2418
+ }
2419
+ const hasuraV = deploymentHasuraGraphqlEngineImageViolation(obj)
2420
+ if (hasuraV !== null) {
2421
+ fail(`${rel}: Deployment (документ ${docIndex}): ${hasuraV}`)
2422
+ }
2423
+ const svcGcpV = serviceForbiddenGcpAnnotationsViolation(obj)
2424
+ if (svcGcpV !== null) {
2425
+ fail(`${rel}: Service (документ ${docIndex}): ${svcGcpV}`)
2426
+ }
2427
+ if (baseLower === 'svc.yaml') {
2428
+ const svcT = serviceSvcYamlClusterIpTypeViolation(obj)
2429
+ if (svcT !== null) {
2430
+ fail(`${rel}: Service (документ ${docIndex}): ${svcT}`)
2431
+ }
2432
+ }
2433
+ if (baseLower === 'svc-hl.yaml') {
2434
+ const svcH = serviceSvcHlYamlHeadlessViolation(obj)
2435
+ if (svcH !== null) {
2436
+ fail(`${rel}: Service (документ ${docIndex}): ${svcH}`)
2437
+ }
2438
+ }
2439
+ const hcpHl = healthCheckPolicyTargetRefHeadlessServiceViolation(obj)
2440
+ if (hcpHl !== null) {
2441
+ fail(`${rel}: документ ${docIndex}: ${hcpHl}`)
2442
+ }
1996
2443
  }
1997
2444
 
1998
2445
  /**
@@ -2024,52 +2471,8 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
2024
2471
  fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
2025
2472
  } else {
2026
2473
  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
- }
2474
+ failIfK8sPolicyNamespaceRulesViolated(rel, di + 1, obj, skipMetaNs, inBaseManifest, kustomizeManaged, fail)
2475
+ failIfK8sPolicyResourceRulesViolated(rel, baseLower, di + 1, obj, fail)
2073
2476
  }
2074
2477
  }
2075
2478
  }
@@ -2080,31 +2483,24 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
2080
2483
  * @returns {string} рядок для шаблону імені файлу схеми
2081
2484
  */
2082
2485
  function kindToSchemaFilePart(kind) {
2083
- return kind.replaceAll(/[^a-zA-Z0-9]/gu, '').toLowerCase()
2486
+ let out = ''
2487
+ for (const ch of kind) {
2488
+ const c = ch.codePointAt(0)
2489
+ if (c !== undefined && ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122))) {
2490
+ out += ch
2491
+ }
2492
+ }
2493
+ return out.toLowerCase()
2084
2494
  }
2085
2495
 
2086
2496
  /**
2087
- * Очікуваний $schema для маніфесту згідно з k8s.mdc.
2088
- * @param {string} filePath шлях до файлу (для імені kustomization)
2089
- * @param {string} doc перший YAML-документ після modeline
2090
- * @returns {{ expected: string | null, reason: string }} reason для повідомлень про помилку
2497
+ * Очікуваний URL схеми за **apiVersion/kind** (не **kustomization.yaml**).
2498
+ * @param {string} doc текст першого документа
2499
+ * @param {string} apiVersion значення **apiVersion** з маніфесту
2500
+ * @param {string} kind значення **kind** з маніфесту
2501
+ * @returns {{ expected: string | null, reason: string }} очікуваний URL і пояснення для повідомлень
2091
2502
  */
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
-
2503
+ function expectedSchemaUrlForTypedManifest(doc, apiVersion, kind) {
2108
2504
  const manifestType = extractTopLevelManifestType(doc)
2109
2505
  const explicit = lookupExplicitK8sSchema(apiVersion, kind, manifestType)
2110
2506
  if (explicit) {
@@ -2140,13 +2536,122 @@ export function expectedSchemaUrl(filePath, doc) {
2140
2536
  return { expected: url, reason: 'CRD / група поза yannh (datree CRDs-catalog)' }
2141
2537
  }
2142
2538
 
2539
+ /**
2540
+ * Очікуваний $schema для маніфесту згідно з k8s.mdc.
2541
+ * @param {string} filePath шлях до файлу (для імені kustomization)
2542
+ * @param {string} doc перший YAML-документ після modeline
2543
+ * @returns {{ expected: string | null, reason: string }} reason — для повідомлень про помилку
2544
+ */
2545
+ export function expectedSchemaUrl(filePath, doc) {
2546
+ const base = basename(filePath)
2547
+ const baseLower = base.toLowerCase()
2548
+
2549
+ if (baseLower === 'kustomization.yaml') {
2550
+ return { expected: KUSTOMIZATION_SCHEMA, reason: 'kustomization (ім’я файлу)' }
2551
+ }
2552
+
2553
+ const { apiVersion, kind } = extractApiVersionAndKind(doc)
2554
+ if (!apiVersion || !kind) {
2555
+ return {
2556
+ expected: null,
2557
+ reason: 'не знайдено apiVersion/kind у першому документі (потрібні для перевірки $schema)'
2558
+ }
2559
+ }
2560
+
2561
+ return expectedSchemaUrlForTypedManifest(doc, apiVersion, kind)
2562
+ }
2563
+
2143
2564
  /**
2144
2565
  * Підраховує рядки з modeline $schema у файлі.
2145
2566
  * @param {string[]} lines рядки файлу
2146
2567
  * @returns {number} скільки рядків містять modeline `$schema`
2147
2568
  */
2148
2569
  function countSchemaModelines(lines) {
2149
- return lines.filter(l => /^\s*#\s*yaml-language-server:\s*\$schema=\S+/u.test(l.trim())).length
2570
+ return lines.filter(l => OXLINT_SCHEMA_MODELINE_RE.test(l.trim())).length
2571
+ }
2572
+
2573
+ /**
2574
+ * Політики маніфестів і Gateway backendRefs після розбору тіла.
2575
+ * @param {string} rel відносний шлях
2576
+ * @param {string} baseLower basename (нижній регістр)
2577
+ * @param {string} body YAML після modeline
2578
+ * @param {(msg: string) => void} fail реєстрація помилки
2579
+ * @param {Set<string>} kustomizeManagedRel kustomize-managed шляхи
2580
+ * @returns {void}
2581
+ */
2582
+ function runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel) {
2583
+ const kustomizeManaged = kustomizeManagedRel.has(rel)
2584
+ validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
2585
+ scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
2586
+ }
2587
+
2588
+ /**
2589
+ * Файл з першим документом **HttpBackendGroup** (ALB Yandex): без modeline **$schema**.
2590
+ * @param {string} rel відносний шлях
2591
+ * @param {string} baseLower basename
2592
+ * @param {string[]} lines рядки файлу
2593
+ * @param {(msg: string) => void} fail реєстрація помилки
2594
+ * @param {(msg: string) => void} pass реєстрація успіху
2595
+ * @param {Set<string>} kustomizeManagedRel kustomize-managed шляхи
2596
+ * @returns {void}
2597
+ */
2598
+ function checkK8sYamlHttpBackendGroupFile(rel, baseLower, lines, fail, pass, kustomizeManagedRel) {
2599
+ const body = lines.join('\n')
2600
+ scanIngressInYamlDocuments(rel, body, fail)
2601
+ pass(`${rel}: HttpBackendGroup (alb.yc.io/v1alpha1) — modeline $schema не застосовується (k8s.mdc)`)
2602
+ runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel)
2603
+ }
2604
+
2605
+ /**
2606
+ * Стандартний файл: перший рядок — modeline **$schema**, далі перевірка URL і політики.
2607
+ * @param {string} abs абсолютний шлях
2608
+ * @param {string} rel відносний шлях
2609
+ * @param {string} baseLower basename
2610
+ * @param {string[]} lines рядки файлу
2611
+ * @param {(msg: string) => void} fail реєстрація помилки
2612
+ * @param {(msg: string) => void} pass реєстрація успіху
2613
+ * @param {Set<string>} kustomizeManagedRel kustomize-managed шляхи
2614
+ * @returns {void}
2615
+ */
2616
+ function checkK8sYamlFileWithSchemaModeline(abs, rel, baseLower, lines, fail, pass, kustomizeManagedRel) {
2617
+ const match = lines[0].match(MODELINE_RE)
2618
+ if (!match) {
2619
+ fail(`${rel}: некоректний modeline $schema у першому рядку`)
2620
+ return
2621
+ }
2622
+ const schemaUrl = match[1]
2623
+ if (countSchemaModelines(lines) > 1) {
2624
+ fail(`${rel}: кілька рядків yaml-language-server $schema — лиш один modeline на файл (див. k8s.mdc)`)
2625
+ return
2626
+ }
2627
+
2628
+ const body = yamlBodyAfterModeline(lines)
2629
+
2630
+ scanIngressInYamlDocuments(rel, body, fail)
2631
+
2632
+ if (schemaUrl.startsWith('file:')) {
2633
+ pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
2634
+ } else if (HTTPS_SCHEMA_RE.test(schemaUrl)) {
2635
+ const doc = firstYamlDocument(body)
2636
+ const { expected, reason } = expectedSchemaUrl(abs, doc)
2637
+
2638
+ if (expected === null) {
2639
+ fail(`${rel}: ${reason}`)
2640
+ return
2641
+ }
2642
+
2643
+ if (schemaUrl !== expected) {
2644
+ fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
2645
+ return
2646
+ }
2647
+
2648
+ pass(`${rel}: $schema узгоджено (${reason})`)
2649
+ } else {
2650
+ fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
2651
+ return
2652
+ }
2653
+
2654
+ runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel)
2150
2655
  }
2151
2656
 
2152
2657
  /**
@@ -2199,12 +2704,7 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
2199
2704
  )
2200
2705
  return
2201
2706
  }
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)
2707
+ checkK8sYamlHttpBackendGroupFile(rel, baseLower, lines, fail, pass, kustomizeManagedRel)
2208
2708
  return
2209
2709
  }
2210
2710
 
@@ -2213,43 +2713,7 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
2213
2713
  return
2214
2714
  }
2215
2715
 
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)
2716
+ checkK8sYamlFileWithSchemaModeline(abs, rel, baseLower, lines, fail, pass, kustomizeManagedRel)
2253
2717
  }
2254
2718
 
2255
2719
  /**
@@ -2268,6 +2732,38 @@ function assertNoForbiddenK8sDevPaths(yamlFiles, root, fail) {
2268
2732
  }
2269
2733
  }
2270
2734
 
2735
+ /**
2736
+ * Один файл **k8s/base/kustomization.yaml**: непорожній **namespace:**.
2737
+ * @param {string} root корінь репозиторію
2738
+ * @param {string} abs абсолютний шлях до файлу
2739
+ * @param {(msg: string) => void} fail реєстрація порушення
2740
+ * @returns {Promise<void>}
2741
+ */
2742
+ async function verifyBaseKustomizationNamespaceOnFile(root, abs, fail) {
2743
+ const rel = relative(root, abs).replaceAll('\\', '/')
2744
+ try {
2745
+ const raw = await readFile(abs, 'utf8')
2746
+ const lines = toLines(raw)
2747
+ const body = yamlBodyAfterModeline(lines)
2748
+ /** @type {import('yaml').Document[] | undefined} */
2749
+ let docs
2750
+ try {
2751
+ docs = parseAllDocuments(body)
2752
+ } catch {
2753
+ fail(`${rel}: не вдалося розпарсити YAML для перевірки namespace у base (див. k8s.mdc)`)
2754
+ return
2755
+ }
2756
+ const first = docs[0]?.toJSON()
2757
+ const v = baseKustomizationNamespaceViolation(first)
2758
+ if (v) {
2759
+ fail(`${rel}: ${v}`)
2760
+ }
2761
+ } catch (error) {
2762
+ const msg = error instanceof Error ? error.message : String(error)
2763
+ fail(`${rel}: не вдалося прочитати (${msg})`)
2764
+ }
2765
+ }
2766
+
2271
2767
  /**
2272
2768
  * Якщо є **`k8s/base/kustomization.yaml`**, у ньому **завжди** має бути непорожній **`namespace:`**.
2273
2769
  * @param {string} root корінь репозиторію
@@ -2279,28 +2775,7 @@ async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
2279
2775
  for (const abs of yamlFiles) {
2280
2776
  const rel = relative(root, abs).replaceAll('\\', '/')
2281
2777
  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
- }
2778
+ await verifyBaseKustomizationNamespaceOnFile(root, abs, fail)
2304
2779
  }
2305
2780
  }
2306
2781
  }