@nitra/cursor 1.13.28 → 1.13.31
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 +19 -0
- package/package.json +1 -1
- package/rules/changelog/changelog.mdc +44 -19
- package/rules/changelog/fix/consistency/check.mjs +318 -196
- package/scripts/utils/package-manifest.mjs +158 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@
|
|
|
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.31] - 2026-05-18
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `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.
|
|
12
|
+
|
|
13
|
+
## [1.13.30] - 2026-05-18
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `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. Тести.
|
|
18
|
+
|
|
19
|
+
## [1.13.29] - 2026-05-18
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `changelog` rule (`n-changelog.mdc` `2.1` → `2.2`): розширені `globs` (типові шляхи пакета + `*.rego` / `*.mdc`) — правило потрапляє в контекст агента при правках коду, не лише `package.json` / `CHANGELOG.md`; секція «Чеклист агента» для будь-якого репозиторію з правилом.
|
|
24
|
+
- `changelog/fix/consistency/check.mjs`: npm-published режим — якщо `version` збігається з реєстром, але в git є зміни workspace без bump (feature vs `dev` або незакомічене на `dev`) → fail. Регресійні тести.
|
|
25
|
+
|
|
7
26
|
## [1.13.28] - 2026-05-18
|
|
8
27
|
|
|
9
28
|
### 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
|
}
|
|
@@ -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
|
+
}
|