@nitra/cursor 1.8.61 → 1.8.63

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.
package/mdc/k8s.mdc CHANGED
@@ -117,6 +117,14 @@ resources: {}
117
117
 
118
118
  Вони потрібні для інтеграції з **Ingress** / класичним балансуванням GKE; після переходу на **Gateway API** їх слід **прибрати** з маніфестів. **`check k8s`** завершиться помилкою, якщо хоча б один із цих ключів присутній.
119
119
 
120
+ ## Service: `svc.yaml` і `svc-hl.yaml` (Gateway API)
121
+
122
+ - У тому самому каталозі, де лежить **`svc.yaml`**, обов’язково має бути **`svc-hl.yaml`**. Якщо файлу ще немає — створи його як копію **`svc.yaml`**: для кожного **Service** задай **`metadata.name`** з суфіксом **`-hl`**, **`spec.clusterIP: None`** (headless), збережи селектор і порти як у кластерного сервісу; **`check k8s`** не генерує файли автоматично.
123
+ - У **`svc.yaml`** кожен **Service** має мати **`spec.type: ClusterIP`**.
124
+ - У **`svc-hl.yaml`** кожен **Service** має мати **`spec.clusterIP: None`** та **`metadata.name`**, що закінчується на **`-hl`**, узгоджено з відповідним ім’ям у **`svc.yaml`** (наприклад **`app`** → **`app-hl`**).
125
+ - У маршрутах **Gateway API** з групи **`gateway.networking.k8s.io`** (**`HTTPRoute`**, **`GRPCRoute`**, **`TCPRoute`**, **`TLSRoute`**, **`UDPRoute`**) посилання **`backendRefs`** / **`backendRef`** на **Service** мають вказувати лише сервіси з цього headless-файлу (ім’я з суфіксом **`-hl`**).
126
+ - Якщо **`svc.yaml`** підключено через **`kustomization.yaml`** (**`resources`**, **`bases`**, **`components`**, **`crds`**, **`patches[].path`**, **`patchesStrategicMerge`**), у **тому ж** **`kustomization.yaml`** додай посилання на відповідний **`svc-hl.yaml`** (той самий відносний префікс каталогу, що й для **`svc.yaml`**).
127
+
120
128
  ## Kustomize: структура каталогів (`base` / overlays)
121
129
 
