@nitra/cursor 1.8.204 → 1.8.206

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,28 @@
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.206] - 2026-05-08
8
+
9
+ ### Added
10
+
11
+ - `mdc/rego.mdc` (нова версія 1.1, з 1.0): VS Code-секція з рекомендованим розширенням `tsandall.opa` (LSP від автора OPA: підсвічування, hover, go-to-definition, format-on-save через `opa fmt`), `.vscode/extensions.json` і `.vscode/settings.json` сніпети для `[rego]` (`editor.defaultFormatter: tsandall.opa`, `formatOnSave: true`); опис кроків `lint-rego` (preflight `opa`+`regal`, далі `opa check --strict` і `regal lint`); `package.json`-сніпет зі скриптом `lint-rego`; install-команди (`brew install opa regal` + universal лінки); приклад `.regal/config.yaml`. Раніше файл містив лише placeholder `npx @nitra/cursor check rego`.
12
+
13
+ ### Changed
14
+
15
+ - `scripts/lint-rego.mjs`: додано preflight на `opa` (поряд з `regal`) з install-hint `brew install opa` і покликом до VS Code-розширення `tsandall.opa`; до `regal lint` додано попередній крок `opa check --strict <targets>` (типи + строгий режим: мертвий код, неоднозначні правила, незадекларовані змінні) — `opa check` ловить compile-помилки, які `regal` навмисно лишає поза скоупом. Якщо хоч один з `opa`/`regal` відсутній у `PATH` — exit 1 ще до запуску, з підказкою встановлення для обох.
16
+
17
+ ## [1.8.205] - 2026-05-08
18
+
19
+ ### Added
20
+
21
+ - `npm/policy/ga/lint_ga/lint_ga.rego` — порт `validateLintGaWorkflowStructure` + `validateLintGaOnTriggers`: `name` / `on.push.branches∋{dev,main}` / `on.pull_request.branches∋{dev,main}` / `on.push.paths∋{.github/actions/**,.github/workflows/**}` / `concurrency` / `jobs.lint-ga.runs-on` / `jobs.lint-ga.permissions.contents=read` / `steps` non-empty / `uses` set містить `actions/checkout@v6`, `./.github/actions/setup-bun-deps`, `astral-sh/setup-uv@v8.0.0` / `run` blob містить `bun run lint-ga`.
22
+ - `npm/policy/ga/git_ai/git_ai.rego` — порт `validateGitAiWorkflowStructure`: `name` / `on.pull_request.types∋closed` / `concurrency` / `jobs.git-ai.if` містить `merged == true` / `permissions.contents=write` / `run` blob містить `curl … usegitai.com … bash` і `git-ai ci github run`.
23
+
24
+ ### Changed
25
+
26
+ - `scripts/lint-ga.mjs`: `CONFTEST_TARGETS` тепер охоплює всі 4 канонічні GA-workflow — `clean-ga-workflows.yml`, `clean-merged-branch.yml`, `lint-ga.yml`, `git-ai.yml` — кожен зі своїм `--namespace ga.<name>`.
27
+ - `scripts/check-ga.mjs`: видалено `validateLintGaWorkflowStructure`, `validateLintGaOnTriggers`, `validateGitAiWorkflowStructure`, `validateGitAiParsedYaml`, `hasPullRequestClosedTrigger`, `hasJobMergedCondition`, `checkLintGaWorkflow`, `checkGitAiWorkflow`, `checkCanonicalWorkflowsMatchRule`, локальний `isExactString` і відповідні імпорти `anyRunStepIncludes`/`flattenWorkflowSteps`/`getStepRun`/`getStepUses`. Файл скоротився з 1074 → 570 рядків (≈47%) — структурні перевірки канонічних GA-workflow повністю мігрували в conftest. У JS лишилися: file-existence (zizmor.yml, .vscode/settings.json, setup-bun-deps), `package.json` script `lint-ga`, MegaLinter-зачистка, `verifyConcurrencyBlock` для всіх workflow без винятків (включно з не-канонічними), `verifyNoDirectBunOrCache`, `verifyCheckoutBeforeLocalSetupBunDeps`, paths-globs через `git ls-files`, preflight `shellcheck`.
28
+
7
29
  ## [1.8.204] - 2026-05-07
8
30
 
9
31
  ### Changed
@@ -120,7 +142,7 @@
120
142
  ### Added
121
143
 
122
144
  - `run-shellcheck-text.mjs`: для `lint-text` — перевірка наявності `shellcheck`/`patch`, авто-виправлення через `shellcheck -f diff` + `patch -p1`, фінальний прогін по tracked `*.sh` (git) або `**/*.sh` без `node_modules`.
123
- - `text` (mdc v1.25 → v1.26): **shellcheck** у ланцюжку `lint-text`, рекомендація **`timonwong.shellcheck`**, тригер workflow **`**/*.sh`**; тести `run-shellcheck-text.test.mjs`.
145
+ - `text` (mdc v1.25 → v1.26): **shellcheck** у ланцюжку `lint-text`, рекомендація **`timonwong.shellcheck`**, тригер workflow **`**/\*.sh`**; тести `run-shellcheck-text.test.mjs`.
124
146
 
125
147
  ### Changed
126
148
 
package/bin/auto-rules.md CHANGED
@@ -42,6 +42,8 @@ npm-module - якщо в корені присутня директорія npm
42
42
 
43
43
  php - якщо в корені є composer.json
44
44
 
45
+ rego - якщо в проекті є хоч один rego
46
+
45
47
  style-lint - якщо присутній хоч один vue або css файл
46
48
 
47
49
  text - завжди
