@nitra/cursor 1.10.0 → 1.11.0

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/bin/n-cursor.js +29 -29
  3. package/package.json +2 -1
  4. package/rules/abie/js/applies/check.mjs +24 -0
  5. package/rules/abie/js/env_dns/check.mjs +53 -0
  6. package/rules/abie/js/firebase_hosting/check.mjs +49 -0
  7. package/rules/abie/js/hc_pairing/check.mjs +58 -0
  8. package/rules/abie/js/ua_http_route/check.mjs +86 -0
  9. package/rules/abie/js/ua_node_selector/check.mjs +65 -0
  10. package/rules/abie/policy/base_deployment_preem/target.json +10 -0
  11. package/rules/abie/policy/clean_merged_ignore_branches/target.json +4 -0
  12. package/rules/abie/policy/health_check_policy/target.json +4 -0
  13. package/rules/abie/policy/http_route_base/target.json +4 -0
  14. package/rules/abie/utils/enabled.mjs +35 -0
  15. package/rules/abie/utils/env-dns.mjs +81 -0
  16. package/rules/abie/utils/hc-yaml.mjs +27 -0
  17. package/rules/abie/utils/http-route.mjs +93 -0
  18. package/rules/abie/utils/k8s-tree.mjs +102 -0
  19. package/rules/abie/utils/kustomization-patches.mjs +224 -0
  20. package/rules/abie/utils/overlay-paths.mjs +97 -0
  21. package/rules/abie/utils/yaml.mjs +72 -0
  22. package/rules/adr/policy/settings_json/target.json +4 -0
  23. package/rules/adr/policy/settings_local_json/target.json +4 -0
  24. package/rules/bun/policy/bunfig/target.json +4 -0
  25. package/rules/bun/policy/package_json/target.json +4 -0
  26. package/rules/capacitor/policy/package_json/target.json +4 -0
  27. package/rules/docker/policy/lint_docker_yml/target.json +4 -0
  28. package/rules/docker/policy/package_json/target.json +4 -0
  29. package/rules/hasura/policy/svc_hl/target.json +4 -0
  30. package/rules/image-avif/policy/package_json/target.json +4 -0
  31. package/rules/image-compress/policy/package_json/target.json +4 -0
  32. package/rules/js-bun-db/policy/package_json/target.json +4 -0
  33. package/rules/js-bun-redis/policy/package_json/target.json +4 -0
  34. package/rules/js-lint/policy/lint_js_yml/target.json +4 -0
  35. package/rules/js-lint/policy/package_json/target.json +4 -0
  36. package/rules/js-mssql/policy/package_json/target.json +4 -0
  37. package/rules/js-run/policy/configmap/target.json +4 -0
  38. package/rules/js-run/policy/package_json/target.json +4 -0
  39. package/rules/k8s/policy/base_kustomization/target.json +4 -0
  40. package/rules/k8s/policy/base_manifest/target.json +10 -0
  41. package/rules/k8s/policy/gateway/target.json +4 -0
  42. package/rules/k8s/policy/hpa_pdb/target.json +4 -0
  43. package/rules/k8s/policy/kustomization/target.json +4 -0
  44. package/rules/k8s/policy/manifest/target.json +4 -0
  45. package/rules/k8s/policy/svc_hl_yaml/target.json +4 -0
  46. package/rules/k8s/policy/svc_yaml/target.json +4 -0
  47. package/rules/npm-module/policy/emit_types_config/target.json +4 -0
  48. package/rules/npm-module/policy/npm_package_json/target.json +4 -0
  49. package/rules/npm-module/policy/npm_publish_yml/target.json +4 -0
  50. package/rules/npm-module/policy/root_package_json/target.json +4 -0
  51. package/rules/php/policy/lint_php_yml/target.json +4 -0
  52. package/rules/php/policy/package_json/target.json +4 -0
  53. package/rules/rego/js/applies/check.mjs +54 -0
  54. package/rules/rego/policy/package_json/target.json +5 -0
  55. package/rules/rego/policy/vscode_extensions/target.json +5 -0
  56. package/rules/rego/policy/vscode_settings/target.json +5 -0
  57. package/rules/style-lint/policy/lint_style_yml/target.json +4 -0
  58. package/rules/style-lint/policy/package_json/target.json +4 -0
  59. package/rules/style-lint/policy/vscode_extensions/target.json +4 -0
  60. package/rules/style-lint/policy/vscode_settings/target.json +4 -0
  61. package/rules/text/policy/cspell/target.json +4 -0
  62. package/rules/text/policy/markdownlint/target.json +4 -0
  63. package/rules/text/policy/oxfmtrc/target.json +4 -0
  64. package/rules/text/policy/package_json/target.json +4 -0
  65. package/rules/text/policy/vscode_extensions/target.json +4 -0
  66. package/rules/text/policy/vscode_settings/target.json +4 -0
  67. package/rules/vue/policy/package_json/target.json +4 -0
  68. package/schemas/target.json +58 -0
  69. package/scripts/lint-conftest.mjs +65 -414
  70. package/scripts/utils/discover-checkable-rules.mjs +123 -0
  71. package/scripts/utils/resolve-target-files.mjs +109 -0
  72. package/scripts/utils/run-rule.mjs +131 -0
  73. package/rules/abie/js/check.mjs +0 -1152
  74. package/rules/rego/js/check.mjs +0 -106
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Перевірка кластерного DNS у abie env-файлах (`*.dev.env`, `*.ua.env`).
3
+ *
4
+ * abie живе у двох GKE-кластерах (`abie-dev.internal`, `abie-ua.internal`); внутрішньокластерні
5
+ * URL у env-файлі мусять відповідати кластеру за іменем файла. `validateAbieEnvInternalUrls`
6
+ * сканує всі URL виду `http://<svc>.<ns>.svc.<dns>` і вимагає коректний `<dns>` + namespace-префікс.
7
+ * Файл `.env` без імені (локальний для розробника) виключено.
8
+ */
9
+ import { basename } from 'node:path'
10
+
11
+ import { walkDir } from '../../../scripts/utils/walkDir.mjs'
12
+
13
+ const ABIE_ENV_FILE_BASENAME_RE = /^\.?(dev|ua)\.env$/u
14
+
15
+ const ABIE_INTERNAL_URL_GLOBAL_RE =
16
+ /\bhttp:\/\/([a-z0-9][a-z0-9-]*)\.([a-z0-9][a-z0-9-]*)\.svc\.([a-z0-9][a-z0-9-]*\.internal)(?::\d+)?(?:\/[^\s"'`]*)?/giu
17
+
18
+ const ABIE_ENV_CLUSTER_DNS_MAP = Object.freeze({
19
+ dev: Object.freeze({ clusterDns: 'abie-dev.internal', namespacePrefix: 'dev-' }),
20
+ ua: Object.freeze({ clusterDns: 'abie-ua.internal', namespacePrefix: 'ua-' })
21
+ })
22
+
23
+ /**
24
+ * Дістає `dev` / `ua` з basename env-файлу abie.
25
+ * Не-abie env-файли (`production.env`, `.env` без імені) → null.
26
+ * @param {string} basenameOfEnvFile
27
+ * @returns {('dev' | 'ua') | null}
28
+ */
29
+ export function abieEnvNameFromBasename(basenameOfEnvFile) {
30
+ const m = basenameOfEnvFile.match(ABIE_ENV_FILE_BASENAME_RE)
31
+ return m ? /** @type {'dev' | 'ua'} */ (m[1]) : null
32
+ }
33
+
34
+ /**
35
+ * Сканує вміст env-файла, повертає помилки невідповідності кластерного DNS / namespace
36
+ * для кожного internal URL (один URL у двох змінних = дві окремі помилки).
37
+ * @param {string} content вміст env-файла (UTF-8)
38
+ * @param {'dev' | 'ua'} envName
39
+ * @returns {string[]} порожній масив, якщо все OK
40
+ */
41
+ export function validateAbieEnvInternalUrls(content, envName) {
42
+ const expected = ABIE_ENV_CLUSTER_DNS_MAP[envName]
43
+ if (!expected) return []
44
+ /** @type {string[]} */
45
+ const errors = []
46
+ for (const match of content.matchAll(ABIE_INTERNAL_URL_GLOBAL_RE)) {
47
+ const [fullUrl, , namespace, clusterDns] = match
48
+ if (clusterDns !== expected.clusterDns) {
49
+ errors.push(
50
+ `${fullUrl}: кластерний DNS "${clusterDns}" не відповідає env "${envName}" (очікується "${expected.clusterDns}")`
51
+ )
52
+ }
53
+ if (!namespace.startsWith(expected.namespacePrefix)) {
54
+ errors.push(
55
+ `${fullUrl}: namespace "${namespace}" не починається з "${expected.namespacePrefix}" (env "${envName}")`
56
+ )
57
+ }
58
+ }
59
+ return errors
60
+ }
61
+
62
+ /**
63
+ * Збирає `*.env` файли, які є abie env (`dev.env`/`ua.env`, опц. з провідною крапкою).
64
+ * @param {string} root корінь репозиторію
65
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів-виключень
66
+ * @returns {Promise<string[]>}
67
+ */
68
+ export async function collectAbieEnvFiles(root, ignorePaths) {
69
+ /** @type {string[]} */
70
+ const out = []
71
+ await walkDir(
72
+ root,
73
+ absPath => {
74
+ if (abieEnvNameFromBasename(basename(absPath)) !== null) {
75
+ out.push(absPath)
76
+ }
77
+ },
78
+ ignorePaths
79
+ )
80
+ return out.toSorted((a, b) => a.localeCompare(b))
81
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Валідація modeline у `hc.yaml` для abie.
3
+ * Per-document структурна валідація `HealthCheckPolicy` живе у
4
+ * `policy/health_check_policy/health_check_policy.rego` (CLI прогонить через target.json).
5
+ */
6
+ import { LINE_SPLIT_RE, MODELINE_RE, stripBom } from './yaml.mjs'
7
+
8
+ /** Очікуваний URL `$schema` для **hc.yaml** (abie.mdc). */
9
+ export const ABIE_HC_SCHEMA_URL = 'https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json'
10
+
11
+ /**
12
+ * Перевіряє modeline (`# yaml-language-server: $schema=...`) у `hc.yaml`.
13
+ * @param {string} raw вміст файла
14
+ * @param {string} relPath відносний шлях (для повідомлень)
15
+ * @returns {string | null} текст помилки або null, якщо OK
16
+ */
17
+ export function validateAbieHcModeline(raw, relPath) {
18
+ const body = stripBom(raw)
19
+ const lines = body.split(LINE_SPLIT_RE)
20
+ if (lines.length === 0 || lines[0].trim() === '') {
21
+ return `${relPath}: перший рядок порожній — потрібен # yaml-language-server: $schema=… (abie.mdc)`
22
+ }
23
+ const m = lines[0].match(MODELINE_RE)
24
+ if (!m) return `${relPath}: перший рядок має бути modeline $schema (abie.mdc)`
25
+ if (m[1] !== ABIE_HC_SCHEMA_URL) return `${relPath}: $schema має бути\n ${ABIE_HC_SCHEMA_URL}\n (abie.mdc)`
26
+ return null
27
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Cross-документна аналітика abie HTTPRoute: підрахунок `backendRefs` до спільних
3
+ * сервісів (`auth-run-hl`, `file-link-hl`) у base-маніфестах пакета (поза overlay `ua`).
4
+ * Використовується ua_http_route-концерном для синхронізації числа patch-ів namespace
5
+ * у overlay із кількістю base-referencе.
6
+ */
7
+ import { relative } from 'node:path'
8
+
9
+ import { isK8sYamlInAbiePackageExcludingUaOverlay } from './overlay-paths.mjs'
10
+ import { readAndParseYamlDocs, silentFail } from './yaml.mjs'
11
+
12
+ export const ABIE_SHARED_CROSS_NS_BACKEND_NAMES = Object.freeze(['auth-run-hl', 'file-link-hl'])
13
+ const ABIE_SHARED_CROSS_NS_BACKEND_SET = new Set(ABIE_SHARED_CROSS_NS_BACKEND_NAMES)
14
+
15
+ /**
16
+ * Перевіряє один `backendRef`: якщо це спільний `-hl` сервіс, має бути `namespace: dev`.
17
+ * @param {unknown} br
18
+ * @param {string} rel rel-шлях файла
19
+ * @param {string[]} errors мутабельний список помилок
20
+ * @returns {number} 1 — це shared backend, 0 — інакше
21
+ */
22
+ function checkSharedBackendRef(br, rel, errors) {
23
+ if (br === null || typeof br !== 'object' || Array.isArray(br)) return 0
24
+ const brRec = /** @type {Record<string, unknown>} */ (br)
25
+ const name = brRec.name
26
+ if (typeof name !== 'string' || !ABIE_SHARED_CROSS_NS_BACKEND_SET.has(name)) return 0
27
+ if (typeof brRec.namespace !== 'string' || brRec.namespace !== 'dev') {
28
+ errors.push(`${rel}: HTTPRoute backendRefs до ${name} має містити namespace: dev (abie.mdc)`)
29
+ }
30
+ return 1
31
+ }
32
+
33
+ /**
34
+ * Збирає по HTTPRoute-документу кількість посилань на shared backends і порушення namespace.
35
+ * @param {unknown} obj корінь YAML
36
+ * @param {string} rel
37
+ * @returns {{ refCount: number, errors: string[] }}
38
+ */
39
+ function httpRouteDocSharedCrossNsBackendStats(obj, rel) {
40
+ /** @type {string[]} */
41
+ const errors = []
42
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return { refCount: 0, errors }
43
+ const rec = /** @type {Record<string, unknown>} */ (obj)
44
+ if (rec.kind !== 'HTTPRoute') return { refCount: 0, errors }
45
+ const spec = rec.spec
46
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return { refCount: 0, errors }
47
+ const rules = /** @type {Record<string, unknown>} */ (spec).rules
48
+ if (!Array.isArray(rules)) return { refCount: 0, errors }
49
+ let refCount = 0
50
+ for (const rule of rules) {
51
+ if (rule !== null && typeof rule === 'object' && !Array.isArray(rule)) {
52
+ const brs = /** @type {Record<string, unknown>} */ (rule).backendRefs
53
+ if (Array.isArray(brs)) {
54
+ for (const br of brs) {
55
+ refCount += checkSharedBackendRef(br, rel, errors)
56
+ }
57
+ }
58
+ }
59
+ }
60
+ return { refCount, errors }
61
+ }
62
+
63
+ /**
64
+ * Збирає по yaml-файлах пакета (поза overlay ua) кількість shared-`-hl` `backendRefs`
65
+ * і базові помилки (без `namespace: dev`).
66
+ * @param {string} root корінь репозиторію
67
+ * @param {string} pkgAbs абсолютний шлях каталогу пакета
68
+ * @param {string[]} yamlFilesAbs усі yaml під k8s
69
+ * @returns {Promise<{ refCount: number, baseErrors: string[] }>}
70
+ */
71
+ export async function analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yamlFilesAbs) {
72
+ const pkgRel = relative(root, pkgAbs).replaceAll('\\', '/') || pkgAbs
73
+ let refCount = 0
74
+ /** @type {string[]} */
75
+ const baseErrors = []
76
+ for (const abs of yamlFilesAbs) {
77
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
78
+ if (isK8sYamlInAbiePackageExcludingUaOverlay(rel, pkgRel)) {
79
+ const docs = await readAndParseYamlDocs(abs, rel, silentFail)
80
+ if (docs) {
81
+ for (const doc of docs) {
82
+ if (doc.errors.length === 0) {
83
+ const json = doc.toJSON()
84
+ const st = httpRouteDocSharedCrossNsBackendStats(json, rel)
85
+ refCount += st.refCount
86
+ baseErrors.push(...st.errors)
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ return { refCount, baseErrors }
93
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Обхід k8s-дерева abie з кешуванням на час одного прогону:
3
+ * - `findK8sYamlFiles(root, ignorePaths)` — yaml/yml файли під сегментом `k8s/`.
4
+ * - `collectDeploymentDirs(root, yamlAbs)` — каталоги, де знайдено `kind: Deployment`.
5
+ *
6
+ * Кеш — module-level singleton, ключований за `(root, ignorePaths)`. Перший виклик
7
+ * платить за обхід; наступні концерни в межах того ж прогону отримують готове.
8
+ * Для тестів — `resetAbieK8sTreeCache()` (інакше withTmpCwd-фікстури злипатимуться).
9
+ */
10
+ import { dirname, relative } from 'node:path'
11
+
12
+ import { pathHasK8sSegment } from '../../k8s/js/check.mjs'
13
+ import { walkDir } from '../../../scripts/utils/walkDir.mjs'
14
+ import { isDeploymentDoc, readAndParseYamlDocs } from './yaml.mjs'
15
+
16
+ const YAML_EXTENSION_RE = /\.ya?ml$/iu
17
+
18
+ /** @type {Map<string, Promise<string[]>>} */
19
+ const yamlCache = new Map()
20
+ /** @type {Map<string, Promise<Set<string>>>} */
21
+ const deploymentCache = new Map()
22
+
23
+ /**
24
+ * Скидає кеш — тести мусять викликати між фікстурами.
25
+ * @returns {void}
26
+ */
27
+ export function resetAbieK8sTreeCache() {
28
+ yamlCache.clear()
29
+ deploymentCache.clear()
30
+ }
31
+
32
+ /**
33
+ * Стабільний ключ кешу за (root, ignorePaths).
34
+ * @param {string} root
35
+ * @param {string[]} ignorePaths
36
+ * @returns {string}
37
+ */
38
+ function cacheKey(root, ignorePaths) {
39
+ return `${root}|${[...ignorePaths].toSorted((a, b) => a.localeCompare(b)).join(':')}`
40
+ }
41
+
42
+ /**
43
+ * Збирає абсолютні шляхи до `.yaml`/`.yml` під деревом, де є сегмент `k8s/`.
44
+ * Каталог `.github/` свідомо пропускається (належить `ga.mdc`).
45
+ * @param {string} root корінь репозиторію
46
+ * @param {string[]} [ignorePaths] абсолютні шляхи каталогів-виключень
47
+ * @returns {Promise<string[]>}
48
+ */
49
+ export function findK8sYamlFiles(root, ignorePaths = []) {
50
+ const key = cacheKey(root, ignorePaths)
51
+ const cached = yamlCache.get(key)
52
+ if (cached) return cached
53
+ const promise = (async () => {
54
+ /** @type {string[]} */
55
+ const out = []
56
+ await walkDir(
57
+ root,
58
+ p => {
59
+ const rel = relative(root, p).replaceAll('\\', '/')
60
+ if (rel.startsWith('.github/')) return
61
+ if (!pathHasK8sSegment(p, root)) return
62
+ if (!YAML_EXTENSION_RE.test(p)) return
63
+ out.push(p)
64
+ },
65
+ ignorePaths
66
+ )
67
+ return [...out].toSorted((a, b) => a.localeCompare(b))
68
+ })()
69
+ yamlCache.set(key, promise)
70
+ return promise
71
+ }
72
+
73
+ /**
74
+ * Каталоги, де є хоча б один `kind: Deployment` у YAML під `k8s/`.
75
+ * @param {string} root корінь репозиторію
76
+ * @param {string[]} yamlAbs абсолютні шляхи `.yaml`/`.yml` під `k8s/` (як з `findK8sYamlFiles`)
77
+ * @param {(msg: string) => void} [fail] репортер помилок парсингу (опц.)
78
+ * @returns {Promise<Set<string>>} абсолютні шляхи директорій
79
+ */
80
+ export function collectDeploymentDirs(root, yamlAbs, fail = () => {}) {
81
+ const key = `${root}|${[...yamlAbs].toSorted((a, b) => a.localeCompare(b)).join(':')}`
82
+ const cached = deploymentCache.get(key)
83
+ if (cached) return cached
84
+ const promise = (async () => {
85
+ /** @type {Set<string>} */
86
+ const dirs = new Set()
87
+ for (const abs of yamlAbs) {
88
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
89
+ const docs = await readAndParseYamlDocs(abs, rel, fail)
90
+ if (docs) {
91
+ for (const doc of docs) {
92
+ if (doc.errors.length === 0 && isDeploymentDoc(doc.toJSON())) {
93
+ dirs.add(dirname(abs))
94
+ }
95
+ }
96
+ }
97
+ }
98
+ return dirs
99
+ })()
100
+ deploymentCache.set(key, promise)
101
+ return promise
102
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Парсинг inline JSON6902-патчів у abie ua-kustomization:
3
+ * - **nodeSelector** patch на `Deployment` (preem: false);
4
+ * - **HTTPRoute** patch (hostnames, parentRefs namespace, backendRefs namespace).
5
+ *
6
+ * Regex використовуються, бо `patch:` — це YAML-string з вкладеним JSON6902, який ми не парсимо
7
+ * вдруге; підрядки на кшталт `path: /spec/hostnames` і `value: ua` достатньо інформативні.
8
+ */
9
+ import { parseAllDocuments } from 'yaml'
10
+
11
+ import { LINE_SPLIT_RE, MODELINE_RE, stripBom } from './yaml.mjs'
12
+
13
+ const PATCH_NODE_SELECTOR_PATH_RE = /path:\s*\/spec\/template\/spec\/nodeSelector\b/u
14
+ const PATCH_PREEM_FALSE_RE = /\bpreem:\s*['"]?false['"]?\b/u
15
+ const PATCH_HOSTNAMES_PATH_RE = /path:\s*\/spec\/hostnames\b/mu
16
+ // Overlay namespaces: дозволено `ua` і `ua-*` (наприклад `ua-b2b`).
17
+ const PATCH_PARENT_REF_NS_UA_RE =
18
+ /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/imu
19
+
20
+ /** Домени `hostnames` для overlay `ua` (підрядки у JSON6902-тексті patch). */
21
+ const ABIE_UA_HTTPROUTE_HOST_MARKERS = ['abie.app', 'vybeerai.com.ua', '*.abie.app', '*.vybeerai.com.ua']
22
+
23
+ // ── nodeSelector (ua) ─────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Чи patch-рядок містить очікуваний ua nodeSelector (preem: false).
27
+ * @param {string} patchText
28
+ * @returns {boolean}
29
+ */
30
+ function jsonPatchTextHasUaDeploymentNodeSelector(patchText) {
31
+ if (typeof patchText !== 'string' || patchText.trim() === '') return false
32
+ if (!PATCH_NODE_SELECTOR_PATH_RE.test(patchText)) return false
33
+ if (!PATCH_PREEM_FALSE_RE.test(patchText)) return false
34
+ return true
35
+ }
36
+
37
+ /**
38
+ * Чи один елемент `patches` відповідає abie nodeSelector для `mode`.
39
+ * @param {unknown} p
40
+ * @param {'ua'} mode
41
+ * @returns {boolean}
42
+ */
43
+ function inlineKustomizationPatchMatchesAbieMode(p, mode) {
44
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) return false
45
+ const pr = /** @type {Record<string, unknown>} */ (p)
46
+ const target = pr.target
47
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) return false
48
+ const tg = /** @type {Record<string, unknown>} */ (target)
49
+ if (tg.kind !== 'Deployment') return false
50
+ const patchStr = pr.patch
51
+ if (typeof patchStr !== 'string') return false
52
+ if (mode === 'ua' && jsonPatchTextHasUaDeploymentNodeSelector(patchStr)) return true
53
+ return false
54
+ }
55
+
56
+ /**
57
+ * Чи документ Kustomization містить відповідний inline patch на Deployment.
58
+ * @param {import('yaml').Document} doc
59
+ * @param {'ua'} mode
60
+ * @returns {boolean}
61
+ */
62
+ function kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode) {
63
+ if (doc.errors.length > 0) return false
64
+ const root = doc.toJSON()
65
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return false
66
+ const rec = /** @type {Record<string, unknown>} */ (root)
67
+ if (rec.kind !== 'Kustomization') return false
68
+ const patches = rec.patches
69
+ if (!Array.isArray(patches)) return false
70
+ for (const p of patches) {
71
+ if (inlineKustomizationPatchMatchesAbieMode(p, mode)) return true
72
+ }
73
+ return false
74
+ }
75
+
76
+ /**
77
+ * Чи `kustomization.yaml` містить валідні inline patch для Deployment nodeSelector (ua).
78
+ * @param {string} raw повний текст файла
79
+ * @param {'ua'} mode
80
+ * @returns {boolean}
81
+ */
82
+ export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
83
+ const body = stripBom(raw)
84
+ const lines = body.split(LINE_SPLIT_RE)
85
+ const first = lines[0] ?? ''
86
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
87
+ /** @type {import('yaml').Document[]} */
88
+ let docs
89
+ try {
90
+ docs = parseAllDocuments(rest)
91
+ } catch {
92
+ return false
93
+ }
94
+ for (const doc of docs) {
95
+ if (kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode)) return true
96
+ }
97
+ return false
98
+ }
99
+
100
+ // ── HTTPRoute (ua) ────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * @param {unknown} p
104
+ * @returns {string | null}
105
+ */
106
+ function extractHttpRoutePatchString(p) {
107
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) return null
108
+ const pr = /** @type {Record<string, unknown>} */ (p)
109
+ const target = pr.target
110
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) return null
111
+ const tg = /** @type {Record<string, unknown>} */ (target)
112
+ if (tg.kind !== 'HTTPRoute' || typeof tg.name !== 'string' || tg.name.trim() === '') return null
113
+ const patchStr = pr.patch
114
+ return typeof patchStr === 'string' && patchStr.trim() !== '' ? patchStr : null
115
+ }
116
+
117
+ /**
118
+ * Збирає inline `patch`-рядки для HTTPRoute (непорожній `target.name`) з одного Kustomization-документа.
119
+ * @param {import('yaml').Document} doc
120
+ * @returns {string[]}
121
+ */
122
+ function collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc) {
123
+ if (doc.errors.length > 0) return []
124
+ const root = doc.toJSON()
125
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return []
126
+ const rec = /** @type {Record<string, unknown>} */ (root)
127
+ if (rec.kind !== 'Kustomization' || !Array.isArray(rec.patches)) return []
128
+ /** @type {string[]} */
129
+ const out = []
130
+ for (const p of rec.patches) {
131
+ const s = extractHttpRoutePatchString(p)
132
+ if (s !== null) out.push(s)
133
+ }
134
+ return out
135
+ }
136
+
137
+ /**
138
+ * Збирає всі inline JSON6902-фрагменти HTTPRoute (непорожній `target.name`) у kustomization.yaml.
139
+ * @param {string} raw повний текст файла
140
+ * @returns {string}
141
+ */
142
+ export function getCombinedNginxRunPatchTextFromKustomization(raw) {
143
+ const body = stripBom(raw)
144
+ const lines = body.split(LINE_SPLIT_RE)
145
+ const first = lines[0] ?? ''
146
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
147
+ /** @type {import('yaml').Document[]} */
148
+ let docs
149
+ try {
150
+ docs = parseAllDocuments(rest)
151
+ } catch {
152
+ return ''
153
+ }
154
+ /** @type {string[]} */
155
+ const chunks = []
156
+ for (const doc of docs) {
157
+ chunks.push(...collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc))
158
+ }
159
+ return chunks.join('\n')
160
+ }
161
+
162
+ /**
163
+ * Рахує операції JSON6902 з `path: /spec/rules/.../backendRefs/.../namespace` і `value: ua[-…]`.
164
+ * @param {string} combined сукупний текст patch
165
+ * @param {'ua'} mode
166
+ * @returns {number}
167
+ */
168
+ function countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode) {
169
+ if (mode !== 'ua') return 0
170
+ const re =
171
+ /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/gimu
172
+ return [...combined.matchAll(re)].length
173
+ }
174
+
175
+ /**
176
+ * Перевіряє сукупний текст patch(ів) HTTPRoute на відповідність abie.mdc.
177
+ * @param {string} combined сукупний текст patch
178
+ * @param {'ua'} mode
179
+ * @param {string} [_fullKustomizationRaw] зберігається для API-сумісності, не використовується
180
+ * @param {number} [sharedCrossNsBackendRefCount] кількість `auth-run-hl`/`file-link-hl` у base HTTPRoute
181
+ * @returns {string | null} повідомлення про помилку або null
182
+ */
183
+ export function validateAbieNginxRunHttpRoutePatches(
184
+ combined,
185
+ mode,
186
+ _fullKustomizationRaw,
187
+ sharedCrossNsBackendRefCount = 0
188
+ ) {
189
+ if (typeof combined !== 'string' || combined.trim() === '') {
190
+ return `очікується patch target kind HTTPRoute з непорожнім target.name (hostnames, parentRefs namespace ${mode}) — abie.mdc`
191
+ }
192
+ if (!PATCH_HOSTNAMES_PATH_RE.test(combined)) {
193
+ return 'HTTPRoute: потрібен path /spec/hostnames у patch (abie.mdc)'
194
+ }
195
+ const markers = ABIE_UA_HTTPROUTE_HOST_MARKERS
196
+ if (!markers.some(m => combined.includes(m))) {
197
+ return `HTTPRoute: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
198
+ }
199
+ if (!PATCH_PARENT_REF_NS_UA_RE.test(combined)) {
200
+ return `HTTPRoute: потрібен path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
201
+ }
202
+ const sharedCount =
203
+ typeof sharedCrossNsBackendRefCount === 'number' && Number.isFinite(sharedCrossNsBackendRefCount)
204
+ ? Math.max(0, Math.floor(sharedCrossNsBackendRefCount))
205
+ : 0
206
+ if (sharedCount > 0) {
207
+ const patchHits = countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode)
208
+ if (patchHits < sharedCount) {
209
+ return `HTTPRoute: для backendRefs до спільних сервісів auth-run-hl, file-link-hl очікується ${sharedCount} JSON6902 patch(ів) з path /spec/rules/…/backendRefs/…/namespace та value ${mode} (зараз ${patchHits}) — abie.mdc`
210
+ }
211
+ }
212
+ return null
213
+ }
214
+
215
+ /**
216
+ * Чи kustomization містить валідні patch для HTTPRoute (ua).
217
+ * @param {string} raw повний текст kustomization.yaml
218
+ * @param {'ua'} mode
219
+ * @returns {boolean}
220
+ */
221
+ export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
222
+ const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
223
+ return validateAbieNginxRunHttpRoutePatches(combined, mode, raw) === null
224
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Path-хелпери для overlay-перевірок abie:
3
+ * - класифікація шляхів (`isUaKustomizationPath`, `isAbieK8sBaseYamlPath`),
4
+ * - вилучення каталогу пакета з overlay-шляху (`abiePackageDirFromK8sOverlay`),
5
+ * - умовний gate для HTTPRoute через наявність `vite.config.*` (`abieOverlayRequiresHttpRouteByVite`),
6
+ * - перевірка наявності `Deployment` у дереві пакета (`abieOverlayK8sTreeHasDeployment`),
7
+ * - чи yaml належить base-шару пакета (`isK8sYamlInAbiePackageExcludingUaOverlay`).
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { join, relative } from 'node:path'
11
+
12
+ const UA_KUSTOMIZATION_PATH_RE = /(^|\/)ua\/kustomization\.yaml$/u
13
+ const OVERLAY_PACKAGE_DIR_RE = /^(.+)\/k8s\/ua\/kustomization\.yaml$/u
14
+ const BASE_SEGMENT_RE = /(^|\/)base\//u
15
+ const TRAILING_SLASH_RE = /\/$/u
16
+
17
+ /**
18
+ * Чи `rel` — це `…/ua/kustomization.yaml` (abie overlay).
19
+ * @param {string} rel посі від кореня репозиторію
20
+ * @returns {boolean}
21
+ */
22
+ export function isUaKustomizationPath(rel) {
23
+ const norm = rel.replaceAll('\\', '/')
24
+ return UA_KUSTOMIZATION_PATH_RE.test(norm)
25
+ }
26
+
27
+ /**
28
+ * Каталог пакета (батько `k8s/`) для overlay `…/k8s/ua/kustomization.yaml`.
29
+ * @param {string} root корінь репозиторію
30
+ * @param {string} kustomizationAbs абсолютний шлях до ua kustomization
31
+ * @returns {string | null}
32
+ */
33
+ export function abiePackageDirFromK8sOverlay(root, kustomizationAbs) {
34
+ const rel = relative(root, kustomizationAbs).replaceAll('\\', '/') || kustomizationAbs
35
+ const m = rel.match(OVERLAY_PACKAGE_DIR_RE)
36
+ return m ? join(root, m[1]) : null
37
+ }
38
+
39
+ /**
40
+ * Чи у каталозі пакета (батько `k8s/`) є `vite.config.{js,mjs,ts}` — HTTPRoute-вимога abie
41
+ * застосовується лише до Vite-пакетів.
42
+ * @param {string} root корінь репозиторію
43
+ * @param {string} kustomizationAbs абсолютний шлях до ua kustomization
44
+ * @returns {boolean}
45
+ */
46
+ export function abieOverlayRequiresHttpRouteByVite(root, kustomizationAbs) {
47
+ const pkg = abiePackageDirFromK8sOverlay(root, kustomizationAbs)
48
+ if (!pkg) return false
49
+ return (
50
+ existsSync(join(pkg, 'vite.config.js')) ||
51
+ existsSync(join(pkg, 'vite.config.mjs')) ||
52
+ existsSync(join(pkg, 'vite.config.ts'))
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Чи у дереві `k8s/` пакета є `Deployment` (за каталогами з `collectDeploymentDirs`).
58
+ * @param {Set<string>} deploymentDirs абсолютні каталоги з Deployment
59
+ * @param {string} root корінь репозиторію
60
+ * @param {string} kustomizationAbs абсолютний шлях до ua kustomization
61
+ * @returns {boolean}
62
+ */
63
+ export function abieOverlayK8sTreeHasDeployment(deploymentDirs, root, kustomizationAbs) {
64
+ const pkg = abiePackageDirFromK8sOverlay(root, kustomizationAbs)
65
+ if (!pkg) return false
66
+ const k8sRoot = join(pkg, 'k8s').replaceAll('\\', '/')
67
+ for (const dir of deploymentDirs) {
68
+ const norm = dir.replaceAll('\\', '/')
69
+ if (norm === k8sRoot || norm.startsWith(`${k8sRoot}/`)) return true
70
+ }
71
+ return false
72
+ }
73
+
74
+ /**
75
+ * Чи rel-шлях `…/k8s/base/…` (base-шар abie, не overlay).
76
+ * @param {string} rel
77
+ * @returns {boolean}
78
+ */
79
+ export function isAbieK8sBaseYamlPath(rel) {
80
+ const norm = rel.replaceAll('\\', '/')
81
+ return BASE_SEGMENT_RE.test(norm)
82
+ }
83
+
84
+ /**
85
+ * Чи yaml належить до `<pkgRel>/k8s/**` поза `ua/` піддеревом (base-шар abie).
86
+ * @param {string} relFromRoot шлях від кореня
87
+ * @param {string} pkgRelFromRoot каталог пакета від кореня
88
+ * @returns {boolean}
89
+ */
90
+ export function isK8sYamlInAbiePackageExcludingUaOverlay(relFromRoot, pkgRelFromRoot) {
91
+ const normRel = relFromRoot.replaceAll('\\', '/')
92
+ const pkg = pkgRelFromRoot.replaceAll('\\', '/').replace(TRAILING_SLASH_RE, '')
93
+ const prefix = `${pkg}/k8s/`
94
+ if (!normRel.startsWith(prefix)) return false
95
+ const after = normRel.slice(prefix.length)
96
+ return !after.startsWith('ua/')
97
+ }