122
130
  Трансформуй дерева **`**/k8s`**, щоб **винести спільне** через [Kustomize](https://kustomize.io/): один канонічний **`base`** і тонкі **overlays** для інших середовищ.
@@ -184,7 +192,7 @@ resources: {}
184
192
 
185
193
  Не дублюй тут повний перелік — він у **верхньому JSDoc** **`npm/scripts/check-k8s.mjs`** і в константах (**`YANNH_PIN`**, **`YANNH_GROUPS`**, **`EXPLICIT_K8S_SCHEMAS`**, **`CLUSTER_SCOPED_KINDS`**, **`HASURA_GRAPHQL_ENGINE_IMAGE`** тощо).
186
194
 
187
- Коротко: **`$schema`** (один modeline, без **`.yml`** під **`k8s`**), заборона **Ingress**, **Deployment.resources**, пін **hasura/graphql-engine**, **Service** без анотацій NEG/backend-config, **`metadata.namespace`** залежно від **base** і графа Kustomize, заборона **`…/k8s/dev/…`**, непорожній **`namespace:`** у **`k8s/base/kustomization.yaml`**, видалення файлів, де всі документи — лише **BackendConfig** (змішані файли — помилка).
195
+ Коротко: **`$schema`** (один modeline, без **`.yml`** під **`k8s`**), заборона **Ingress**, **Deployment.resources**, пін **hasura/graphql-engine**, **Service** без анотацій NEG/backend-config, пари **`svc.yaml`** / **`svc-hl.yaml`** (**ClusterIP** / headless **`-hl`**) і їхні записи в **`kustomization.yaml`**, **Gateway API** маршрути — **backendRef** лише на **`-hl`**, **`metadata.namespace`** залежно від **base** і графа Kustomize, заборона **`…/k8s/dev/…`**, непорожній **`namespace:`** у **`k8s/base/kustomization.yaml`**, видалення файлів, де всі документи — лише **BackendConfig** (змішані файли — помилка).
188
196
 
189
197
  При зміні **PIN** версії Kubernetes оновлюй узгоджено **`check-k8s.mjs`**, **`run-k8s.mjs`** (**`KUBERNETES_VERSION`**, **`DATREE_CRD_SCHEMA_LOCATION`**) і розділи **lint-k8s** / **Визначення схеми YAML** у цьому файлі (**`YANNH_REF`**, **`YANNH_GROUPS`**, **`DATREE_CRD_BASE`** — за потреби в скрипті).
190
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.61",
3
+ "version": "1.8.63",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -27,6 +27,16 @@
27
27
  * У **`kind: Service`** у **`metadata.annotations`** не повинно бути ключів **`cloud.google.com/neg`**
28
28
  * та **`cloud.google.com/backend-config`** (див. k8s.mdc).
29
29
  *
30
+ * Файли **`svc.yaml`** / **`svc-hl.yaml`** у **одному каталозі** (див. k8s.mdc): для кожного **`svc.yaml`**
31
+ * поруч обов’язковий **`svc-hl.yaml`** (headless-копія: той самий селектор/порти, **`metadata.name`** з суфіксом **`-hl`**,
32
+ * **`spec.clusterIP: None`**). У **`svc.yaml`** кожен **Service** має **`spec.type: ClusterIP`**. У **`svc-hl.yaml`**
33
+ * кожен **Service** — **`spec.clusterIP: None`** та ім’я на **`-hl`**. У маршрутах **Gateway API**
34
+ * (**`HTTPRoute`**, **`GRPCRoute`**, **`TCPRoute`**, **`TLSRoute`**, **`UDPRoute`**, група **`gateway.networking.k8s.io`**)
35
+ * посилання **`backendRefs` / `backendRef`** на **Service** мають вказувати лише сервіси з суфіксом **`-hl`** у **`name`**.
36
+ * Якщо **`kustomization.yaml`** посилається на **`svc.yaml`** (**`resources`**, **`bases`**, **`components`**, **`crds`**,
37
+ * **`patches[].path`**, **`patchesStrategicMerge`**), у **тому ж** файлі має бути посилання на відповідний **`svc-hl.yaml`**
38
+ * в **тому ж каталозі**, що й **`svc.yaml`** (логіка збігається з **`pathsFromKustomizationObject`**).
39
+ *
30
40
  * Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; у **`k8s/base/kustomization.yaml`**
31
41
  * завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує).
32
42
  *
@@ -296,6 +306,79 @@ function pathsFromKustomizationObject(obj) {
296
306
  return out
297
307
  }
298
308
 