package/mdc/rego.mdc ADDED
@@ -0,0 +1,77 @@
1
+ ---
2
+ description: Opa, Rego — інструментарій (VS Code, lint-rego)
3
+ version: '1.1'
4
+ globs: "**/*.rego"
5
+ alwaysApply: false
6
+ ---
7
+
8
+ # Rego (opa)
9
+
10
+ Синтаксичні правила (`rego.v1`, `import rego.v1`, заборона legacy v0) — у `conftest.mdc` (alwaysApply). Цей файл — про **інструментарій**: VS Code, лінтери, форматування.
11
+
12
+ ## VS Code
13
+
14
+ Розширення `tsandall.opa` (від автора OPA): підсвічування, hover, go-to-definition, оцінка виразів і `format-on-save` через `opa fmt`. Працює лише за наявності `opa` у `PATH` — встановити нижче.
15
+
16
+ ```json title=".vscode/extensions.json"
17
+ {
18
+ "recommendations": ["tsandall.opa"]
19
+ }
20
+ ```
21
+
22
+ ```json title=".vscode/settings.json"
23
+ {
24
+ "[rego]": {
25
+ "editor.defaultFormatter": "tsandall.opa",
26
+ "editor.formatOnSave": true
27
+ }
28
+ }
29
+ ```
30
+
31
+ `opa.checkOnSave` за замовчуванням увімкнено в розширенні — діагностика від `opa check` показується в редакторі, тож синтаксичні/типові помилки видно одразу, без запуску `lint-rego`.
32
+
33
+ ## Перевірка
34
+
35
+ ```bash
36
+ bun run lint-rego
37
+ ```
38
+
39
+ Скрипт делегує до `bun ./npm/scripts/lint-rego.mjs`. Послідовність:
40
+
41
+ 1. **preflight** — наявність `opa` і `regal` у `PATH`; якщо хоча б одного нема — exit 1 з підказкою встановлення;
42
+ 2. `opa check --strict <targets>` — компіляція з типами та `--strict` (мертвий код, неоднозначні правила, незадекларовані змінні);
43
+ 3. `regal lint <targets>` — статичний лінтер Rego ([Styra Regal](https://docs.styra.com/regal)): ловить v0-синтаксис, неявні set-rules і відхилення від `rego.v1`, плюс bugs/idiomatic/style-правила.
44
+
45
+ Цілі — `npm/policy/` (де живуть Rego-полісі пакета). Інші *.rego поза деревом додай у `LINT_TARGETS` у `lint-rego.mjs`.
46
+
47
+ ### Встановлення інструментів
48
+
49
+ - macOS: `brew install opa regal`
50
+ - Linux/Windows:
51
+ - opa — <https://www.openpolicyagent.org/docs/latest/#1-download-opa>
52
+ - regal — <https://docs.styra.com/regal#installation>
53
+
54
+ Обидва — лише в `PATH`, **не** додавай у `dependencies` / `devDependencies` (як `shellcheck` у `text.mdc`).
55
+
56
+ ### `package.json`
57
+
58
+ ```json title="package.json"
59
+ {
60
+ "scripts": {
61
+ "lint-rego": "bun ./npm/scripts/lint-rego.mjs"
62
+ }
63
+ }
64
+ ```
65
+
66
+ У кореневому `lint` (з `text.mdc` і дотичних) включай `bun run lint-rego` — щоб локальний прогін співпадав з CI.
67
+
68
+ ## Конфіг regal
69
+
70
+ У корені — `.regal/config.yaml`. Дозволено вимикати окремі правила під специфіку репо (наприклад, conftest-полісі — `deny`-правила як де-факто entrypoint-и):
71
+
72
+ ```yaml title=".regal/config.yaml"
73
+ rules:
74
+ idiomatic:
75
+ no-defined-entrypoint:
76
+ level: ignore
77
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.204",
3
+ "version": "1.8.206",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,109 @@
1
+ # Порт перевірки `validateGitAiWorkflowStructure` з `npm/scripts/check-ga.mjs` (ga.mdc).
2
+ #
3
+ # Запуск (локально):
4
+ # conftest test .github/workflows/git-ai.yml \
5
+ # -p npm/policy/ga --namespace ga.git_ai
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.git_ai
11
+
12
+ import rego.v1
13
+
14
+ # ── Очікувані значення ─────────────────────────────────────────────────────
15
+
16
+ expected_concurrency_group := concat("", ["$", "{{ github.ref }}-$", "{{ github.workflow }}"])
17
+
18
+ expected_name := "Git AI"
19
+
20
+ expected_if_substring := "github.event.pull_request.merged == true"
21
+
22
+ expected_install_substring := "curl -fsSL https://usegitai.com/install.sh | bash"
23
+
24
+ expected_run_substring := "git-ai ci github run"
25
+
26
+ # Шаблон повідомлення про відсутню `concurrency`-секцію — через `concat` для
27
+ # regal style/line-length.
28
+ concurrency_missing_template := concat(" ", [
29
+ "git-ai.yml: відсутня секція concurrency —",
30
+ "додай concurrency.group: %s і cancel-in-progress: true (ga.mdc)",
31
+ ])
32
+
33
+ # ── Аліаси на input ────────────────────────────────────────────────────────
34
+ #
35
+ # YAML 1.1 quirk: `on:` → boolean true → у конфтесті ключ "true".
36
+
37
+ gha_on := input["true"]
38
+
39
+ # Job-id містить дефіс — звертаємося через `[…]`. Імʼя `job` (без префіксу пакету)
40
+ # — щоб уникнути regal-правила `rule-name-repeats-package`.
41
+ job := input.jobs["git-ai"]
42
+
43
+ # Усі `run:` зі steps цього job-а, склеєні в один blob — для substring-перевірки.
44
+ job_run_blob := concat("\n", [run |
45
+ run := job.steps[_].run
46
+ ])
47
+
48
+ # ── deny rules (контигно — regal: messy-rule) ──────────────────────────────
49
+
50
+ deny contains msg if {
51
+ input.name != expected_name
52
+ msg := sprintf("git-ai.yml: name має бути %q (ga.mdc)", [expected_name])
53
+ }
54
+
55
+ deny contains msg if {
56
+ not "closed" in {t | some t in gha_on.pull_request.types}
57
+ msg := "git-ai.yml: on.pull_request.types має містити closed (ga.mdc)"
58
+ }
59
+
60
+ deny contains msg if {
61
+ not is_object(input.concurrency)
62
+ msg := sprintf(concurrency_missing_template, [expected_concurrency_group])
63
+ }
64
+
65
+ deny contains msg if {
66
+ is_object(input.concurrency)
67
+ input.concurrency.group != expected_concurrency_group
68
+ msg := sprintf("git-ai.yml: concurrency.group має бути %s (ga.mdc)", [expected_concurrency_group])
69
+ }
70
+
71
+ deny contains msg if {
72
+ is_object(input.concurrency)
73
+ input.concurrency["cancel-in-progress"] != true
74
+ msg := "git-ai.yml: concurrency.cancel-in-progress має бути true (ga.mdc)"
75
+ }
76
+
77
+ deny contains msg if {
78
+ not job
79
+ msg := "git-ai.yml: jobs.git-ai відсутній (ga.mdc)"
80
+ }
81
+
82
+ deny contains msg if {
83
+ not contains(job_if_str, expected_if_substring)
84
+ msg := "git-ai.yml: job має містити if: github.event.pull_request.merged == true (ga.mdc)"
85
+ }
86
+
87
+ deny contains msg if {
88
+ job.permissions.contents != "write"
89
+ msg := "git-ai.yml: permissions мають бути contents: write (ga.mdc)"
90
+ }
91
+
92
+ deny contains msg if {
93
+ not contains(job_run_blob, expected_install_substring)
94
+ msg := "git-ai.yml: має встановлювати git-ai через curl | bash (ga.mdc)"
95
+ }
96
+
97
+ deny contains msg if {
98
+ not contains(job_run_blob, expected_run_substring)
99
+ msg := "git-ai.yml: має виконувати git-ai ci github run (ga.mdc)"
100
+ }
101
+
102
+ # ── helpers ────────────────────────────────────────────────────────────────
103
+
104
+ # `if` поле job-а може бути відсутнім — тоді `sprintf` дає невизначене значення
105
+ # і спрацьовує `default`, повертаючи порожній рядок; `contains(…)` нижче дасть
106
+ # false і відповідне `deny`-правило спрацює зі зрозумілим повідомленням.
107
+ default job_if_str := ""
108
+
109
+ job_if_str := sprintf("%v", [job.if])
@@ -0,0 +1,144 @@
1
+ # Порт перевірок `validateLintGaWorkflowStructure` + `validateLintGaOnTriggers` з
2
+ # `npm/scripts/check-ga.mjs` (ga.mdc).
3
+ #
4
+ # Запуск (локально):
5
+ # conftest test .github/workflows/lint-ga.yml \
6
+ # -p npm/policy/ga --namespace ga.lint_ga
7
+ #
8
+ # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
9
+ # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`
10
+ # (.cursor/rules/conftest.mdc). Лінт — `bun run lint-rego` (regal).
11
+ package ga.lint_ga
12
+
13
+ import rego.v1
14
+
15
+ # ── Очікувані значення ─────────────────────────────────────────────────────
16
+
17
+ expected_concurrency_group := concat("", ["$", "{{ github.ref }}-$", "{{ github.workflow }}"])
18
+
19
+ expected_name := "Lint GA"
20
+
21
+ expected_branches := {"dev", "main"}
22
+
23
+ expected_push_paths := {".github/actions/**", ".github/workflows/**"}
24
+
25
+ # Шаблон повідомлення про відсутню `concurrency`-секцію — через `concat` для
26
+ # regal style/line-length.
27
+ concurrency_missing_template := concat(" ", [
28
+ "lint-ga.yml: відсутня секція concurrency —",
29
+ "додай concurrency.group: %s і cancel-in-progress: true (ga.mdc)",
30
+ ])
31
+
32
+ # ── Аліаси на input ────────────────────────────────────────────────────────
33
+ #
34
+ # YAML 1.1 quirk: `on:` → boolean true → у конфтесті ключ "true".
35
+
36
+ gha_on := input["true"]
37
+
38
+ # Job-id містить дефіс — звертаємося через `[…]`. Імʼя `job` (без префіксу пакету)
39
+ # — щоб уникнути regal-правила `rule-name-repeats-package`.
40
+ job := input.jobs["lint-ga"]
41
+
42
+ # Усі `uses:` зі steps цього job-а — для перевірки членства.
43
+ job_uses_set contains job.steps[_].uses
44
+
45
+ # Усі `run:` зі steps цього job-а, склеєні в один blob — для substring-перевірки.
46
+ job_run_blob := concat("\n", [run |
47
+ run := job.steps[_].run
48
+ ])
49
+
50
+ # ── deny rules (контигно — regal: messy-rule) ──────────────────────────────
51
+
52
+ deny contains msg if {
53
+ input.name != expected_name
54
+ msg := sprintf("lint-ga.yml: name має бути %q (ga.mdc)", [expected_name])
55
+ }
56
+
57
+ deny contains msg if {
58
+ not push_branches_have_dev_and_main
59
+ msg := "lint-ga.yml: on.push.branches має містити dev і main (ga.mdc)"
60
+ }
61
+
62
+ deny contains msg if {
63
+ not pr_branches_have_dev_and_main
64
+ msg := "lint-ga.yml: on.pull_request.branches має містити dev і main (ga.mdc)"
65
+ }
66
+
67
+ deny contains msg if {
68
+ not push_paths_have_required
69
+ msg := "lint-ga.yml: on.push.paths має містити .github/actions/** і .github/workflows/** (ga.mdc)"
70
+ }
71
+
72
+ deny contains msg if {
73
+ not is_object(input.concurrency)
74
+ msg := sprintf(concurrency_missing_template, [expected_concurrency_group])
75
+ }
76
+
77
+ deny contains msg if {
78
+ is_object(input.concurrency)
79
+ input.concurrency.group != expected_concurrency_group
80
+ msg := sprintf("lint-ga.yml: concurrency.group має бути %s (ga.mdc)", [expected_concurrency_group])
81
+ }
82
+
83
+ deny contains msg if {
84
+ is_object(input.concurrency)
85
+ input.concurrency["cancel-in-progress"] != true
86
+ msg := "lint-ga.yml: concurrency.cancel-in-progress має бути true (ga.mdc)"
87
+ }
88
+
89
+ deny contains msg if {
90
+ not job
91
+ msg := "lint-ga.yml: jobs.lint-ga відсутній (ga.mdc)"
92
+ }
93
+
94
+ deny contains msg if {
95
+ job["runs-on"] != "ubuntu-latest"
96
+ msg := "lint-ga.yml: runs-on має бути ubuntu-latest (ga.mdc)"
97
+ }
98
+
99
+ deny contains msg if {
100
+ job.permissions.contents != "read"
101
+ msg := "lint-ga.yml: permissions мають бути contents: read (ga.mdc)"
102
+ }
103
+
104
+ deny contains msg if {
105
+ count(job.steps) == 0
106
+ msg := "lint-ga.yml: jobs.lint-ga.steps відсутні (ga.mdc)"
107
+ }
108
+
109
+ deny contains msg if {
110
+ not "actions/checkout@v6" in job_uses_set
111
+ msg := "lint-ga.yml: має бути uses: actions/checkout@v6 (ga.mdc)"
112
+ }
113
+
114
+ deny contains msg if {
115
+ not "./.github/actions/setup-bun-deps" in job_uses_set
116
+ msg := "lint-ga.yml: має бути uses: ./.github/actions/setup-bun-deps (ga.mdc)"
117
+ }
118
+
119
+ deny contains msg if {
120
+ not "astral-sh/setup-uv@v8.0.0" in job_uses_set
121
+ msg := "lint-ga.yml: має бути uses: astral-sh/setup-uv@v8.0.0 (ga.mdc)"
122
+ }
123
+
124
+ deny contains msg if {
125
+ not contains(job_run_blob, "bun run lint-ga")
126
+ msg := "lint-ga.yml: має бути крок run: bun run lint-ga (ga.mdc)"
127
+ }
128
+
129
+ # ── helpers ────────────────────────────────────────────────────────────────
130
+
131
+ push_branches_have_dev_and_main if {
132
+ branches := gha_on.push.branches
133
+ expected_branches & {b | some b in branches} == expected_branches
134
+ }
135
+
136
+ pr_branches_have_dev_and_main if {
137
+ branches := gha_on.pull_request.branches
138
+ expected_branches & {b | some b in branches} == expected_branches
139
+ }
140
+
141
+ push_paths_have_required if {
142
+ paths := gha_on.push.paths
143
+ expected_push_paths & {p | some p in paths} == expected_push_paths
144
+ }
@@ -41,6 +41,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
41
41
  'nginx-default-tpl',
42
42
  'npm-module',
43
43
  'php',
44
+ 'rego',
44
45
  'style-lint',
45
46
  'text',
46
47
  'vue'
@@ -104,6 +105,7 @@ export const AUTO_RULE_DEPENDENCIES = Object.freeze(
104
105
  const ABIE_REPOSITORY_URL_MARKER = 'https://github.com/abinbevefes/'
105
106
  const HASURA_CONFIG_MARKER = 'metadata_directory: metadata'
106
107
  const JS_LIKE_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx)$/iu
108
+ const REGO_RE = /\.rego$/iu
107
109
  const STYLE_RE = /\.(?:css|vue)$/iu
108
110
  const VUE_RE = /\.vue$/iu
109
111
  const NGINX_DEFAULT_FILES = new Set(['default.conf.template', 'default.conf', 'nginx.conf'])
@@ -287,6 +289,7 @@ function updateDirFacts(dirName, facts) {
287
289
  * hasDockerfile: boolean,
288
290
  * hasJsLikeSource: boolean,
289
291
  * hasNginxDefaultTplFile: boolean,
292
+ * hasRegoFile: boolean,
290
293
  * hasVueOrCssSource: boolean,
291
294
  * hasVueSource: boolean
292
295
  * }} facts агреговані факти
@@ -311,6 +314,9 @@ function updateFileFacts(fileName, relPath, facts) {
311
314
  if (STYLE_RE.test(relPath)) {
312
315
  facts.hasVueOrCssSource = true
313
316
  }
317
+ if (REGO_RE.test(relPath)) {
318
+ facts.hasRegoFile = true
319
+ }
314
320
  }
315
321
 
316
322
  /**
@@ -401,6 +407,7 @@ async function updateHasuraFactFromFile(absPath, fileName, facts) {
401
407
  * hasHasuraConfig: boolean,
402
408
  * hasJsLikeSource: boolean,
403
409
  * hasNginxDefaultTplFile: boolean,
410
+ * hasRegoFile: boolean,
404
411
  * hasVueOrCssSource: boolean,
405
412
  * hasVueSource: boolean
406
413
  * }} facts агреговані факти
@@ -489,6 +496,7 @@ export function isMonorepoPackage(packageJson) {
489
496
  * hasJsLikeSource: boolean,
490
497
  * hasK8sDir: boolean,
491
498
  * hasNginxDefaultTplFile: boolean,
499
+ * hasRegoFile: boolean,
492
500
  * hasTempoDir: boolean,
493
501
  * hasVueSource: boolean,
494
502
  * hasVueOrCssSource: boolean
@@ -505,6 +513,7 @@ export async function collectAutoRuleFacts(root) {
505
513
  hasJsLikeSource: false,
506
514
  hasK8sDir: false,
507
515
  hasNginxDefaultTplFile: false,
516
+ hasRegoFile: false,
508
517
  hasTempoDir: false,
509
518
  hasVueSource: false,
510
519
  hasVueOrCssSource: false
@@ -650,6 +659,7 @@ export async function detectAutoRulesAndSkills({
650
659
  { enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
651
660
  { enabled: npmDirExists, id: 'npm-module' },
652
661
  { enabled: composerJsonExists, id: 'php' },
662
+ { enabled: facts.hasRegoFile, id: 'rego' },
653
663
  { enabled: facts.hasVueOrCssSource, id: 'style-lint' }
654
664
  ]
655
665
  for (const item of autoRuleChecks) {
@@ -68,7 +68,10 @@ async function checkHookScript(reporter) {
68
68
  fail(`канонічний скрипт у пакеті не знайдено: ${BUNDLED_HOOK_PATH} — перевстанови @nitra/cursor`)
69
69
  return
70
70
  }
71
- const [project, bundled] = await Promise.all([readFile(PROJECT_HOOK_PATH, 'utf8'), readFile(BUNDLED_HOOK_PATH, 'utf8')])
71
+ const [project, bundled] = await Promise.all([
72
+ readFile(PROJECT_HOOK_PATH, 'utf8'),
73
+ readFile(BUNDLED_HOOK_PATH, 'utf8')
74
+ ])
72
75
  if (project === bundled) {
73
76
  pass(`${PROJECT_HOOK_PATH} збігається з канонічним`)
74
77
  } else {
@@ -24,15 +24,11 @@ import { join } from 'node:path'
24
24
 
25
25
  import { createCheckReporter } from './utils/check-reporter.mjs'
26
26
  import {
27
- anyRunStepIncludes,
28
27
  eventPathsIncludeExact,
29
28
  findForbiddenUsesOrRunPatterns,
30
29
  findRunStepsWithShellLineContinuationBackslash,
31
30
  hasAnyStepUsesContaining,
32
31
  hasCheckoutBeforeLocalSetupBunDeps,
33
- flattenWorkflowSteps,
34
- getStepRun,
35
- getStepUses,
36
32
  parseWorkflowYaml
37
33
  } from './utils/gha-workflow.mjs'
38
34
  import { resolveCmd } from './utils/resolve-cmd.mjs'
@@ -160,156 +156,6 @@ function getObjKey(obj, key) {
160
156
  : undefined
161
157
  }
162
158
 
163
- /**
164
- * Очікує, що значення є рядком рівно `expected`.
165
- * @param {unknown} v значення
166
- * @param {string} expected очікуваний рядок
167
- * @returns {boolean} true, якщо збігається
168
- */
169
- function isExactString(v, expected) {
170
- return typeof v === 'string' && v === expected
171
- }
172
-
173
- /**
174
- * Перевіряє тригери `on.push` / `on.pull_request` у `lint-ga.yml`.
175
- * @param {unknown} on корінь `on:` з YAML
176
- * @param {(msg: string) => void} failFn fail
177
- */
178
- function validateLintGaOnTriggers(on, failFn) {
179
- const push = getObjKey(on, 'push')
180
- const pr = getObjKey(on, 'pull_request')
181
- const pushBranches = getObjKey(push, 'branches')
182
- const pushPaths = getObjKey(push, 'paths')
183
- const prBranches = getObjKey(pr, 'branches')
184
-
185
- if (!Array.isArray(pushBranches) || !(pushBranches.includes('dev') && pushBranches.includes('main'))) {
186
- failFn('lint-ga.yml: on.push.branches має містити dev і main (ga.mdc)')
187
- }
188
- if (!Array.isArray(prBranches) || !(prBranches.includes('dev') && prBranches.includes('main'))) {
189
- failFn('lint-ga.yml: on.pull_request.branches має містити dev і main (ga.mdc)')
190
- }
191
- if (
192
- !Array.isArray(pushPaths) ||
193
- !(pushPaths.includes('.github/actions/**') && pushPaths.includes('.github/workflows/**'))
194
- ) {
195
- failFn('lint-ga.yml: on.push.paths має містити .github/actions/** і .github/workflows/** (ga.mdc)')
196
- }
197
- }
198
-
199
- /**
200
- * Перевіряє структуру workflow `lint-ga.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 validateLintGaWorkflowStructure(root, passFn, failFn) {
206
- if (!root) {
207
- failFn('lint-ga.yml: YAML не вдалося розібрати (ga.mdc)')
208
- return
209
- }
210
-
211
- if (!isExactString(root.name, 'Lint GA')) {
212
- failFn('lint-ga.yml: name має бути "Lint GA" (ga.mdc)')
213
- }
214
-
215
- validateLintGaOnTriggers(root.on, failFn)
216
-
217
- validateConcurrencyOnRoot('lint-ga.yml', root, passFn, failFn)
218
-
219
- const jobs = getObjKey(root, 'jobs')
220
- const job = getObjKey(jobs, 'lint-ga')
221
- if (!job) {
222
- failFn('lint-ga.yml: jobs.lint-ga відсутній (ga.mdc)')
223
- return
224
- }
225
-
226
- if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
227
- failFn('lint-ga.yml: runs-on має бути ubuntu-latest (ga.mdc)')
228
- }
229
- const perm = getObjKey(job, 'permissions')
230
- if (getObjKey(perm, 'contents') !== 'read') {
231
- failFn('lint-ga.yml: permissions мають бути contents: read (ga.mdc)')
232
- }
233
-
234
- const steps = getObjKey(job, 'steps')
235
- if (!Array.isArray(steps) || steps.length === 0) {
236
- failFn('lint-ga.yml: jobs.lint-ga.steps відсутні (ga.mdc)')
237
- return
238
- }
239
-
240
- const flat = flattenWorkflowSteps(root)
241
- const usesList = new Set(flat.map(s => getStepUses(s.step)))
242
- const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
243
-
244
- if (!usesList.has('actions/checkout@v6')) {
245
- failFn('lint-ga.yml: має бути uses: actions/checkout@v6 (ga.mdc)')
246
- }
247
- if (!usesList.has('./.github/actions/setup-bun-deps')) {
248
- failFn('lint-ga.yml: має бути uses: ./.github/actions/setup-bun-deps (ga.mdc)')
249
- }
250
- if (!usesList.has('astral-sh/setup-uv@v8.0.0')) {
251
- failFn('lint-ga.yml: має бути uses: astral-sh/setup-uv@v8.0.0 (ga.mdc)')
252
- }
253
- if (runBlob.includes('bun run lint-ga')) {
254
- passFn('lint-ga.yml: структура jobs/steps OK')
255
- } else {
256
- failFn('lint-ga.yml: має бути крок run: bun run lint-ga (ga.mdc)')
257
- }
258
- }
259
-
260
- /**
261
- * Перевіряє структуру workflow `git-ai.yml` (ga.mdc).
262
- * @param {Record<string, unknown> | null} root parsed YAML
263
- * @param {(msg: string) => void} passFn pass
264
- * @param {(msg: string) => void} failFn fail
265
- */
266
- function validateGitAiWorkflowStructure(root, passFn, failFn) {
267
- if (!root) {
268
- failFn('git-ai.yml: YAML не вдалося розібрати (ga.mdc)')
269
- return
270
- }
271
-
272
- if (!isExactString(root.name, 'Git AI')) {
273
- failFn('git-ai.yml: name має бути "Git AI" (ga.mdc)')
274
- }
275
-
276
- const on = root.on
277
- const pr = getObjKey(on, 'pull_request')
278
- const types = getObjKey(pr, 'types')
279
- if (!Array.isArray(types) || !types.includes('closed')) {
280
- failFn('git-ai.yml: on.pull_request.types має містити closed (ga.mdc)')
281
- }
282
-
283
- validateConcurrencyOnRoot('git-ai.yml', root, passFn, failFn)
284
-
285
- const jobs = getObjKey(root, 'jobs')
286
- const job = getObjKey(jobs, 'git-ai')
287
- if (!job) {
288
- failFn('git-ai.yml: jobs.git-ai відсутній (ga.mdc)')
289
- return
290
- }
291
-
292
- if (!String(getObjKey(job, 'if') ?? '').includes('github.event.pull_request.merged == true')) {
293
- failFn('git-ai.yml: job має містити if: github.event.pull_request.merged == true (ga.mdc)')
294
- }
295
-
296
- const perm = getObjKey(job, 'permissions')
297
- if (getObjKey(perm, 'contents') !== 'write') {
298
- failFn('git-ai.yml: permissions мають бути contents: write (ga.mdc)')
299
- }
300
-
301
- const flat = flattenWorkflowSteps(root)
302
- const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
303
- if (!runBlob.includes('curl -fsSL https://usegitai.com/install.sh | bash')) {
304
- failFn('git-ai.yml: має встановлювати git-ai через curl | bash (ga.mdc)')
305
- }
306
- if (runBlob.includes('git-ai ci github run')) {
307
- passFn('git-ai.yml: структура jobs/steps OK')
308
- } else {
309
- failFn('git-ai.yml: має виконувати git-ai ci github run (ga.mdc)')
310
- }
311
- }
312
-
313
159
  /**
314
160
  * Перевіряє блок `concurrency` на вже розпарсеному корені workflow (ga.mdc).
315
161
  *
@@ -624,33 +470,6 @@ function checkShellcheckInstalled(passFn, failFn) {
624
470
  )
625
471
  }
626
472
 
627
- /**
628
- * Перевіряє lint-ga.yml workflow.
629
- * @param {string} wfDir директорія workflows
630
- * @param {(msg: string) => void} passFn callback при успішній перевірці
631
- * @param {(msg: string) => void} failFn callback при помилці
632
- */
633
- async function checkLintGaWorkflow(wfDir, passFn, failFn) {
634
- const lintGaWf = join(wfDir, 'lint-ga.yml')
635
- if (!existsSync(lintGaWf)) return
636
- const lgContent = await readFile(lintGaWf, 'utf8')
637
- const root = parseWorkflowYaml(lgContent)
638
- const hasBunRun = root ? anyRunStepIncludes(root, 'bun run lint-ga') : lgContent.includes('bun run lint-ga')
639
- const hasSetupUv = root
640
- ? hasAnyStepUsesContaining(root, ['astral-sh/setup-uv']) || lgContent.includes('astral-sh/setup-uv')
641
- : lgContent.includes('astral-sh/setup-uv')
642
- if (hasBunRun) {
643
- passFn('lint-ga.yml викликає bun run lint-ga')
644
- } else {
645
- failFn('lint-ga.yml: крок має містити bun run lint-ga')
646
- }
647
- if (hasSetupUv) {
648
- passFn('lint-ga.yml містить astral-sh/setup-uv')
649
- } else {
650
- failFn('lint-ga.yml: додай astral-sh/setup-uv для uvx zizmor (ga.mdc)')
651
- }
652
- }
653
-
654
473
  /**
655
474
  * Перевіряє розширення workflow-файлів і наявність обов'язкових workflow.
656
475
  * @param {string} wfDir шлях до директорії workflows
@@ -684,105 +503,6 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
684
503
  }
685
504
  }
686
505
 
687
- /**
688
- * Перевіряє, чи on.pull_request.types у parsed YAML містить 'closed'.
689
- * @param {Record<string, unknown>} root розібраний YAML workflow
690
- * @returns {boolean} true, якщо тригер pull_request має тип closed
691
- */
692
- function hasPullRequestClosedTrigger(root) {
693
- const on = root.on
694
- if (!on || typeof on !== 'object') return false
695
- const pr = /** @type {Record<string, unknown>} */ (on)['pull_request']
696
- if (!pr || typeof pr !== 'object') return false
697
- const types = /** @type {Record<string, unknown>} */ (pr).types
698
- return Array.isArray(types) && types.includes('closed')
699
- }
700
-
701
- /**
702
- * Перевіряє, чи будь-який job у parsed YAML має if-умову з 'merged'.
703
- * @param {Record<string, unknown>} root розібраний YAML workflow
704
- * @returns {boolean} true, якщо хоча б один job містить умову merged
705
- */
706
- function hasJobMergedCondition(root) {
707
- const { jobs } = root
708
- if (!jobs || typeof jobs !== 'object') return false
709
- return Object.values(jobs).some(job => {
710
- if (!job || typeof job !== 'object') return false
711
- const ifCond = String(/** @type {Record<string, unknown>} */ (job).if ?? '')
712
- return ifCond.includes('merged')
713
- })
714
- }
715
-
716
- /**
717
- * Перевіряє parsed YAML git-ai.yml: тригер closed та умова merged.
718
- * @param {Record<string, unknown>} root розібраний YAML workflow
719
- * @param {(msg: string) => void} passFn callback при успішній перевірці
720
- * @param {(msg: string) => void} failFn callback при помилці
721
- */
722
- function validateGitAiParsedYaml(root, passFn, failFn) {
723
- if (hasPullRequestClosedTrigger(root)) {
724
- passFn('git-ai.yml: on.pull_request.types містить closed')
725
- } else {
726
- failFn('git-ai.yml: on.pull_request.types має містити closed (ga.mdc)')
727
- }
728
-
729
- if (hasJobMergedCondition(root)) {
730
- passFn('git-ai.yml: job має умову merged')
731
- } else {
732
- failFn('git-ai.yml: job має містити if: github.event.pull_request.merged == true (ga.mdc)')
733
- }
734
- }
735
-
736
- /**
737
- * Перевіряє git-ai.yml: тригер pull_request з types: [closed], умова merged у job, виклик git-ai.
738
- * @param {string} wfDir директорія workflows
739
- * @param {(msg: string) => void} passFn callback при успішній перевірці
740
- * @param {(msg: string) => void} failFn callback при помилці
741
- */
742
- async function checkGitAiWorkflow(wfDir, passFn, failFn) {
743
- const gitAiWf = join(wfDir, 'git-ai.yml')
744
- if (!existsSync(gitAiWf)) return
745
- const content = await readFile(gitAiWf, 'utf8')
746
- const root = parseWorkflowYaml(content)
747
-
748
- if (root) {
749
- validateGitAiParsedYaml(root, passFn, failFn)
750
- }
751
-
752
- const hasGitAiRun = root ? anyRunStepIncludes(root, 'git-ai ci github run') : content.includes('git-ai ci github run')
753
- if (hasGitAiRun) {
754
- passFn('git-ai.yml: крок виконує git-ai ci github run')
755
- } else {
756
- failFn('git-ai.yml: крок має містити git-ai ci github run (ga.mdc)')
757
- }
758
- }
759
-
760
- /**
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` — їх перенесення в наступних ітераціях.
766
- * @param {string} wfDir директорія workflows
767
- * @param {(msg: string) => void} passFn pass
768
- * @param {(msg: string) => void} failFn fail
769
- */
770
- async function checkCanonicalWorkflowsMatchRule(wfDir, passFn, failFn) {
771
- const paths = {
772
- lintGa: join(wfDir, 'lint-ga.yml'),
773
- gitAi: join(wfDir, 'git-ai.yml')
774
- }
775
-
776
- if (existsSync(paths.lintGa)) {
777
- const c = await readFile(paths.lintGa, 'utf8')
778
- validateLintGaWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
779
- }
780
- if (existsSync(paths.gitAi)) {
781
- const c = await readFile(paths.gitAi, 'utf8')
782
- validateGitAiWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
783
- }
784
- }
785
-
786
506
  /**
787
507
  * Перевіряє відповідність проєкту правилам ga.mdc
788
508
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -841,12 +561,8 @@ export async function check() {
841
561
  }
842
562
  }
843
563
 
844
- await checkCanonicalWorkflowsMatchRule(wfDir, pass, fail)
845
-
846
564
  await checkZizmor(pass, fail)
847
565
  await checkLintGaScript(pass, fail)
848
- await checkLintGaWorkflow(wfDir, pass, fail)
849
- await checkGitAiWorkflow(wfDir, pass, fail)
850
566
  checkShellcheckInstalled(pass, fail)
851
567
 
852
568
  return reporter.getExitCode()
@@ -45,8 +45,7 @@ const ENV_FILE_RE = /\.env$/u
45
45
  const HASURA_ENDPOINT_LINE_RE = /^[ \t]*(?:export[ \t]+)?HASURA_GRAPHQL_ENDPOINT[ \t]*=[ \t]*['"]?([^'"\r\n#]+)/mu
46
46
  // Дозволяємо два DNS-суфікси кластера: `<name>.internal` (GKE/GCP) і `cluster.local`
47
47
  // (стандартний k8s / Yandex Cloud). У YC namespace.yaml + cluster mode дають коротший суфікс.
48
- const INTERNAL_HASURA_URL_RE =
49
- /^http:\/\/([^./]+)\.([^./]+)\.svc\.((?:[^./:]+\.internal)|cluster\.local):(\d+)\/?$/u
48
+ const INTERNAL_HASURA_URL_RE = /^http:\/\/([^./]+)\.([^./]+)\.svc\.((?:[^./:]+\.internal)|cluster\.local):(\d+)\/?$/u
50
49
  const CLUSTER_LOCAL_SUFFIX = 'cluster.local'
51
50
  const INTERNAL_DNS_SUFFIX = '.internal'
52
51
 
@@ -151,7 +150,8 @@ async function checkEnvFile(relPath, expected, reporter) {
151
150
  const parsed = parseInternalHasuraEndpoint(value)
152
151
  if (!parsed.ok) {
153
152
  // eslint-disable-next-line @microsoft/sdl/no-insecure-url, sonarjs/no-clear-text-protocols -- hasura.mdc вимагає саме http:// для кластерного URL (TLS не використовується)
154
- const example = 'http://<service>.<namespace>.svc.<cluster>.internal:<port> або http://<service>.<namespace>.svc.cluster.local:<port>'
153
+ const example =
154
+ 'http://<service>.<namespace>.svc.<cluster>.internal:<port> або http://<service>.<namespace>.svc.cluster.local:<port>'
155
155
  fail(
156
156
  `${relPath}: HASURA_GRAPHQL_ENDPOINT="${value}" — потрібен внутрішній кластерний URL виду ${example} (hasura.mdc)`
157
157
  )
@@ -47,10 +47,7 @@ import {
47
47
  resolveConnDirFromPackageJson
48
48
  } from './utils/conn-imports-scan.mjs'
49
49
  import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
50
- import {
51
- findPromiseSetTimeoutInText,
52
- isPromiseSetTimeoutScanSourceFile
53
- } from './utils/promise-settimeout-scan.mjs'
50
+ import { findPromiseSetTimeoutInText, isPromiseSetTimeoutScanSourceFile } from './utils/promise-settimeout-scan.mjs'
54
51
  import { walkDir } from './utils/walkDir.mjs'
55
52
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
56
53
 
@@ -524,7 +524,8 @@ function kustomizationPatchSortKey(patchItem) {
524
524
  const rec = /** @type {Record<string, unknown>} */ (patchItem)
525
525
  const t = rec.target
526
526
  /** @type {Record<string, unknown>} */
527
- const target = t !== null && typeof t === 'object' && !Array.isArray(t) ? /** @type {Record<string, unknown>} */ (t) : {}
527
+ const target =
528
+ t !== null && typeof t === 'object' && !Array.isArray(t) ? /** @type {Record<string, unknown>} */ (t) : {}
528
529
  const kind = typeof target.kind === 'string' ? target.kind : ''
529
530
  const name = typeof target.name === 'string' ? target.name : ''
530
531
  const ns = typeof target.namespace === 'string' ? target.namespace : ''
@@ -46,6 +46,16 @@ const CONFTEST_TARGETS = [
46
46
  workflow: '.github/workflows/clean-merged-branch.yml',
47
47
  namespace: 'ga.clean_merged_branch',
48
48
  label: 'clean-merged-branch.yml structure'
49
+ },
50
+ {
51
+ workflow: '.github/workflows/lint-ga.yml',
52
+ namespace: 'ga.lint_ga',
53
+ label: 'lint-ga.yml structure'
54
+ },
55
+ {
56
+ workflow: '.github/workflows/git-ai.yml',
57
+ namespace: 'ga.git_ai',
58
+ label: 'git-ai.yml structure'
49
59
  }
50
60
  ]
