@nitra/cursor 1.8.105 → 1.8.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,7 +55,7 @@
55
55
  * компонент). Спочатку шукається збіг за фактичним `type`, потім за **`*`**.
56
56
  * Dockerfile — правило docker.mdc, скрипт check-docker.mjs.
57
57
  *
58
- * Структура **`HTTPRoute`** для **Deployment** з образом **`hasura/graphql-engine`** (редиректи **`/ql`**, **WebSocket**, **`URLRewrite`**) — лише в **k8s.mdc**, автоматично не звіряється.
58
+ * **Структура `HTTPRoute` для Hasura-Deployment:** звіряється канон 4 правил у **`spec.rules`** (редиректи **`<prefix>/ql`** і **`<prefix>/ql/`** на **`<prefix>/ql/console`** 302, **`PathPrefix <prefix>/ql`** + **URLRewrite** на **`/`**, окреме WebSocket-правило з **`RequestHeaderModifier`** remove **`Authorization`**). **Префікс параметризовано** (рядок перед **`/ql`** у першому Hasura-правилі). **Прив'язка** за **`metadata.name`** у тому ж каталозі, що й **Deployment** з образом **`hasura/graphql-engine`** (див. k8s.mdc). **Додаткові правила** поверх канону дозволені.
59
59
  */
60
60
  import { existsSync } from 'node:fs'
61
61
  import { readFile, stat, unlink } from 'node:fs/promises'
