@nitra/cursor 1.8.221 → 1.8.228

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 (44) hide show
  1. package/.claude-template/npm-CLAUDE.md +4 -0
  2. package/CHANGELOG.md +69 -0
  3. package/bin/auto-rules.md +2 -0
  4. package/bin/n-cursor.js +3 -2
  5. package/mdc/abie.mdc +13 -0
  6. package/mdc/ci4.mdc +8 -0
  7. package/mdc/tauri.mdc +20 -0
  8. package/package.json +1 -1
  9. package/policy/abie/base_deployment_preem/base_deployment_preem.rego +56 -0
  10. package/policy/abie/base_deployment_preem/base_deployment_preem_test.rego +60 -0
  11. package/policy/abie/clean_merged_ignore_branches/clean_merged_ignore_branches.rego +100 -0
  12. package/policy/abie/clean_merged_ignore_branches/clean_merged_ignore_branches_test.rego +48 -0
  13. package/policy/abie/health_check_policy/health_check_policy.rego +91 -22
  14. package/policy/abie/health_check_policy/health_check_policy_test.rego +99 -0
  15. package/policy/abie/http_route_base/http_route_base_test.rego +64 -0
  16. package/policy/k8s/base_kustomization/base_kustomization.rego +40 -0
  17. package/policy/k8s/base_kustomization/base_kustomization_test.rego +36 -0
  18. package/policy/k8s/base_manifest/base_manifest.rego +154 -0
  19. package/policy/k8s/base_manifest/base_manifest_test.rego +94 -0
  20. package/policy/k8s/gateway/gateway.rego +151 -0
  21. package/policy/k8s/gateway/gateway_test.rego +122 -0
  22. package/policy/k8s/hasura_configmap/hasura_configmap.rego +69 -0
  23. package/policy/k8s/hasura_configmap/hasura_configmap_test.rego +49 -0
  24. package/policy/k8s/hasura_httproute/hasura_httproute.rego +298 -0
  25. package/policy/k8s/hasura_httproute/hasura_httproute_test.rego +148 -0
  26. package/policy/k8s/hpa_pdb/hpa_pdb.rego +139 -0
  27. package/policy/k8s/hpa_pdb/hpa_pdb_test.rego +101 -0
  28. package/policy/k8s/kustomization/kustomization.rego +220 -0
  29. package/policy/k8s/kustomization/kustomization_test.rego +128 -0
  30. package/policy/k8s/kustomize_managed/kustomize_managed.rego +31 -0
  31. package/policy/k8s/kustomize_managed/kustomize_managed_test.rego +30 -0
  32. package/policy/k8s/manifest/manifest.rego +151 -4
  33. package/policy/k8s/manifest/manifest_test.rego +309 -0
  34. package/policy/k8s/svc_hl_yaml/svc_hl_yaml.rego +51 -0
  35. package/policy/k8s/svc_hl_yaml/svc_hl_yaml_test.rego +42 -0
  36. package/policy/k8s/svc_yaml/svc_yaml.rego +31 -0
  37. package/policy/k8s/svc_yaml/svc_yaml_test.rego +41 -0
  38. package/scripts/check-abie.mjs +102 -369
  39. package/scripts/check-ga.mjs +89 -9
  40. package/scripts/check-k8s.mjs +128 -569
  41. package/scripts/lint-conftest.mjs +98 -3
  42. package/scripts/lint-ga.mjs +18 -132
  43. package/scripts/lint-rego.mjs +19 -4
  44. package/scripts/utils/run-conftest-batch.mjs +117 -0
@@ -79,6 +79,18 @@ const K8S_DIR_PATH_RE = /(^|\/)k8s\//u
79
79
  const K8S_HC_YAML_PATH_RE = /(^|\/)k8s\/.+\/hc\.yaml$/u
80
80
  /** `…/k8s/…/base/…/hr.yaml` (HTTPRoute у base-шарі). */