51
61
 
@@ -1,14 +1,22 @@
1
1
  /**
2
- * Запуск `regal lint` по Rego-полісі репозиторію (`conftest.mdc`).
2
+ * Лінт Rego-полісі (`conftest.mdc` + `rego.mdc`): preflight на `opa` і `regal`,
3
+ * далі послідовно `opa check --strict` і `regal lint`.
3
4
  *
4
- * Regal (https://docs.styra.com/regal) — статичний лінтер Rego, який ловить v0-синтаксис,
5
- * неявні set-rules та інші відхилення від `rego.v1`. Без preflight-у на наявність бінарника
6
- * лінт мовчки злетить з невиразним повідомленням від shell тут друкуємо явний install-hint
7
- * (як це робить `lint-ga.mjs` для shellcheck/uv).
5
+ * Чому два інструменти:
6
+ * - `opa check --strict` компіляція з типами і строгим режимом (мертвий код, неоднозначні
7
+ * правила, незадекларовані змінні). Ловить помилки, які `regal` навмисно лишає поза скоупом
8
+ * (він про стиль і ідіоматичність, а не про компіляцію).
9
+ * - `regal lint` (https://docs.styra.com/regal) — статичний лінтер Rego: ловить v0-синтаксис,
10
+ * неявні set-rules та інші відхилення від `rego.v1`, плюс bugs/idiomatic/performance-правила.
11
+ *
12
+ * Без preflight-у на бінарники лінт мовчки злетить з невиразним повідомленням від shell —
13
+ * друкуємо явні install-hints (як це робить `lint-ga.mjs` для shellcheck/uv). `opa` додатково
14
+ * потрібен VS Code-розширенню `tsandall.opa` (LSP, format-on-save через `opa fmt`) — деталі в
15
+ * `mdc/rego.mdc`.
8
16
  *
9
17
  * Цілі лінту: `npm/policy/` (місце, де поки що живуть Rego-полісі пакета `@nitra/cursor`).
10
18
  * Якщо в репозиторії з’являться інші *.rego поза цим деревом, додай шлях у `LINT_TARGETS` —
11
- * `regal lint` приймає кілька шляхів і сам рекурсивно обходить директорії.
19
+ * обидва інструменти приймають кілька шляхів і самі рекурсивно обходять директорії.
12
20
  */