309
+ /**
310
+ * Чи для кожного посилання kustomization на файл **`svc.yaml`** у списку є посилання на sibling **`svc-hl.yaml`**
311
+ * (той самий каталог після **`resolve`** відносно каталогу **`kustomization.yaml`**).
312
+ * @param {string} kustomizationDir абсолютний шлях до каталогу з **`kustomization.yaml`**
313
+ * @param {string[]} pathRefs рядки з **`pathsFromKustomizationObject`**
314
+ * @returns {string | null} текст порушення або null, якщо ок
315
+ */
316
+ export function kustomizationSvcYamlMissingSvcHlViolation(kustomizationDir, pathRefs) {
317
+ /** @type {Set<string>} */
318
+ const resolved = new Set()
319
+ for (const ref of pathRefs) {
320
+ if (typeof ref === 'string' && !ref.includes('://')) {
321
+ resolved.add(resolve(kustomizationDir, ref))
322
+ }
323
+ }
324
+ for (const ref of pathRefs) {
325
+ if (typeof ref === 'string' && !ref.includes('://')) {
326
+ const abs = resolve(kustomizationDir, ref)
327
+ if (basename(abs).toLowerCase() === 'svc.yaml') {
328
+ const hlAbs = resolve(dirname(abs), 'svc-hl.yaml')
329
+ if (!resolved.has(hlAbs)) {
330
+ return `kustomization посилається на «${ref}» — додай у тому ж kustomization.yaml посилання на відповідний svc-hl.yaml (очікуваний шлях поруч, напр. той самий префікс каталогу + svc-hl.yaml; див. k8s.mdc)`
331
+ }
332
+ }
333
+ }
334
+ }
335
+ return null
336
+ }
337
+
338
+ /**
339
+ * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: разом із **`svc.yaml`** має бути **`svc-hl.yaml`** у полях шляхів.
340
+ * @param {string} root корінь репозиторію
341
+ * @param {string[]} yamlFiles абсолютні шляхи до yaml під k8s
342
+ * @param {(msg: string) => void} fail callback помилки
343
+ * @returns {Promise<void>}
344
+ */
345
+ async function validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail) {
346
+ for (const kustAbs of yamlFiles) {
347
+ if (basename(kustAbs).toLowerCase() === 'kustomization.yaml') {
348
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
349
+ let raw
350
+ try {
351
+ raw = await readFile(kustAbs, 'utf8')
352
+ } catch (error) {
353
+ const msg = error instanceof Error ? error.message : String(error)
354
+ fail(`${rel}: не вдалося прочитати для перевірки svc.yaml/svc-hl.yaml у kustomization (${msg})`)
355
+ }
356
+ if (raw !== undefined) {
357
+ const lines = toLines(raw)
358
+ const body = yamlBodyAfterModeline(lines)
359
+ /** @type {import('yaml').Document[] | undefined} */
360
+ let docs
361
+ try {
362
+ docs = parseAllDocuments(body)
363
+ } catch {
364
+ fail(`${rel}: не вдалося розпарсити YAML для перевірки svc.yaml/svc-hl.yaml у kustomization (див. k8s.mdc)`)
365
+ }
366
+ if (docs !== undefined) {
367
+ const first = docs[0]?.toJSON()
368
+ if (first !== null && first !== undefined && typeof first === 'object' && !Array.isArray(first)) {
369
+ const pathRefs = pathsFromKustomizationObject(first)
370
+ const kustDir = dirname(kustAbs)
371
+ const v = kustomizationSvcYamlMissingSvcHlViolation(kustDir, pathRefs)
372
+ if (v !== null) {
373
+ fail(`${rel}: ${v}`)
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
299
382
  /**
300
383
  * Збирає відносні шляхи (posix) до YAML, підключених до Kustomize з будь-якого **`kustomization.yaml`** під `k8s`.
301
384
  * Обходить **`resources`**, **`bases`**, **`components`**, **`crds`**, **`patches[].path`**, **`patchesStrategicMerge`**;
@@ -439,7 +522,6 @@ function k8sYamlBodyForDocumentParse(lines) {
439
522
 
440
523
  /**
441
524
  * Чи всі нетривіальні документи у тілі — **`kind: BackendConfig`**, чи є змішування з іншими kind.
442
- *
443
525
  * @param {string} body YAML без обов’язкового modeline (див. `k8sYamlBodyForDocumentParse`)
444
526
  * @returns {'none' | 'only' | 'mixed' | 'unparsed'} unparsed — не вдалося розпарсити YAML
445
527
  */
@@ -475,7 +557,6 @@ export function classifyBackendConfigManifestPresence(body) {
475
557
 
476
558
  /**
477
559
  * Видаляє під **`k8s`** YAML-файли, що містять **лише** ресурси **BackendConfig**; змішані файли — `fail`.
478
- *
479
560
  * @param {string} root корінь репозиторію
480
561
  * @param {(msg: string) => void} fail реєстрація порушення
481
562
  * @param {(msg: string) => void} pass реєстрація успіху
@@ -530,6 +611,37 @@ function yamlBodyAfterModeline(lines) {
530
611
  return lines.slice(i).join('\n')
531
612
  }
532
613
 
614
+ /**
615
+ * Читає k8s YAML і повертає фрагмент після modeline `$schema`, якщо перший рядок — modeline.
616
+ * Потрібно для парної перевірки **`svc.yaml`** / **`svc-hl.yaml`**.
617
+ * @param {string} abs абсолютний шлях до файлу
618
+ * @returns {Promise<string>} тіло для `parseAllDocuments`
619
+ */
620
+ async function readK8sYamlBodyAfterModelineForSvcPair(abs) {
621
+ const raw = await readFile(abs, 'utf8')
622
+ const lines = toLines(raw)
623
+ if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
624
+ return yamlBodyAfterModeline(lines)
625
+ }
626
+ return lines.join('\n')
627
+ }
628
+
629
+ /**
630
+ * Розбирає YAML на корені документів-об’єктів (ігнорує зламані документи).
631
+ * @param {string} body фрагмент YAML
632
+ * @returns {unknown[]} масив об’єктів-документів
633
+ */
634
+ function parseK8sYamlDocumentObjectRoots(body) {
635
+ try {
636
+ return parseAllDocuments(body)
637
+ .filter(d => d.errors.length === 0)
638
+ .map(d => d.toJSON())
639
+ .filter(x => x !== null && x !== undefined && typeof x === 'object' && !Array.isArray(x))
640
+ } catch {
641
+ return []
642
+ }
643
+ }
644
+
533
645
  /**
534
646
  * Перший YAML-документ (до наступного `---` на окремому рядку).
535
647
  * @param {string} body фрагмент YAML
@@ -747,6 +859,287 @@ export function serviceForbiddenGcpAnnotationsViolation(manifest) {
747
859
  return `metadata.annotations: прибери заборонені ключі GKE: ${found.join(', ')} (див. k8s.mdc)`
748
860
  }
749
861
 
862
+ /** Суфікс **`metadata.name`** headless-сервісу поруч із **`svc.yaml`** (див. k8s.mdc). */
863
+ const SVC_HL_NAME_SUFFIX = '-hl'
864
+
865
+ /**
866
+ * Kind маршрутів Gateway API, у **`spec`** яких шукаємо **`backendRefs`** / **`backendRef`** до **Service**.
867
+ * @type {Set<string>}
868
+ */
869
+ const GATEWAY_API_ROUTE_KINDS = new Set(['HTTPRoute', 'GRPCRoute', 'TCPRoute', 'TLSRoute', 'UDPRoute'])
870
+
871
+ /** Префікс **`apiVersion`** стандартних ресурсів Gateway API. */
872
+ const GATEWAY_API_GROUP_PREFIX = 'gateway.networking.k8s.io/'
873
+
874
+ /**
875
+ * Чи **Service** у **`svc.yaml`** має **`spec.type: ClusterIP`** (k8s.mdc).
876
+ * @param {unknown} manifest корінь YAML-документа
877
+ * @returns {string | null} текст порушення або null
878
+ */
879
+ export function serviceSvcYamlClusterIpTypeViolation(manifest) {
880
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
881
+ return null
882
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
883
+ if (rec.kind !== 'Service') return null
884
+ const spec = rec.spec
885
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
886
+ return 'Service: додай spec.type: ClusterIP (svc.yaml, див. k8s.mdc)'
887
+ }
888
+ const s = /** @type {Record<string, unknown>} */ (spec)
889
+ if (s.type !== 'ClusterIP') {
890
+ const cur = s.type === undefined ? 'відсутнє' : String(s.type)
891
+ return `Service spec.type має бути ClusterIP (svc.yaml; зараз: ${cur}; див. k8s.mdc)`
892
+ }
893
+ return null
894
+ }
895
+
896
+ /**
897
+ * Чи **Service** у **`svc-hl.yaml`** headless (**`spec.clusterIP: None`**) з суфіксом **`-hl`** у **`metadata.name`**.
898
+ * @param {unknown} manifest корінь YAML-документа
899
+ * @returns {string | null} текст порушення або null
900
+ */
901
+ export function serviceSvcHlYamlHeadlessViolation(manifest) {
902
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
903
+ return null
904
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
905
+ if (rec.kind !== 'Service') return null
906
+ const meta = rec.metadata
907
+ if (meta === null || meta === undefined || typeof meta !== 'object' || Array.isArray(meta)) {
908
+ return 'Service: потрібні metadata.name з суфіксом -hl (svc-hl.yaml, див. k8s.mdc)'
909
+ }
910
+ const m = /** @type {Record<string, unknown>} */ (meta)
911
+ const n = m.name
912
+ if (typeof n !== 'string' || !n.endsWith(SVC_HL_NAME_SUFFIX)) {
913
+ return `Service metadata.name має закінчуватися на «${SVC_HL_NAME_SUFFIX}» (svc-hl.yaml; див. k8s.mdc)`
914
+ }
915
+ const spec = rec.spec
916
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
917
+ return 'Service: додай spec.clusterIP: None (svc-hl.yaml, див. k8s.mdc)'
918
+ }
919
+ const s = /** @type {Record<string, unknown>} */ (spec)
920
+ if (s.clusterIP !== 'None') {
921
+ const cur = s.clusterIP === undefined ? 'відсутнє' : String(s.clusterIP)
922
+ return `Service spec.clusterIP має бути None (headless, svc-hl.yaml; зараз: ${cur}; див. k8s.mdc)`
923
+ }
924
+ return null
925
+ }
926
+
927
+ /**
928
+ * Чи об’єкт схожий на **backendRef** до **Kubernetes Service** у Gateway API.
929
+ * @param {unknown} obj вузол у дереві **`spec`**
930
+ * @returns {boolean} true, якщо враховуємо поле **`name`** як посилання на Service
931
+ */
932
+ function isGatewayApiBackendRefToService(obj) {
933
+ if (obj === null || obj === undefined || typeof obj !== 'object' || Array.isArray(obj)) return false
934
+ const o = /** @type {Record<string, unknown>} */ (obj)
935
+ if (typeof o.name !== 'string') return false
936
+ const kind = o.kind
937
+ if (kind !== undefined && kind !== 'Service') return false
938
+ const group = o.group
939
+ if (typeof group === 'string' && group !== '' && group !== 'core') return false
940
+ return true
941
+ }
942
+
943
+ /**
944
+ * Збирає імена **Service** з **`backendRefs`** / **`backendRef`** у піддереві **`spec`** маршруту Gateway API.
945
+ * @param {unknown} spec значення **`spec`** маршруту
946
+ * @returns {string[]} імена backend-сервісів (можливі дублікати)
947
+ */
948
+ export function collectGatewayApiRouteBackendServiceNames(spec) {
949
+ /** @type {string[]} */
950
+ const out = []
951
+
952
+ /**
953
+ * @param {unknown} node вузол для обходу
954
+ * @returns {void}
955
+ */
956
+ function walk(node) {
957
+ if (node === null || node === undefined) return
958
+ if (Array.isArray(node)) {
959
+ for (const x of node) {
960
+ walk(x)
961
+ }
962
+ return
963
+ }
964
+ if (typeof node !== 'object') return
965
+ if (isGatewayApiBackendRefToService(node)) {
966
+ out.push(String(/** @type {Record<string, unknown>} */ (node).name))
967
+ }
968
+ for (const v of Object.values(node)) {
969
+ walk(v)
970
+ }
971
+ }
972
+
973
+ walk(spec)
974
+ return out
975
+ }
976
+
977
+ /**
978
+ * Реєструє порушення: маршрути Gateway API мають посилатися на **Service** з суфіксом **`-hl`**.
979
+ * @param {string} rel відносний шлях до файлу
980
+ * @param {string} body YAML після modeline
981
+ * @param {(msg: string) => void} fail callback помилки
982
+ * @returns {void}
983
+ */
984
+ function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
985
+ /** @type {import('yaml').Document[]} */
986
+ let docs
987
+ try {
988
+ docs = parseAllDocuments(body)
989
+ } catch {
990
+ return
991
+ }
992
+
993
+ for (const [di, doc] of docs.entries()) {
994
+ if (doc.errors.length === 0) {
995
+ const obj = doc.toJSON()
996
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
997
+ const rec = /** @type {Record<string, unknown>} */ (obj)
998
+ const av = rec.apiVersion
999
+ const kind = rec.kind
1000
+ if (
1001
+ typeof av === 'string' &&
1002
+ av.startsWith(GATEWAY_API_GROUP_PREFIX) &&
1003
+ typeof kind === 'string' &&
1004
+ GATEWAY_API_ROUTE_KINDS.has(kind)
1005
+ ) {
1006
+ const names = collectGatewayApiRouteBackendServiceNames(rec.spec)
1007
+ for (const svcName of names) {
1008
+ if (!svcName.endsWith(SVC_HL_NAME_SUFFIX)) {
1009
+ fail(
1010
+ `${rel}: Gateway API ${kind} (документ ${di + 1}): backendRef до Service має вказувати headless-сервіс з суфіксом «${SVC_HL_NAME_SUFFIX}» у name (зараз: «${svcName}»; див. k8s.mdc)`
1011
+ )
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ /**
1021
+ * Перевіряє пари **`svc.yaml`** / **`svc-hl.yaml`** у каталозі (наявність, узгоджені імена **Service**).
1022
+ * @param {string} root корінь репозиторію
1023
+ * @param {string[]} yamlFiles абсолютні шляхи до `*.yaml` під `k8s`
1024
+ * @param {(msg: string) => void} fail callback помилки
1025
+ * @returns {Promise<void>}
1026
+ */
1027
+ async function validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail) {
1028
+ const absSet = new Set(yamlFiles)
1029
+
1030
+ for (const abs of yamlFiles) {
1031
+ if (basename(abs).toLowerCase() === 'svc-hl.yaml') {
1032
+ const svcAbs = join(dirname(abs), 'svc.yaml')
1033
+ if (!absSet.has(svcAbs)) {
1034
+ const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
1035
+ fail(`${rel}: svc-hl.yaml потребує svc.yaml у тому самому каталозі (див. k8s.mdc)`)
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ for (const svcAbs of yamlFiles) {
1041
+ if (basename(svcAbs).toLowerCase() === 'svc.yaml') {
1042
+ const rel = (relative(root, svcAbs) || svcAbs).replaceAll('\\', '/')
1043
+ const hlAbs = join(dirname(svcAbs), 'svc-hl.yaml')
1044
+ if (absSet.has(hlAbs)) {
1045
+ /** @type {string | undefined} */
1046
+ let svcBody
1047
+ /** @type {string | undefined} */
1048
+ let hlBody
1049
+ try {
1050
+ svcBody = await readK8sYamlBodyAfterModelineForSvcPair(svcAbs)
1051
+ hlBody = await readK8sYamlBodyAfterModelineForSvcPair(hlAbs)
1052
+ } catch (error) {
1053
+ const msg = error instanceof Error ? error.message : String(error)
1054
+ fail(`${rel}: не вдалося прочитати svc.yaml / svc-hl.yaml (${msg})`)
1055
+ }
1056
+ if (svcBody !== undefined && hlBody !== undefined) {
1057
+ const svcRoots = parseK8sYamlDocumentObjectRoots(svcBody)
1058
+ const hlRoots = parseK8sYamlDocumentObjectRoots(hlBody)
1059
+
1060
+ /** @type {string[]} */
1061
+ const svcNames = []
1062
+ for (const [i, rootObj] of svcRoots.entries()) {
1063
+ const r = /** @type {Record<string, unknown>} */ (rootObj)
1064
+ if (r.kind === 'Service') {
1065
+ const meta = r.metadata
1066
+ if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
1067
+ const nm = /** @type {Record<string, unknown>} */ (meta).name
1068
+ if (typeof nm === 'string') {
1069
+ svcNames.push(nm)
1070
+ } else {
1071
+ fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
1072
+ }
1073
+ } else {
1074
+ fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ if (svcNames.length === 0) {
1080
+ fail(`${rel}: svc.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
1081
+ } else {
1082
+ /** @type {string[]} */
1083
+ const hlNames = []
1084
+ for (const [i, rootObj] of hlRoots.entries()) {
1085
+ const r = /** @type {Record<string, unknown>} */ (rootObj)
1086
+ if (r.kind === 'Service') {
1087
+ const meta = r.metadata
1088
+ if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
1089
+ const nm = /** @type {Record<string, unknown>} */ (meta).name
1090
+ if (typeof nm === 'string') {
1091
+ hlNames.push(nm)
1092
+ } else {
1093
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1094
+ fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
1095
+ }
1096
+ } else {
1097
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1098
+ fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ if (hlNames.length === 0) {
1104
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1105
+ fail(`${hlRel}: svc-hl.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
1106
+ } else {
1107
+ const hlSet = new Set(hlNames)
1108
+ for (const n of svcNames) {
1109
+ const expectHl = `${n}${SVC_HL_NAME_SUFFIX}`
1110
+ if (!hlSet.has(expectHl)) {
1111
+ fail(
1112
+ `${rel}: для Service «${n}» у svc.yaml у svc-hl.yaml має бути Service з metadata.name «${expectHl}» (див. k8s.mdc)`
1113
+ )
1114
+ }
1115
+ }
1116
+
1117
+ for (const h of hlNames) {
1118
+ if (h.endsWith(SVC_HL_NAME_SUFFIX)) {
1119
+ const base = h.slice(0, -SVC_HL_NAME_SUFFIX.length)
1120
+ if (!svcNames.includes(base)) {
1121
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1122
+ fail(
1123
+ `${hlRel}: Service «${h}» у svc-hl.yaml не відповідає жодному Service у svc.yaml (очікується базове ім’я «${base}»; див. k8s.mdc)`
1124
+ )
1125
+ }
1126
+ } else {
1127
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1128
+ fail(
1129
+ `${hlRel}: Service «${h}» у svc-hl.yaml: metadata.name має закінчуватися на «${SVC_HL_NAME_SUFFIX}» (див. k8s.mdc)`
1130
+ )
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+ }
1136
+ } else {
1137
+ fail(`${rel}: поруч обов’язковий svc-hl.yaml (headless-копія з суфіксом -hl у metadata.name; див. k8s.mdc)`)
1138
+ }
1139
+ }
1140
+ }
1141
+ }
1142
+
750
1143
  /**
751
1144
  * Для маніфестів, **підключених** до Kustomize (шлях у `resources` / `patches` / …), **metadata.namespace** не додають.
752
1145
  * @param {unknown} manifest корінь YAML-документа
@@ -816,7 +1209,8 @@ export function isK8sBaseManifestYamlPath(rel, baseLower) {
816
1209
 
817
1210
  /**
818
1211
  * Парсить усі YAML-документи: **metadata.namespace**, **Deployment.resources**, **Hasura image pin**,
819
- * **Service — заборонені GKE-анотації**.
1212
+ * **Service — заборонені GKE-анотації**, **`svc.yaml`** (**`spec.type: ClusterIP`**), **`svc-hl.yaml`**
1213
+ * (**headless**, суфікс **`-hl`** у **`metadata.name`**).
820
1214
  * @param {string} rel відносний шлях
821
1215
  * @param {string} baseLower basename файлу (нижній регістр)
822
1216
  * @param {string} body вміст після modeline
@@ -872,6 +1266,18 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
872
1266
  if (svcGcpV !== null) {
873
1267
  fail(`${rel}: Service (документ ${di + 1}): ${svcGcpV}`)
874
1268
  }
1269
+ if (baseLower === 'svc.yaml') {
1270
+ const svcT = serviceSvcYamlClusterIpTypeViolation(obj)
1271
+ if (svcT !== null) {
1272
+ fail(`${rel}: Service (документ ${di + 1}): ${svcT}`)
1273
+ }
1274
+ }
1275
+ if (baseLower === 'svc-hl.yaml') {
1276
+ const svcH = serviceSvcHlYamlHeadlessViolation(obj)
1277
+ if (svcH !== null) {
1278
+ fail(`${rel}: Service (документ ${di + 1}): ${svcH}`)
1279
+ }
1280
+ }
875
1281
  }
876
1282
  }
877
1283
  }
@@ -1024,6 +1430,8 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
1024
1430
 
1025
1431
  const kustomizeManaged = kustomizeManagedRel.has(rel)
1026
1432
  validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
1433
+
1434
+ scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
1027
1435
  }
1028
1436
 
1029
1437
  /**
@@ -1111,6 +1519,10 @@ export async function check() {
1111
1519
  await checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel)
1112
1520
  }
1113
1521
 
1522
+ await validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail)
1523
+
1524
+ await validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail)
1525
+
1114
1526
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
1115
1527
 
1116
1528
  return exitCode