@nitra/cursor 1.8.202 → 1.8.204

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,31 @@
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.204] - 2026-05-07
8
+
9
+ ### Changed
10
+
11
+ - Реструктурував `npm/policy/ga/` під namespaced sub-packages, які проходять regal: `ga/clean_ga_workflows/clean_ga_workflows.rego` та новий `ga/clean_merged_branch/clean_merged_branch.rego` (порт `validateCleanMergedBranch` з check-ga.mjs — `name` / `cron 0 1 15 * *` / `workflow_dispatch` / `concurrency` / `jobs.cleanup_old_branches` / step0 `phpdocker-io/github-actions-delete-abandoned-branches@v2.0.3` з token / age=90 / ignore_branches main,dev / `dry_run: false` (YAML 1.1) / step1 `Get output` + `DELETED_BRANCHES` env + echo).
12
+ - `scripts/lint-ga.mjs`: `CONFTEST_TARGETS` тепер містить `clean-ga-workflows.yml` і `clean-merged-branch.yml`, conftest викликаємо з `--namespace ga.<name>` для ізоляції правил між workflow.
13
+ - `scripts/check-ga.mjs`: видалено `validateCleanGaWorkflows*` і `validateCleanMergedBranch*` — їх повністю покриває conftest у `lint-ga`. `checkCanonicalWorkflowsMatchRule` тепер валідує лише `lint-ga.yml` і `git-ai.yml` (наступні кандидати на міграцію).
14
+
15
+ ### Added
16
+
17
+ - `.regal/config.yaml` у корені — вимикає `idiomatic.no-defined-entrypoint` (для conftest-полісі `deny`-правила є де-факто entrypoint-ами, формальна анотація не несе семантики).
18
+
19
+ ## [1.8.203] - 2026-05-07
20
+
21
+ ### Changed
22
+
23
+ - `check-k8s.mjs` (автоконверт `image-replace` patches → `images:`): тепер працює і для `patches[i].patch` із **кількома** ops, а не лише з одинокою image-replace op. Сканує всі ops у патчі, конвертує **кожну** `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у запис `images:`; якщо всі ops патча конвертовано — `patches[i]` видаляється повністю; інакше inline `patch:` переписується через `parseDocument` без конвертованих ops зі збереженням block-literal scalar (`|-`) і вихідного порядку решти ops. Реалізовано через нові функції `tryParseJson6902Array` (≥ 1 op, замість `tryParseSingleJson6902Array`) і `rewriteInlinePatchWithoutOps`; `imageReplaceDeploymentPatchInfo` повертає `{ deployName, totalOps, ops: [{ containerIndex, newImage, opIndex }] }` (раніше — одиничний `{ deployName, containerIndex, newImage }` лише за `length === 1`); `applyConversionsToDoc` групує конвертації по індексу патча й вирізає ops або сам патч за потреби. Сортування решти ops після видалення лишається поза цією зміною — за нього відповідає окрема перевірка `kustomizationInlinePatchOpsSortedViolation`.
24
+ - `mdc/k8s.mdc` (v1.26 → v1.27): уточнено крок 1 авто-перевірки в розділі «Зміна image — через `images:`, не через `patches[]`» — тепер описує і випадок, коли в `patches[i].patch` лишаються не-image ops (їх зберігає, у вихідному порядку, без коментарів).
25
+ - `check-js-lint.mjs` + `mdc/js-lint.mdc` (v1.16 → v1.17): мінімум `@nitra/eslint-config` піднято з `^3.8.0` до `^3.9.2`. Обґрунтування: з 3.9.2 у `getConfig` вбудовано ignore для `**/adr/**`, тож ADR-документи не валідуються ESLint, і консьюмерам не треба додавати цей glob у `eslint.config.js` локально. `nitraEslintConfigMeetsMinVersion` тепер повертає `false` для діапазонів `^3.8.x`–`^3.9.1`; `workspace:*` лишається ok без змін. Pass/fail-повідомлення `checkPackageJsonLintDeps` оновлено під новий мінімум; `for...in`-бан з 3.8.0 згадується як накопичена відмінність. Тести `nitraEslintConfigMeetsMinVersion` розширено: `^3.9.2`/`^3.9.10`/`^3.10.0`/`^4.0.0` — ok; `^3.9.1`/`^3.8.0`/`^3.6.12`/`^3.4.3` — ні.
26
+ - `bin/n-cursor.js` (`reexecIfPackageVersionChanged` + `spawnSync`-виклик): `process.env.NITRA_CURSOR_REEXEC` і `...process.env` замінено на `env.NITRA_CURSOR_REEXEC` і `...env` з `node:process` (`import { cwd, env } from 'node:process'`). Підстава: правило `js-run.mdc` забороняє прямий `process.env.*` у Node-коді; `NITRA_CURSOR_REEXEC` — опційна змінна (виставляється лише при re-exec), тож імпорт `env` з `node:process` (а не з `@nitra/check-env`) — канонічна форма для опційних. Поведінка не змінена; раніше `npm/scripts/check-js-run.mjs` помилявся на `bin/n-cursor.js:1136` (правило `process-env`), тепер intergation-test `check-* на реальному репозиторії` проходить.
27
+
28
+ ### Added
29
+
30
+ - `tests/check-k8s-images.test.mjs`: нова форма `imageReplaceDeploymentPatchInfo` (`ops`/`totalOps`/`opIndex`); e2e-тести на multi-op patch (image + `add nodeSelector`), три не-image ops + image у hasura-стилі (`add containers/-` + `add volumes` + `replace nodeSelector`), multi-image patch (containers/0 + containers/1 → обидва конвертовано, патч видаляється), mixed patch з digest у одному з image-values (звичайний tag конвертовано, digest op лишається у патчі) і одиничний digest-image (повертає `errors`, патч на диску не змінюється).
31
+
7
32
  ## [1.8.202] - 2026-05-07
8
33
 
9
34
  ### Added
package/bin/n-cursor.js CHANGED
@@ -53,7 +53,7 @@ import { spawnSync } from 'node:child_process'
53
53
  import { existsSync } from 'node:fs'
54
54
  import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
55
55
  import { basename, dirname, join } from 'node:path'
56
- import { cwd } from 'node:process'
56
+ import { cwd, env } from 'node:process'
57
57
  import { fileURLToPath } from 'node:url'
58
58
 
59
59
  import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
@@ -1133,7 +1133,7 @@ async function readBundledVersionAt(packageRoot) {
1133
1133
  * @returns {Promise<void>} повертається лише якщо re-exec не потрібен (інакше викликає `process.exit`)
1134
1134
  */