13
21
  import { spawnSync } from 'node:child_process'
14
22
  import { existsSync } from 'node:fs'
@@ -19,6 +27,24 @@ import { resolveCmd } from './utils/resolve-cmd.mjs'
19
27
  /** Шляхи з Rego-полісі (відносно cwd). Існують не всі на ранніх стадіях — фільтруємо нижче. */
20
28
  const LINT_TARGETS = ['npm/policy']
21
29
 
30
+ /**
31
+ * Друкує підказку зі встановлення `opa` (потрібен для `opa check --strict` і VS Code LSP).
32
+ * @returns {void}
33
+ */
34
+ function printOpaInstallHints() {
35
+ process.stderr.write(
36
+ [
37
+ '❌ opa не знайдено в PATH.',
38
+ ' Без нього не запускається `opa check --strict` (типи + мертвий код у *.rego),',
39
+ ' і не працює VS Code-розширення `tsandall.opa` (LSP, format-on-save через opa fmt).',
40
+ ' Встанови:',
41
+ ' macOS: brew install opa',
42
+ ' Universal: https://www.openpolicyagent.org/docs/latest/#1-download-opa',
43
+ ''
44
+ ].join('\n')
45
+ )
46
+ }
47
+
22
48
  /**
23
49
  * Друкує підказку зі встановлення `regal`.
24
50
  * @returns {void}
@@ -29,7 +55,7 @@ function printRegalInstallHints() {
29
55
  '❌ regal не знайдено в PATH.',
30
56
  ' Без нього не перевіряється rego.v1 синтаксис у *.rego (правило `conftest`).',
31
57
  ' Встанови:',
32
- ' macOS: brew install regal',
58
+ ' macOS: brew install regal',
33
59
  ' Universal: https://docs.styra.com/regal#installation',
34
60
  ''
35
61
  ].join('\n')
@@ -37,34 +63,54 @@ function printRegalInstallHints() {
37
63
  }
38
64
 
39
65
  /**
40
- * Запускає `regal lint` по існуючих цілях. Якщо жодної цілі немає — пропускає лінт із кодом 0.
66
+ * Запускає крок з відображенням команди користувачу. Stdout/stderr передаємо як є
67
+ * (`stdio: 'inherit'`), щоб виглядало як прямий виклик у shell.
68
+ * @param {string} bin абсолютний шлях до бінарника
69
+ * @param {string[]} args аргументи
70
+ * @param {string} cwd робочий каталог
71
+ * @returns {number} код виходу (0 — OK)
72
+ */
73
+ function runStep(bin, args, cwd) {
74
+ console.log(`▶ ${bin} ${args.join(' ')}`)
75
+ const result = spawnSync(bin, args, { cwd, stdio: 'inherit', env: process.env })
76
+ if (result.error) {
77
+ process.stderr.write(`❌ Не вдалося запустити ${bin}: ${result.error.message}\n`)
78
+ return 1
79
+ }
80
+ return result.status ?? 1
81
+ }
82
+
83
+ /**
84
+ * Запускає `opa check --strict` і `regal lint` по існуючих цілях. Якщо жодної цілі немає —
85
+ * пропускає лінт із кодом 0. Якщо хоча б один preflight не пройшов — exit 1 ще до запусків.
41
86
  * @param {string} [cwd] робочий каталог (за замовчуванням `process.cwd()`)
42
- * @returns {number} 0 — OK або skip; інакше код виходу regal
87
+ * @returns {number} 0 — OK або skip; інакше код виходу першого кроку, що впав
43
88
  */