81
81
  const K8S_BASE_HR_YAML_PATH_RE = /(^|\/)k8s\/.*base\/.*hr\.yaml$/u
82
+ /** Будь-який ресурсний YAML під `…/k8s/.../base/...` (для abie.base_deployment_preem). */
83
+ const K8S_BASE_RESOURCE_PATH_RE = /(^|\/)k8s\/.*base\//u
84
+ /** `kustomization.yaml` будь-де під сегментом `k8s/`. */
85
+ const K8S_KUSTOMIZATION_PATH_RE = /(^|\/)k8s\/.*\/kustomization\.yaml$/u
86
+ /** `…/k8s/.../base/.../kustomization.yaml`. */
87
+ const K8S_BASE_KUSTOMIZATION_PATH_RE = /(^|\/)k8s\/.*base\/(?:.*\/)?kustomization\.yaml$/u
88
+ /** Будь-який ресурсний `*.yaml` під сегментом `…/k8s/.../base/...`, окрім `kustomization.yaml`. */
89
+ const K8S_BASE_MANIFEST_PATH_RE = /(^|\/)k8s\/.*base\//u
90
+ /** `…/k8s/.../svc.yaml` (cluster-IP Service). */
91
+ const K8S_SVC_YAML_PATH_RE = /(^|\/)k8s\/.+\/svc\.yaml$/u
92
+ /** `…/k8s/.../svc-hl.yaml` (headless Service). */
93
+ const K8S_SVC_HL_YAML_PATH_RE = /(^|\/)k8s\/.+\/svc-hl\.yaml$/u
82
94
 
83
95
  /** @type {ConftestTarget[]} */
