@nitra/cursor 1.13.28 → 1.13.34
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 +37 -0
- package/package.json +1 -1
- package/rules/changelog/changelog.mdc +44 -19
- package/rules/changelog/fix/consistency/check.mjs +318 -196
- package/rules/image-avif/image-avif.mdc +2 -2
- package/rules/k8s/fix/kubescape_exceptions/template/.kubescape-exceptions.json.snippet.json +21 -0
- package/rules/k8s/k8s.mdc +9 -1
- package/rules/k8s/lint/lint.mjs +29 -4
- package/rules/style-lint/style-lint.mdc +20 -1
- package/scripts/utils/package-manifest.mjs +158 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.13.34] - 2026-05-18
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `k8s` rule: винесено канонічний приклад `.kubescape-exceptions.json` з inline-fenced-блоку в `k8s.mdc` у `fix/kubescape_exceptions/template/.kubescape-exceptions.json.snippet.json`; `.mdc` тепер посилається на template markdown-лінком, `inlineTemplateLinks` підставить вміст у `.cursor/rules/n-k8s.mdc` під час sync. Dogfood новій клаузі `scripts.mdc` ("Принцип поширюється і на pure-doc канони"). Bump `k8s.mdc` `1.30` → `1.31`.
|
|
12
|
+
|
|
13
|
+
## [1.13.33] - 2026-05-18
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `style-lint`, `image-avif` rules: markdown-посилання на `policy/*/template/*` у канонічних `<id>.mdc` — `findMissingMdcRefs` (викликається з `run-rule.mjs`) падав, бо шаблони не були згадані в `npm/rules/<id>/<id>.mdc`. Bump: `style-lint.mdc` `1.3` → `1.4`, `image-avif.mdc` `1.2` → `1.3`.
|
|
18
|
+
|
|
19
|
+
## [1.13.32] - 2026-05-18
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `k8s` rule (`lint-k8s`): підтримка per-project винятків kubescape — якщо в корені проєкту є `.kubescape-exceptions.json`, `runKubescape` автоматично передає його через `--exceptions <file>`. Канонічний приклад — control **C-0012** (`Applications credentials in configuration files`) на ConfigMap з публічним JWT-конфігом (`HASURA_GRAPHQL_JWT_SECRET={"jwk_url": "https://…"}`): control тригериться лише на імʼя env, не на значення, тому точкове `postureExceptionPolicy` з `kind: ConfigMap` + `attributes.name` знімає false-positive без глобального вимкнення контролю. Bump `k8s.mdc` `1.29` → `1.30`. Документація — секція "Винятки kubescape" в `k8s.mdc`.
|
|
24
|
+
|
|
25
|
+
## [1.13.31] - 2026-05-18
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `changelog` rule (`n-changelog.mdc` `2.3` → `2.4`): підтримка Python — `pyproject.toml` (`[project]` / Poetry) поряд із `package.json`; discovery воркспейсів через `getMonorepoProjectRootDirs`; PyPI-порівняння для registry-published Python-пакетів. `package-manifest.mjs` — уніфікований маніфест npm/python.
|
|
30
|
+
|
|
31
|
+
## [1.13.30] - 2026-05-18
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `changelog` rule (`n-changelog.mdc` `2.2` → `2.3`): `alwaysApply: true` без `globs`; інверсія — не вимагати bump для `docs/`/`doc/` і шляхів з `.gitignore`. `check changelog`: інтеграційна база `dev` **або** `main` (перша наявна); на `dev`/`main` local-only пропускається; релевантні зміни фільтруються в git. Тести.
|
|
36
|
+
|
|
37
|
+
## [1.13.29] - 2026-05-18
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- `changelog` rule (`n-changelog.mdc` `2.1` → `2.2`): розширені `globs` (типові шляхи пакета + `*.rego` / `*.mdc`) — правило потрапляє в контекст агента при правках коду, не лише `package.json` / `CHANGELOG.md`; секція «Чеклист агента» для будь-якого репозиторію з правилом.
|
|
42
|
+
- `changelog/fix/consistency/check.mjs`: npm-published режим — якщо `version` збігається з реєстром, але в git є зміни workspace без bump (feature vs `dev` або незакомічене на `dev`) → fail. Регресійні тести.
|
|
43
|
+
|
|
7
44
|
## [1.13.28] - 2026-05-18
|
|
8
45
|
|
|
9
46
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,36 +1,61 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння
|
|
3
|
-
version: '2.
|
|
4
|
-
|
|
5
|
-
alwaysApply: false
|
|
2
|
+
description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння (npm і Python)
|
|
3
|
+
version: '2.4'
|
|
4
|
+
alwaysApply: true
|
|
6
5
|
---
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
У кожному **пакетному** workspace (каталог із `package.json` або `pyproject.toml`) має бути власний **`CHANGELOG.md`**. Спільного на репозиторій змісту змін **не існує** — кожен пакет веде свій.
|
|
8
|
+
|
|
9
|
+
**Маніфест версії:**
|
|
10
|
+
|
|
11
|
+
- **JS / Bun / npm** — `<ws>/package.json` (`version`, для монорепо ще `workspaces` у кореневому `package.json`).
|
|
12
|
+
- **Python** — `<ws>/pyproject.toml`: `[project].name` / `[project].version` (PEP 621) або `[tool.poetry].name` / `[tool.poetry].version`.
|
|
13
|
+
|
|
14
|
+
Каталоги лише з `pyproject.toml` (без `package.json`) теж враховуються; `node_modules/`, `.venv/`, `venv/` при пошуку ігноруються.
|
|
15
|
+
|
|
16
|
+
## Чеклист агента (будь-який репозиторій з цим правилом)
|
|
17
|
+
|
|
18
|
+
**Інверсія (за замовчуванням не вимагають bump/CHANGELOG):**
|
|
19
|
+
|
|
20
|
+
- зміни **лише** під `docs/` або `doc/`;
|
|
21
|
+
- файли під **`.gitignore`**;
|
|
22
|
+
- правки **лише** `CHANGELOG.md` або поля `version` у маніфесті як сам релізний крок.
|
|
23
|
+
|
|
24
|
+
**Вимагають bump + нову секцію CHANGELOG** — усі інші зміни в каталозі workspace (код, rego, правила, конфіги, тести тощо).
|
|
25
|
+
|
|
26
|
+
Перед завершенням задачі:
|
|
27
|
+
|
|
28
|
+
1. **`version`** у `<ws>/package.json` або `[project].version` у `<ws>/pyproject.toml` підвищено (build/patch +1 на PR), якщо зміни входять у реліз.
|
|
29
|
+
2. **`CHANGELOG.md`** — **нова** секція `## [версія] - YYYY-MM-DD` зверху (не дописуй bullet-и в стару версію).
|
|
30
|
+
3. **`npx @nitra/cursor check changelog`** — exit `0`.
|
|
31
|
+
|
|
32
|
+
Перевірка програмна (`changelog/fix/consistency/check.mjs`).
|
|
9
33
|
|
|
10
34
|
## Дві моделі бази порівняння
|
|
11
35
|
|
|
12
|
-
|
|
36
|
+
Режим визначається автоматично з маніфесту.
|
|
13
37
|
|
|
14
|
-
###
|
|
38
|
+
### registry-published (npm / PyPI)
|
|
15
39
|
|
|
16
|
-
|
|
40
|
+
**npm:** непорожнє `name`, не `private: true`, масив `files`.
|
|
17
41
|
|
|
18
|
-
|
|
19
|
-
2. Якщо локальна `version` відрізняється від опублікованої — потрібен запис у `<ws>/CHANGELOG.md` для локальної версії (формат `## [версія] - YYYY-MM-DD`) і `"CHANGELOG.md"` у `files`.
|
|
20
|
-
3. Якщо реєстр недосяжний (офлайн / пакет ще не публікувався) — fail-safe pass із поясненням, щоб локальна розробка не блокувалася.
|
|
42
|
+
**Python:** статичні `project.name` і `project.version` у `pyproject.toml` (або Poetry-секція).
|
|
21
43
|
|
|
22
|
-
|
|
44
|
+
1. **Локальна `version` ≠ опублікованій** (npm / PyPI): запис у `<ws>/CHANGELOG.md`; для npm також `"CHANGELOG.md"` у `files`.
|
|
45
|
+
2. **Версії збігаються**, але в git є **релевантні** зміни без bump → fail.
|
|
46
|
+
3. **Реєстр недосяжний** — fail-safe pass.
|
|
47
|
+
4. **Немає релевантних змін** — pass.
|
|
23
48
|
|
|
24
|
-
### local-only
|
|
49
|
+
### local-only
|
|
25
50
|
|
|
26
|
-
|
|
51
|
+
**npm:** `private: true` або без `files`. **Python:** без пари name+version для реєстру. База = **`dev` або `main`** (перша наявна), `git merge-base`:
|
|
27
52
|
|
|
28
|
-
1. На
|
|
29
|
-
2. На feature-гілці
|
|
30
|
-
3.
|
|
31
|
-
4. Direct-commit на `main`
|
|
53
|
+
1. На **`dev`** local-only не активний (крім незакомічених registry-published).
|
|
54
|
+
2. На feature-гілці — bump + CHANGELOG **раз на PR**.
|
|
55
|
+
3. Після merge на інтеграційну гілку diff порожній → pass.
|
|
56
|
+
4. Direct-commit на `main` ловиться так само.
|
|
32
57
|
|
|
33
|
-
Якщо
|
|
58
|
+
Якщо немає git або немає `dev`/`main` — local-only пропускається.
|
|
34
59
|
|
|
35
60
|
## Формат CHANGELOG.md
|
|
36
61
|
|
|
@@ -1,25 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Перевіряє, що в кожному workspace із
|
|
3
|
-
*
|
|
4
|
-
* (формат Keep a Changelog).
|
|
2
|
+
* Перевіряє, що в кожному workspace із релізно-релевантними змінами підвищена `version`
|
|
3
|
+
* у маніфесті (`package.json` або `pyproject.toml`) і в `<ws>/CHANGELOG.md` є запис
|
|
4
|
+
* `## [version] - YYYY-MM-DD` (формат Keep a Changelog).
|
|
5
5
|
*
|
|
6
|
-
* Дві моделі
|
|
6
|
+
* Дві моделі бази — на рівні воркспейсу (див. n-changelog.mdc):
|
|
7
7
|
*
|
|
8
|
-
* 1) **
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* у
|
|
12
|
-
* (немає мережі / пакет ще не публікувався) — fail-safe pass із поясненням, щоб локальна
|
|
13
|
-
* розробка офлайн не блокувалась.
|
|
8
|
+
* 1) **registry-published** (npm: `name` + `files`, не `private`; Python: `project.name` +
|
|
9
|
+
* статична `project.version` у `pyproject.toml`): база = опублікована версія в npm / PyPI.
|
|
10
|
+
* Якщо локальна версія відрізняється — потрібен CHANGELOG; для npm також `"CHANGELOG.md"`
|
|
11
|
+
* у `files`. Якщо версії збігаються, але в git є релевантні зміни без bump — fail.
|
|
14
12
|
*
|
|
15
|
-
* 2) **local-only
|
|
16
|
-
*
|
|
17
|
-
* - на feature-гілці бачити лише унікальні коміти цієї гілки;
|
|
18
|
-
* - на `main` після merge `dev → main` diff був порожній (нічого не вимагати);
|
|
19
|
-
* - direct-commit на `main` поза PR-flow ловився як зміна, що потребує bump + CHANGELOG.
|
|
20
|
-
* Якщо не git-репо, поточна гілка = `dev`, або `dev`/`origin/dev` не існує — пропуск.
|
|
13
|
+
* 2) **local-only** (приватні npm, без `files`, Python без імені/версії для реєстру): PR-scoped
|
|
14
|
+
* перевірка проти `dev` / `main` через `git merge-base`.
|
|
21
15
|
*
|
|
22
|
-
* Усі `git` і
|
|
16
|
+
* Усі `git` і зовнішні виклики — через `execFile` / `fetch`, без shell-інтерполяції.
|
|
23
17
|
*/
|
|
24
18
|
import { execFile } from 'node:child_process'
|
|
25
19
|
import { existsSync } from 'node:fs'
|
|
@@ -28,20 +22,34 @@ import { join } from 'node:path'
|
|
|
28
22
|
import { promisify } from 'node:util'
|
|
29
23
|
|
|
30
24
|
import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mjs'
|
|
31
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
getMonorepoProjectRootDirs,
|
|
27
|
+
manifestFilePath,
|
|
28
|
+
parsePyprojectFields,
|
|
29
|
+
readPackageManifest,
|
|
30
|
+
} from '../../../../scripts/utils/package-manifest.mjs'
|
|
32
31
|
|
|
33
32
|
const execFileAsync = promisify(execFile)
|
|
34
33
|
|
|
35
|
-
/**
|
|
36
|
-
const
|
|
34
|
+
/** Кандидати інтеграційної гілки (перша наявна в репо; див. n-changelog.mdc) */
|
|
35
|
+
const BASE_BRANCH_CANDIDATES = Object.freeze(['dev', 'main'])
|
|
37
36
|
|
|
38
|
-
/**
|
|
39
|
-
const
|
|
37
|
+
/** Гілки, на яких local-only перевірку пропускаємо (крім незакомічених registry-published). */
|
|
38
|
+
const INTEGRATION_BRANCHES = Object.freeze(['dev', 'main'])
|
|
39
|
+
|
|
40
|
+
/** Префікси шляхів (posix), які не вважаються релізними змінами — інверсія glob (n-changelog.mdc). */
|
|
41
|
+
const CHANGELOG_IGNORE_PATH_PREFIXES = Object.freeze(['docs/', 'doc/'])
|
|
42
|
+
|
|
43
|
+
/** Точні шляхи каталогів документації (posix), без bump. */
|
|
44
|
+
const CHANGELOG_IGNORE_PATH_EXACT = Object.freeze(['docs', 'doc'])
|
|
45
|
+
|
|
46
|
+
/** Таймаут на `npm view` / PyPI (мс) */
|
|
47
|
+
const REGISTRY_TIMEOUT_MS = 10_000
|
|
40
48
|
|
|
41
49
|
/**
|
|
42
50
|
* Тихо запускає `git` і повертає stdout або `null` при будь-якій помилці.
|
|
43
51
|
* @param {string[]} args аргументи `git`
|
|
44
|
-
* @returns {Promise<string | null>}
|
|
52
|
+
* @returns {Promise<string | null>}
|
|
45
53
|
*/
|
|
46
54
|
async function gitOrNull(args) {
|
|
47
55
|
try {
|
|
@@ -53,8 +61,7 @@ async function gitOrNull(args) {
|
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/**
|
|
56
|
-
*
|
|
57
|
-
* @returns {Promise<boolean>} `true`, якщо `git rev-parse --is-inside-work-tree` повернув `true`
|
|
64
|
+
* @returns {Promise<boolean>}
|
|
58
65
|
*/
|
|
59
66
|
async function isInsideGitRepo() {
|
|
60
67
|
const out = await gitOrNull(['rev-parse', '--is-inside-work-tree'])
|
|
@@ -62,8 +69,7 @@ async function isInsideGitRepo() {
|
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/**
|
|
65
|
-
*
|
|
66
|
-
* @returns {Promise<string | null>} назва гілки чи `'HEAD'`, або `null` (поза git / помилка)
|
|
72
|
+
* @returns {Promise<string | null>}
|
|
67
73
|
*/
|
|
68
74
|
async function currentBranchName() {
|
|
69
75
|
const out = await gitOrNull(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
@@ -71,25 +77,64 @@ async function currentBranchName() {
|
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
|
|
80
|
+
* @param {string | null} branch
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
function isIntegrationBranch(branch) {
|
|
84
|
+
return branch !== null && INTEGRATION_BRANCHES.includes(branch)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} ref
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function baseRefLabel(ref) {
|
|
92
|
+
return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} relPath
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
function isChangelogIgnoredPath(relPath) {
|
|
100
|
+
const p = relPath.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
101
|
+
if (CHANGELOG_IGNORE_PATH_EXACT.includes(p)) {
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
return CHANGELOG_IGNORE_PATH_PREFIXES.some(prefix => p.startsWith(prefix))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} relPath
|
|
109
|
+
* @returns {Promise<boolean>}
|
|
110
|
+
*/
|
|
111
|
+
async function isPathGitIgnored(relPath) {
|
|
112
|
+
try {
|
|
113
|
+
await execFileAsync('git', ['check-ignore', '-q', '--', relPath])
|
|
114
|
+
return true
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @returns {Promise<string | null>}
|
|
77
122
|
*/
|
|
78
123
|
async function resolveBaseRef() {
|
|
79
|
-
for (const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
124
|
+
for (const name of BASE_BRANCH_CANDIDATES) {
|
|
125
|
+
for (const ref of [name, `origin/${name}`]) {
|
|
126
|
+
const out = await gitOrNull(['rev-parse', '--verify', '--quiet', ref])
|
|
127
|
+
if (typeof out === 'string' && out.trim().length > 0) {
|
|
128
|
+
return ref
|
|
129
|
+
}
|
|
83
130
|
}
|
|
84
131
|
}
|
|
85
132
|
return null
|
|
86
133
|
}
|
|
87
134
|
|
|
88
135
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* @param {string} baseRef SHA або ref-name бази (зазвичай `dev` / `origin/dev`)
|
|
92
|
-
* @returns {Promise<string | null>} SHA точки розгалуження або `null`, якщо merge-base нема
|
|
136
|
+
* @param {string} baseRef
|
|
137
|
+
* @returns {Promise<string | null>}
|
|
93
138
|
*/
|
|
94
139
|
async function resolveMergeBase(baseRef) {
|
|
95
140
|
const out = await gitOrNull(['merge-base', baseRef, 'HEAD'])
|
|
@@ -99,14 +144,9 @@ async function resolveMergeBase(baseRef) {
|
|
|
99
144
|
}
|
|
100
145
|
|
|
101
146
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* щоб зміни всередині sub-workspace не вважалися змінами кореня.
|
|
106
|
-
* Для звичайного воркспейсу — просто `<ws>/`.
|
|
107
|
-
* @param {string} ws шлях воркспейсу (`'.'` для кореня, інакше — відносний шлях, як у `workspaces`)
|
|
108
|
-
* @param {string[]} subWorkspaces усі під-воркспейси (зокрема для `'.'` потрібно виключити їх)
|
|
109
|
-
* @returns {string[]} pathspec для git: масив, що передається після `--`
|
|
147
|
+
* @param {string} ws
|
|
148
|
+
* @param {string[]} subWorkspaces
|
|
149
|
+
* @returns {string[]}
|
|
110
150
|
*/
|
|
111
151
|
function pathspecForWorkspace(ws, subWorkspaces) {
|
|
112
152
|
if (ws !== '.') return [`${ws}/`]
|
|
@@ -114,50 +154,74 @@ function pathspecForWorkspace(ws, subWorkspaces) {
|
|
|
114
154
|
}
|
|
115
155
|
|
|
116
156
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* правки tracked-файлів. Untracked-файли — `git ls-files --others --exclude-standard`.
|
|
121
|
-
* @param {string} baseRef SHA або ref-name (зокрема merge-base)
|
|
122
|
-
* @param {string} ws шлях воркспейсу (`'.'` для кореня)
|
|
123
|
-
* @param {string[]} subWorkspaces усі під-воркспейси для коректного формування pathspec кореня
|
|
124
|
-
* @returns {Promise<boolean>} `true`, якщо в межах воркспейсу є будь-які зміни (committed або untracked)
|
|
157
|
+
* @param {string} baseRef
|
|
158
|
+
* @param {string[]} pathspec
|
|
159
|
+
* @returns {Promise<string[]>}
|
|
125
160
|
*/
|
|
126
|
-
async function
|
|
161
|
+
async function listChangedPathsAgainstBase(baseRef, pathspec) {
|
|
162
|
+
/** @type {string[]} */
|
|
163
|
+
const out = []
|
|
164
|
+
const diffArgs =
|
|
165
|
+
baseRef === 'HEAD'
|
|
166
|
+
? ['diff', '--name-only', 'HEAD', '--', ...pathspec]
|
|
167
|
+
: ['diff', '--name-only', baseRef, '--', ...pathspec]
|
|
168
|
+
const diffOut = await gitOrNull(diffArgs)
|
|
169
|
+
if (typeof diffOut === 'string' && diffOut.trim().length > 0) {
|
|
170
|
+
out.push(...diffOut.trim().split('\n'))
|
|
171
|
+
}
|
|
172
|
+
const untrackedOut = await gitOrNull(['ls-files', '--others', '--exclude-standard', '--', ...pathspec])
|
|
173
|
+
if (typeof untrackedOut === 'string' && untrackedOut.trim().length > 0) {
|
|
174
|
+
out.push(...untrackedOut.trim().split('\n'))
|
|
175
|
+
}
|
|
176
|
+
return [...new Set(out)]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} baseRef
|
|
181
|
+
* @param {string} ws
|
|
182
|
+
* @param {string[]} subWorkspaces
|
|
183
|
+
* @returns {Promise<boolean>}
|
|
184
|
+
*/
|
|
185
|
+
async function workspaceHasRelevantChangesAgainstBase(baseRef, ws, subWorkspaces) {
|
|
127
186
|
const pathspec = pathspecForWorkspace(ws, subWorkspaces)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
187
|
+
const paths = await listChangedPathsAgainstBase(baseRef, pathspec)
|
|
188
|
+
for (const p of paths) {
|
|
189
|
+
if (isChangelogIgnoredPath(p)) {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (await isPathGitIgnored(p)) {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
return true
|
|
133
196
|
}
|
|
134
|
-
|
|
135
|
-
return typeof untracked === 'string' && untracked.trim().length > 0
|
|
197
|
+
return false
|
|
136
198
|
}
|
|
137
199
|
|
|
138
200
|
/**
|
|
139
|
-
* Версія з
|
|
140
|
-
* @param {string} baseRef
|
|
141
|
-
* @param {
|
|
142
|
-
* @returns {Promise<string | null>}
|
|
201
|
+
* Версія з маніфесту на `baseRef`.
|
|
202
|
+
* @param {string} baseRef
|
|
203
|
+
* @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
|
|
204
|
+
* @returns {Promise<string | null>}
|
|
143
205
|
*/
|
|
144
|
-
async function readBaseVersion(baseRef,
|
|
145
|
-
const wsPath = ws === '.' ?
|
|
206
|
+
async function readBaseVersion(baseRef, manifest) {
|
|
207
|
+
const wsPath = manifest.ws === '.' ? manifest.manifestRel : `${manifest.ws}/${manifest.manifestRel}`
|
|
146
208
|
const out = await gitOrNull(['show', `${baseRef}:${wsPath}`])
|
|
147
209
|
if (out === null) return null
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
210
|
+
if (manifest.kind === 'npm') {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = JSON.parse(out)
|
|
213
|
+
return typeof parsed?.version === 'string' ? parsed.version : null
|
|
214
|
+
} catch {
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
153
217
|
}
|
|
218
|
+
return parsePyprojectFields(out).version
|
|
154
219
|
}
|
|
155
220
|
|
|
156
221
|
/**
|
|
157
|
-
*
|
|
158
|
-
* @param {string}
|
|
159
|
-
* @
|
|
160
|
-
* @returns {boolean} `true`, якщо запис для `version` знайдено
|
|
222
|
+
* @param {string} text
|
|
223
|
+
* @param {string} version
|
|
224
|
+
* @returns {boolean}
|
|
161
225
|
*/
|
|
162
226
|
function changelogHasVersionEntry(text, version) {
|
|
163
227
|
const needle = `## [${version}]`
|
|
@@ -165,62 +229,68 @@ function changelogHasVersionEntry(text, version) {
|
|
|
165
229
|
}
|
|
166
230
|
|
|
167
231
|
/**
|
|
168
|
-
*
|
|
169
|
-
* @
|
|
170
|
-
* @returns {Promise<Record<string, unknown> | null>} розпарсений `package.json` або `null`
|
|
232
|
+
* @param {string} name
|
|
233
|
+
* @returns {Promise<string | null>}
|
|
171
234
|
*/
|
|
172
|
-
async function
|
|
173
|
-
const path = join(ws, 'package.json')
|
|
174
|
-
if (!existsSync(path)) return null
|
|
235
|
+
async function defaultGetPublishedNpmVersion(name) {
|
|
175
236
|
try {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
: null
|
|
237
|
+
const { stdout } = await execFileAsync('npm', ['view', name, 'version'], { timeout: REGISTRY_TIMEOUT_MS })
|
|
238
|
+
const v = stdout.trim()
|
|
239
|
+
return v.length > 0 ? v : null
|
|
180
240
|
} catch {
|
|
181
241
|
return null
|
|
182
242
|
}
|
|
183
243
|
}
|
|
184
244
|
|
|
185
245
|
/**
|
|
186
|
-
*
|
|
187
|
-
* @
|
|
188
|
-
* @returns {boolean} `true`, якщо пакет придатний для публікації в npm
|
|
246
|
+
* @param {string} name
|
|
247
|
+
* @returns {Promise<string | null>}
|
|
189
248
|
*/
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
249
|
+
async function defaultGetPublishedPyPiVersion(name) {
|
|
250
|
+
try {
|
|
251
|
+
const res = await fetch(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, {
|
|
252
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
253
|
+
})
|
|
254
|
+
if (!res.ok) return null
|
|
255
|
+
const data = await res.json()
|
|
256
|
+
const v = data?.info?.version
|
|
257
|
+
return typeof v === 'string' && v.length > 0 ? v : null
|
|
258
|
+
} catch {
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
195
261
|
}
|
|
196
262
|
|
|
197
263
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* @
|
|
201
|
-
* @returns {Promise<string | null>} опублікована версія або `null` (нема пакета / офлайн)
|
|
264
|
+
* @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
|
|
265
|
+
* @param {(name: string, kind?: import('../../../../scripts/utils/package-manifest.mjs').PackageKind) => Promise<string | null>} getPublishedVersion
|
|
266
|
+
* @returns {Promise<string | null>}
|
|
202
267
|
*/
|
|
203
|
-
async function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
268
|
+
async function resolvePublishedVersion(manifest, getPublishedVersion) {
|
|
269
|
+
if (!manifest.name) return null
|
|
270
|
+
return getPublishedVersion(manifest.name, manifest.kind)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @returns {(name: string, kind?: import('../../../../scripts/utils/package-manifest.mjs').PackageKind) => Promise<string | null>}
|
|
275
|
+
*/
|
|
276
|
+
function createDefaultGetPublishedVersion() {
|
|
277
|
+
return async (name, kind = 'npm') => {
|
|
278
|
+
if (kind === 'python') {
|
|
279
|
+
return defaultGetPublishedPyPiVersion(name)
|
|
280
|
+
}
|
|
281
|
+
return defaultGetPublishedNpmVersion(name)
|
|
210
282
|
}
|
|
211
283
|
}
|
|
212
284
|
|
|
213
285
|
/**
|
|
214
|
-
*
|
|
215
|
-
* @param {
|
|
216
|
-
* @param {string
|
|
217
|
-
* @param {(msg: string) => void} pass callback при успішній перевірці
|
|
218
|
-
* @param {(msg: string) => void} fail callback при помилці
|
|
286
|
+
* @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
|
|
287
|
+
* @param {(msg: string) => void} pass
|
|
288
|
+
* @param {(msg: string) => void} fail
|
|
219
289
|
*/
|
|
220
|
-
function
|
|
221
|
-
if (
|
|
222
|
-
const pkgPath =
|
|
223
|
-
if (
|
|
290
|
+
function checkNpmFilesArrayContainsChangelog(manifest, pass, fail) {
|
|
291
|
+
if (manifest.kind !== 'npm' || !manifest.npmFiles) return
|
|
292
|
+
const pkgPath = manifestFilePath(manifest.ws, manifest)
|
|
293
|
+
if (manifest.npmFiles.includes('CHANGELOG.md')) {
|
|
224
294
|
pass(`${pkgPath}: files містить "CHANGELOG.md"`)
|
|
225
295
|
} else {
|
|
226
296
|
fail(`${pkgPath}: масив files має містити "CHANGELOG.md", щоб публікувати changelog із пакетом`)
|
|
@@ -228,12 +298,11 @@ function checkFilesArrayContainsChangelog(pkg, ws, pass, fail) {
|
|
|
228
298
|
}
|
|
229
299
|
|
|
230
300
|
/**
|
|
231
|
-
*
|
|
232
|
-
* @param {string}
|
|
233
|
-
* @param {string
|
|
234
|
-
* @param {(msg: string) => void}
|
|
235
|
-
* @
|
|
236
|
-
* @returns {Promise<boolean>} `false`, якщо файл відсутній або немає запису
|
|
301
|
+
* @param {string} ws
|
|
302
|
+
* @param {string} version
|
|
303
|
+
* @param {(msg: string) => void} pass
|
|
304
|
+
* @param {(msg: string) => void} fail
|
|
305
|
+
* @returns {Promise<boolean>}
|
|
237
306
|
*/
|
|
238
307
|
async function verifyChangelogEntry(ws, version, pass, fail) {
|
|
239
308
|
const label = ws === '.' ? '<root>' : ws
|
|
@@ -252,89 +321,147 @@ async function verifyChangelogEntry(ws, version, pass, fail) {
|
|
|
252
321
|
}
|
|
253
322
|
|
|
254
323
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* правило fail-safe пасує (щоб офлайн-розробка не блокувалась).
|
|
258
|
-
* @param {string} ws шлях воркспейсу (`'.'` для кореня)
|
|
259
|
-
* @param {Record<string, unknown>} pkg розпарсений `package.json` воркспейсу
|
|
260
|
-
* @param {(name: string) => Promise<string | null>} getPublishedVersion стаб/реальна функція отримання опублікованої версії
|
|
261
|
-
* @param {(msg: string) => void} pass callback при успішній перевірці
|
|
262
|
-
* @param {(msg: string) => void} fail callback при помилці
|
|
324
|
+
* @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
|
|
325
|
+
* @returns {string}
|
|
263
326
|
*/
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
327
|
+
function workspaceLabel(manifest) {
|
|
328
|
+
return manifest.ws === '.' ? '<root>' : manifest.ws
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
|
|
333
|
+
* @param {string} Vcurrent
|
|
334
|
+
* @param {string[]} subWorkspaces
|
|
335
|
+
* @param {(msg: string) => void} pass
|
|
336
|
+
* @param {(msg: string) => void} fail
|
|
337
|
+
* @returns {Promise<void>}
|
|
338
|
+
*/
|
|
339
|
+
async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail) {
|
|
340
|
+
const label = workspaceLabel(manifest)
|
|
341
|
+
const mf = manifestFilePath(manifest.ws, manifest)
|
|
342
|
+
if (!(await isInsideGitRepo())) {
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const branch = await currentBranchName()
|
|
347
|
+
if (isIntegrationBranch(branch)) {
|
|
348
|
+
if (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces)) {
|
|
349
|
+
fail(
|
|
350
|
+
`${label}: у registry-published пакеті є незакомічені зміни при version ${Vcurrent}, що вже в реєстрі. ` +
|
|
351
|
+
`Підвищ version у ${mf} і додай запис у CHANGELOG.md (n-changelog.mdc)`
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const baseRef = await resolveBaseRef()
|
|
358
|
+
if (!baseRef) {
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
const mergeBase = await resolveMergeBase(baseRef)
|
|
362
|
+
if (!mergeBase) {
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
if (!(await workspaceHasRelevantChangesAgainstBase(mergeBase, manifest.ws, subWorkspaces))) {
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const Vbase = await readBaseVersion(mergeBase, manifest)
|
|
370
|
+
const baseLabel = baseRefLabel(baseRef)
|
|
371
|
+
if (Vbase === null || Vbase === Vcurrent) {
|
|
372
|
+
fail(
|
|
373
|
+
`${label}: у цій гілці є зміни в registry-published пакеті, але version у ${mf} ` +
|
|
374
|
+
`не підвищено (на ${baseLabel} — ${Vbase ?? '∅'}). Bump + запис у CHANGELOG.md обов'язкові на PR (n-changelog.mdc)`
|
|
375
|
+
)
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
pass(`${label}: version змінено (${Vbase} → ${Vcurrent}) — очікується запис CHANGELOG після bump`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
|
|
383
|
+
* @param {string[]} subWorkspaces
|
|
384
|
+
* @param {(name: string, kind?: import('../../../../scripts/utils/package-manifest.mjs').PackageKind) => Promise<string | null>} getPublishedVersion
|
|
385
|
+
* @param {(msg: string) => void} pass
|
|
386
|
+
* @param {(msg: string) => void} fail
|
|
387
|
+
* @returns {Promise<void>}
|
|
388
|
+
*/
|
|
389
|
+
async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVersion, pass, fail) {
|
|
390
|
+
const label = workspaceLabel(manifest)
|
|
391
|
+
const mf = manifestFilePath(manifest.ws, manifest)
|
|
392
|
+
const Vcurrent = manifest.version
|
|
267
393
|
if (!Vcurrent) {
|
|
268
|
-
fail(`${label}: у
|
|
394
|
+
fail(`${label}: у ${mf} відсутнє поле version (registry-published воркспейс)`)
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
const name = manifest.name
|
|
398
|
+
if (!name) {
|
|
399
|
+
fail(`${label}: у ${mf} відсутнє ім'я пакета (registry-published воркспейс)`)
|
|
269
400
|
return
|
|
270
401
|
}
|
|
271
|
-
const
|
|
272
|
-
const Vpublished = await getPublishedVersion(name)
|
|
402
|
+
const Vpublished = await resolvePublishedVersion(manifest, getPublishedVersion)
|
|
273
403
|
if (Vpublished === null) {
|
|
274
404
|
pass(`${label}: ${name} — опублікована версія недоступна (мережа/реєстр), перевірку пропущено`)
|
|
275
405
|
return
|
|
276
406
|
}
|
|
277
407
|
if (Vpublished === Vcurrent) {
|
|
278
|
-
pass(`${label}: ${name}@${Vcurrent}
|
|
408
|
+
pass(`${label}: ${name}@${Vcurrent} збігається з реєстром — перевіряємо git на незрелізні зміни`)
|
|
409
|
+
await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail)
|
|
279
410
|
return
|
|
280
411
|
}
|
|
281
412
|
pass(`${label}: ${name} — нова локальна версія (${Vpublished} → ${Vcurrent})`)
|
|
282
|
-
await verifyChangelogEntry(ws, Vcurrent, pass, fail)
|
|
283
|
-
|
|
413
|
+
await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail)
|
|
414
|
+
checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
|
|
284
415
|
}
|
|
285
416
|
|
|
286
417
|
/**
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* @param {string}
|
|
290
|
-
* @param {string
|
|
291
|
-
* @param {
|
|
292
|
-
* @param {(msg: string) => void} pass callback при успішній перевірці
|
|
293
|
-
* @param {(msg: string) => void} fail callback при помилці
|
|
418
|
+
* @param {string} mergeBase
|
|
419
|
+
* @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
|
|
420
|
+
* @param {string} baseLabel
|
|
421
|
+
* @param {(msg: string) => void} pass
|
|
422
|
+
* @param {(msg: string) => void} fail
|
|
294
423
|
*/
|
|
295
|
-
async function checkLocalOnlyChangedWorkspace(mergeBase,
|
|
296
|
-
const label =
|
|
297
|
-
const
|
|
424
|
+
async function checkLocalOnlyChangedWorkspace(mergeBase, manifest, baseLabel, pass, fail) {
|
|
425
|
+
const label = workspaceLabel(manifest)
|
|
426
|
+
const mf = manifestFilePath(manifest.ws, manifest)
|
|
427
|
+
const Vcurrent = manifest.version
|
|
298
428
|
if (!Vcurrent) {
|
|
299
|
-
fail(`${label}: у
|
|
429
|
+
fail(`${label}: у ${mf} відсутнє поле version (потрібне для запису в CHANGELOG)`)
|
|
300
430
|
return
|
|
301
431
|
}
|
|
302
|
-
const Vbase = await readBaseVersion(mergeBase,
|
|
303
|
-
if (Vbase
|
|
432
|
+
const Vbase = await readBaseVersion(mergeBase, manifest)
|
|
433
|
+
if (Vbase === null || Vbase === Vcurrent) {
|
|
304
434
|
fail(
|
|
305
|
-
`${label}: у цій гілці є зміни, але version у ${
|
|
435
|
+
`${label}: у цій гілці є зміни, але version у ${mf} не підвищено (на ${baseLabel} — ${Vbase ?? '∅'}). Bump + запис у CHANGELOG.md обов'язкові на PR`
|
|
306
436
|
)
|
|
307
437
|
return
|
|
308
438
|
}
|
|
309
|
-
pass(`${label}: version підвищено (${Vbase
|
|
310
|
-
if (!(await verifyChangelogEntry(ws, Vcurrent, pass, fail))) return
|
|
311
|
-
|
|
439
|
+
pass(`${label}: version підвищено (${Vbase} → ${Vcurrent})`)
|
|
440
|
+
if (!(await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail))) return
|
|
441
|
+
checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
|
|
312
442
|
}
|
|
313
443
|
|
|
314
444
|
/**
|
|
315
|
-
*
|
|
316
|
-
* @param {string[]}
|
|
317
|
-
* @param {
|
|
318
|
-
* @param {string
|
|
319
|
-
* @param {(msg: string) => void} pass callback при успішній перевірці
|
|
320
|
-
* @param {(msg: string) => void} fail callback при помилці
|
|
321
|
-
* @returns {Promise<void>} резолвиться по завершенню перевірок усіх local-only воркспейсів
|
|
445
|
+
* @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest[]} localOnly
|
|
446
|
+
* @param {string[]} subWorkspaces
|
|
447
|
+
* @param {(msg: string) => void} pass
|
|
448
|
+
* @param {(msg: string) => void} fail
|
|
322
449
|
*/
|
|
323
|
-
async function runLocalOnlyChecks(
|
|
324
|
-
if (
|
|
450
|
+
async function runLocalOnlyChecks(localOnly, subWorkspaces, pass, fail) {
|
|
451
|
+
if (localOnly.length === 0) return
|
|
325
452
|
|
|
326
453
|
if (!(await isInsideGitRepo())) {
|
|
327
454
|
pass('changelog: не git-репозиторій — local-only перевірку пропущено')
|
|
328
455
|
return
|
|
329
456
|
}
|
|
330
457
|
const branch = await currentBranchName()
|
|
331
|
-
if (branch ===
|
|
332
|
-
pass(
|
|
458
|
+
if (branch === 'dev') {
|
|
459
|
+
pass('changelog: поточна гілка = dev — local-only перевірку пропущено')
|
|
333
460
|
return
|
|
334
461
|
}
|
|
335
462
|
const baseRef = await resolveBaseRef()
|
|
336
463
|
if (!baseRef) {
|
|
337
|
-
pass(
|
|
464
|
+
pass('changelog: ref dev/main (та origin/*) не знайдено — local-only перевірку пропущено')
|
|
338
465
|
return
|
|
339
466
|
}
|
|
340
467
|
const mergeBase = await resolveMergeBase(baseRef)
|
|
@@ -343,11 +470,12 @@ async function runLocalOnlyChecks(localOnlyWorkspaces, pkgByWs, subWorkspaces, p
|
|
|
343
470
|
return
|
|
344
471
|
}
|
|
345
472
|
|
|
473
|
+
const baseLabel = baseRefLabel(baseRef)
|
|
346
474
|
let checkedAny = false
|
|
347
|
-
for (const
|
|
348
|
-
if (!(await
|
|
475
|
+
for (const manifest of localOnly) {
|
|
476
|
+
if (!(await workspaceHasRelevantChangesAgainstBase(mergeBase, manifest.ws, subWorkspaces))) continue
|
|
349
477
|
checkedAny = true
|
|
350
|
-
await checkLocalOnlyChangedWorkspace(mergeBase,
|
|
478
|
+
await checkLocalOnlyChangedWorkspace(mergeBase, manifest, baseLabel, pass, fail)
|
|
351
479
|
}
|
|
352
480
|
if (!checkedAny) {
|
|
353
481
|
pass(`changelog: local-only воркспейси без змін відносно merge-base(${baseRef})`)
|
|
@@ -355,46 +483,40 @@ async function runLocalOnlyChecks(localOnlyWorkspaces, pkgByWs, subWorkspaces, p
|
|
|
355
483
|
}
|
|
356
484
|
|
|
357
485
|
/**
|
|
358
|
-
*
|
|
359
|
-
* @param {
|
|
360
|
-
* @
|
|
361
|
-
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
486
|
+
* @param {object} [opts]
|
|
487
|
+
* @param {(name: string, kind?: import('../../../../scripts/utils/package-manifest.mjs').PackageKind) => Promise<string | null>} [opts.getPublishedVersion] перевизначення npm/PyPI у тестах
|
|
488
|
+
* @returns {Promise<number>}
|
|
362
489
|
*/
|
|
363
490
|
export async function check(opts = {}) {
|
|
364
491
|
const reporter = createCheckReporter()
|
|
365
492
|
const { pass, fail } = reporter
|
|
366
|
-
const getPublishedVersion = opts.getPublishedVersion ??
|
|
493
|
+
const getPublishedVersion = opts.getPublishedVersion ?? createDefaultGetPublishedVersion()
|
|
367
494
|
|
|
368
|
-
const workspaces = await
|
|
495
|
+
const workspaces = await getMonorepoProjectRootDirs(process.cwd())
|
|
369
496
|
const subWorkspaces = workspaces.filter(w => w !== '.')
|
|
370
497
|
|
|
371
|
-
/** @type {
|
|
372
|
-
const
|
|
373
|
-
/** @type {
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
const localOnlyWorkspaces = []
|
|
498
|
+
/** @type {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest[]} */
|
|
499
|
+
const published = []
|
|
500
|
+
/** @type {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest[]} */
|
|
501
|
+
const localOnly = []
|
|
502
|
+
|
|
377
503
|
for (const ws of workspaces) {
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
504
|
+
const manifest = await readPackageManifest(ws)
|
|
505
|
+
if (!manifest) {
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
if (manifest.registryPublishable) {
|
|
509
|
+
published.push(manifest)
|
|
382
510
|
} else {
|
|
383
|
-
|
|
511
|
+
localOnly.push(manifest)
|
|
384
512
|
}
|
|
385
513
|
}
|
|
386
514
|
|
|
387
|
-
for (const
|
|
388
|
-
await checkPublishedWorkspace(
|
|
389
|
-
ws,
|
|
390
|
-
/** @type {Record<string, unknown>} */ (pkgByWs.get(ws)),
|
|
391
|
-
getPublishedVersion,
|
|
392
|
-
pass,
|
|
393
|
-
fail
|
|
394
|
-
)
|
|
515
|
+
for (const manifest of published) {
|
|
516
|
+
await checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVersion, pass, fail)
|
|
395
517
|
}
|
|
396
518
|
|
|
397
|
-
await runLocalOnlyChecks(
|
|
519
|
+
await runLocalOnlyChecks(localOnly, subWorkspaces, pass, fail)
|
|
398
520
|
|
|
399
521
|
return reporter.getExitCode()
|
|
400
522
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: AVIF-двійники для raster-зображень з ув'язуванням у .vue/.html
|
|
3
|
-
version: '1.
|
|
3
|
+
version: '1.3'
|
|
4
4
|
globs: "**/*.{png,jpg,jpeg,gif,avif,vue,html}"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -31,7 +31,7 @@ AVIF-двійники **зберігаємо в git** — це готові ар
|
|
|
31
31
|
|
|
32
32
|
## Опт-аут для конкретного пакета
|
|
33
33
|
|
|
34
|
-
У workspace-пакеті, де AVIF-імпорти небажані (наприклад, мобільний бандл або публічний сайт без гарантованої AVIF-підтримки), додай у `package.json` цього
|
|
34
|
+
У workspace-пакеті, де AVIF-імпорти небажані (наприклад, мобільний бандл або публічний сайт без гарантованої AVIF-підтримки), додай у `package.json` цього пакета. Заборонений typo `disabled-avif` (канон — `disable-avif`): [package.json.deny.json](./policy/package_json/template/package.json.deny.json).
|
|
35
35
|
|
|
36
36
|
```json title="apps/site/package.json"
|
|
37
37
|
{
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "hasura-jwt-public-config",
|
|
4
|
+
"policyType": "postureExceptionPolicy",
|
|
5
|
+
"actions": ["alertOnly"],
|
|
6
|
+
"resources": [
|
|
7
|
+
{
|
|
8
|
+
"designatorType": "Attributes",
|
|
9
|
+
"attributes": {
|
|
10
|
+
"kind": "ConfigMap",
|
|
11
|
+
"name": "hasura-config"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"posturePolicies": [
|
|
16
|
+
{
|
|
17
|
+
"controlID": "C-0012"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
]
|
package/rules/k8s/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.31'
|
|
4
4
|
globs: "**/k8s/**/*.yaml"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -35,6 +35,14 @@ alwaysApply: false
|
|
|
35
35
|
|
|
36
36
|
**kubescape:** типово **`kubescape scan <каталог-k8s>`**; поріг серйозності підлаштуй під проєкт (наприклад **`--severity-threshold high`**). Перший запуск може завантажувати артефакти — у CI потрібна мережа або [offline](https://github.com/kubescape/kubescape#readme). На відміну від kubeconform, у **kubescape scan** немає прапорця **`-kubernetes-version`**: перевірка йде за **framework/control** (NSA, MITRE, CIS тощо), а не проти OpenAPI-схеми конкретного релізу Kubernetes. **Орієнтир** для репозиторію той самий, що й для kubeconform — кластер **v1.33.9** (див. **`-kubernetes-version 1.33.9`** вище); для CIS і подібних наближень обирай актуальний framework під політику команди (**`kubescape list frameworks`**, див. [CLI reference](https://github.com/kubescape/kubescape/blob/master/docs/cli-reference.md)).
|
|
37
37
|
|
|
38
|
+
### Винятки kubescape: `.kubescape-exceptions.json`
|
|
39
|
+
|
|
40
|
+
Якщо в **корені проєкту** є файл **`.kubescape-exceptions.json`** — `lint-k8s` автоматично передає його в `kubescape scan` через **`--exceptions`** ([postureExceptionPolicy](https://github.com/kubescape/kubescape/blob/master/docs/exceptions.md)). Файл — JSON-масив об'єктів з полями `name`, `policyType: "postureExceptionPolicy"`, `actions` (`["alertOnly"]` — знижує fail до alert, не блокує lint), `resources` (resource designator) і `posturePolicies` (масив `controlID`).
|
|
41
|
+
|
|
42
|
+
Канонічний кейс — **C-0012** (`Applications credentials in configuration files`, High): control тригериться на **імʼя** env, що містить підрядок `secret`/`password`/`key`/`token`, а **не** на значення. Для `HASURA_GRAPHQL_JWT_SECRET` у ConfigMap значення — публічний JWT-конфіг (`jwk_url`, `issuer`), не credentials, але kubescape падає лише через імʼя. Точкове виключення для ConfigMap із цим env — канон: [.kubescape-exceptions.json.snippet.json](./fix/kubescape_exceptions/template/.kubescape-exceptions.json.snippet.json)
|
|
43
|
+
|
|
44
|
+
Підстав свою `attributes.name` (рядок або regex), якщо ConfigMap зветься інакше; виключай контрольно, а не глобально (не додавай винятки без `attributes.name`/`labels`, бо тоді C-0012 знімається для усіх ConfigMap-ів проєкту і реальні витоки credentials теж пройдуть).
|
|
45
|
+
|
|
38
46
|
У репозиторії пакета **`@nitra/cursor`** скрипт **`lint-k8s`** делегує до CLI **`n-cursor lint-k8s`** (реалізація — **`npm/rules/k8s/js/run.mjs`**). У інших проєктах достатньо встановити **`@nitra/cursor`** у `devDependencies` — бінарка **`n-cursor`** буде у **`node_modules/.bin/`**.
|
|
39
47
|
|
|
40
48
|
```json title="package.json"
|
package/rules/k8s/lint/lint.mjs
CHANGED
|
@@ -13,13 +13,17 @@
|
|
|
13
13
|
* Kubescape не має аналога цього прапорця; орієнтир цільового кластера — та сама лінія релізу (див. k8s.mdc).
|
|
14
14
|
*/
|
|
15
15
|
import { spawnSync } from 'node:child_process'
|
|
16
|
-
import {
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
17
18
|
|
|
18
19
|
import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
|
|
19
20
|
import { loadCursorIgnorePaths } from '../../../scripts/utils/load-cursor-config.mjs'
|
|
20
21
|
import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
|
|
21
22
|
import { walkDir } from '../../../scripts/utils/walkDir.mjs'
|
|
22
23
|
|
|
24
|
+
/** Per-project kubescape exceptions file; підмішується через --exceptions, якщо існує в корені. */
|
|
25
|
+
const KUBESCAPE_EXCEPTIONS_FILE = '.kubescape-exceptions.json'
|
|
26
|
+
|
|
23
27
|
const PATH_SEPARATOR_RE = /[/\\]/u
|
|
24
28
|
const YAML_EXT_RE = /\.yaml$/iu
|
|
25
29
|
|
|
@@ -118,20 +122,41 @@ function runKubeconform(dirs) {
|
|
|
118
122
|
return r.status ?? 1
|
|
119
123
|
}
|
|
120
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Будує аргументи `--exceptions <file>` для kubescape, якщо в корені проєкту є
|
|
127
|
+
* `.kubescape-exceptions.json`. Інакше — порожній масив.
|
|
128
|
+
* @param {string} root корінь репозиторію
|
|
129
|
+
* @returns {string[]} `['--exceptions', '<abs-path>']` або `[]`
|
|
130
|
+
*/
|
|
131
|
+
export function buildKubescapeExceptionsArgs(root) {
|
|
132
|
+
const exceptionsPath = join(root, KUBESCAPE_EXCEPTIONS_FILE)
|
|
133
|
+
return existsSync(exceptionsPath) ? ['--exceptions', exceptionsPath] : []
|
|
134
|
+
}
|
|
135
|
+
|
|
121
136
|
/**
|
|
122
137
|
* Запускає kubescape scan для кожного каталогу окремо (узгоджено з прикладами CLI).
|
|
123
138
|
* Немає прапорця версії Kubernetes — за потреби додай `scan framework <ім’я>` під CIS/інші набори.
|
|
139
|
+
*
|
|
140
|
+
* Якщо в корені проєкту є `.kubescape-exceptions.json` — підмішується через `--exceptions <file>`.
|
|
141
|
+
* Файл потрібен для точкових винятків control'ів kubescape (напр. C-0012 на ConfigMap, що містить
|
|
142
|
+
* публічний JWT-конфіг типу `HASURA_GRAPHQL_JWT_SECRET={"jwk_url": "https://…"}` — control тригериться
|
|
143
|
+
* на ім'я env, а не на значення; див. приклад у `k8s.mdc`).
|
|
124
144
|
* @param {string[]} dirs абсолютні шляхи до `…/k8s`
|
|
145
|
+
* @param {string} root корінь репозиторію (для пошуку exceptions-файлу)
|
|
125
146
|
* @returns {number} 0 при успіху, інакше код останнього невдалого scan або 127, якщо kubescape відсутній у PATH
|
|
126
147
|
*/
|
|
127
|
-
function runKubescape(dirs) {
|
|
148
|
+
function runKubescape(dirs, root) {
|
|
149
|
+
const exceptionsArgs = buildKubescapeExceptionsArgs(root)
|
|
150
|
+
if (exceptionsArgs.length > 0) {
|
|
151
|
+
console.log(`run-k8s: kubescape exceptions — ${KUBESCAPE_EXCEPTIONS_FILE}`)
|
|
152
|
+
}
|
|
128
153
|
for (const d of dirs) {
|
|
129
154
|
const kubescapePath = resolveCmd('kubescape')
|
|
130
155
|
if (!kubescapePath) {
|
|
131
156
|
console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
|
|
132
157
|
return 127
|
|
133
158
|
}
|
|
134
|
-
const r = spawnSync(kubescapePath, ['scan', d, '--severity-threshold', 'high'], {
|
|
159
|
+
const r = spawnSync(kubescapePath, ['scan', d, '--severity-threshold', 'high', ...exceptionsArgs], {
|
|
135
160
|
stdio: 'inherit',
|
|
136
161
|
shell: false
|
|
137
162
|
})
|
|
@@ -165,7 +190,7 @@ export async function runLintK8s() {
|
|
|
165
190
|
const kc = runKubeconform(dirs)
|
|
166
191
|
if (kc !== 0) return kc
|
|
167
192
|
|
|
168
|
-
const ks = runKubescape(dirs)
|
|
193
|
+
const ks = runKubescape(dirs, root)
|
|
169
194
|
return ks
|
|
170
195
|
}
|
|
171
196
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Правила стилів CSS та SCSS
|
|
3
|
-
version: '1.
|
|
3
|
+
version: '1.4'
|
|
4
4
|
globs: "**/*.{css,scss,vue}"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -12,6 +12,25 @@ alwaysApply: false
|
|
|
12
12
|
- **Запуск stylelint:** лише **`npx stylelint`**. Локально — через скрипт **`lint-style`** (`bun run lint-style`); у **GitHub Actions** у кроці **`run`** викликай `npx stylelint '**/*.{css,scss,vue}' --fix` напряму (не через **`bun run lint-style`**). Не використовуй **`bunx stylelint`**. Після змін запускай **`bun run lint-style`** і виправляй усе, що лишилось після auto-fix; за потреби — повний `bun run lint` (навичка **`/n-lint`**).
|
|
13
13
|
- **Не розширюй винятки:** не додавай зайві **`stylelint-disable`** без потреби; краще підлаштувати стилі під правила проєкту.
|
|
14
14
|
|
|
15
|
+
## Канон
|
|
16
|
+
|
|
17
|
+
### `package.json`
|
|
18
|
+
|
|
19
|
+
- `lint-style` (substring requirement): [package.json.contains.json](./policy/package_json/template/package.json.contains.json)
|
|
20
|
+
- `stylelint.extends`: [package.json.snippet.json](./policy/package_json/template/package.json.snippet.json)
|
|
21
|
+
|
|
22
|
+
### `.vscode/extensions.json`
|
|
23
|
+
|
|
24
|
+
- Канон `recommendations`: [extensions.json.snippet.json](./policy/vscode_extensions/template/extensions.json.snippet.json)
|
|
25
|
+
|
|
26
|
+
### `.vscode/settings.json`
|
|
27
|
+
|
|
28
|
+
- Вимкнення вбудованої CSS-валідації VS Code: [settings.json.snippet.json](./policy/vscode_settings/template/settings.json.snippet.json)
|
|
29
|
+
|
|
30
|
+
### CI: `.github/workflows/lint-style.yml`
|
|
31
|
+
|
|
32
|
+
- Канон: [lint-style.yml.snippet.yml](./policy/lint_style_yml/template/lint-style.yml.snippet.yml)
|
|
33
|
+
|
|
15
34
|
**`package.json`:**
|
|
16
35
|
|
|
17
36
|
```json title="package.json"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Уніфікований маніфест пакета для перевірок changelog: `package.json` (npm/JS)
|
|
3
|
+
* або `pyproject.toml` (Python / PEP 621, Poetry).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { glob, readFile } from 'node:fs/promises'
|
|
7
|
+
import { dirname, join, relative } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { parse as parseToml } from 'smol-toml'
|
|
10
|
+
|
|
11
|
+
import { getMonorepoPackageRootDirs } from './workspaces.mjs'
|
|
12
|
+
|
|
13
|
+
/** @typedef {'npm' | 'python'} PackageKind */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} PackageManifest
|
|
17
|
+
* @property {PackageKind} kind
|
|
18
|
+
* @property {string} ws відносний шлях воркспейсу (`'.'` для кореня)
|
|
19
|
+
* @property {string} manifestRel `package.json` | `pyproject.toml`
|
|
20
|
+
* @property {string | null} name ім'я пакета (npm / PyPI)
|
|
21
|
+
* @property {string | null} version semver-рядок
|
|
22
|
+
* @property {boolean} registryPublishable чи застосовується режим порівняння з реєстром
|
|
23
|
+
* @property {string[] | null} [npmFiles] лише npm: `files` з package.json
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const PYPROJECT_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**', '**/.venv/**', '**/venv/**']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} doc розпарсений pyproject.toml
|
|
30
|
+
* @returns {{ name: string | null, version: string | null }}
|
|
31
|
+
*/
|
|
32
|
+
function projectFieldsFromPyprojectDoc(doc) {
|
|
33
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
34
|
+
return { name: null, version: null }
|
|
35
|
+
}
|
|
36
|
+
const root = /** @type {Record<string, unknown>} */ (doc)
|
|
37
|
+
const project = root.project
|
|
38
|
+
if (project && typeof project === 'object' && !Array.isArray(project)) {
|
|
39
|
+
const p = /** @type {Record<string, unknown>} */ (project)
|
|
40
|
+
return {
|
|
41
|
+
name: typeof p.name === 'string' ? p.name : null,
|
|
42
|
+
version: typeof p.version === 'string' ? p.version : null,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const tool = root.tool
|
|
46
|
+
if (tool && typeof tool === 'object' && !Array.isArray(tool)) {
|
|
47
|
+
const poetry = /** @type {Record<string, unknown>} */ (tool).poetry
|
|
48
|
+
if (poetry && typeof poetry === 'object' && !Array.isArray(poetry)) {
|
|
49
|
+
const po = /** @type {Record<string, unknown>} */ (poetry)
|
|
50
|
+
return {
|
|
51
|
+
name: typeof po.name === 'string' ? po.name : null,
|
|
52
|
+
version: typeof po.version === 'string' ? po.version : null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { name: null, version: null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} text вміст pyproject.toml
|
|
61
|
+
* @returns {{ name: string | null, version: string | null }}
|
|
62
|
+
*/
|
|
63
|
+
export function parsePyprojectFields(text) {
|
|
64
|
+
try {
|
|
65
|
+
return projectFieldsFromPyprojectDoc(parseToml(text))
|
|
66
|
+
} catch {
|
|
67
|
+
return { name: null, version: null }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} ws шлях воркспейсу
|
|
73
|
+
* @returns {Promise<PackageManifest | null>}
|
|
74
|
+
*/
|
|
75
|
+
export async function readPackageManifest(ws) {
|
|
76
|
+
const pkgPath = join(ws, 'package.json')
|
|
77
|
+
if (existsSync(pkgPath)) {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
80
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
const pkg = /** @type {Record<string, unknown>} */ (parsed)
|
|
84
|
+
const registryPublishable =
|
|
85
|
+
typeof pkg.name === 'string' &&
|
|
86
|
+
pkg.name.length > 0 &&
|
|
87
|
+
pkg.private !== true &&
|
|
88
|
+
Array.isArray(pkg.files)
|
|
89
|
+
return {
|
|
90
|
+
kind: 'npm',
|
|
91
|
+
ws,
|
|
92
|
+
manifestRel: 'package.json',
|
|
93
|
+
name: typeof pkg.name === 'string' ? pkg.name : null,
|
|
94
|
+
version: typeof pkg.version === 'string' ? pkg.version : null,
|
|
95
|
+
registryPublishable,
|
|
96
|
+
npmFiles: Array.isArray(pkg.files) ? pkg.files : null,
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const pyPath = join(ws, 'pyproject.toml')
|
|
104
|
+
if (!existsSync(pyPath)) {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
const fields = parsePyprojectFields(await readFile(pyPath, 'utf8'))
|
|
108
|
+
const registryPublishable = Boolean(fields.name && fields.version)
|
|
109
|
+
return {
|
|
110
|
+
kind: 'python',
|
|
111
|
+
ws,
|
|
112
|
+
manifestRel: 'pyproject.toml',
|
|
113
|
+
name: fields.name,
|
|
114
|
+
version: fields.version,
|
|
115
|
+
registryPublishable,
|
|
116
|
+
npmFiles: null,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Каталоги пакетів: npm (`package.json` / workspaces) + Python (`pyproject.toml` без package.json).
|
|
122
|
+
* @param {string} [repoRoot]
|
|
123
|
+
* @returns {Promise<string[]>}
|
|
124
|
+
*/
|
|
125
|
+
export async function getMonorepoProjectRootDirs(repoRoot = '.') {
|
|
126
|
+
const roots = new Set(await getMonorepoPackageRootDirs(repoRoot))
|
|
127
|
+
|
|
128
|
+
if (existsSync(join(repoRoot, 'pyproject.toml')) && !existsSync(join(repoRoot, 'package.json'))) {
|
|
129
|
+
roots.add('.')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for await (const relPy of glob('**/pyproject.toml', { cwd: repoRoot, ignore: PYPROJECT_GLOB_IGNORE })) {
|
|
133
|
+
const absDir = dirname(join(repoRoot, relPy))
|
|
134
|
+
const relRoot = relative(repoRoot, absDir)
|
|
135
|
+
const ws = relRoot === '' ? '.' : relRoot
|
|
136
|
+
if (!existsSync(join(repoRoot, ws, 'package.json'))) {
|
|
137
|
+
roots.add(ws)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const list = [...roots]
|
|
142
|
+
list.sort((a, b) => {
|
|
143
|
+
if (a === '.') return -1
|
|
144
|
+
if (b === '.') return 1
|
|
145
|
+
return a.localeCompare(b)
|
|
146
|
+
})
|
|
147
|
+
return list
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Шлях до файлу маніфесту воркспейсу.
|
|
152
|
+
* @param {string} ws
|
|
153
|
+
* @param {PackageManifest} manifest
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
export function manifestFilePath(ws, manifest) {
|
|
157
|
+
return join(ws, manifest.manifestRel)
|
|
158
|
+
}
|