44
89
  export function runLintRego(cwd = process.cwd()) {
45
90
  const root = resolve(cwd)
91
+ const opa = resolveCmd('opa')
46
92
  const regal = resolveCmd('regal')
93
+
94
+ let preflightOk = true
95
+ if (!opa) {
96
+ printOpaInstallHints()
97
+ preflightOk = false
98
+ }
47
99
  if (!regal) {
48
100
  printRegalInstallHints()
49
- return 1
101
+ preflightOk = false
50
102
  }
103
+ if (!preflightOk) return 1
51
104
 
52
105
  const targets = LINT_TARGETS.filter(rel => existsSync(resolve(root, rel)))
53
106
  if (targets.length === 0) {
54
107
  return 0
55
108
  }
56
109
 
57
- console.log(`▶ regal lint ${targets.join(' ')}`)
58
- const result = spawnSync(regal, ['lint', ...targets], {
59
- cwd: root,
60
- stdio: 'inherit',
61
- env: process.env
62
- })
63
- if (result.error) {
64
- process.stderr.write(`❌ Не вдалося запустити regal: ${result.error.message}\n`)
65
- return 1
66
- }
67
- return result.status ?? 1
110
+ const opaCode = runStep(opa, ['check', '--strict', ...targets], root)
111
+ if (opaCode !== 0) return opaCode
112
+
113
+ return runStep(regal, ['lint', ...targets], root)
68
114
  }
