@nitra/cursor 1.8.199 → 1.8.200

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/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.8.200] - 2026-05-07
8
+
9
+ ### Added
10
+
11
+ - `policy/ga/clean-ga-workflows.rego` + новий PoC-крок у `scripts/lint-ga.mjs`: запускає `conftest test` на `.github/workflows/clean-ga-workflows.yml` проти Rego-полісі (структура `name` / `on` / `concurrency` / `jobs.cleanup_old_workflows.steps[0]`). Якщо `conftest` не в PATH — `ℹ` skip без помилки (паралельні JS-перевірки в `check-ga.mjs` залишаються джерелом істини). Додав `policy` у `files` пакету.
12
+ - `check-k8s.mjs`: структурний сорт `patches[]` у `kustomization.yaml` за tuple `[target.kind, target.name, target.namespace, path]` (`localeCompare('en', base)`); поля `target.group` / `target.version` у tuple не входять (діє правило «patches[].target: лише kind і name»). Додатково: вміст inline `patches[i].patch` (literal block scalar — масив JSON6902) сортується за `path`, **але лише** коли всі ops — `add` / `replace` і всі `path` попарно дизʼюнктні (жоден не префікс іншого) — інакше порядок не чіпається, бо `move` / `copy` / `test` / `remove` чи спільні шляхи семантично залежні (RFC 6902). Експортовані чисті валідатори: `kustomizationPatchesSortedViolation`, `kustomizationInlinePatchOpsSortedViolation`.
13
+ - `tests/check-k8s-schema.test.mjs`: тести на обидва нові валідатори (приклад із `k8s.mdc`: `ReferenceGrant atlas/apruv` → `apruv/atlas`; `add /spec/minReplicas` + `replace /spec/maxReplicas` → пересорт за `path`; пропуск для `test` / `move` / `copy` / `remove` і недизʼюнктних шляхів типу `/spec` vs `/spec/template`).
14
+ - `mdc/k8s.mdc`: розділ «Структурний сорт `patches[]` і inline JSON6902» з обома прикладами «❌/✅».
15
+
7
16
  ## [1.8.199] - 2026-05-07
8
17
 
9
18
  ### Added
package/mdc/k8s.mdc CHANGED
@@ -531,6 +531,52 @@ patches:
531
531
 
532
532
  **Виняток:** залишай `group` / `version`, лише якщо в дереві overlay реально співіснують ресурси з однаковими `kind`+`name`, але різними API-групами/версіями (наприклад, дві CRD з одним `kind`). У такому разі вкажи мінімальний набір полів, потрібний для дисамбігуації.
533
533
 
534
+ ### Структурний сорт `patches[]` і inline JSON6902
535
+
536
+ `patches[]` у `kustomization.yaml` має бути відсортовано за tuple **`target.kind` → `target.name` → `target.namespace` → `path`** (`localeCompare('en', { sensitivity: 'base' })`). Це робить діфи передбачуваними і прибирає «гойдання» порядку при додаванні нових цілей. Поля `target.group` / `target.version` у tuple не входять — для них діє правило «patches[].target: лише kind і name».
537
+
538
+ ```yaml
539
+ # ❌ atlas йде перед apruv
540
+ patches:
541
+ - target:
542
+ kind: ReferenceGrant
543
+ name: atlas-to-base
544
+ - target:
545
+ kind: ReferenceGrant
546
+ name: apruv-to-base
547
+
548
+ # ✅
549
+ patches:
550
+ - target:
551
+ kind: ReferenceGrant
552
+ name: apruv-to-base
553
+ - target:
554
+ kind: ReferenceGrant
555
+ name: atlas-to-base
556
+ ```
557
+
558
+ Усередині кожного inline `patches[i].patch` (literal block scalar — масив JSON6902-операцій) операції теж сортуються за **`path`**, **але лише** коли набір «безпечний»: усі ops — `add` / `replace` і всі `path` попарно дизʼюнктні (жоден не префікс іншого, наприклад `/spec` і `/spec/replicas`). Інакше порядок не чіпається — у `move` / `copy` / `test` / `remove` чи на спільних шляхах послідовність ops семантично значуща (RFC 6902), і пересорт ламає логіку.
559
+
560
+ ```yaml
561
+ # ❌ minReplicas перед maxReplicas (за алфавітом max < min)
562
+ patch: |-
563
+ - op: add
564
+ path: /spec/minReplicas
565
+ value: 2
566
+ - op: replace
567
+ path: /spec/maxReplicas
568
+ value: 10
569
+
570
+ # ✅
571
+ patch: |-
572
+ - op: replace
573
+ path: /spec/maxReplicas
574
+ value: 10
575
+ - op: add
576
+ path: /spec/minReplicas
577
+ value: 2
578
+ ```
579
+
534
580
  ## Перевірка
