@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 +25 -0
- package/bin/n-cursor.js +3 -3
- package/mdc/js-lint.mdc +3 -3
- package/mdc/k8s.mdc +2 -2
- package/package.json +1 -1
- package/policy/ga/{clean-ga-workflows.rego → clean_ga_workflows/clean_ga_workflows.rego} +49 -46
- package/policy/ga/clean_merged_branch/clean_merged_branch.rego +167 -0
- package/scripts/check-ga.mjs +4 -224
- package/scripts/check-js-lint.mjs +10 -8
- package/scripts/check-k8s.mjs +111 -35
- package/scripts/lint-ga.mjs +17 -5
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 (
|
|
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: { ...
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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,26 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Порт перевірки `validateCleanGaWorkflows` з `npm/scripts/check-ga.mjs` (ga.mdc).
|
|
2
2
|
#
|
|
3
3
|
# Запуск (локально):
|
|
4
|
-
# conftest test .github/workflows/clean-ga-workflows.yml
|
|
4
|
+
# conftest test .github/workflows/clean-ga-workflows.yml \
|
|
5
|
+
# -p npm/policy/ga --namespace ga.clean_ga_workflows
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
package
|
|
11
|
+
# Усі `deny`-правила йдуть контигно (regal: messy-rule); helpers і константи —
|
|
12
|
+
# секціями вище та нижче.
|
|
13
|
+
package ga.clean_ga_workflows
|
|
12
14
|
|
|
13
15
|
import rego.v1
|
|
14
16
|
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# `
|
|
18
|
-
#
|
|
19
|
-
|
|
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
|
-
#
|
|
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
|
+
}
|
package/scripts/check-ga.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
58
|
-
* (заборона `for...in` через `no-restricted-syntax` +
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -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
|
|
5630
|
-
*
|
|
5629
|
+
* Якщо `patchObj` — JSON6902 для `kind: Deployment`, повертає всі image-replace ops
|
|
5630
|
+
* у його `patch:` разом із `opIndex` (позиція в масиві ops) і `totalOps` (загальна довжина).
|
|
5631
5631
|
* @param {unknown} patchObj елемент масиву `patches[]`
|
|
5632
|
-
* @returns {{
|
|
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 =
|
|
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
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
containerIndex
|
|
5653
|
-
|
|
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-масив
|
|
5687
|
+
* Парсить `patch`-рядок як YAML-масив JSON6902-операцій (≥ 1 елемент).
|
|
5683
5688
|
* @param {string} patch текст YAML-масиву JSON6902-операцій
|
|
5684
|
-
* @returns {unknown[] | null} масив
|
|
5689
|
+
* @returns {unknown[] | null} масив операцій або null
|
|
5685
5690
|
*/
|
|
5686
|
-
function
|
|
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
|
|
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
|
-
* (
|
|
5884
|
-
* розпарсився, не є Kustomization або не має масиву `patches:`.
|
|
5888
|
+
* (по одному кандидату на кожну image-replace op у `patches[i].patch` — патч може містити кілька).
|
|
5889
|
+
* Повертає null, якщо документ не розпарсився, не є Kustomization або не має масиву `patches:`.
|
|
5885
5890
|
* @param {string} raw текст файлу
|
|
5886
|
-
* @returns {{
|
|
5887
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
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`).
|
package/scripts/lint-ga.mjs
CHANGED
|
@@ -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-полісі
|
|
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-полісі.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
{
|
|
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
|