69
115
 
70
116
  process.exitCode = runLintRego()
@@ -85,10 +85,7 @@ export function listShellScriptPaths(cwd) {
85
85
 
86
86
  const fromGlob = globSync('**/*.sh', {
87
87
  cwd,
88
- exclude: p =>
89
- p.includes('node_modules') ||
90
- p.startsWith(`node_modules/`) ||
91
- p.split('/').includes('node_modules')
88
+ exclude: p => p.includes('node_modules') || p.startsWith(`node_modules/`) || p.split('/').includes('node_modules')
92
89
  })
93
90
  return [...new Set(fromGlob.map(p => p.replaceAll('\\', '/')))].sort()
94
91
  }
@@ -19,11 +19,7 @@
19
19
  import { readdir, readFile } from 'node:fs/promises'
20
20
  import { join, relative } from 'node:path'
21
21
 
22
- import {
23
- flattenWorkflowSteps,
24
- getStepRun,
25
- parseWorkflowYaml
26
- } from './gha-workflow.mjs'
22
+ import { flattenWorkflowSteps, getStepRun, parseWorkflowYaml } from './gha-workflow.mjs'
27
23
 
28
24
  const WORKFLOWS_DIR_REL = '.github/workflows'
29
25
  const REQUIRED_IGNORES = ['graphql', 'bun']
@@ -122,7 +118,7 @@ export function evaluateDepcheckStepForPackage(root, pkgRoot) {
122
118
  // Усі знайдені кроки існують, але жоден не має повного списку обов'язкових ignores —
123
119
  // повертаємо missing з першого, щоб дати конкретний фідбек.
124
120
  const firstMissing = REQUIRED_IGNORES.filter(
125
- req => !((parseDepcheckIgnoresArg(stepsForThisPackage[0].args) ?? []).includes(req))
121
+ req => !(parseDepcheckIgnoresArg(stepsForThisPackage[0].args) ?? []).includes(req)
126
122
  )
127
123
  return { kind: 'missing-ignores', missing: firstMissing }
128
124
  }