535
581
 
536
582
  **`npx @nitra/cursor check k8s`** — програмні критерії в **JSDoc на початку** **`npm/scripts/check-k8s.mjs`**. Якщо під **`k8s`** немає **`*.yaml`** — крок пропущено. Канон **`$schema`** для редактора — розділ **«Визначення схеми YAML`** нижче.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.199",
3
+ "version": "1.8.200",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -27,6 +27,7 @@
27
27
  "mdc",
28
28
  "bin",
29
29
  "github-actions",
30
+ "policy",
30
31
  "schemas",
31
32
  "scripts",
32
33
  "skills",
@@ -0,0 +1,133 @@
1
+ # PoC-порт перевірки `validateCleanGaWorkflows` з `npm/scripts/check-ga.mjs`.
2
+ #
3
+ # Запуск (локально):
4
+ # conftest test .github/workflows/clean-ga-workflows.yml -p npm/policy/ga
5
+ #
6
+ # Conftest читає YAML і дає його в `input`. Кожне правило `deny contains msg if { … }`,
7
+ # що матчиться, друкується як порушення; пустий список — exit 0.
8
+ #
9
+ # Rego v1 синтаксис (OPA 1.x за замовчуванням; `import rego.v1` робить файл портованим
10
+ # і на старі OPA 0.x): `contains` для partial set rules, `if` перед тілом правила.
11
+ package main
12
+
13
+ import rego.v1
14
+
15
+ # GHA YAML quirk: ключ `on:` парситься як YAML 1.1 boolean `true`, після чого conftest
16
+ # серіалізує його в Rego-input як рядок `"true"`. Тому `input.on` / `input["on"]` /
17
+ # `input[true]` всі недоступні; реальний шлях — `input["true"]`. Виносимо в alias, щоб
18
+ # решта правил читалася як `gha_on.schedule` без бойлерплейту.
19
+ gha_on := input["true"]
20
+
21
+ # `${{ … }}` — це шаблонний синтаксис GitHub Actions, але `{{` у Rego починає
22
+ # string interpolation. Збираємо очікувані рядки з фрагментів, як це зроблено в
23
+ # check-ga.mjs, щоб і Rego-парсер, і людина-читач не плуталися.
24
+ expected_concurrency_group := concat("", ["$", "{{ github.ref }}-$", "{{ github.workflow }}"])
25
+
26
+ expected_github_token := concat("", ["$", "{{ github.token }}"])
27
+
28
+ expected_name := "Clean action for removing completed workflow runs"
29
+
30
+ expected_cron := "0 1 16 * *"
31
+
32
+ # --- name --------------------------------------------------------------------
33
+
34
+ deny contains msg if {
35
+ input.name != expected_name
36
+ msg := sprintf("clean-ga-workflows.yml: name має бути %q (ga.mdc)", [expected_name])
37
+ }
38
+
39
+ # --- on.schedule.cron --------------------------------------------------------
40
+
41
+ deny contains msg if {
42
+ not has_expected_cron
43
+ msg := sprintf("clean-ga-workflows.yml: on.schedule має містити cron: '%s' (ga.mdc)", [expected_cron])
44
+ }
45
+
46
+ has_expected_cron if {
47
+ gha_on.schedule[_].cron == expected_cron
48
+ }
49
+
50
+ # --- on.workflow_dispatch ----------------------------------------------------
51
+
52
+ deny contains msg if {
53
+ not has_workflow_dispatch
54
+ msg := "clean-ga-workflows.yml: має бути workflow_dispatch: {} (ga.mdc)"
55
+ }
56
+
57
+ has_workflow_dispatch if {
58
+ is_object(gha_on.workflow_dispatch)
59
+ }
60
+
61
+ # --- concurrency -------------------------------------------------------------
62
+
63
+ deny contains msg if {
64
+ not is_object(input.concurrency)
65
+ msg := sprintf(
66
+ "clean-ga-workflows.yml: відсутня секція concurrency — додай concurrency.group: %s і cancel-in-progress: true (ga.mdc)",
67
+ [expected_concurrency_group],
68
+ )
69
+ }
70
+
71
+ deny contains msg if {
72
+ is_object(input.concurrency)
73
+ input.concurrency.group != expected_concurrency_group
74
+ msg := sprintf("clean-ga-workflows.yml: concurrency.group має бути %s (ga.mdc)", [expected_concurrency_group])
75
+ }
76
+
77
+ deny contains msg if {
78
+ is_object(input.concurrency)
79
+ input.concurrency["cancel-in-progress"] != true
80
+ msg := "clean-ga-workflows.yml: concurrency.cancel-in-progress має бути true (ga.mdc)"
81
+ }
82
+
83
+ # --- jobs.cleanup_old_workflows ---------------------------------------------
84
+
85
+ deny contains msg if {
86
+ not input.jobs.cleanup_old_workflows
87
+ msg := "clean-ga-workflows.yml: jobs.cleanup_old_workflows відсутній (ga.mdc)"
88
+ }
89
+
90
+ deny contains msg if {
91
+ job := input.jobs.cleanup_old_workflows
92
+ job["runs-on"] != "ubuntu-latest"
93
+ msg := "clean-ga-workflows.yml: runs-on має бути ubuntu-latest (ga.mdc)"
94
+ }
95
+
96
+ deny contains msg if {
97
+ perms := input.jobs.cleanup_old_workflows.permissions
98
+ not actions_write_contents_read(perms)
99
+ msg := "clean-ga-workflows.yml: permissions мають бути actions: write, contents: read (ga.mdc)"
100
+ }
101
+
102
+ actions_write_contents_read(perms) if {
103
+ perms.actions == "write"
104
+ perms.contents == "read"
105
+ }
106
+
107
+ # --- jobs.cleanup_old_workflows.steps[0] ------------------------------------
108
+
109
+ step0 := input.jobs.cleanup_old_workflows.steps[0]
110
+
111
+ deny contains msg if {
112
+ step0.name != "Delete workflow runs"
113
+ msg := "clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)"
114
+ }
115
+
116
+ deny contains msg if {
117
+ step0.uses != "dmvict/clean-workflow-runs@v1"
118
+ msg := "clean-ga-workflows.yml: перший крок має uses: dmvict/clean-workflow-runs@v1 (ga.mdc)"
119
+ }
120
+
121
+ # Триплет полів `with`: token (gh-токен), save_period=31, save_min_runs_number=0.
122
+ # В JS-перевірці помилка спільна для всіх трьох — лишаємо такий самий формат, щоб
123
+ # повідомлення збігалися. Окремі правила нижче роблять діагноз точнішим.
124
+ deny contains msg if {
125
+ not step0_with_canonical
126
+ msg := "clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc"
127
+ }
128
+
129
+ step0_with_canonical if {
130
+ step0.with.token == expected_github_token
131
+ step0.with.save_period == 31
132
+ step0.with.save_min_runs_number == 0
133
+ }
@@ -49,6 +49,13 @@
49
49
  * завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує). У **`apiVersion: kustomize.config.k8s.io/…`**, **`kind: Kustomization`**
50
50
  * перелік **`resources:`** (лише непорожні рядки) має бути відсортовано за алфавітом (**en**, `localeCompare`).
51
51
  *
52
+ * **Структурний сорт `patches[]` у kustomization.yaml:** масив **`patches`** має бути відсортовано за tuple
53
+ * **`[target.kind, target.name, target.namespace, path]`** (`localeCompare('en', base)`). Поля **`group`** / **`version`**
54
+ * у tuple не входять — для них діє правило «patches[].target: лише kind і name». Додатково: вміст
55
+ * **inline `patches[i].patch`** (literal block scalar — масив JSON6902-операцій) має бути відсортовано за **`path`**,
56
+ * але **лише** якщо всі операції — **`add`** / **`replace`** і всі **`path`** попарно дизʼюнктні (жоден не префікс іншого).
57
+ * Інакше порядок не чіпається — `move` / `copy` / `test` / `remove` чи спільні шляхи можуть бути семантично залежні (RFC 6902).
58
+ *
52
59
  * **Inline JSON6902** у **`patches`** (і зовнішні файли з **`patches[].path`** під **`k8s`**, якщо вміст — масив JSON Patch): не допускається пара **`remove`** і **`add`**
53
60
  * на один і той самий **`path`** у межах одного фрагмента — потрібен **`op: replace`** (k8s.mdc). **check-k8s** це перевіряє.
54
61
  *
@@ -471,6 +478,197 @@ async function validateKustomizationResourcesSortedAlphabetically(root, yamlFile
471
478
  }
472
479
  }
473
480
 
481
+ /**
482
+ * Лексичне порівняння двох тuplіе рядків через `localeCompare('en', { sensitivity: 'base' })`.
483
+ * Менший за довжиною список доповнюється порожніми рядками.
484
+ * @param {string[]} a перший tuple
485
+ * @param {string[]} b другий tuple
486
+ * @returns {number} `< 0` якщо `a` менший, `> 0` якщо більший, `0` — рівні
487
+ */
488
+ function compareStringTuplesEn(a, b) {
489
+ const n = Math.max(a.length, b.length)
490
+ for (let i = 0; i < n; i++) {
491
+ const av = a[i] ?? ''
492
+ const bv = b[i] ?? ''
493
+ const c = av.localeCompare(bv, 'en', { sensitivity: 'base' })
494
+ if (c !== 0) return c
495
+ }
496
+ return 0
497
+ }
498
+
499
+ /**
500
+ * Чи послідовність tuple-ключів відсортована за `compareStringTuplesEn`.
501
+ * @param {string[][]} tuples масив tuple-ключів у порядку, як у файлі
502
+ * @returns {boolean} true, якщо порядок неспадний
503
+ */
504
+ function stringTuplesAreSortedEn(tuples) {
505
+ for (let i = 1; i < tuples.length; i++) {
506
+ if (compareStringTuplesEn(tuples[i - 1], tuples[i]) > 0) return false
507
+ }
508
+ return true
509
+ }
510
+
511
+ /**
512
+ * Tuple-ключ для сортування одного запису `patches[]` Kustomization.
513
+ * Порядок ключів: `target.kind` → `target.name` → `target.namespace` → `path`. Відсутні поля = `''`
514
+ * (порожні раніше за заповнені у `localeCompare` — стабільний детермінізм).
515
+ * Поля `target.group` / `target.version` навмисно не входять у ключ — у repo діє правило
516
+ * «patches[].target: лише kind і name», тому опертися на них не можна.
517
+ * @param {unknown} patchItem елемент масиву `patches[]`
518
+ * @returns {string[]} tuple для порівняння
519
+ */
520
+ function kustomizationPatchSortKey(patchItem) {
521
+ if (patchItem === null || typeof patchItem !== 'object' || Array.isArray(patchItem)) {
522
+ return ['', '', '', '']
523
+ }
524
+ const rec = /** @type {Record<string, unknown>} */ (patchItem)
525
+ const t = rec.target
526
+ /** @type {Record<string, unknown>} */
527
+ const target = t !== null && typeof t === 'object' && !Array.isArray(t) ? /** @type {Record<string, unknown>} */ (t) : {}
528
+ const kind = typeof target.kind === 'string' ? target.kind : ''
529
+ const name = typeof target.name === 'string' ? target.name : ''
530
+ const ns = typeof target.namespace === 'string' ? target.namespace : ''
531
+ const path = typeof rec.path === 'string' ? rec.path : ''
532
+ return [kind, name, ns, path]
533
+ }
534
+
535
+ /**
536
+ * Короткий ярлик запису `patches[]` для звітів («kind/name», або «path=…», або «#i»).
537
+ * @param {unknown} patchItem елемент масиву
538
+ * @param {number} i індекс у масиві (для fallback)
539
+ * @returns {string} людинозрозумілий ярлик
540
+ */
541
+ function kustomizationPatchLabel(patchItem, i) {
542
+ const [kind, name, , path] = kustomizationPatchSortKey(patchItem)
543
+ if (kind && name) return `${kind}/${name}`
544
+ if (path) return `path=${path}`
545
+ return `#${i}`
546
+ }
547
+
548
+ /**
549
+ * Порушення сорту **`patches[]`**: лише для **`kustomize.config.k8s.io/…`**, **`kind: Kustomization`**.
550
+ * Сортування за tuple `[target.kind, target.name, target.namespace, path]` (`localeCompare('en', base)`).
551
+ * @param {unknown} obj корінь першого YAML-документа kustomization.yaml
552
+ * @returns {string | null} причина або `null`, якщо обмеження не застосовується чи порядок ОК
553
+ */
554
+ export function kustomizationPatchesSortedViolation(obj) {
555
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return null
556
+ const rec = /** @type {Record<string, unknown>} */ (obj)
557
+ if (rec.kind !== 'Kustomization') return null
558
+ const av = rec.apiVersion
559
+ if (typeof av !== 'string' || !av.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
560
+ const patches = rec.patches
561
+ if (patches === undefined) return null
562
+ if (!Array.isArray(patches)) {
563
+ return 'Kustomization.patches має бути масивом (k8s.mdc)'
564
+ }
565
+ if (patches.length < 2) return null
566
+ const keys = patches.map(p => kustomizationPatchSortKey(p))
567
+ if (stringTuplesAreSortedEn(keys)) return null
568
+ const order = patches.map((p, i) => ({ p, i, key: keys[i] }))
569
+ order.sort((a, b) => compareStringTuplesEn(a.key, b.key) || a.i - b.i)
570
+ const have = patches.map((p, i) => kustomizationPatchLabel(p, i)).join(', ')
571
+ const want = order.map(x => kustomizationPatchLabel(x.p, x.i)).join(', ')
572
+ return `Kustomization.patches має бути за алфавітом (target.kind → target.name → target.namespace → path). Зараз: ${have}; очікувано: ${want} (k8s.mdc)`
573
+ }
574
+
575
+ /** Чи рядок виглядає як JSON-Pointer-шлях `/…` (порожнє і `/` теж приймаються — `/` = корінь). */
576
+ const JSON_POINTER_RE = /^\/[^\s]*$|^$|^\/$/u
577
+
578
+ /**
579
+ * Чи кожен `path` у наборі — окремий вузол JSON-Pointer (немає прямого префікс-збігу типу `/spec` vs `/spec/replicas`).
580
+ * Однакові `path` теж вважаються «недизʼюнктними». Реалізація: `O(n²)` достатня для розмірів реальних patch-наборів.
581
+ * @param {string[]} paths шляхи у тому ж порядку, що й у файлі
582
+ * @returns {boolean} true, якщо всі шляхи попарно дизʼюнктні
583
+ */
584
+ function jsonPointerPathsAreDisjoint(paths) {
585
+ for (let i = 0; i < paths.length; i++) {
586
+ for (let j = 0; j < paths.length; j++) {
587
+ if (i === j) continue
588
+ if (paths[i] === paths[j]) return false
589
+ if (paths[j].startsWith(`${paths[i]}/`)) return false
590
+ }
591
+ }
592
+ return true
593
+ }
594
+
595
+ /**
596
+ * Парсить рядок JSON6902-патчa в плоский масив операцій `{ op, path }` (без значень).
597
+ * Повертає `null`, якщо це не YAML-масив об'єктів з полями `op`/`path` як рядки.
598
+ * @param {string} raw тіло inline `patch:` (literal block scalar)
599
+ * @returns {{ op: string, path: string }[] | null} нормалізований список ops або `null` за невідповідного формату
600
+ */
601
+ function parseJson6902OpsFromText(raw) {
602
+ let parsed
603
+ try {
604
+ parsed = parseDocument(raw).toJSON()
605
+ } catch {
606
+ return null
607
+ }
608
+ if (!Array.isArray(parsed)) return null
609
+ /** @type {{ op: string, path: string }[]} */
610
+ const out = []
611
+ for (const item of parsed) {
612
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) return null
613
+ const rec = /** @type {Record<string, unknown>} */ (item)
614
+ if (typeof rec.op !== 'string' || typeof rec.path !== 'string') return null
615
+ out.push({ op: rec.op, path: rec.path })
616
+ }
617
+ return out
618
+ }
619
+
620
+ /**
621
+ * Порушення сорту inline JSON6902-ops у одному `patches[i].patch`.
622
+ * Сортуємо **тільки** «безпечний» набір: всі `op ∈ { add, replace }` і всі `path` дизʼюнктні
623
+ * (немає префікс-зв'язку між шляхами). Інакше повертаємо `null` — порядок зберігаємо як у файлі,
624
+ * бо `move`/`copy`/`test`/`remove` чи спільні шляхи можуть бути семантично залежні (RFC 6902).
625
+ * @param {string} patchText вміст literal block (inline `patch:`)
626
+ * @returns {string | null} опис порушення або `null`
627
+ */
628
+ export function kustomizationInlinePatchOpsSortedViolation(patchText) {
629
+ const ops = parseJson6902OpsFromText(patchText)
630
+ if (ops === null) return null
631
+ if (ops.length < 2) return null
632
+ for (const o of ops) {
633
+ if (o.op !== 'add' && o.op !== 'replace') return null
634
+ if (!JSON_POINTER_RE.test(o.path)) return null
635
+ }
636
+ const paths = ops.map(o => o.path)
637
+ if (!jsonPointerPathsAreDisjoint(paths)) return null
638
+ /** @type {string[][]} */
639
+ const keys = paths.map(p => [p])
640
+ if (stringTuplesAreSortedEn(keys)) return null
641
+ const want = paths.toSorted((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
642
+ return `inline patch (JSON6902) має бути за алфавітом по path. Зараз: ${paths.join(', ')}; очікувано: ${want.join(', ')} (k8s.mdc)`
643
+ }
644
+
645
+ /**
646
+ * Усі **`kustomization.yaml`**: `patches[]` відсортовано за `[target.kind, target.name, …]`,
647
+ * а вміст inline `patches[i].patch` (де всі ops — `add`/`replace` і шляхи дизʼюнктні) — за `path`.
648
+ * @param {string} root корінь репо
649
+ * @param {string[]} yamlFilesAbs yaml під k8s
650
+ * @param {(msg: string) => void} fail функція для фіксації порушення
651
+ * @returns {Promise<void>} завершується після перевірки всіх kustomization.yaml
652
+ */
653
+ async function validateKustomizationPatchesStructuralSort(root, yamlFilesAbs, fail) {
654
+ for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
655
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
656
+ const kust = await readFirstYamlObject(kustAbs)
657
+ if (kust === null) continue
658
+ const outer = kustomizationPatchesSortedViolation(kust)
659
+ if (outer !== null) fail(`${rel}: ${outer}`)
660
+ const patches = kust.patches
661
+ if (!Array.isArray(patches)) continue
662
+ for (const [i, p] of patches.entries()) {
663
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
664
+ const rec = /** @type {Record<string, unknown>} */ (p)
665
+ if (typeof rec.patch !== 'string') continue
666
+ const v = kustomizationInlinePatchOpsSortedViolation(rec.patch)
667
+ if (v !== null) fail(`${rel}: patches[${i}] (${kustomizationPatchLabel(p, i)}): ${v}`)
668
+ }
669
+ }
670
+ }
671
+
474
672
  /**
475
673
  * Шляхи з полів Kustomization для resolve відносно каталогу **`kustomization.yaml`**.
476
674
  * @param {unknown} obj корінь першого документа Kustomization
@@ -5916,6 +6114,8 @@ export async function check() {
5916
6114
 
5917
6115
  await validateKustomizationResourcesSortedAlphabetically(root, yamlFiles, fail)
5918
6116
 
6117
+ await validateKustomizationPatchesStructuralSort(root, yamlFiles, fail)
6118
+
5919
6119
  await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
5920
6120
 
5921
6121
  await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * CLI-обгортка над канонічним `lint-ga` (ga.mdc): робить preflight на `shellcheck` і `uv` (для `uvx`),
3
- * тоді послідовно виконує `bunx github-actionlint` і `uvx zizmor --offline --collect=workflows .`.
3
+ * тоді послідовно виконує `bunx github-actionlint`, `uvx zizmor --offline --collect=workflows .` і
4
+ * (PoC) `conftest test` на структуру канонічних workflow проти Rego-полісі з `npm/policy/ga/`.
5
+ *
6
+ * Conftest-крок навмисно **не** додається в preflight: якщо бінарник не встановлений, виводимо `ℹ`
7
+ * повідомлення й продовжуємо з кодом 0. Структурні перевірки тих самих workflow паралельно живуть у
8
+ * `npm/scripts/check-ga.mjs`, тож відсутність conftest не пропускає порушення мовчки.
4
9
  *
5
10
  * Без preflight `actionlint` (через `bunx github-actionlint`) мовчки пропускає shell-перевірки в
6
11
  * `run:` блоках, коли `shellcheck` відсутній у PATH; локально `bun lint-ga` лишається зеленим, а CI
@@ -11,11 +16,29 @@
11
16
  *
12
17
  * Експортовано окремо `runLintGaCli` — використовується з `bin/n-cursor.js` як підкоманда `lint-ga`.
13
18
  */
19
+ import { existsSync } from 'node:fs'
14
20
  import { spawnSync } from 'node:child_process'
21
+ import { dirname, join } from 'node:path'
15
22
  import { platform } from 'node:process'
23
+ import { fileURLToPath } from 'node:url'
16
24
 
17
25
  import { resolveCmd } from './utils/resolve-cmd.mjs'
18
26
 
27
+ /** Каталог пакету `@nitra/cursor`, від якого ресолвимо вшиту директорію policy/. */
28
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
29
+
30
+ /** Шлях до Rego-полісі (PoC: лише clean-ga-workflows). У npm-tarball публікується через `files` у package.json. */
31
+ const GA_POLICY_DIR = join(PACKAGE_ROOT, 'policy', 'ga')
32
+
33
+ /**
34
+ * Workflow-файли, для яких маємо відповідну Rego-полісі. PoC: один файл; інші підтягуватимемо в міру міграції
35
+ * перевірок із `npm/scripts/check-ga.mjs`.
36
+ * @type {Array<{ workflow: string, label: string }>}
37
+ */
38
+ const CONFTEST_TARGETS = [
39
+ { workflow: '.github/workflows/clean-ga-workflows.yml', label: 'clean-ga-workflows.yml structure' }
40
+ ]
41
+
19
42
  /**
20
43
  * Опис залежності preflight-ом: бінарник, для чого потрібен, і команди встановлення.
21
44
  * @typedef {object} PreflightDep
@@ -153,5 +176,51 @@ export function runLintGaCli() {
153
176
  if (actionlintCode !== 0) return actionlintCode
154
177
 
155
178
  const zizmorCode = runStep('zizmor', 'uvx', ['zizmor', '--offline', '--collect=workflows', '.'])
156
- return zizmorCode
179
+ if (zizmorCode !== 0) return zizmorCode
180
+
181
+ return runConftestStep()
182
+ }
183
+
184
+ /**
185
+ * PoC-крок: запускає conftest на YAML workflow проти Rego-полісі з пакету (`policy/ga/`).
186
+ *
187
+ * Поведінка fallback:
188
+ * - якщо `conftest` не знайдено в PATH — друкуємо `ℹ` повідомлення з підказкою встановлення й
189
+ * повертаємо 0 (тобто конфтест поки що **не** є обовʼязковою залежністю lint-ga; перевірки лежать
190
+ * паралельно в `check-ga.mjs`, і `npx @nitra/cursor check ga` все одно їх запустить);
191
+ * - якщо `conftest` є й полісі-каталог відсутній (нетипова інсталяція) — також `ℹ` skip;
192
+ * - якщо є цільовий workflow і conftest — запускаємо `conftest test <workflow> -p <policy-dir>` і
193
+ * повертаємо його exit-код, щоб порушення зупиняли lint-ga, як це робить actionlint/zizmor.
194
+ *
195
+ * Локальний `conftest` встановлюється через `brew install conftest` / `go install ...` — деталі в
196
+ * https://www.conftest.dev/install/.
197
+ * @returns {number} 0 — OK або skip, інакше — exit-код conftest
198
+ */
199
+ function runConftestStep() {
200
+ const conftestBin = resolveCmd('conftest')
201
+ if (!conftestBin) {
202
+ console.log(
203
+ '\nℹ conftest не знайдено в PATH — пропускаю PoC-перевірку структури workflow через Rego-полісі.\n' +
204
+ ' Встанови, щоб запустити її локально: brew install conftest (macOS) або https://www.conftest.dev/install/'
205
+ )
206
+ return 0
207
+ }
208
+
209
+ if (!existsSync(GA_POLICY_DIR)) {
210
+ console.log(`\nℹ Каталог Rego-полісі не знайдено (${GA_POLICY_DIR}) — пропускаю conftest.`)
211
+ return 0
212
+ }
213
+
214
+ for (const target of CONFTEST_TARGETS) {
215
+ if (!existsSync(target.workflow)) continue
216
+ const code = runStep(`conftest (${target.label})`, conftestBin, [
217
+ 'test',
218
+ target.workflow,
219
+ '-p',
220
+ GA_POLICY_DIR,
221
+ '--no-color'
222
+ ])
223
+ if (code !== 0) return code
224
+ }
225
+ return 0
157
226
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Запуск `regal lint` по Rego-полісі репозиторію (`conftest.mdc`).
3
+ *
4
+ * Regal (https://docs.styra.com/regal) — статичний лінтер Rego, який ловить v0-синтаксис,
5
+ * неявні set-rules та інші відхилення від `rego.v1`. Без preflight-у на наявність бінарника
6
+ * лінт мовчки злетить з невиразним повідомленням від shell — тут друкуємо явний install-hint
7
+ * (як це робить `lint-ga.mjs` для shellcheck/uv).
8
+ *
9
+ * Цілі лінту: `npm/policy/` (місце, де поки що живуть Rego-полісі пакета `@nitra/cursor`).
10
+ * Якщо в репозиторії з’являться інші *.rego поза цим деревом, додай шлях у `LINT_TARGETS` —
11
+ * `regal lint` приймає кілька шляхів і сам рекурсивно обходить директорії.
12
+ */
13
+ import { spawnSync } from 'node:child_process'
14
+ import { existsSync } from 'node:fs'
15
+ import { resolve } from 'node:path'
16
+
17
+ import { resolveCmd } from './utils/resolve-cmd.mjs'
18
+
19
+ /** Шляхи з Rego-полісі (відносно cwd). Існують не всі на ранніх стадіях — фільтруємо нижче. */
20
+ const LINT_TARGETS = ['npm/policy']
21
+
22
+ /**
23
+ * Друкує підказку зі встановлення `regal`.
24
+ * @returns {void}
25
+ */
26
+ function printRegalInstallHints() {
27
+ process.stderr.write(
28
+ [
29
+ '❌ regal не знайдено в PATH.',
30
+ ' Без нього не перевіряється rego.v1 синтаксис у *.rego (правило `conftest`).',
31
+ ' Встанови:',
32
+ ' macOS: brew install regal',
33
+ ' Universal: https://docs.styra.com/regal#installation',
34
+ ''
35
+ ].join('\n')
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Запускає `regal lint` по існуючих цілях. Якщо жодної цілі немає — пропускає лінт із кодом 0.
41
+ * @param {string} [cwd] робочий каталог (за замовчуванням `process.cwd()`)
42
+ * @returns {number} 0 — OK або skip; інакше код виходу regal
43
+ */
44
+ export function runLintRego(cwd = process.cwd()) {
45
+ const root = resolve(cwd)
46
+ const regal = resolveCmd('regal')
47
+ if (!regal) {
48
+ printRegalInstallHints()
49
+ return 1
50
+ }
51
+
52
+ const targets = LINT_TARGETS.filter(rel => existsSync(resolve(root, rel)))
53
+ if (targets.length === 0) {
54
+ return 0
55
+ }
56
+
57
+ console.log(`▶ regal lint ${targets.join(' ')}`)
58
+ const result = spawnSync(regal, ['lint', ...targets], {
59
+ cwd: root,
60
+ stdio: 'inherit',
61
+ env: process.env
62
+ })
63
+ if (result.error) {
64
+ process.stderr.write(`❌ Не вдалося запустити regal: ${result.error.message}\n`)
65
+ return 1
66
+ }
67
+ return result.status ?? 1
68
+ }
69
+
70
+ process.exitCode = runLintRego()