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