1135
1135
  async function reexecIfPackageVersionChanged(effectivePackageRoot) {
1136
- if (process.env.NITRA_CURSOR_REEXEC === '1') {
1136
+ if (env.NITRA_CURSOR_REEXEC === '1') {
1137
1137
  return
1138
1138
  }
1139
1139
  if (effectivePackageRoot === BUNDLED_PACKAGE_ROOT) {
@@ -1155,7 +1155,7 @@ async function reexecIfPackageVersionChanged(effectivePackageRoot) {
1155
1155
  )
1156
1156
  const result = spawnSync(process.execPath, [newBinPath, ...process.argv.slice(2)], {
1157
1157
  stdio: 'inherit',
1158
- env: { ...process.env, NITRA_CURSOR_REEXEC: '1' }
1158
+ env: { ...env, NITRA_CURSOR_REEXEC: '1' }
1159
1159
  })
1160
1160
  if (result.error) {
1161
1161
  throw result.error
package/mdc/js-lint.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  description: Перевірка JavaScript коду
3
3
  alwaysApply: true
4
- version: '1.16'
4
+ version: '1.17'
5
5
  ---
6
6
 
7
- **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.8.0`** (з цієї версії правило `no-restricted-syntax` забороняє `for...in`; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
7
+ **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.9.2`** (з **3.8.0** правило `no-restricted-syntax` забороняє `for...in`; з **3.9.2** у `getConfig` вбудовано ignore для **`**/adr/**`** — ADR-документи не валідуються ESLint, локально цей glob додавати не потрібно; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
8
8
 
9
9
  ```json title=".vscode/extensions.json"
10
10
  {
@@ -25,7 +25,7 @@ version: '1.16'
25
25
  "lint-js": "bunx oxlint --fix && bunx eslint --fix . && bunx jscpd ."
26
26
  },
27
27
  "devDependencies": {
28
- "@nitra/eslint-config": "^3.8.0"
28
+ "@nitra/eslint-config": "^3.9.2"
29
29
  }
30
30
  }
31
31
  ```
package/mdc/k8s.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: K8s YAML — $schema (yaml-language-server); lint-k8s (kubeconform, kubescape); check-k8s
3
- version: '1.26'
3
+ version: '1.27'
4
4
  globs: "**/k8s/**/*.yaml"
5
5
  alwaysApply: false
6
6
  ---
@@ -318,7 +318,7 @@ images:
318
318
 
319
319
  **`check k8s` автоматично** для кожного `kustomization.yaml`:
320
320
 
321
- 1. конвертує JSON6902-патч `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у запис `images:` (резолвить оригінальний image у base через `resources:` / `bases` / `components` / `crds`); якщо в `patches[]` залишається лише ця операція — патч прибирається повністю;
321
+ 1. конвертує кожну JSON6902-операцію `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у запис `images:` (резолвить оригінальний image у base через `resources:` / `bases` / `components` / `crds`). Якщо у `patches[i].patch` після конвертації не залишилось ops — патч прибирається повністю; інакше у `patches[i].patch` залишаються лише не-image ops у вихідному порядку;
322
322
  2. чистить існуючий блок `images:` — зрізає `:tag` з `name` і видаляє `newTag`, який збігається з відрізаним тегом.
323
323
 
324
324
  ## Ingress → Gateway API (GKE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.202",
3
+ "version": "1.8.204",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,26 +1,25 @@
1
- # PoC-порт перевірки `validateCleanGaWorkflows` з `npm/scripts/check-ga.mjs`.
1
+ # Порт перевірки `validateCleanGaWorkflows` з `npm/scripts/check-ga.mjs` (ga.mdc).
2
2
  #
3
3
  # Запуск (локально):
4
- # conftest test .github/workflows/clean-ga-workflows.yml -p npm/policy/ga
4
+ # conftest test .github/workflows/clean-ga-workflows.yml \
5
+ # -p npm/policy/ga --namespace ga.clean_ga_workflows
5
6
  #
6
- # Conftest читає YAML і дає його в `input`. Кожне правило `deny contains msg if { … }`,
7
- # що матчиться, друкується як порушення; пустий список exit 0.
7
+ # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
8
+ # Конвенція проєкту `import rego.v1` + multi-value `deny contains msg if { … }`
9
+ # (.cursor/rules/conftest.mdc). Лінт — `bun run lint-rego` (regal).
8
10
  #
9
- # Rego v1 синтаксис (OPA 1.x за замовчуванням; `import rego.v1` робить файл портованим
10
- # і на старі OPA 0.x): `contains` для partial set rules, `if` перед тілом правила.
11
- package main
11
+ # Усі `deny`-правила йдуть контигно (regal: messy-rule); helpers і константи
12
+ # секціями вище та нижче.
13
+ package ga.clean_ga_workflows
12
14
 
13
15
  import rego.v1
14
16
 
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"]
17
+ # ── Очікувані значення ─────────────────────────────────────────────────────
18
+ #
19
+ # `${{ … }}` шаблонний синтаксис GitHub Actions; `{{` у Rego починає string
20
+ # interpolation. Збираємо очікувані рядки з фрагментів через `concat`, як це
21
+ # зроблено в check-ga.mjs, щоб і Rego-парсер, і людина-читач не плуталися.
20
22
 
21
- # `${{ … }}` — це шаблонний синтаксис GitHub Actions, але `{{` у Rego починає
22
- # string interpolation. Збираємо очікувані рядки з фрагментів, як це зроблено в
23
- # check-ga.mjs, щоб і Rego-парсер, і людина-читач не плуталися.
24
23
  expected_concurrency_group := concat("", ["$", "{{ github.ref }}-$", "{{ github.workflow }}"])
25
24
 
26
25
  expected_github_token := concat("", ["$", "{{ github.token }}"])
@@ -29,43 +28,43 @@ expected_name := "Clean action for removing completed workflow runs"
29
28
 
30
29
  expected_cron := "0 1 16 * *"
31
30
 
32
- # --- name --------------------------------------------------------------------
31
+ # Шаблон повідомлення про відсутню `concurrency`-секцію — винесено через `concat`,
32
+ # щоб дотриматися regal style/line-length.
33
+ concurrency_missing_template := concat(" ", [
34
+ "clean-ga-workflows.yml: відсутня секція concurrency —",
35
+ "додай concurrency.group: %s і cancel-in-progress: true (ga.mdc)",
36
+ ])
37
+
38
+ # ── Аліаси на input ────────────────────────────────────────────────────────
39
+ #
40
+ # GHA YAML quirk: ключ `on:` — YAML 1.1 boolean `true`, конфтест серіалізує його
41
+ # як рядковий ключ "true". Ані `input.on`, ані `input["on"]`, ані `input[true]`
42
+ # не працюють — лише `input["true"]`.
43
+
44
+ gha_on := input["true"]
45
+
46
+ step0 := input.jobs.cleanup_old_workflows.steps[0]
47
+
48
+ # ── deny rules (контигно — regal: messy-rule) ──────────────────────────────
33
49
 
34
50
  deny contains msg if {
35
51
  input.name != expected_name
36
52
  msg := sprintf("clean-ga-workflows.yml: name має бути %q (ga.mdc)", [expected_name])
37
53
  }
38
54
 
39
- # --- on.schedule.cron --------------------------------------------------------
40
-
41
55
  deny contains msg if {
42
56
  not has_expected_cron
43
57
  msg := sprintf("clean-ga-workflows.yml: on.schedule має містити cron: '%s' (ga.mdc)", [expected_cron])
44
58
  }
45
59
 
46
- has_expected_cron if {
47
- gha_on.schedule[_].cron == expected_cron
48
- }
49
-
50
- # --- on.workflow_dispatch ----------------------------------------------------
51
-
52
60
  deny contains msg if {
53
61
  not has_workflow_dispatch
54
62
  msg := "clean-ga-workflows.yml: має бути workflow_dispatch: {} (ga.mdc)"
55
63
  }
56
64
 
57
- has_workflow_dispatch if {
58
- is_object(gha_on.workflow_dispatch)
59
- }
60
-
61
- # --- concurrency -------------------------------------------------------------
62
-
63
65
  deny contains msg if {
64
66
  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
- )
67
+ msg := sprintf(concurrency_missing_template, [expected_concurrency_group])
69
68
  }
70
69
 
71
70
  deny contains msg if {
@@ -80,8 +79,6 @@ deny contains msg if {
80
79
  msg := "clean-ga-workflows.yml: concurrency.cancel-in-progress має бути true (ga.mdc)"
81
80
  }
82
81
 
83
- # --- jobs.cleanup_old_workflows ---------------------------------------------
84
-
85
82
  deny contains msg if {
86
83
  not input.jobs.cleanup_old_workflows
87
84
  msg := "clean-ga-workflows.yml: jobs.cleanup_old_workflows відсутній (ga.mdc)"
@@ -99,15 +96,6 @@ deny contains msg if {
99
96
  msg := "clean-ga-workflows.yml: permissions мають бути actions: write, contents: read (ga.mdc)"
100
97
  }
101
98
 
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
99
  deny contains msg if {
112
100
  step0.name != "Delete workflow runs"
113
101
  msg := "clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)"
@@ -120,12 +108,27 @@ deny contains msg if {
120
108
 
121
109
  # Триплет полів `with`: token (gh-токен), save_period=31, save_min_runs_number=0.
122
110
  # В JS-перевірці помилка спільна для всіх трьох — лишаємо такий самий формат, щоб
123
- # повідомлення збігалися. Окремі правила нижче роблять діагноз точнішим.
111
+ # повідомлення збігалися.
124
112
  deny contains msg if {
125
113
  not step0_with_canonical
126
114
  msg := "clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc"
127
115
  }
128
116
 
117
+ # ── helpers ────────────────────────────────────────────────────────────────
118
+
119
+ has_expected_cron if {
120
+ gha_on.schedule[_].cron == expected_cron
121
+ }
122
+
123
+ has_workflow_dispatch if {
124
+ is_object(gha_on.workflow_dispatch)
125
+ }
126
+
127
+ actions_write_contents_read(perms) if {
128
+ perms.actions == "write"
129
+ perms.contents == "read"
130
+ }
131
+
129
132
  step0_with_canonical if {
130
133
  step0.with.token == expected_github_token
131
134
  step0.with.save_period == 31
@@ -0,0 +1,167 @@
1
+ # Порт перевірки `validateCleanMergedBranch` з `npm/scripts/check-ga.mjs` (ga.mdc).
2
+ #
3
+ # Запуск (локально):
4
+ # conftest test .github/workflows/clean-merged-branch.yml \
5
+ # -p npm/policy/ga --namespace ga.clean_merged_branch
6
+ #
7
+ # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
8
+ # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`
9
+ # (.cursor/rules/conftest.mdc). Лінт — `bun run lint-rego` (regal).
10
+ package ga.clean_merged_branch
11
+
12
+ import rego.v1
13
+
14
+ # ── Очікувані значення ─────────────────────────────────────────────────────
15
+ #
16
+ # Шаблонні токени GitHub Actions (`${{ … }}`) збираємо з фрагментів через
17
+ # `concat`, бо `{{` у Rego починає string interpolation.
18
+
19
+ expected_concurrency_group := concat("", ["$", "{{ github.ref }}-$", "{{ github.workflow }}"])
20
+
21
+ expected_github_token := concat("", ["$", "{{ github.token }}"])
22
+
23
+ expected_deleted_branches_expr := concat("", ["$", "{{ steps.delete_stuff.outputs.deleted_branches }}"])
24
+
25
+ expected_echo_substring := concat("", ["echo \"Deleted branches: $", "{DELETED_BRANCHES}\""])
26
+
27
+ expected_name := "Clean abandoned branches"
28
+
29
+ expected_cron := "0 1 15 * *"
30
+
31
+ # Шаблони повідомлень — через `concat` для regal style/line-length.
32
+ concurrency_missing_template := concat(" ", [
33
+ "clean-merged-branch.yml: відсутня секція concurrency —",
34
+ "додай concurrency.group: %s і cancel-in-progress: true (ga.mdc)",
35
+ ])
36
+
37
+ # ── Аліаси на input ────────────────────────────────────────────────────────
38
+ #
39
+ # YAML 1.1 quirk: `on:` → boolean true → у конфтесті ключ "true".
40
+
41
+ gha_on := input["true"]
42
+
43
+ steps := input.jobs.cleanup_old_branches.steps
44
+
45
+ step0 := steps[0]
46
+
47
+ step1 := steps[1]
48
+
49
+ # ── deny rules (контигно — regal: messy-rule) ──────────────────────────────
50
+
51
+ deny contains msg if {
52
+ input.name != expected_name
53
+ msg := sprintf("clean-merged-branch.yml: name має бути %q (ga.mdc)", [expected_name])
54
+ }
55
+
56
+ deny contains msg if {
57
+ not has_expected_cron
58
+ msg := sprintf("clean-merged-branch.yml: on.schedule має містити cron: '%s' (ga.mdc)", [expected_cron])
59
+ }
60
+
61
+ deny contains msg if {
62
+ not has_workflow_dispatch
63
+ msg := "clean-merged-branch.yml: має бути workflow_dispatch: {} (ga.mdc)"
64
+ }
65
+
66
+ deny contains msg if {
67
+ not is_object(input.concurrency)
68
+ msg := sprintf(concurrency_missing_template, [expected_concurrency_group])
69
+ }
70
+
71
+ deny contains msg if {
72
+ is_object(input.concurrency)
73
+ input.concurrency.group != expected_concurrency_group
74
+ msg := sprintf("clean-merged-branch.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-merged-branch.yml: concurrency.cancel-in-progress має бути true (ga.mdc)"
81
+ }
82
+
83
+ deny contains msg if {
84
+ not input.jobs.cleanup_old_branches
85
+ msg := "clean-merged-branch.yml: jobs.cleanup_old_branches відсутній (ga.mdc)"
86
+ }
87
+
88
+ deny contains msg if {
89
+ input.jobs.cleanup_old_branches.permissions.contents != "write"
90
+ msg := "clean-merged-branch.yml: permissions мають бути contents: write (ga.mdc)"
91
+ }
92
+
93
+ deny contains msg if {
94
+ count(steps) < 2
95
+ msg := "clean-merged-branch.yml: steps має містити 2 кроки як у ga.mdc"
96
+ }
97
+
98
+ # ── Step 0 (delete_stuff) ──────────────────────────────────────────────────
99
+
100
+ deny contains msg if {
101
+ step0.id != "delete_stuff"
102
+ msg := "clean-merged-branch.yml: перший крок має id: delete_stuff (ga.mdc)"
103
+ }
104
+
105
+ deny contains msg if {
106
+ step0.uses != "phpdocker-io/github-actions-delete-abandoned-branches@v2.0.3"
107
+ msg := "clean-merged-branch.yml: перший крок має uses як у ga.mdc"
108
+ }
109
+
110
+ deny contains msg if {
111
+ step0.with.github_token != expected_github_token
112
+ msg := sprintf("clean-merged-branch.yml: with.github_token має бути %s (ga.mdc)", [expected_github_token])
113
+ }
114
+
115
+ deny contains msg if {
116
+ step0.with.last_commit_age_days != 90
117
+ msg := "clean-merged-branch.yml: with.last_commit_age_days має бути 90 (ga.mdc)"
118
+ }
119
+
120
+ deny contains msg if {
121
+ not ignore_branches_has_main_and_dev
122
+ msg := "clean-merged-branch.yml: with.ignore_branches має містити main,dev (ga.mdc)"
123
+ }
124
+
125
+ # `dry_run: no` у YAML парситься як boolean `false`. JS-перевірка порівнює зі
126
+ # рядком "no", але в нас input уже Go-yaml-парсений — тому очікуємо `false`.
127
+ # (Якщо комусь схочеться явного `"no"` — треба буде брати in quotes у YAML.)
128
+ deny contains msg if {
129
+ step0.with.dry_run != false # noqa: rules-style-no-equality-with-false
130
+ msg := "clean-merged-branch.yml: with.dry_run має бути no (ga.mdc)"
131
+ }
132
+
133
+ # ── Step 1 (Get output) ────────────────────────────────────────────────────
134
+
135
+ deny contains msg if {
136
+ step1.name != "Get output"
137
+ msg := "clean-merged-branch.yml: другий крок має name: Get output (ga.mdc)"
138
+ }
139
+
140
+ deny contains msg if {
141
+ step1.env.DELETED_BRANCHES != expected_deleted_branches_expr
142
+ msg := "clean-merged-branch.yml: env.DELETED_BRANCHES має бути як у ga.mdc"
143
+ }
144
+
145
+ deny contains msg if {
146
+ not echo_deleted_branches
147
+ msg := "clean-merged-branch.yml: run має echo Deleted branches як у ga.mdc"
148
+ }
149
+
150
+ # ── helpers ────────────────────────────────────────────────────────────────
151
+
152
+ has_expected_cron if {
153
+ gha_on.schedule[_].cron == expected_cron
154
+ }
155
+
156
+ has_workflow_dispatch if {
157
+ is_object(gha_on.workflow_dispatch)
158
+ }
159
+
160
+ ignore_branches_has_main_and_dev if {
161
+ contains(step0.with.ignore_branches, "main")
162
+ contains(step0.with.ignore_branches, "dev")
163
+ }
164
+
165
+ echo_deleted_branches if {
166
+ contains(step1.run, expected_echo_substring)
167
+ }
@@ -170,220 +170,6 @@ function isExactString(v, expected) {
170
170
  return typeof v === 'string' && v === expected
171
171
  }
172
172
 
173
- /**
174
- * Перевіряє крок dmvict/clean-workflow-runs@v1 у `clean-ga-workflows.yml`.
175
- * @param {unknown} step0 перший крок workflow
176
- * @param {(msg: string) => void} passFn pass
177
- * @param {(msg: string) => void} failFn fail
178
- */
179
- function validateCleanGaWorkflowsStep0(step0, passFn, failFn) {
180
- if (!isExactString(getObjKey(step0, 'name'), 'Delete workflow runs')) {
181
- failFn('clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)')
182
- }
183
- if (!isExactString(getObjKey(step0, 'uses'), 'dmvict/clean-workflow-runs@v1')) {
184
- failFn('clean-ga-workflows.yml: перший крок має uses: dmvict/clean-workflow-runs@v1 (ga.mdc)')
185
- }
186
- const withObj = getObjKey(step0, 'with')
187
- const githubToken = ['$', '{{ github.token }}'].join('')
188
- if (
189
- getObjKey(withObj, 'token') === githubToken &&
190
- getObjKey(withObj, 'save_period') === 31 &&
191
- getObjKey(withObj, 'save_min_runs_number') === 0
192
- ) {
193
- passFn('clean-ga-workflows.yml: jobs/steps OK')
194
- } else {
195
- failFn('clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc')
196
- }
197
- }
198
-
199
- /**
200
- * Перевіряє структуру workflow `clean-ga-workflows.yml` (ga.mdc).
201
- * @param {Record<string, unknown> | null} root parsed YAML
202
- * @param {(msg: string) => void} passFn pass
203
- * @param {(msg: string) => void} failFn fail
204
- */
205
- function validateCleanGaWorkflows(root, passFn, failFn) {
206
- if (!root) {
207
- failFn('clean-ga-workflows.yml: YAML не вдалося розібрати (ga.mdc)')
208
- return
209
- }
210
-
211
- if (isExactString(root.name, 'Clean action for removing completed workflow runs')) {
212
- passFn('clean-ga-workflows.yml: name OK')
213
- } else {
214
- failFn('clean-ga-workflows.yml: name має бути "Clean action for removing completed workflow runs" (ga.mdc)')
215
- }
216
-
217
- const on = root.on
218
- const schedule = getObjKey(on, 'schedule')
219
- const wfDispatch = getObjKey(on, 'workflow_dispatch')
220
-
221
- const hasCron =
222
- Array.isArray(schedule) &&
223
- schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 16 * *')
224
-
225
- if (hasCron) {
226
- passFn('clean-ga-workflows.yml: cron OK')
227
- } else {
228
- failFn("clean-ga-workflows.yml: on.schedule має містити cron: '0 1 16 * *' (ga.mdc)")
229
- }
230
-
231
- if (!wfDispatch || typeof wfDispatch !== 'object') {
232
- failFn('clean-ga-workflows.yml: має бути workflow_dispatch: {} (ga.mdc)')
233
- } else {
234
- passFn('clean-ga-workflows.yml: workflow_dispatch OK')
235
- }
236
-
237
- validateConcurrencyOnRoot('clean-ga-workflows.yml', root, passFn, failFn)
238
-
239
- const jobs = getObjKey(root, 'jobs')
240
- const job = getObjKey(jobs, 'cleanup_old_workflows')
241
- if (!job) {
242
- failFn('clean-ga-workflows.yml: jobs.cleanup_old_workflows відсутній (ga.mdc)')
243
- return
244
- }
245
-
246
- if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
247
- failFn('clean-ga-workflows.yml: runs-on має бути ubuntu-latest (ga.mdc)')
248
- }
249
-
250
- const perm = getObjKey(job, 'permissions')
251
- if (!(getObjKey(perm, 'actions') === 'write' && getObjKey(perm, 'contents') === 'read')) {
252
- failFn('clean-ga-workflows.yml: permissions мають бути actions: write, contents: read (ga.mdc)')
253
- }
254
-
255
- const steps = getObjKey(job, 'steps')
256
- const step0 = Array.isArray(steps) ? steps[0] : null
257
- if (!step0 || typeof step0 !== 'object') {
258
- failFn('clean-ga-workflows.yml: steps має містити крок з dmvict/clean-workflow-runs@v1 (ga.mdc)')
259
- return
260
- }
261
-
262
- validateCleanGaWorkflowsStep0(step0, passFn, failFn)
263
- }
264
-
265
- /**
266
- * Перевіряє крок `phpdocker-io/github-actions-delete-abandoned-branches` у `clean-merged-branch.yml`.
267
- * @param {unknown} step0 перший крок workflow
268
- * @param {(msg: string) => void} failFn fail
269
- */
270
- function validateCleanMergedBranchStep0(step0, failFn) {
271
- if (!isExactString(getObjKey(step0, 'id'), 'delete_stuff')) {
272
- failFn('clean-merged-branch.yml: перший крок має id: delete_stuff (ga.mdc)')
273
- }
274
- if (!isExactString(getObjKey(step0, 'uses'), 'phpdocker-io/github-actions-delete-abandoned-branches@v2.0.3')) {
275
- failFn('clean-merged-branch.yml: перший крок має uses як у ga.mdc')
276
- }
277
- const withObj = getObjKey(step0, 'with')
278
- const ghToken = ['$', '{{ github.token }}'].join('')
279
- if (getObjKey(withObj, 'github_token') !== ghToken) {
280
- failFn(['clean-merged-branch.yml: with.github_token має бути $', '{{ github.token }} (ga.mdc)'].join(''))
281
- }
282
- if (getObjKey(withObj, 'last_commit_age_days') !== 90) {
283
- failFn('clean-merged-branch.yml: with.last_commit_age_days має бути 90 (ga.mdc)')
284
- }
285
- const ignoreBranches = String(getObjKey(withObj, 'ignore_branches') ?? '')
286
- if (!(ignoreBranches.includes('main') && ignoreBranches.includes('dev'))) {
287
- failFn('clean-merged-branch.yml: with.ignore_branches має містити main,dev (ga.mdc)')
288
- }
289
- if (getObjKey(withObj, 'dry_run') !== 'no') {
290
- failFn('clean-merged-branch.yml: with.dry_run має бути no (ga.mdc)')
291
- }
292
- }
293
-
294
- /**
295
- * Перевіряє крок виводу в `clean-merged-branch.yml`.
296
- * @param {unknown} step1 другий крок workflow
297
- * @param {(msg: string) => void} passFn pass
298
- * @param {(msg: string) => void} failFn fail
299
- */
300
- function validateCleanMergedBranchStep1(step1, passFn, failFn) {
301
- if (!isExactString(getObjKey(step1, 'name'), 'Get output')) {
302
- failFn('clean-merged-branch.yml: другий крок має name: Get output (ga.mdc)')
303
- }
304
- const env = getObjKey(step1, 'env')
305
- const deletedBranchesExpr = ['$', '{{ steps.delete_stuff.outputs.deleted_branches }}'].join('')
306
- if (getObjKey(env, 'DELETED_BRANCHES') !== deletedBranchesExpr) {
307
- failFn('clean-merged-branch.yml: env.DELETED_BRANCHES має бути як у ga.mdc')
308
- }
309
- const echoDeletedBranches = ['echo "Deleted branches: $', '{DELETED_BRANCHES}"'].join('')
310
- if (String(getObjKey(step1, 'run') ?? '').includes(echoDeletedBranches)) {
311
- passFn('clean-merged-branch.yml: jobs/steps OK')
312
- } else {
313
- failFn('clean-merged-branch.yml: run має echo Deleted branches як у ga.mdc')
314
- }
315
- }
316
-
317
- /**
318
- * Перевіряє структуру workflow `clean-merged-branch.yml` (ga.mdc).
319
- * @param {Record<string, unknown> | null} root parsed YAML
320
- * @param {(msg: string) => void} passFn pass
321
- * @param {(msg: string) => void} failFn fail
322
- */
323
- function validateCleanMergedBranch(root, passFn, failFn) {
324
- if (!root) {
325
- failFn('clean-merged-branch.yml: YAML не вдалося розібрати (ga.mdc)')
326
- return
327
- }
328
-
329
- if (isExactString(root.name, 'Clean abandoned branches')) {
330
- passFn('clean-merged-branch.yml: name OK')
331
- } else {
332
- failFn('clean-merged-branch.yml: name має бути "Clean abandoned branches" (ga.mdc)')
333
- }
334
-
335
- const on = root.on
336
- const schedule = getObjKey(on, 'schedule')
337
- const wfDispatch = getObjKey(on, 'workflow_dispatch')
338
- const hasCron =
339
- Array.isArray(schedule) &&
340
- schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 15 * *')
341
-
342
- if (hasCron) {
343
- passFn('clean-merged-branch.yml: cron OK')
344
- } else {
345
- failFn("clean-merged-branch.yml: on.schedule має містити cron: '0 1 15 * *' (ga.mdc)")
346
- }
347
-
348
- if (!wfDispatch || typeof wfDispatch !== 'object') {
349
- failFn('clean-merged-branch.yml: має бути workflow_dispatch: {} (ga.mdc)')
350
- }
351
-
352
- validateConcurrencyOnRoot('clean-merged-branch.yml', root, passFn, failFn)
353
-
354
- const jobs = getObjKey(root, 'jobs')
355
- const job = getObjKey(jobs, 'cleanup_old_branches')
356
- if (!job) {
357
- failFn('clean-merged-branch.yml: jobs.cleanup_old_branches відсутній (ga.mdc)')
358
- return
359
- }
360
-
361
- const perm = getObjKey(job, 'permissions')
362
- if (getObjKey(perm, 'contents') !== 'write') {
363
- failFn('clean-merged-branch.yml: permissions мають бути contents: write (ga.mdc)')
364
- }
365
-
366
- const steps = getObjKey(job, 'steps')
367
- if (!Array.isArray(steps) || steps.length < 2) {
368
- failFn('clean-merged-branch.yml: steps має містити 2 кроки як у ga.mdc')
369
- return
370
- }
371
-
372
- const step0 = steps[0]
373
- if (!step0 || typeof step0 !== 'object') {
374
- failFn('clean-merged-branch.yml: перший крок невалідний (ga.mdc)')
375
- return
376
- }
377
- validateCleanMergedBranchStep0(step0, failFn)
378
-
379
- const step1 = steps[1]
380
- if (!step1 || typeof step1 !== 'object') {
381
- failFn('clean-merged-branch.yml: другий крок невалідний (ga.mdc)')
382
- return
383
- }
384
- validateCleanMergedBranchStep1(step1, passFn, failFn)
385
- }
386
-
387
173
  /**
388
174
  * Перевіряє тригери `on.push` / `on.pull_request` у `lint-ga.yml`.
389
175
  * @param {unknown} on корінь `on:` з YAML
@@ -973,26 +759,20 @@ async function checkGitAiWorkflow(wfDir, passFn, failFn) {
973
759
 
974
760
  /**
975
761
  * Перевіряє, що “канонічні” workflows відповідають ga.mdc (структура і значення).
762
+ *
763
+ * Структурні валідатори `clean-ga-workflows.yml` і `clean-merged-branch.yml` мігровано в Rego-полісі
764
+ * під `npm/policy/ga/clean_ga_workflows/` та `npm/policy/ga/clean_merged_branch/` (виконує conftest з
765
+ * `bun run lint-ga`). Тут лишаються `lint-ga.yml` і `git-ai.yml` — їх перенесення в наступних ітераціях.
976
766
  * @param {string} wfDir директорія workflows
977
767
  * @param {(msg: string) => void} passFn pass
978
768
  * @param {(msg: string) => void} failFn fail
979
769
  */
980
770
  async function checkCanonicalWorkflowsMatchRule(wfDir, passFn, failFn) {
981
771
  const paths = {
982
- cleanGa: join(wfDir, 'clean-ga-workflows.yml'),
983
- cleanMerged: join(wfDir, 'clean-merged-branch.yml'),
984
772
  lintGa: join(wfDir, 'lint-ga.yml'),
985
773
  gitAi: join(wfDir, 'git-ai.yml')
986
774
  }
987
775
 
988
- if (existsSync(paths.cleanGa)) {
989
- const c = await readFile(paths.cleanGa, 'utf8')
990
- validateCleanGaWorkflows(parseWorkflowYaml(c), passFn, failFn)
991
- }
992
- if (existsSync(paths.cleanMerged)) {
993
- const c = await readFile(paths.cleanMerged, 'utf8')
994
- validateCleanMergedBranch(parseWorkflowYaml(c), passFn, failFn)
995
- }
996
776
  if (existsSync(paths.lintGa)) {
997
777
  const c = await readFile(paths.lintGa, 'utf8')
998
778
  validateLintGaWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
@@ -4,8 +4,9 @@
4
4
  * Канонічний `lint-js`, flat ESLint з getConfig і ignore для auto-imports, рекомендації VSCode,
5
5
  * `.oxlintrc.json` має збігатися з каноном oxlint у пакеті (`npm/scripts/utils/oxlint-canonical.json`):
6
6
  * plugins, jsPlugins, categories, усі правила з канону (додаткові записи в `rules` дозволені), settings, env,
7
- * globals, ignorePatterns. `@nitra/eslint-config` у devDependencies мінімум **3.8.0** (з цієї версії
8
- * правило `no-restricted-syntax` для `ForInStatement` забороняє `for...in`; також тягне транзитивний
7
+ * globals, ignorePatterns. `@nitra/eslint-config` у devDependencies мінімум **3.9.2** (з 3.8.0 правило
8
+ * `no-restricted-syntax` для `ForInStatement` забороняє `for...in`; з 3.9.2 у `getConfig` вбудовано
9
+ * ignore для ADR-каталогів — локально цей glob додавати не потрібно; також тягне транзитивний
9
10
  * `@e18e/eslint-plugin` для oxlint), `.jscpd.json` (gitignore, exitCode, reporters, minLines), workflow
10
11
  * `lint-js.yml` (checkout@v6, setup-bun-deps, bunx без --fix), без prettier, `engines.node` >= 24,
11
12
  * `engines.bun` >= 1.3, `"type": "module"` у кореневому і всіх workspace `package.json`. Дубль перевірки JS у `lint.yml` — заборонено.
@@ -54,10 +55,11 @@ export function isCanonicalLintJs(script) {
54
55
  }
55
56
 
56
57
  /**
57
- * Чи діапазон `@nitra/eslint-config` у `package.json` задовольняє мінімум `>= 3.8.0`
58
- * (заборона `for...in` через `no-restricted-syntax` + транзитивний `@e18e/eslint-plugin` для oxlint).
58
+ * Чи діапазон `@nitra/eslint-config` у `package.json` задовольняє мінімум `>= 3.9.2`
59
+ * (заборона `for...in` через `no-restricted-syntax` з 3.8.0 + вбудований ignore для ADR-каталогів
60
+ * у `getConfig` з 3.9.2 + транзитивний `@e18e/eslint-plugin` для oxlint).
59
61
  * @param {unknown} versionSpec значення `devDependencies['@nitra/eslint-config']`
60
- * @returns {boolean} true для `workspace:*` або першої semver у рядку >= 3.8.0
62
+ * @returns {boolean} true для `workspace:*` або першої semver у рядку >= 3.9.2
61
63
  */
62
64
  export function nitraEslintConfigMeetsMinVersion(versionSpec) {
63
65
  const s = String(versionSpec).trim()
@@ -72,7 +74,7 @@ export function nitraEslintConfigMeetsMinVersion(versionSpec) {
72
74
  if ([major, minor, patch].some(n => Number.isNaN(n))) {
73
75
  return false
74
76
  }
75
- return major > 3 || (major === 3 && minor >= 8 && patch >= 0)
77
+ return major > 3 || (major === 3 && (minor > 9 || (minor === 9 && patch >= 2)))
76
78
  }
77
79
 
78
80
  /**
@@ -269,11 +271,11 @@ function checkPackageJsonLintDeps(pkg, passFn, failFn) {
269
271
  passFn('@nitra/eslint-config є в devDependencies')
270
272
  if (nitraEslintConfigMeetsMinVersion(nitraEslint)) {
271
273
  passFn(
272
- '@nitra/eslint-config: мінімум 3.8.0 (no-restricted-syntax для ForInStatement + @e18e/eslint-plugin транзитивно, js-lint.mdc)'
274
+ '@nitra/eslint-config: мінімум 3.9.2 (no-restricted-syntax для ForInStatement з 3.8.0 + вбудований ignore "**/adr/**" з 3.9.2 + @e18e/eslint-plugin транзитивно, js-lint.mdc)'
273
275
  )
274
276
  } else {
275
277
  failFn(
276
- '@nitra/eslint-config: онови до мінімум "^3.8.0" — з цієї версії правило no-restricted-syntax забороняє for...in (плюс транзитивний @e18e/eslint-plugin для oxlint, js-lint.mdc)'
278
+ '@nitra/eslint-config: онови до мінімум "^3.9.2" — з 3.9.2 у getConfig вбудовано ignore для "**/adr/**" (ADR-документи не валідуються), плюс транзитивний @e18e/eslint-plugin для oxlint і заборона for...in з 3.8.0 (js-lint.mdc)'
277
279
  )
278
280
  }
279
281
  } else {
@@ -5626,10 +5626,14 @@ function applyNameStripTag(originalLine, parsed) {
5626
5626
  const KUSTOMIZATION_DEPLOYMENT_CONTAINER_IMAGE_PATH_RE = /^\/spec\/template\/spec\/containers\/(\d+)\/image$/u
5627
5627
 
5628
5628
  /**
5629
- * Якщо `patchObj` — JSON6902 з єдиною операцією `replace` на шляху image-контейнера у `Deployment`,
5630
- * повертає `{ deployName, containerIndex, newImage }`. Інакше null.
5629
+ * Якщо `patchObj` — JSON6902 для `kind: Deployment`, повертає всі image-replace ops
5630
+ * у його `patch:` разом із `opIndex` (позиція в масиві ops) і `totalOps` (загальна довжина).
5631
5631
  * @param {unknown} patchObj елемент масиву `patches[]`
5632
- * @returns {{ deployName: string, containerIndex: number, newImage: string } | null} інформація про патч або null, якщо це не очікувана JSON6902-операція
5632
+ * @returns {{
5633
+ * deployName: string,
5634
+ * totalOps: number,
5635
+ * ops: Array<{ containerIndex: number, newImage: string, opIndex: number }>
5636
+ * } | null} інформація про image-replace ops у патчі або null
5633
5637
  */
5634
5638
  export function imageReplaceDeploymentPatchInfo(patchObj) {
5635
5639
  const pr = asPlainObject(patchObj)
@@ -5638,20 +5642,21 @@ export function imageReplaceDeploymentPatchInfo(patchObj) {
5638
5642
  if (deployName === null) return null
5639
5643
  if (typeof pr.patch !== 'string') return null
5640
5644
 
5641
- const parsedArr = tryParseSingleJson6902Array(pr.patch)
5645
+ const parsedArr = tryParseJson6902Array(pr.patch)
5642
5646
  if (parsedArr === null) return null
5643
- const op = asPlainObject(parsedArr[0])
5644
- if (op === null) return null
5645
5647
 
5646
- const containerIndex = singleImageReplaceContainerIndex(op)
5647
- if (containerIndex === null) return null
5648
- if (typeof op.value !== 'string' || op.value.trim() === '') return null
5649
-
5650
- return {
5651
- deployName,
5652
- containerIndex,
5653
- newImage: op.value.trim()
5648
+ /** @type {Array<{ containerIndex: number, newImage: string, opIndex: number }>} */
5649
+ const ops = []
5650
+ for (let i = 0; i < parsedArr.length; i++) {
5651
+ const op = asPlainObject(parsedArr[i])
5652
+ if (op === null) continue
5653
+ const containerIndex = singleImageReplaceContainerIndex(op)
5654
+ if (containerIndex === null) continue
5655
+ if (typeof op.value !== 'string' || op.value.trim() === '') continue
5656
+ ops.push({ containerIndex, newImage: op.value.trim(), opIndex: i })
5654
5657
  }
5658
+ if (ops.length === 0) return null
5659
+ return { deployName, totalOps: parsedArr.length, ops }
5655
5660
  }
5656
5661
 
5657
5662
  /**
@@ -5679,11 +5684,11 @@ function deploymentTargetName(target) {
5679
5684
  }
5680
5685
 
5681
5686
  /**
5682
- * Парсить `patch`-рядок як YAML-масив з рівно однією операцією JSON6902.
5687
+ * Парсить `patch`-рядок як YAML-масив JSON6902-операцій (≥ 1 елемент).
5683
5688
  * @param {string} patch текст YAML-масиву JSON6902-операцій
5684
- * @returns {unknown[] | null} масив однієї операції або null
5689
+ * @returns {unknown[] | null} масив операцій або null
5685
5690
  */
5686
- function tryParseSingleJson6902Array(patch) {
5691
+ function tryParseJson6902Array(patch) {
5687
5692
  let parsedArr
5688
5693
  try {
5689
5694
  for (const d of parseAllDocuments(patch.trim())) {
@@ -5697,7 +5702,7 @@ function tryParseSingleJson6902Array(patch) {
5697
5702
  } catch {
5698
5703
  return null
5699
5704
  }
5700
- return Array.isArray(parsedArr) && parsedArr.length === 1 ? parsedArr : null
5705
+ return Array.isArray(parsedArr) && parsedArr.length >= 1 ? parsedArr : null
5701
5706
  }
5702
5707
 
5703
5708
  /**
@@ -5880,11 +5885,17 @@ export async function convertImagePatchesToImagesInKustomization(kustAbs, rootNo
5880
5885
 
5881
5886
  /**
5882
5887
  * Парсить kustomization.yaml як Document і повертає його разом зі списком кандидатів-патчів
5883
- * (`patches[i]` що відповідає `image-replace` для Deployment). Повертає null, якщо документ не
5884
- * розпарсився, не є Kustomization або не має масиву `patches:`.
5888
+ * (по одному кандидату на кожну image-replace op у `patches[i].patch` патч може містити кілька).
5889
+ * Повертає null, якщо документ не розпарсився, не є Kustomization або не має масиву `patches:`.
5885
5890
  * @param {string} raw текст файлу
5886
- * @returns {{ doc: ReturnType<typeof parseDocument>, candidates: Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }> } | null}
5887
- * document і список кандидатів, або null
5891
+ * @returns {{
5892
+ * doc: ReturnType<typeof parseDocument>,
5893
+ * candidates: Array<{
5894
+ * index: number,
5895
+ * totalOps: number,
5896
+ * info: { deployName: string, containerIndex: number, newImage: string, opIndex: number }
5897
+ * }>
5898
+ * } | null} document і список кандидатів, або null
5888
5899
  */
5889
5900
  function parseKustomizationWithPatches(raw) {
5890
5901
  let doc
@@ -5901,11 +5912,23 @@ function parseKustomizationWithPatches(raw) {
5901
5912
  if (typeof rec.apiVersion !== 'string' || !rec.apiVersion.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
5902
5913
  if (!Array.isArray(rec.patches)) return null
5903
5914
 
5904
- /** @type {Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }>} */
5915
+ /** @type {Array<{ index: number, totalOps: number, info: { deployName: string, containerIndex: number, newImage: string, opIndex: number } }>} */
5905
5916
  const candidates = []
5906
5917
  for (const [i, p] of rec.patches.entries()) {
5907
5918
  const info = imageReplaceDeploymentPatchInfo(p)
5908
- if (info !== null) candidates.push({ index: i, info })
5919
+ if (info === null) continue
5920
+ for (const op of info.ops) {
5921
+ candidates.push({
5922
+ index: i,
5923
+ totalOps: info.totalOps,
5924
+ info: {
5925
+ deployName: info.deployName,
5926
+ containerIndex: op.containerIndex,
5927
+ newImage: op.newImage,
5928
+ opIndex: op.opIndex
5929
+ }
5930
+ })
5931
+ }
5909
5932
  }
5910
5933
  return { doc, candidates }
5911
5934
  }
@@ -5915,17 +5938,17 @@ function parseKustomizationWithPatches(raw) {
5915
5938
  * (або повідомлення про помилку, чому конвертація неможлива).
5916
5939
  * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5917
5940
  * @param {string} rootNorm нормалізований корінь репо
5918
- * @param {Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }>} candidates кандидати з `patches[]`
5919
- * @returns {Promise<{ conversions: Array<{ index: number, name: string, newName: string, newTag: string | null }>, errors: string[] }>}
5941
+ * @param {Array<{ index: number, totalOps: number, info: { deployName: string, containerIndex: number, newImage: string, opIndex: number } }>} candidates кандидати з `patches[]`
5942
+ * @returns {Promise<{ conversions: Array<{ index: number, opIndex: number, totalOps: number, name: string, newName: string, newTag: string | null }>, errors: string[] }>}
5920
5943
  * результати конвертації та зібрані нефатальні помилки
5921
5944
  */
5922
5945
  async function buildPatchToImageConversions(kustAbs, rootNorm, candidates) {
5923
- /** @type {Array<{ index: number, name: string, newName: string, newTag: string | null }>} */
5946
+ /** @type {Array<{ index: number, opIndex: number, totalOps: number, name: string, newName: string, newTag: string | null }>} */
5924
5947
  const conversions = []
5925
5948
  /** @type {string[]} */
5926
5949
  const errors = []
5927
5950
 
5928
- for (const { index, info } of candidates) {
5951
+ for (const { index, totalOps, info } of candidates) {
5929
5952
  const baseImage = await walkKustomizationForDeploymentImage(
5930
5953
  kustAbs,
5931
5954
  rootNorm,
@@ -5934,7 +5957,7 @@ async function buildPatchToImageConversions(kustAbs, rootNorm, candidates) {
5934
5957
  new Set()
5935
5958
  )
5936
5959
  const conversion = buildConversionForCandidate(index, info, baseImage, errors)
5937
- if (conversion !== null) conversions.push(conversion)
5960
+ if (conversion !== null) conversions.push({ ...conversion, opIndex: info.opIndex, totalOps })
5938
5961
  }
5939
5962
 
5940
5963
  return { conversions, errors }
@@ -5944,7 +5967,7 @@ async function buildPatchToImageConversions(kustAbs, rootNorm, candidates) {
5944
5967
  * Будує одну конвертацію `patches[index]` → `images[]` запис з відповідним `newTag`.
5945
5968
  * Якщо щось не так (немає baseImage, digest у base/new) — додає текст у `errors` і повертає null.
5946
5969
  * @param {number} index індекс патча в `patches[]`
5947
- * @param {{ deployName: string, containerIndex: number, newImage: string }} info результат `imageReplaceDeploymentPatchInfo`
5970
+ * @param {{ deployName: string, containerIndex: number, newImage: string, opIndex: number }} info один із записів `imageReplaceDeploymentPatchInfo().ops` (плюс `deployName` патча)
5948
5971
  * @param {string | null} baseImage знайдений базовий image або null
5949
5972
  * @param {string[]} errors буфер нефатальних помилок (мутується)
5950
5973
  * @returns {{ index: number, name: string, newName: string, newTag: string | null } | null} запис конвертації або null
@@ -5975,19 +5998,43 @@ function buildConversionForCandidate(index, info, baseImage, errors) {
5975
5998
  }
5976
5999
 
5977
6000
  /**
5978
- * Видаляє конвертовані елементи з `patches:` (за потреби і сам ключ) і дописує `images:`.
6001
+ * Застосовує конвертації до Document: для кожного `patches[i]` або видаляє патч цілком (коли всі
6002
+ * його ops конвертовано), або переписує inline `patch:`, лишаючи решту ops без коментарів.
6003
+ * Допише `images:` з усіма конвертованими записами.
5979
6004
  * @param {ReturnType<typeof parseDocument>} doc документ kustomization.yaml
5980
- * @param {Array<{ index: number, name: string, newName: string, newTag: string | null }>} conversions конвертації
6005
+ * @param {Array<{ index: number, opIndex: number, totalOps: number, name: string, newName: string, newTag: string | null }>} conversions конвертації
5981
6006
  * @returns {boolean} true, якщо мутації відбулися (документ можна серіалізувати)
5982
6007
  */
5983
6008
  function applyConversionsToDoc(doc, conversions) {
5984
6009
  const patchesNode = doc.get('patches', true)
5985
6010
  if (!isSeq(patchesNode)) return false
5986
6011
 
5987
- const removeIdx = new Set(conversions.map(c => c.index))
5988
- for (let i = patchesNode.items.length - 1; i >= 0; i--) {
5989
- if (removeIdx.has(i)) patchesNode.delete(i)
6012
+ /** @type {Map<number, { totalOps: number, opIdx: number[] }>} */
6013
+ const byPatch = new Map()
6014
+ for (const c of conversions) {
6015
+ const slot = byPatch.get(c.index) ?? { totalOps: c.totalOps, opIdx: [] }
6016
+ slot.opIdx.push(c.opIndex)
6017
+ byPatch.set(c.index, slot)
6018
+ }
6019
+
6020
+ const sortedIdx = [...byPatch.keys()].sort((a, b) => b - a)
6021
+ for (const i of sortedIdx) {
6022
+ const slot = byPatch.get(i)
6023
+ if (slot === undefined) continue
6024
+ const { totalOps, opIdx } = slot
6025
+ if (opIdx.length === totalOps) {
6026
+ patchesNode.delete(i)
6027
+ continue
6028
+ }
6029
+ const patchEntry = patchesNode.get(i, true)
6030
+ if (patchEntry === undefined || patchEntry === null) continue
6031
+ const patchScalar = patchEntry.get('patch', true)
6032
+ if (patchScalar === undefined || patchScalar === null || typeof patchScalar.value !== 'string') continue
6033
+ const rewritten = rewriteInlinePatchWithoutOps(patchScalar.value, opIdx)
6034
+ if (rewritten === null) continue
6035
+ patchScalar.value = rewritten
5990
6036
  }
6037
+
5991
6038
  if (patchesNode.items.length === 0) {
5992
6039
  doc.delete('patches')
5993
6040
  }
@@ -6004,6 +6051,35 @@ function applyConversionsToDoc(doc, conversions) {
6004
6051
  return true
6005
6052
  }
6006
6053
 
6054
+ /**
6055
+ * Видаляє ops за списком індексів з inline `patch:` (текст YAML-масиву JSON6902-ops)
6056
+ * і повертає переписаний текст. Зберігає block-style. Повертає null, якщо не вдалося розпарсити
6057
+ * або після видалення не лишилось ops.
6058
+ * @param {string} patchText текст YAML-масиву ops (literal block scalar)
6059
+ * @param {number[]} opIndices індекси ops, які треба видалити
6060
+ * @returns {string | null} переписаний текст або null
6061
+ */
6062
+ function rewriteInlinePatchWithoutOps(patchText, opIndices) {
6063
+ let inner
6064
+ try {
6065
+ inner = parseDocument(patchText)
6066
+ } catch {
6067
+ return null
6068
+ }
6069
+ if (inner.errors.length > 0) return null
6070
+ const seq = inner.contents
6071
+ if (!isSeq(seq)) return null
6072
+
6073
+ const toRemove = [...new Set(opIndices)].sort((a, b) => b - a)
6074
+ for (const i of toRemove) {
6075
+ if (i < 0 || i >= seq.items.length) return null
6076
+ seq.delete(i)
6077
+ }
6078
+ if (seq.items.length === 0) return null
6079
+ seq.flow = false
6080
+ return inner.toString().replace(/\n+$/u, '')
6081
+ }
6082
+
6007
6083
  /**
6008
6084
  * Прохід для всіх `kustomization.yaml`: конвертує image-replace patches у `images:`,
6009
6085
  * потім чистить `images:` (зрізає теги в `name`, видаляє надлишкові `newTag`).
@@ -27,16 +27,26 @@ import { resolveCmd } from './utils/resolve-cmd.mjs'
27
27
  /** Каталог пакету `@nitra/cursor`, від якого ресолвимо вшиту директорію policy/. */
28
28
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
29
29
 
30
- /** Шлях до Rego-полісі (PoC: лише clean-ga-workflows). У npm-tarball публікується через `files` у package.json. */
30
+ /** Шлях до кореня Rego-полісі для GA. У npm-tarball публікується через `files: ["policy"]` у package.json. */
31
31
  const GA_POLICY_DIR = join(PACKAGE_ROOT, 'policy', 'ga')
32
32
 
33
33
  /**
34
- * Workflow-файли, для яких маємо відповідну Rego-полісі. PoC: один файл; інші підтягуватимемо в міру міграції
35
- * перевірок із `npm/scripts/check-ga.mjs`.
36
- * @type {Array<{ workflow: string, label: string }>}
34
+ * Workflow-файли, для яких маємо відповідну Rego-полісі. Кожен таргет посилається на під-пакет
35
+ * `ga.<name>` у `policy/ga/<name>/<name>.rego`; conftest викликаємо з `--namespace`, щоб правила
36
+ * іншого workflow не застосовувалися до чужого файлу.
37
+ * @type {Array<{ workflow: string, namespace: string, label: string }>}
37
38
  */
38
39
  const CONFTEST_TARGETS = [
39
- { workflow: '.github/workflows/clean-ga-workflows.yml', label: 'clean-ga-workflows.yml structure' }
40
+ {
41
+ workflow: '.github/workflows/clean-ga-workflows.yml',
42
+ namespace: 'ga.clean_ga_workflows',
43
+ label: 'clean-ga-workflows.yml structure'
44
+ },
45
+ {
46
+ workflow: '.github/workflows/clean-merged-branch.yml',
47
+ namespace: 'ga.clean_merged_branch',
48
+ label: 'clean-merged-branch.yml structure'
49
+ }
40
50
  ]
41
51
 
42
52
  /**
@@ -218,6 +228,8 @@ function runConftestStep() {
218
228
  target.workflow,
219
229
  '-p',
220
230
  GA_POLICY_DIR,
231
+ '--namespace',
232
+ target.namespace,
221
233
  '--no-color'
222
234
  ])
223
235
  if (code !== 0) return code