@@ -949,10 +949,7 @@ function formatKustomizePatchTargetForMessage(target) {
949
949
  */
950
950
  function failIfExplicitPatchTargetsNotInCatalog(rel, first, catalog, fail) {
951
951
  for (const { section, index, target } of extractExplicitPatchTargetsFromKustomization(first)) {
952
- if (
953
- shouldValidateKustomizePatchTarget(target) &&
954
- !kustomizeResourceCatalogMatchesPatchTarget(catalog, target)
955
- ) {
952
+ if (shouldValidateKustomizePatchTarget(target) && !kustomizeResourceCatalogMatchesPatchTarget(catalog, target)) {
956
953
  fail(
957
954
  `${rel}: ${section}[${index}].target — немає відповідного ресурсу в resources/bases/components/crds (рекурсивно): ${formatKustomizePatchTargetForMessage(target)}`
958
955
  )
@@ -1163,7 +1160,16 @@ async function validatePatchTargetsOneKustomizationFile(root, kustAbs, rootNorm,
1163
1160
  const kustNs = typeof rec.namespace === 'string' && rec.namespace.trim() !== '' ? rec.namespace.trim() : ''
1164
1161
  failIfExplicitPatchTargetsNotInCatalog(rel, first, catalog, fail)
1165
1162
  await failIfPathOnlyPatchesNotInCatalog(rel, rec.patches, kustDir, rootNorm, root, catalog, kustNs, fail)
1166
- await failIfStrategicMergePatchesNotInCatalog(rel, rec.patchesStrategicMerge, 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
+ )
1167
1173
  }
1168
1174
 
1169
1175
  /**
@@ -1607,7 +1613,9 @@ async function auditJson6902PatchExternalFile(rel, resolved, root, patchRef, fai
1607
1613
  return
1608
1614
  }
1609
1615
  const relPatch = (relative(root, resolved) || patchRef).replaceAll('\\', '/')
1610
- fail(`${rel}: patch-файл «${relPatch}»: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`)
1616
+ fail(
1617
+ `${rel}: patch-файл «${relPatch}»: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
1618
+ )
1611
1619
  }
1612
1620
 
1613
1621
  /**
@@ -1621,15 +1629,7 @@ async function auditJson6902PatchExternalFile(rel, resolved, root, patchRef, fai
1621
1629
  * @param {(msg: string) => void} fail реєстрація порушення
1622
1630
  * @returns {Promise<void>}
1623
1631
  */
1624
- async function auditOneKustomizationJson6902Patch(
1625
- rel,
1626
- pr,
1627
- patchIdx,
1628
- kustAbs,
1629
- rootNorm,
1630
- root,
1631
- fail
1632
- ) {
1632
+ async function auditOneKustomizationJson6902Patch(rel, pr, patchIdx, kustAbs, rootNorm, root, fail) {
1633
1633
  if (typeof pr.patch === 'string' && pr.patch.trim() !== '') {
1634
1634
  failIfJson6902RemoveAddConflictOnSamePath(rel, `patches[${patchIdx}] inline JSON6902`, pr.patch, fail)
1635
1635
  }
@@ -1924,6 +1924,41 @@ export function deploymentHasuraGraphqlEngineImageViolation(manifest) {
1924
1924
  return hasuraGraphqlEngineViolationInContainerList('initContainers', podSpec.initContainers)
1925
1925
  }
1926
1926
 
1927
+ /**
1928
+ * Чи у списку контейнерів є хоча б один з образом **hasura/graphql-engine** (будь-який тег).
1929
+ * @param {unknown} containers значення **containers** / **initContainers** із podSpec
1930
+ * @returns {boolean} true — якщо знайдено хоча б один контейнер з образом Hasura
1931
+ */
1932
+ function containerListHasHasuraImage(containers) {
1933
+ if (!Array.isArray(containers)) return false
1934
+ for (const c of containers) {
1935
+ if (c !== null && typeof c === 'object' && !Array.isArray(c)) {
1936
+ const image = /** @type {Record<string, unknown>} */ (c).image
1937
+ if (typeof image === 'string' && image !== '' && isHasuraGraphqlEngineImageRef(image)) return true
1938
+ }
1939
+ }
1940
+ return false
1941
+ }
1942
+
1943
+ /**
1944
+ * Чи **Deployment** використовує образ **hasura/graphql-engine** у будь-якому контейнері (маркер для прив'язки HTTPRoute-канона).
1945
+ * @param {unknown} manifest корінь YAML-документа
1946
+ * @returns {boolean} true — для Deployment з Hasura-контейнером у containers / initContainers
1947
+ */
1948
+ export function isHasuraDeploymentManifest(manifest) {
1949
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false
1950
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
1951
+ if (rec.kind !== 'Deployment') return false
1952
+ const spec = rec.spec
1953
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
1954
+ const template = /** @type {Record<string, unknown>} */ (spec).template
1955
+ if (template === null || typeof template !== 'object' || Array.isArray(template)) return false
1956
+ const podSpec = /** @type {Record<string, unknown>} */ (template).spec
1957
+ if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec)) return false
1958
+ const p = /** @type {Record<string, unknown>} */ (podSpec)
1959
+ return containerListHasHasuraImage(p.containers) || containerListHasHasuraImage(p.initContainers)
1960
+ }
1961
+
1927
1962
  /**
1928
1963
  * Чи **Service** містить заборонені анотації GKE у **`metadata.annotations`** (k8s.mdc).
1929
1964
  * @param {unknown} manifest корінь YAML-документа
@@ -2154,6 +2189,247 @@ function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
2154
2189
  }
2155
2190
  }
2156
2191
 
2192
+ /**
2193
+ * Звузити `unknown` до `Record<string, unknown>` (`null`, масиви, примітиви → null).
2194
+ * @param {unknown} node довільний вузол YAML-документа
2195
+ * @returns {Record<string, unknown> | null} plain-об'єкт або null, якщо це не plain-запис
2196
+ */
2197
+ function asPlainRecord(node) {
2198
+ if (node === null || node === undefined || typeof node !== 'object' || Array.isArray(node)) return null
2199
+ return /** @type {Record<string, unknown>} */ (node)
2200
+ }
2201
+
2202
+ /**
2203
+ * Чи `match` — рівно один шлях заданого типу з потрібним значенням, **без** `headers`.
2204
+ * @param {unknown} rule одне правило `HTTPRoute`
2205
+ * @param {'Exact' | 'PathPrefix'} pathType очікуваний `path.type`
2206
+ * @param {string} pathValue очікуваний `path.value`
2207
+ * @returns {boolean} true — якщо `matches` рівно один і відповідає критерію
2208
+ */
2209
+ function hasuraRuleMatchesSinglePathNoHeaders(rule, pathType, pathValue) {
2210
+ const r = asPlainRecord(rule)
2211
+ if (r === null) return false
2212
+ const matches = r.matches
2213
+ if (!Array.isArray(matches) || matches.length !== 1) return false
2214
+ const m = asPlainRecord(matches[0])
2215
+ if (m === null) return false
2216
+ if (m.headers !== undefined) return false
2217
+ const p = asPlainRecord(m.path)
2218
+ if (p === null) return false
2219
+ return p.type === pathType && p.value === pathValue
2220
+ }
2221
+
2222
+ /**
2223
+ * Чи **filters** — рівно один `RequestRedirect` з `ReplaceFullPath` на `toPath` і `statusCode: 302`.
2224
+ * @param {unknown} rule одне правило `HTTPRoute`
2225
+ * @param {string} toPath очікуваний `requestRedirect.path.replaceFullPath`
2226
+ * @returns {boolean} true — якщо filters відповідають канону редиректу
2227
+ */
2228
+ function hasuraRuleHasExactRedirect(rule, toPath) {
2229
+ const r = asPlainRecord(rule)
2230
+ if (r === null) return false
2231
+ const filters = r.filters
2232
+ if (!Array.isArray(filters) || filters.length !== 1) return false
2233
+ const f = asPlainRecord(filters[0])
2234
+ if (f === null || f.type !== 'RequestRedirect') return false
2235
+ const rr = asPlainRecord(f.requestRedirect)
2236
+ if (rr === null || rr.statusCode !== 302) return false
2237
+ const p = asPlainRecord(rr.path)
2238
+ return p !== null && p.type === 'ReplaceFullPath' && p.replaceFullPath === toPath
2239
+ }
2240
+
2241
+ /**
2242
+ * Чи серед **filters** є `URLRewrite` з `ReplacePrefixMatch: /`.
2243
+ * @param {unknown[]} filters масив filters з одного правила `HTTPRoute`
2244
+ * @returns {boolean} true — якщо фільтр `URLRewrite` має `ReplacePrefixMatch: /`
2245
+ */
2246
+ function hasuraFiltersIncludeUrlRewriteToSlash(filters) {
2247
+ for (const f of filters) {
2248
+ const fr = asPlainRecord(f)
2249
+ if (fr !== null && fr.type === 'URLRewrite') {
2250
+ const rw = asPlainRecord(fr.urlRewrite)
2251
+ if (rw === null) return false
2252
+ const p = asPlainRecord(rw.path)
2253
+ return p !== null && p.type === 'ReplacePrefixMatch' && p.replacePrefixMatch === '/'
2254
+ }
2255
+ }
2256
+ return false
2257
+ }
2258
+
2259
+ /**
2260
+ * Чи серед **filters** є `RequestHeaderModifier` з `remove: [Authorization]`.
2261
+ * @param {unknown[]} filters масив filters з одного правила `HTTPRoute`
2262
+ * @returns {boolean} true — якщо фільтр `RequestHeaderModifier` видаляє саме `Authorization`
2263
+ */
2264
+ function hasuraFiltersRemoveAuthorization(filters) {
2265
+ for (const f of filters) {
2266
+ const fr = asPlainRecord(f)
2267
+ if (fr !== null && fr.type === 'RequestHeaderModifier') {
2268
+ const mod = asPlainRecord(fr.requestHeaderModifier)
2269
+ if (mod === null) return false
2270
+ const remove = mod.remove
2271
+ if (!Array.isArray(remove) || remove.length !== 1) return false
2272
+ return remove[0] === 'Authorization'
2273
+ }
2274
+ }
2275
+ return false
2276
+ }
2277
+
2278
+ /**
2279
+ * Ім'я єдиного `backendRef` у правилі (або null, якщо backend-ів не рівно один).
2280
+ * @param {unknown} rule одне правило `HTTPRoute`
2281
+ * @returns {string | null} `backendRefs[0].name` або null, якщо backend-ів не рівно один
2282
+ */
2283
+ function hasuraRuleSingleBackendName(rule) {
2284
+ const r = asPlainRecord(rule)
2285
+ if (r === null) return null
2286
+ const refs = r.backendRefs
2287
+ if (!Array.isArray(refs) || refs.length !== 1) return null
2288
+ const b = asPlainRecord(refs[0])
2289
+ if (b === null || typeof b.name !== 'string') return null
2290
+ return b.name
2291
+ }
2292
+
2293
+ /**
2294
+ * Правило 3: `PathPrefix <qlPath>` + **filters** = 1 × `URLRewrite(ReplacePrefixMatch: /)`.
2295
+ * @param {unknown} rule одне правило `HTTPRoute`
2296
+ * @param {string} qlPath очікуваний `path.value` (`<prefix>/ql`)
2297
+ * @returns {boolean} true — якщо правило відповідає канону пункту 3
2298
+ */
2299
+ function hasuraRuleIsQlUrlRewrite(rule, qlPath) {
2300
+ if (!hasuraRuleMatchesSinglePathNoHeaders(rule, 'PathPrefix', qlPath)) return false
2301
+ const r = asPlainRecord(rule)
2302
+ if (r === null) return false
2303
+ const filters = r.filters
2304
+ if (!Array.isArray(filters) || filters.length !== 1) return false
2305
+ return hasuraFiltersIncludeUrlRewriteToSlash(filters)
2306
+ }
2307
+
2308
+ /**
2309
+ * Правило 4: WebSocket — `PathPrefix <qlPath>` + `Upgrade: websocket`, **filters** = `URLRewrite` + `RequestHeaderModifier(remove Authorization)`.
2310
+ * @param {unknown} rule одне правило `HTTPRoute`
2311
+ * @param {string} qlPath очікуваний `path.value` (`<prefix>/ql`)
2312
+ * @returns {boolean} true — якщо правило відповідає канону пункту 4 (WebSocket)
2313
+ */
2314
+ function hasuraRuleIsWebsocket(rule, qlPath) {
2315
+ const r = asPlainRecord(rule)
2316
+ if (r === null) return false
2317
+ const matches = r.matches
2318
+ if (!Array.isArray(matches) || matches.length !== 1) return false
2319
+ const m = asPlainRecord(matches[0])
2320
+ if (m === null) return false
2321
+ const p = asPlainRecord(m.path)
2322
+ if (p === null || p.type !== 'PathPrefix' || p.value !== qlPath) return false
2323
+ const headers = m.headers
2324
+ if (!Array.isArray(headers) || headers.length !== 1) return false
2325
+ const h = asPlainRecord(headers[0])
2326
+ if (h === null || h.type !== 'Exact' || h.name !== 'Upgrade' || h.value !== 'websocket') return false
2327
+ const filters = r.filters
2328
+ if (!Array.isArray(filters) || filters.length !== 2) return false
2329
+ return hasuraFiltersIncludeUrlRewriteToSlash(filters) && hasuraFiltersRemoveAuthorization(filters)
2330
+ }
2331
+
2332
+ /**
2333
+ * Знаходить перше правило з **`matches`** = `[{ path: { type: 'Exact', value: '<prefix>/ql' } }]` (без headers),
2334
+ * повертає `<prefix>` (може бути порожнім) і позицію правила 1.
2335
+ * @param {unknown[]} rules вміст `spec.rules` HTTPRoute
2336
+ * @returns {{ prefix: string, startIndex: number } | null} виявлений префікс і позиція правила 1 або null
2337
+ */
2338
+ function findHasuraCanonStart(rules) {
2339
+ for (let i = 0; i < rules.length; i++) {
2340
+ const r = asPlainRecord(rules[i])
2341
+ const matches = r === null ? null : r.matches
2342
+ if (!Array.isArray(matches) || matches.length !== 1) {
2343
+ // наступне правило
2344
+ } else {
2345
+ const m = asPlainRecord(matches[0])
2346
+ const p = m === null || m.headers !== undefined ? null : asPlainRecord(m.path)
2347
+ if (
2348
+ p !== null &&
2349
+ p.type === 'Exact' &&
2350
+ typeof p.value === 'string' &&
2351
+ p.value.endsWith('/ql')
2352
+ ) {
2353
+ return { prefix: p.value.slice(0, -'/ql'.length), startIndex: i }
2354
+ }
2355
+ }
2356
+ }
2357
+ return null
2358
+ }
2359
+
2360
+ /**
2361
+ * Знаходить перше правило за індексом ≥ `from`, що задовольняє `predicate`. Повертає індекс або -1.
2362
+ * @param {unknown[]} rules вміст `spec.rules` HTTPRoute
2363
+ * @param {number} from мінімальний індекс, з якого починати пошук
2364
+ * @param {(rule: unknown) => boolean} predicate предикат на одне правило
2365
+ * @returns {number} індекс знайденого правила або -1
2366
+ */
2367
+ function findHasuraRule(rules, from, predicate) {
2368
+ for (let i = from; i < rules.length; i++) {
2369
+ if (predicate(rules[i])) return i
2370
+ }
2371
+ return -1
2372
+ }
2373
+
2374
+ /**
2375
+ * Чи **`HTTPRoute`** порушує канон 4 правил Hasura (див. k8s.mdc).
2376
+ * Повертає текст порушення або null, якщо канон витримано. Додаткові правила поверх канону допускаються.
2377
+ * @param {unknown} manifest корінь YAML-документа
2378
+ * @returns {string | null} текст порушення або null, якщо канон витримано
2379
+ */
2380
+ export function httpRouteHasuraCanonViolation(manifest) {
2381
+ const rec = asPlainRecord(manifest)
2382
+ if (rec === null) return null
2383
+ const spec = asPlainRecord(rec.spec)
2384
+ if (spec === null) return 'HTTPRoute без spec — канон Hasura вимагає 4 правил (див. k8s.mdc)'
2385
+ const rules = spec.rules
2386
+ if (!Array.isArray(rules) || rules.length === 0) {
2387
+ return 'spec.rules порожній — канон Hasura вимагає 4 правил у порядку (див. k8s.mdc)'
2388
+ }
2389
+ const start = findHasuraCanonStart(rules)
2390
+ if (start === null) {
2391
+ return 'не знайдено правило 1 Hasura-канона: Exact "<prefix>/ql" + RequestRedirect ReplaceFullPath "<prefix>/ql/console" statusCode 302 (див. k8s.mdc)'
2392
+ }
2393
+ const { prefix, startIndex } = start
2394
+ const qlPath = `${prefix}/ql`
2395
+ const qlSlashPath = `${prefix}/ql/`
2396
+ const consolePath = `${prefix}/ql/console`
2397
+
2398
+ if (!hasuraRuleHasExactRedirect(rules[startIndex], consolePath)) {
2399
+ return `правило 1 Hasura-канона (rules[${startIndex}], prefix «${prefix}»): Exact "${qlPath}" має мати RequestRedirect ReplaceFullPath "${consolePath}" statusCode 302 (див. k8s.mdc)`
2400
+ }
2401
+
2402
+ const i2 = findHasuraRule(
2403
+ rules,
2404
+ startIndex + 1,
2405
+ r => hasuraRuleMatchesSinglePathNoHeaders(r, 'Exact', qlSlashPath) && hasuraRuleHasExactRedirect(r, consolePath)
2406
+ )
2407
+ if (i2 === -1) {
2408
+ return `правило 2 Hasura-канона: після правила 1 має бути Exact "${qlSlashPath}" + RequestRedirect ReplaceFullPath "${consolePath}" statusCode 302 (див. k8s.mdc)`
2409
+ }
2410
+
2411
+ const i3 = findHasuraRule(
2412
+ rules,
2413
+ i2 + 1,
2414
+ r => hasuraRuleIsQlUrlRewrite(r, qlPath) && hasuraRuleSingleBackendName(r) !== null
2415
+ )
2416
+ if (i3 === -1) {
2417
+ return `правило 3 Hasura-канона: після правила 2 має бути PathPrefix "${qlPath}" + URLRewrite ReplacePrefixMatch "/" + один backendRef на headless Service (див. k8s.mdc)`
2418
+ }
2419
+ const backendName = /** @type {string} */ (hasuraRuleSingleBackendName(rules[i3]))
2420
+
2421
+ const i4 = findHasuraRule(
2422
+ rules,
2423
+ i3 + 1,
2424
+ r => hasuraRuleIsWebsocket(r, qlPath) && hasuraRuleSingleBackendName(r) === backendName
2425
+ )
2426
+ if (i4 === -1) {
2427
+ return `правило 4 Hasura-канона (WebSocket): після правила 3 має бути PathPrefix "${qlPath}" + header "Upgrade: websocket" + URLRewrite ReplacePrefixMatch "/" + RequestHeaderModifier remove [Authorization] + backendRef «${backendName}» (див. k8s.mdc)`
2428
+ }
2429
+
2430
+ return null
2431
+ }
2432
+
2157
2433
  /**
2158
2434
  * Збирає **`metadata.name`** для **kind: Service** у коренях документів; при помилці викликає **fail** і повертає false.
2159
2435
  * @param {Record<string, unknown>[]} roots корені YAML-документів
@@ -2300,6 +2576,113 @@ async function validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail) {
2300
2576
  }
2301
2577
  }
2302
2578
 
2579
+ /**
2580
+ * Індексує Hasura-Deployment-и за каталогом (ключ — абсолютний шлях каталогу, значення — множина `metadata.name`).
2581
+ * Паралельно збирає всі `kind: HTTPRoute` Gateway API (`gateway.networking.k8s.io/*`) із doc-індексом.
2582
+ * @param {string[]} yamlFiles абсолютні шляхи до `*.yaml` під `k8s`
2583
+ * @returns {Promise<{
2584
+ * hasuraByDir: Map<string, Set<string>>,
2585
+ * httpRoutes: { abs: string, dir: string, docIndex: number, obj: Record<string, unknown> }[]
2586
+ * }>} індекс Hasura-Deployment-ів за каталогом і список HTTPRoute-документів
2587
+ */
2588
+ async function collectHasuraDeploymentsAndHttpRoutes(yamlFiles) {
2589
+ /** @type {Map<string, Set<string>>} */
2590
+ const hasuraByDir = new Map()
2591
+ /** @type {{ abs: string, dir: string, docIndex: number, obj: Record<string, unknown> }[]} */
2592
+ const httpRoutes = []
2593
+
2594
+ for (const abs of yamlFiles) {
2595
+ await indexOneK8sYamlForHasuraCanon(abs, hasuraByDir, httpRoutes)
2596
+ }
2597
+
2598
+ return { hasuraByDir, httpRoutes }
2599
+ }
2600
+
2601
+ /**
2602
+ * Читає один YAML і додає Hasura-Deployment-и / HTTPRoute-документи до відповідних колекцій (нещасливі читання ігнорує).
2603
+ * @param {string} abs абсолютний шлях до файлу
2604
+ * @param {Map<string, Set<string>>} hasuraByDir індекс Hasura Deployment-ів за каталогом
2605
+ * @param {{ abs: string, dir: string, docIndex: number, obj: Record<string, unknown> }[]} httpRoutes колектор HTTPRoute-документів
2606
+ * @returns {Promise<void>}
2607
+ */
2608
+ async function indexOneK8sYamlForHasuraCanon(abs, hasuraByDir, httpRoutes) {
2609
+ let raw
2610
+ try {
2611
+ raw = await readFile(abs, 'utf8')
2612
+ } catch {
2613
+ return
2614
+ }
2615
+ const lines = toLines(raw)
2616
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
2617
+ /** @type {import('yaml').Document[]} */
2618
+ let docs
2619
+ try {
2620
+ docs = parseAllDocuments(body)
2621
+ } catch {
2622
+ return
2623
+ }
2624
+ const dir = dirname(abs)
2625
+
2626
+ for (const [di, doc] of docs.entries()) {
2627
+ if (doc.errors.length === 0) {
2628
+ const rec = asPlainRecord(doc.toJSON())
2629
+ if (rec !== null) {
2630
+ recordHasuraDeploymentName(rec, dir, hasuraByDir)
2631
+ const av = rec.apiVersion
2632
+ if (rec.kind === 'HTTPRoute' && typeof av === 'string' && av.startsWith(GATEWAY_API_GROUP_PREFIX)) {
2633
+ httpRoutes.push({ abs, dir, docIndex: di + 1, obj: rec })
2634
+ }
2635
+ }
2636
+ }
2637
+ }
2638
+ }
2639
+
2640
+ /**
2641
+ * Якщо документ — Hasura-Deployment із непорожнім `metadata.name`, додає ім'я до індексу за каталогом.
2642
+ * @param {Record<string, unknown>} rec корінь YAML-документа
2643
+ * @param {string} dir абсолютний шлях до каталогу файлу
2644
+ * @param {Map<string, Set<string>>} hasuraByDir індекс Hasura Deployment-ів за каталогом (мутується)
2645
+ * @returns {void}
2646
+ */
2647
+ function recordHasuraDeploymentName(rec, dir, hasuraByDir) {
2648
+ if (!isHasuraDeploymentManifest(rec)) return
2649
+ const meta = asPlainRecord(rec.metadata)
2650
+ const name = meta === null ? undefined : meta.name
2651
+ if (typeof name !== 'string' || name === '') return
2652
+ let set = hasuraByDir.get(dir)
2653
+ if (set === undefined) {
2654
+ set = new Set()
2655
+ hasuraByDir.set(dir, set)
2656
+ }
2657
+ set.add(name)
2658
+ }
2659
+
2660
+ /**
2661
+ * Для кожного `kind: HTTPRoute`, що прив'язаний до **Hasura-Deployment** у тому самому каталозі за **`metadata.name`**,
2662
+ * звіряє канон 4 правил (див. `httpRouteHasuraCanonViolation` і k8s.mdc).
2663
+ * @param {string} root корінь репозиторію
2664
+ * @param {string[]} yamlFiles абсолютні шляхи до `*.yaml` під `k8s`
2665
+ * @param {(msg: string) => void} fail callback реєстрації помилки
2666
+ * @returns {Promise<void>}
2667
+ */
2668
+ async function validateHasuraHttpRouteCanon(root, yamlFiles, fail) {
2669
+ const { hasuraByDir, httpRoutes } = await collectHasuraDeploymentsAndHttpRoutes(yamlFiles)
2670
+ if (hasuraByDir.size === 0 || httpRoutes.length === 0) return
2671
+
2672
+ for (const hr of httpRoutes) {
2673
+ const meta = asPlainRecord(hr.obj.metadata)
2674
+ const name = meta === null ? undefined : meta.name
2675
+ const set = typeof name === 'string' && name !== '' ? hasuraByDir.get(hr.dir) : undefined
2676
+ if (set !== undefined && typeof name === 'string' && set.has(name)) {
2677
+ const v = httpRouteHasuraCanonViolation(hr.obj)
2678
+ if (v !== null) {
2679
+ const rel = (relative(root, hr.abs) || hr.abs).replaceAll('\\', '/')
2680
+ fail(`${rel}: HTTPRoute «${name}» (документ ${hr.docIndex}; прив'язано до Hasura-Deployment у тому ж каталозі): ${v}`)
2681
+ }
2682
+ }
2683
+ }
2684
+ }
2685
+
2303
2686
  /**
2304
2687
  * Для маніфестів, **підключених** до Kustomize (шлях у `resources` / `patches` / …), **metadata.namespace** не додають.
2305
2688
  * @param {unknown} manifest корінь YAML-документа
@@ -2378,15 +2761,7 @@ export function isK8sBaseManifestYamlPath(rel, baseLower) {
2378
2761
  * @param {(msg: string) => void} fail реєстрація помилки
2379
2762
  * @returns {void}
2380
2763
  */
2381
- function failIfK8sPolicyNamespaceRulesViolated(
2382
- rel,
2383
- docIndex,
2384
- obj,
2385
- skipMetaNs,
2386
- inBaseManifest,
2387
- kustomizeManaged,
2388
- fail
2389
- ) {
2764
+ function failIfK8sPolicyNamespaceRulesViolated(rel, docIndex, obj, skipMetaNs, inBaseManifest, kustomizeManaged, fail) {
2390
2765
  if (skipMetaNs) {
2391
2766
  return
2392
2767
  }
@@ -2479,15 +2854,7 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
2479
2854
  fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
2480
2855
  } else {
2481
2856
  const obj = doc.toJSON()
2482
- failIfK8sPolicyNamespaceRulesViolated(
2483
- rel,
2484
- di + 1,
2485
- obj,
2486
- skipMetaNs,
2487
- inBaseManifest,
2488
- kustomizeManaged,
2489
- fail
2490
- )
2857
+ failIfK8sPolicyNamespaceRulesViolated(rel, di + 1, obj, skipMetaNs, inBaseManifest, kustomizeManaged, fail)
2491
2858
  failIfK8sPolicyResourceRulesViolated(rel, baseLower, di + 1, obj, fail)
2492
2859
  }
2493
2860
  }
@@ -2827,6 +3194,8 @@ export async function check() {
2827
3194
 
2828
3195
  await validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail)
2829
3196
 
3197
+ await validateHasuraHttpRouteCanon(root, yamlFiles, fail)
3198
+
2830
3199
  await validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail)
2831
3200
 
2832
3201
  await validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFiles, fail)