84
96
  const TARGETS = [
@@ -221,15 +233,77 @@ const TARGETS = [
221
233
  // Усі YAML у дереві з сегментом `k8s` — пер-документні структурні правила.
222
234
  {
223
235
  namespace: 'k8s.manifest',
224
- policyDir: 'k8s',
236
+ policyDir: 'k8s/manifest',
225
237
  rule: 'k8s',
226
238
  walk: { match: rel => K8S_DIR_PATH_RE.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
227
239
  },
228
240
 
241
+ // Gateway API + HealthCheckPolicy — застосовується до будь-якого YAML під k8s
242
+ // (правила перевіряють лише відповідні kind / apiVersion).
243
+ {
244
+ namespace: 'k8s.gateway',
245
+ policyDir: 'k8s/gateway',
246
+ rule: 'k8s',
247
+ walk: { match: rel => K8S_DIR_PATH_RE.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
248
+ },
249
+
250
+ // Структурні перевірки HPA / PDB (apiVersion / behavior / metrics / selector).
251
+ {
252
+ namespace: 'k8s.hpa_pdb',
253
+ policyDir: 'k8s/hpa_pdb',
254
+ rule: 'k8s',
255
+ walk: { match: rel => K8S_DIR_PATH_RE.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
256
+ },
257
+
258
+ // Kustomization-файли: resources sort, patches sort, JSON6902 conflicts.
259
+ {
260
+ namespace: 'k8s.kustomization',
261
+ policyDir: 'k8s/kustomization',
262
+ rule: 'k8s',
263
+ walk: { match: rel => K8S_KUSTOMIZATION_PATH_RE.test(rel) }
264
+ },
265
+
266
+ // svc.yaml — cluster-IP Service.
267
+ {
268
+ namespace: 'k8s.svc_yaml',
269
+ policyDir: 'k8s/svc_yaml',
270
+ rule: 'k8s',
271
+ walk: { match: rel => K8S_SVC_YAML_PATH_RE.test(rel) }
272
+ },
273
+
274
+ // svc-hl.yaml — headless Service з суфіксом `-hl`.
275
+ {
276
+ namespace: 'k8s.svc_hl_yaml',
277
+ policyDir: 'k8s/svc_hl_yaml',
278
+ rule: 'k8s',
279
+ walk: { match: rel => K8S_SVC_HL_YAML_PATH_RE.test(rel) }
280
+ },
281
+
282
+ // base/kustomization.yaml — обов'язкове непорожнє поле `namespace:`.
283
+ {
284
+ namespace: 'k8s.base_kustomization',
285
+ policyDir: 'k8s/base_kustomization',
286
+ rule: 'k8s',
287
+ walk: { match: rel => K8S_BASE_KUSTOMIZATION_PATH_RE.test(rel) }
288
+ },
289
+
290
+ // Ресурсні маніфести під `…/k8s/.../base/...` (окрім kustomization.yaml).
291
+ {
292
+ namespace: 'k8s.base_manifest',
293
+ policyDir: 'k8s/base_manifest',
294
+ rule: 'k8s',
295
+ walk: {
296
+ match: rel =>
297
+ K8S_BASE_MANIFEST_PATH_RE.test(rel) &&
298
+ !K8S_BASE_KUSTOMIZATION_PATH_RE.test(rel) &&
299
+ (rel.endsWith('.yaml') || rel.endsWith('.yml'))
300
+ }
301
+ },
302
+
229
303
  // abie HealthCheckPolicy: `hc.yaml` у дереві k8s.
230
304
  {
231
305
  namespace: 'abie.health_check_policy',
232
- policyDir: 'abie',
306
+ policyDir: 'abie/health_check_policy',
233
307
  rule: 'abie',
234
308
  walk: { match: rel => K8S_HC_YAML_PATH_RE.test(rel) }
235
309
  },
@@ -237,9 +311,30 @@ const TARGETS = [
237
311
  // abie HTTPRoute у `base/`.
238
312
  {
239
313
  namespace: 'abie.http_route_base',
240
- policyDir: 'abie',
314
+ policyDir: 'abie/http_route_base',
241
315
  rule: 'abie',
242
316
  walk: { match: rel => K8S_BASE_HR_YAML_PATH_RE.test(rel) }
317
+ },
318
+
319
+ // abie Deployment у `…/k8s/.../base/...` має preem nodeSelector.
320
+ {
321
+ namespace: 'abie.base_deployment_preem',
322
+ policyDir: 'abie/base_deployment_preem',
323
+ rule: 'abie',
324
+ walk: {
325
+ match: rel =>
326
+ K8S_BASE_RESOURCE_PATH_RE.test(rel) &&
327
+ !K8S_BASE_KUSTOMIZATION_PATH_RE.test(rel) &&
328
+ (rel.endsWith('.yaml') || rel.endsWith('.yml'))
329
+ }
330
+ },
331
+
332
+ // abie clean-merged-branch.yml: with.ignore_branches має містити dev/ua/ru.
333
+ {
334
+ namespace: 'abie.clean_merged_ignore_branches',
335
+ policyDir: 'abie/clean_merged_ignore_branches',
336
+ rule: 'abie',
337
+ single: '.github/workflows/clean-merged-branch.yml'
243
338
  }
244
339
  ]
245
340
 
@@ -1,18 +1,14 @@
1
1
  /**
2
2
  * CLI-обгортка над канонічним `lint-ga` (ga.mdc): робить preflight на `shellcheck` і `uv` (для `uvx`),
3
3
  * тоді послідовно виконує `bunx github-actionlint`, `uvx zizmor --offline --collect=workflows .` і
4
- * (PoC) `conftest test` на структуру канонічних workflow проти Rego-полісі з `npm/policy/ga/`.
4
+ * делегує до `check-ga.mjs::check()` там і Rego-частина (через `runConftestBatch`),
5
+ * і JS cross-file перевірки правил `ga.mdc`.
5
6
  *
6
- * Conftest-крок навмисно **не** додається в preflight: якщо бінарник не встановлений, виводимо `ℹ`
7
- * повідомлення й продовжуємо з кодом 0. Структурні перевірки тих самих workflow паралельно живуть у
8
- * `npm/scripts/check-ga.mjs`, тож відсутність conftest не пропускає порушення мовчки.
9
- *
10
- * Conftest проганяється у двох режимах:
11
- * 1) per-workflow polysi (`ga.<name>`) — для канонічних `clean-ga-workflows`, `clean-merged-branch`,
12
- * `lint-ga`, `git-ai`, що мають фіксовані поля (cron, ім'я кроку тощо);
13
- * 2) `ga.workflow_common` — універсальні правила (concurrency, заборонені setup-bun/cache/install у
14
- * кроках, shell line-continuation у `run:`, checkout перед локальним setup-bun-deps), які
15
- * застосовуються до **кожного** `.github/workflows/*.yml`.
7
+ * Plan B-патерн (rego-authoritative): Rego-полісі (`npm/policy/ga/`) запускає вже сам
8
+ * `check-ga.mjs::check()` як перший крок `lint-ga.mjs` про це не знає. Раніше `lint-ga.mjs` сам
9
+ * спавнив conftest для `ga.<name>` per-workflow і `ga.workflow_common` (PoC); тепер ця логіка
10
+ * централізована у `check-ga.mjs`, тож одне джерело істини, без дублювання між
11
+ * `lint-ga` і `npx @nitra/cursor check ga`.
16
12
  *
17
13
  * Без preflight `actionlint` (через `bunx github-actionlint`) мовчки пропускає shell-перевірки в
18
14
  * `run:` блоках, коли `shellcheck` відсутній у PATH; локально `bun lint-ga` лишається зеленим, а CI
@@ -23,49 +19,12 @@
23
19
  *
24
20
  * Експортовано окремо `runLintGaCli` — використовується з `bin/n-cursor.js` як підкоманда `lint-ga`.
25
21
  */
26
- import { existsSync, readdirSync } from 'node:fs'
27
22
  import { spawnSync } from 'node:child_process'
28
- import { dirname, join } from 'node:path'
29
23
  import { platform } from 'node:process'
30
- import { fileURLToPath } from 'node:url'
31
24
 
25
+ import { check as checkGa } from './check-ga.mjs'
32
26
  import { resolveCmd } from './utils/resolve-cmd.mjs'
33
27
 
34
- /** Каталог пакету `@nitra/cursor`, від якого ресолвимо вшиту директорію policy/. */
35
- const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
36
-
37
- /** Шлях до кореня Rego-полісі для GA. У npm-tarball публікується через `files: ["policy"]` у package.json. */
38
- const GA_POLICY_DIR = join(PACKAGE_ROOT, 'policy', 'ga')
39
-
40
- /**
41
- * Workflow-файли, для яких маємо відповідну Rego-полісі. Кожен таргет посилається на під-пакет
42
- * `ga.<name>` у `policy/ga/<name>/<name>.rego`; conftest викликаємо з `--namespace`, щоб правила
43
- * іншого workflow не застосовувалися до чужого файлу.
44
- * @type {Array<{ workflow: string, namespace: string, label: string }>}
45
- */
46
- const CONFTEST_TARGETS = [
47
- {
48
- workflow: '.github/workflows/clean-ga-workflows.yml',
49
- namespace: 'ga.clean_ga_workflows',
50
- label: 'clean-ga-workflows.yml structure'
51
- },
52
- {
53
- workflow: '.github/workflows/clean-merged-branch.yml',
54
- namespace: 'ga.clean_merged_branch',
55
- label: 'clean-merged-branch.yml structure'
56
- },
57
- {
58
- workflow: '.github/workflows/lint-ga.yml',
59
- namespace: 'ga.lint_ga',
60
- label: 'lint-ga.yml structure'
61
- },
62
- {
63
- workflow: '.github/workflows/git-ai.yml',
64
- namespace: 'ga.git_ai',
65
- label: 'git-ai.yml structure'
66
- }
67
- ]
68
-
69
28
  /**
70
29
  * Опис залежності preflight-ом: бінарник, для чого потрібен, і команди встановлення.
71
30
  * @typedef {object} PreflightDep
@@ -177,22 +136,24 @@ function runStep(title, cmd, args) {
177
136
  }
178
137
 
179
138
  /**
180
- * Виконує канонічний `lint-ga` з preflight-перевірками й послідовним запуском actionlint + zizmor.
139
+ * Виконує канонічний `lint-ga` з preflight-перевірками і делегує до `check-ga.check()`.
181
140
  *
182
141
  * Послідовність:
183
142
  * 1) preflight: `shellcheck` (для actionlint SC-правил) і `uv` (для `uvx zizmor`); відсутній → exit 1;
184
143
  * 2) `bunx github-actionlint`;
185
- * 3) `uvx zizmor --offline --collect=workflows .`.
144
+ * 3) `uvx zizmor --offline --collect=workflows .`;
145
+ * 4) `check-ga.mjs::check()` — Rego-полісі (батч conftest з `npm/policy/ga/`) + JS cross-file
146
+ * перевірки правил `ga.mdc`. Це **те саме**, що робить `npx @nitra/cursor check ga`, тож
147
+ * `lint-ga` тепер є суперсетом перевірки правила: external-tools + check.
186
148
  *
187
149
  * Якщо хоча б один preflight не пройшов — виходимо одразу з кодом 1, **до** запуску actionlint/zizmor,
188
150
  * бо їхні власні повідомлення про відсутність залежностей менш інформативні (особливо для shellcheck —
189
151
  * actionlint мовчки пропускає SC-правила; ця перевірка — головний сенс обгортки).
190
152
  *
191
- * Першу помилку від actionlint/zizmor повертаємо як код виходу; наступні кроки не запускаються
192
- * (відповідає `&&` у package.json).
193
- * @returns {number} 0 — все OK, інакше — код першого кроку, що впав
153
+ * Першу помилку від actionlint/zizmor/check повертаємо як код виходу; наступні кроки не запускаються.
154
+ * @returns {Promise<number>} 0 — все OK, інакше — код першого кроку, що впав
194
155
  */
195
- export function runLintGaCli() {
156
+ export async function runLintGaCli() {
196
157
  let preflightOk = true
197
158
  for (const dep of [SHELLCHECK_PREFLIGHT, UV_PREFLIGHT]) {
198
159
  if (!preflight(dep)) preflightOk = false
@@ -205,81 +166,6 @@ export function runLintGaCli() {
205
166
  const zizmorCode = runStep('zizmor', 'uvx', ['zizmor', '--offline', '--collect=workflows', '.'])
206
167
  if (zizmorCode !== 0) return zizmorCode
207
168
 
208
- return runConftestStep()
209
- }
210
-
211
- /**
212
- * PoC-крок: запускає conftest на YAML workflow проти Rego-полісі з пакету (`policy/ga/`).
213
- *
214
- * Поведінка fallback:
215
- * - якщо `conftest` не знайдено в PATH — друкуємо `ℹ` повідомлення з підказкою встановлення й
216
- * повертаємо 0 (тобто конфтест поки що **не** є обовʼязковою залежністю lint-ga; перевірки лежать
217
- * паралельно в `check-ga.mjs`, і `npx \@nitra/cursor check ga` все одно їх запустить);
218
- * - якщо `conftest` є й полісі-каталог відсутній (нетипова інсталяція) — також `ℹ` skip;
219
- * - якщо є цільовий workflow і conftest — запускаємо `conftest test <workflow> -p <policy-dir>` і
220
- * повертаємо його exit-код, щоб порушення зупиняли lint-ga, як це робить actionlint/zizmor.
221
- *
222
- * Локальний `conftest` встановлюється через `brew install conftest` / `go install ...` — деталі в
223
- * https://www.conftest.dev/install/.
224
- * @returns {number} 0 — OK або skip, інакше — exit-код conftest
225
- */
226
- function runConftestStep() {
227
- const conftestBin = resolveCmd('conftest')
228
- if (!conftestBin) {
229
- console.log(
230
- '\nℹ conftest не знайдено в PATH — пропускаю PoC-перевірку структури workflow через Rego-полісі.\n' +
231
- ' Встанови, щоб запустити її локально: brew install conftest (macOS) або https://www.conftest.dev/install/'
232
- )
233
- return 0
234
- }
235
-
236
- if (!existsSync(GA_POLICY_DIR)) {
237
- console.log(`\nℹ Каталог Rego-полісі не знайдено (${GA_POLICY_DIR}) — пропускаю conftest.`)
238
- return 0
239
- }
240
-
241
- for (const target of CONFTEST_TARGETS) {
242
- if (!existsSync(target.workflow)) continue
243
- const code = runStep(`conftest (${target.label})`, conftestBin, [
244
- 'test',
245
- target.workflow,
246
- '-p',
247
- GA_POLICY_DIR,
248
- '--namespace',
249
- target.namespace,
250
- '--no-color'
251
- ])
252
- if (code !== 0) return code
253
- }
254
-
255
- return runConftestWorkflowCommon(conftestBin)
256
- }
257
-
258
- /**
259
- * Прогоняє `ga.workflow_common` на кожному `.github/workflows/*.yml` — універсальні перевірки
260
- * (concurrency, заборонені setup-bun/cache/install, shell line-continuation, checkout перед
261
- * локальним setup-bun-deps). Якщо директорії немає або файлів немає — мовчки skip.
262
- *
263
- * Викликаємо conftest на всіх файлах одним прогоном (`conftest test <files...>`) — швидше, ніж
264
- * по одному, і summary-лог зрозуміліший. Перший ненульовий exit-код повертаємо як результат.
265
- * @param {string} conftestBin абсолютний шлях до бінарника conftest
266
- * @returns {number} 0 — OK, інакше exit-код conftest
267
- */
268
- function runConftestWorkflowCommon(conftestBin) {
269
- const wfDir = '.github/workflows'
270
- if (!existsSync(wfDir)) return 0
271
- const ymlFiles = readdirSync(wfDir)
272
- .filter(f => f.endsWith('.yml'))
273
- .map(f => join(wfDir, f))
274
- if (ymlFiles.length === 0) return 0
275
-
276
- return runStep('conftest (workflow_common — усі workflow)', conftestBin, [
277
- 'test',
278
- ...ymlFiles,
279
- '-p',
280
- GA_POLICY_DIR,
281
- '--namespace',
282
- 'ga.workflow_common',
283
- '--no-color'
284
- ])
169
+ console.log('\n▶ check-ga (rego-полісі npm/policy/ga/ + JS cross-file перевірки)')
170
+ return await checkGa()
285
171
  }
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * Лінт Rego-полісі (`conftest.mdc` + `rego.mdc`): preflight на `opa` і `regal`,
3
- * далі послідовно `opa check --strict` і `regal lint`.
3
+ * далі послідовно `opa check --strict`, `regal lint` і опційний
4
+ * `conftest verify` (для `*_test.rego`-файлів) якщо conftest у PATH.
4
5
  *
5
- * Чому два інструменти:
6
+ * Чому два-три інструменти:
6
7
  * - `opa check --strict` — компіляція з типами і строгим режимом (мертвий код, неоднозначні
7
8
  * правила, незадекларовані змінні). Ловить помилки, які `regal` навмисно лишає поза скоупом
8
9
  * (він — про стиль і ідіоматичність, а не про компіляцію).
9
10
  * - `regal lint` (https://docs.styra.com/regal) — статичний лінтер Rego: ловить v0-синтаксис,
10
11
  * неявні set-rules та інші відхилення від `rego.v1`, плюс bugs/idiomatic/performance-правила.
12
+ * - `conftest verify` (опційно) — виконує `test_*` правила у `*_test.rego` (юніт-тести політик).
13
+ * Якщо conftest відсутній у PATH — пропускаємо без помилки (тести опційні в локальному середовищі;
14
+ * у CI потрібно встановити conftest).
11
15
  *
12
16
  * Без preflight-у на бінарники лінт мовчки злетить з невиразним повідомленням від shell —
13
17
  * друкуємо явні install-hints (як це робить `lint-ga.mjs` для shellcheck/uv). `opa` додатково
@@ -16,7 +20,7 @@
16
20
  *
17
21
  * Цілі лінту: `npm/policy/` (місце, де поки що живуть Rego-полісі пакета `@nitra/cursor`).
18
22
  * Якщо в репозиторії з’являться інші *.rego поза цим деревом, додай шлях у `LINT_TARGETS` —
19
- * обидва інструменти приймають кілька шляхів і самі рекурсивно обходять директорії.
23
+ * усі три інструменти приймають кілька шляхів і самі рекурсивно обходять директорії.
20
24
  */
21
25
  import { spawnSync } from 'node:child_process'
22
26
  import { existsSync } from 'node:fs'
@@ -110,7 +114,18 @@ export function runLintRego(cwd = process.cwd()) {
110
114
  const opaCode = runStep(opa, ['check', '--strict', ...targets], root)
111
115
  if (opaCode !== 0) return opaCode
112
116
 
113
- return runStep(regal, ['lint', ...targets], root)
117
+ const regalCode = runStep(regal, ['lint', ...targets], root)
118
+ if (regalCode !== 0) return regalCode
119
+
120
+ const conftest = resolveCmd('conftest')
121
+ if (!conftest) {
122
+ console.log(
123
+ 'ℹ conftest не знайдено в PATH — пропускаю `conftest verify` (юніт-тести *_test.rego).\n' +
124
+ ' Встанови, щоб запустити локально: brew install conftest (macOS) або https://www.conftest.dev/install/'
125
+ )
126
+ return 0
127
+ }
128
+ return runStep(conftest, ['verify', ...targets.flatMap(t => ['-p', t])], root)
114
129
  }
115
130
 
116
131
  process.exitCode = runLintRego()
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Запускає `conftest test` на batched-списку файлів і повертає всі порушення
3
+ * у структурованому вигляді. Використовується з `check-*.mjs`-скриптів, де
4
+ * пер-документні правила винесені у `npm/policy/<rule>/<name>/` як rego-полісі
5
+ * (Rego-authoritative). JS у `check-*.mjs` робить cross-file частину (walking
6
+ * дерева, парність, kustomize-резолюція), а пер-документне валідаційне ядро
7
+ * делегується сюди — один спавн `conftest` на (`namespace`, `policyDir`),
8
+ * незалежно від кількості файлів. Це закриває дублювання JS↔rego і прибирає
9
+ * ризик дрифту (типу `spec.config` vs `spec.default.config` у
10
+ * `health_check_policy.rego`, що ми ловили cross-check тестами).
11
+ *
12
+ * Hard-fail на відсутність `conftest` у PATH — узгоджено з рішенням Plan B:
13
+ * якщо правило делегує свою логіку до Rego, а інструмент відсутній, тиха
14
+ * відмова приховує реальні порушення. Друкуємо install-hint (як `lint-rego.mjs`
15
+ * робить для opa/regal).
16
+ */
17
+ import { spawnSync } from 'node:child_process'
18
+ import { existsSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ import { resolveCmd } from './resolve-cmd.mjs'
23
+
24
+ /** Каталог пакета `@nitra/cursor`, від якого ресолвимо вшиту директорію `policy/`. */
25
+ const PACKAGE_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
26
+
27
+ /** Шлях до кореня rego-полісі. У npm-tarball публікується через `files: ["policy"]`. */
28
+ const POLICY_ROOT = join(PACKAGE_ROOT, 'policy')
29
+
30
+ /**
31
+ * Друкує install-hint для conftest і кидає виняток, щоб викликана `check-*`
32
+ * команда ясно завершилась з кодом 1.
33
+ * @returns {never}
34
+ */
35
+ function failConftestMissing() {
36
+ throw new Error(
37
+ [
38
+ '❌ conftest не знайдено в PATH.',
39
+ ' Без нього не запускається пер-документна валідація через rego-полісі (npm/policy/).',
40
+ ' Встанови:',
41
+ ' macOS: brew install conftest',
42
+ ' Universal: https://www.conftest.dev/install/'
43
+ ].join('\n')
44
+ )
45
+ }
46
+
47
+ /**
48
+ * @typedef {object} ConftestViolation
49
+ * @property {string} filename абсолютний шлях до файла, що дав порушення (з output conftest)
50
+ * @property {string} message текст порушення (як у `deny` rego-пакета)
51
+ * @property {string} namespace namespace rego-пакета (наприклад `abie.base_deployment_preem`)
52
+ */
53
+
54
+ /**
55
+ * @typedef {object} ConftestBatchOptions
56
+ * @property {string} policyDirRel шлях до підкаталогу `npm/policy/...` (наприклад `abie/base_deployment_preem`)
57
+ * @property {string} namespace повне імʼя rego-пакета (наприклад `abie.base_deployment_preem`)
58
+ * @property {string[]} files список абсолютних шляхів файлів для перевірки (порожній — повертаємо порожньо)
59
+ * @property {string[]} [extraArgs] додаткові аргументи для conftest (наприклад `--combine` для крос-документних правил)
60
+ */
61
+
62
+ /**
63
+ * Виконує `conftest test` для всіх файлів одним спавном і повертає масив
64
+ * порушень. Якщо `files` порожній — повертає `[]` без спавна. Якщо `conftest`
65
+ * не у PATH — кидає виняток (hard fail, див. модульний docstring).
66
+ * @param {ConftestBatchOptions} opts параметри запуску
67
+ * @returns {ConftestViolation[]} масив порушень (порожній — все ок)
68
+ */
69
+ export function runConftestBatch(opts) {
70
+ if (opts.files.length === 0) return []
71
+ const conftestBin = resolveCmd('conftest')
72
+ if (!conftestBin) {
73
+ failConftestMissing()
74
+ }
75
+ const policyAbs = join(POLICY_ROOT, opts.policyDirRel)
76
+ if (!existsSync(policyAbs)) {
77
+ throw new Error(`runConftestBatch: rego-каталог не знайдено: ${policyAbs}`)
78
+ }
79
+ const args = [
80
+ 'test',
81
+ ...opts.files,
82
+ '-p',
83
+ policyAbs,
84
+ '--namespace',
85
+ opts.namespace,
86
+ '--output',
87
+ 'json',
88
+ '--no-color',
89
+ ...(opts.extraArgs ?? [])
90
+ ]
91
+ const result = spawnSync(conftestBin, args, { encoding: 'utf8' })
92
+ if (result.error) {
93
+ throw result.error
94
+ }
95
+ // conftest exit 1 = є failures (це валідно для нас); >1 = справжня помилка.
96
+ if (result.status !== 0 && result.status !== 1) {
97
+ throw new Error(
98
+ `conftest exit ${result.status}: ${(result.stderr || result.stdout || '').slice(0, 500)}`
99
+ )
100
+ }
101
+ /** @type {Array<{ filename: string, namespace: string, failures?: Array<{ msg: string }> }>} */
102
+ let parsed
103
+ try {
104
+ parsed = JSON.parse(result.stdout)
105
+ } catch (e) {
106
+ throw new Error(`conftest stdout не парситься як JSON: ${(result.stdout || '').slice(0, 200)}`)
107
+ }
108
+ /** @type {ConftestViolation[]} */
109
+ const out = []
110
+ for (const entry of parsed) {
111
+ const failures = entry.failures ?? []
112
+ for (const f of failures) {
113
+ out.push({ filename: entry.filename, namespace: entry.namespace, message: f.msg })
114
+ }
115
+ }
116
+ return out
117
+ }