@nitra/cursor 1.30.0 → 1.34.1
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 +65 -1
- package/bin/n-cursor.js +13 -1
- package/github-actions/release/action.yml +9 -0
- package/package.json +1 -1
- package/rules/changelog/changelog.mdc +6 -4
- package/rules/changelog/js/consistency.mjs +20 -0
- package/rules/ci4/ci4.mdc +14 -2
- package/rules/ci4/js/data/marksman_config/marksman.baseline.toml +27 -0
- package/rules/ci4/js/marksman_config.mjs +52 -0
- package/rules/ci4/policy/vscode_extensions/target.json +4 -0
- package/rules/ci4/policy/vscode_extensions/template/extensions.json.snippet.json +3 -0
- package/rules/ci4/policy/vscode_extensions/vscode_extensions.rego +12 -0
- package/rules/nginx-default-tpl/js/template.mjs +1 -0
- package/rules/release/change.mjs +57 -0
- package/rules/release/fix.mjs +17 -0
- package/rules/release/lib/aggregate.mjs +82 -0
- package/rules/release/lib/change-file.mjs +99 -0
- package/rules/release/lib/fallback.mjs +48 -0
- package/rules/release/release.mjs +135 -0
- package/rules/test/coverage/coverage.mjs +3 -1
- package/rules/test/js/no-relative-fs-path.mjs +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,73 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.34.1] - 2026-05-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Нові тест-файли (10 файлів, 108+ тестів): rules/abie/js/tests/ (firebase_hosting, env_dns, hc_pairing, ua_node_selector, ua_http_route), rules/abie/lib/tests/hc-yaml.test.mjs, rules/abie/lib/tests/k8s-tree.test.mjs, rules/docker/lib/tests/docker-hadolint.test.mjs, rules/docker/lint/tests/lint.test.mjs, scripts/tests/coverage-fix.test.mjs; розширено scripts/tests/upgrade-nitra-cursor-and-install.test.mjs. Lines coverage: 77.62% → 78.80%, Functions: 84.47% → 86.13%.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- coverage: не додавати підсумковий рядок "Разом" коли провайдер один (дублював би єдиний рядок)
|
|
12
|
+
|
|
3
13
|
Усі помітні зміни цього модуля документуються тут.
|
|
4
14
|
|
|
5
15
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
|
-
|
|
16
|
+
|
|
17
|
+
## [1.34.0] - 2026-05-30
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Тести для `bun-sql-scan.mjs`: `findPgFormatShimDefinitionInText`, `findPgFormatLikeQueryWrapperInText`, `findUnsafeBunSqlInListMissingEmptyGuardInText`, `findPgListenNotifyUsageInText`
|
|
22
|
+
- Тести для `capacitor/platforms.mjs`: semver edge-cases, `recordCapacitorFromOnePackageJson`, `collectCapacitorDataFromAllPackageJson`, nitra-exceptions via config files
|
|
23
|
+
- Тести для `ga/workflows.mjs`: no .github/workflows dir, .yaml extension, MegaLinter detection, apply-k8s paths trigger
|
|
24
|
+
- Тести для `nginx-default-tpl/template.mjs` і `vue/packages.mjs` через check-rule-fixtures.test.mjs
|
|
25
|
+
|
|
26
|
+
## [1.33.0] - 2026-05-30
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `n-cursor change` / `n-cursor release` — change-файли `<ws>/.changes/*.md` замість ручного bump/CHANGELOG; реліз агрегує їх у CI, ставить git-тег `<name>@<version>`. Підтримка npm і Python workspace.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `n-changelog.mdc` v3.0: feature-флоу кладе change-файл; `fix changelog` приймає change-файл або ручний bump.
|
|
35
|
+
|
|
36
|
+
## [1.32.0] - 2026-05-30
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`rules/ci4/js/marksman_config.mjs`** + **`rules/ci4/js/data/marksman_config/marksman.baseline.toml`** — новий JS-концерн `marksman_config` за зразком `test.stryker_config`: при `npx @nitra/cursor fix ci4` копіює canonical `.marksman.toml` baseline у корінь cwd, якщо файлу ще немає. Idempotent через паттерн `ensureBaselineFile` (existsSync → pass+skip vs copyFile → pass+create) — ручні правки користувача між прогонами зберігаються, повторний прогон не перетирає. Baseline містить три ключові опції: `[core] markdown.glfm = true` (GLFM-фічі portable subset — alerts/таблиці/todo), `[completion] wiki.style = "file-stem"` (ADR slug == ім'я файла; стабільний ідентифікатор у AUTOGEN `sources`/manifest/валідаторі — заголовок міняється, посилання не ламається), `[code_action] toc.enable = true` (Insert/Update TOC code action для довгих arc42-сторінок). Дефолти marksman (`title-slug-ref` + вимкнений GLFM) ламали б частину задокументованої навігації. Розміщення в `cwd` (корені репо) робить весь монорепо одним marksman-workspace — README.md і docs/ перехресно навігуються. Тести: `+4` сценарії у `rules/ci4/js/tests/marksman_config.test.mjs` через `withTmpDir` (порожній cwd → файл створюється; idempotency — кастомний контент не перетирається; валідні TOML-секції `[core]`/`[completion]`/`[code_action]`; exit 0 у обох сценаріях). 4/4 PASS через `bunx vitest run rules/ci4/js/tests/marksman_config.test.mjs`.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **`rules/ci4/ci4.mdc`** (`version` 3.1 → 3.2) — у секцію «Viewer/editor: Zed + marksman LSP» додано параграф про авто-створення `.marksman.toml` правилом ci4 з посиланням на canonical baseline і поясненням кожної з трьох ключових опцій (glfm/wiki.style/toc.enable). У frontmatter `description` додано пункт про `.marksman.toml`. Дзеркало `.cursor/rules/n-ci4.mdc` синхронізується наступним прогоном `npx @nitra/cursor`.
|
|
45
|
+
|
|
46
|
+
## [1.31.0] - 2026-05-30
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`rules/ci4/policy/vscode_extensions/`** — нова policy за зразком text/style-lint/rego/ga/js-lint/graphql/nginx-default-tpl/rust/tauri: `vscode_extensions.rego` (deny якщо рекомендація з template-snippet відсутня в `.vscode/extensions.json`) + `vscode_extensions_test.rego` (5 сценаріїв: canonical, missing marksman, empty recommendations, extra recommendations пропускаються, drift-test що канон керується через `data.template`) + `template/extensions.json.snippet.json` (`{"recommendations": ["arr.marksman"]}`) + `target.json` (single `.vscode/extensions.json`). Призначення — контрибʼютори, що працюють у VSCode/Cursor замість Zed, отримують той самий шар marksman-навігації (cmd+click по `[link](file.md)`/`[[wiki-link]]`, find-references, refactor-перейменування заголовків) через офіційне розширення marksman LSP. Дзеркальна правка у репо: `.vscode/extensions.json` отримав `arr.marksman` у `recommendations`. Тести: 5/5 PASS через `opa test npm/rules/ci4/policy/vscode_extensions/`.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- **`rules/ci4/ci4.mdc`** (`version` 3.0 → 3.1) — у секцію «Viewer/editor: Zed + marksman LSP» додано підсекцію **«VSCode-альтернатива»** з канонічним JSON-блоком `.vscode/extensions.json` (`recommendations: ["arr.marksman"]`) і посиланням на snippet-файл policy (за стилем `text.mdc`); також згадка про marksman-сумісні редактори (Neovim, Helix, Emacs). У frontmatter `description` додано пункт про VSCode-розширення. Дзеркало `.cursor/rules/n-ci4.mdc` синхронізується наступним прогоном `npx @nitra/cursor`.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **`vitest.config.js`** — git-залежні тести (`rules/changelog/check.test.mjs`, `rules/ga/workflows.test.mjs`) масово таймаутили локально (`23 failed | 13 passed`, 187s; усі фейли — `Test timed out in 5000ms`, не assertion), хоча в CI зелені. Першопричина: глобальний `~/.gitconfig` тестової машини має `trace2.eventtarget=af_unix:stream:~/.git-ai/.../trace2.sock` (tooling `git-ai`), який успадковується tmp-репо в тестах → кожна git-команда під'єднується до Unix-сокета даемона; коли даемон деградований, запис у сокет блокується (~1s/команда не на CPU), а під `pool: 'forks'` десятки паралельних git-операцій × латентність > 5000ms `testTimeout`. Фікс: `env: { GIT_TRACE2_EVENT: '0' }` прибирає trace2-залежність із гарячого шляху тестового git (root-cause), плюс `testTimeout: 20000` як defence-in-depth проти будь-якої залишкової локальної I/O-латентності. Після фіксу `bun run vitest run rules/changelog/` → `36 passed` за ~7s (відтворювано); повний suite — `1248 passed | 2 skipped`. CI не зачеплено (там немає trace2-таргета).
|
|
59
|
+
|
|
60
|
+
## [1.30.1] - 2026-05-29
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- **`rules/test/coverage/tests/coverage.test.mjs`** — три нові тести `/n-coverage-fix`-ітерації, що вбивають усі чотири вцілілі мутанти на `rules/test/coverage/coverage.mjs`. (1) `opts.fix=false → fixSurvivedMutants НЕ викликається` і (2) `opts.fix=true → fixSurvivedMutants викликається` фіксують умовну гілку `if (opts.fix)` (L189) через лог `'✓ Всі мутанти вбиті — доповнення тестів не потрібне'`, який друкує `fixSurvivedMutants` для порожнього `survived[]`. (3) `source 2-ї стрілки містить fix:false` перевіряє джерело захопленої callback-стрілки 2-го `withLock` через `Function.prototype.toString()`: токени `fix` і `false` мають бути присутні, а `fix: true` — заборонений; це поведінково невловимо (`{}` і `{ fix: false }` дають однаковий falsy `opts.fix`), тому інваріант на рівні джерела.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **`CHANGELOG.md`** — заголовок секції `[1.30.0]` піднято з `#` на `##` (Keep a Changelog vN.M.M вимагає H2 для версій). Чек `npm-module.mdc` зчитував h1 як «без версії», знаходив `[1.29.5]` першою і скаржився на розбіжність із `package.json#version "1.30.0"`.
|
|
69
|
+
|
|
70
|
+
## [1.30.0] - 2026-05-29
|
|
7
71
|
|
|
8
72
|
### Changed
|
|
9
73
|
|
package/bin/n-cursor.js
CHANGED
|
@@ -1501,6 +1501,18 @@ try {
|
|
|
1501
1501
|
|
|
1502
1502
|
break
|
|
1503
1503
|
}
|
|
1504
|
+
case 'change': {
|
|
1505
|
+
const { runChangeCli } = await import('../rules/release/change.mjs')
|
|
1506
|
+
process.exitCode = await runChangeCli(args)
|
|
1507
|
+
|
|
1508
|
+
break
|
|
1509
|
+
}
|
|
1510
|
+
case 'release': {
|
|
1511
|
+
const { runReleaseCli } = await import('../rules/release/release.mjs')
|
|
1512
|
+
process.exitCode = await runReleaseCli(args)
|
|
1513
|
+
|
|
1514
|
+
break
|
|
1515
|
+
}
|
|
1504
1516
|
case 'skill': {
|
|
1505
1517
|
process.exitCode = runSkillsCli(args)
|
|
1506
1518
|
|
|
@@ -1515,7 +1527,7 @@ try {
|
|
|
1515
1527
|
default: {
|
|
1516
1528
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1517
1529
|
console.error(
|
|
1518
|
-
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, skill`
|
|
1530
|
+
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, change, release, skill`
|
|
1519
1531
|
)
|
|
1520
1532
|
process.exitCode = 1
|
|
1521
1533
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння (npm і Python)
|
|
3
|
-
version: '
|
|
3
|
+
version: '3.0'
|
|
4
4
|
alwaysApply: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -8,9 +8,11 @@ alwaysApply: true
|
|
|
8
8
|
|
|
9
9
|
> **Якщо в цій сесії ти змінив(ла) файли в пакетному workspace** (код, rego, правила, скіли, скрипти, конфіги, тести — **не** лише `docs/` / `doc/`) — **не завершуй задачу**, поки не виконаєш **усі три** кроки нижче в **тому ж** наборі змін. Це не «опційно після синку» — це частина PR.
|
|
10
10
|
|
|
11
|
-
1.
|
|
12
|
-
2.
|
|
13
|
-
3. **`npx @nitra/cursor fix changelog`** (
|
|
11
|
+
1. **Поклади change-файл** `<ws>/.changes/<timestamp>-<rand>.md` з frontmatter `bump:` (`major|minor|patch`) + `section:` (`Added|Changed|Fixed|Removed`) і текстом опису. Команда: `npx @nitra/cursor change --bump <…> --section <…> --message "<…>" [--ws <шлях>]`.
|
|
12
|
+
2. **Не** редагуй `version` і `CHANGELOG.md` вручну — їх формує `n-cursor release` у CI на `main` (агрегує change-файли, ставить git-тег `<name>@<version>`).
|
|
13
|
+
3. **`npx @nitra/cursor fix changelog`** → exit **`0`** (м'яка перевірка: достатньо наявності change-файлу **або** вже піднятого `version`).
|
|
14
|
+
|
|
15
|
+
**Legacy / hotfix:** ручний bump `version` + новий запис у `CHANGELOG.md` усе ще приймається перевіркою як альтернатива change-файлу.
|
|
14
16
|
|
|
15
17
|
**Тригер шляхів (приклади):** `npm/**`, `packages/foo/**`, будь-який каталог з власним `package.json` / `pyproject.toml`, куди потрапили правки.
|
|
16
18
|
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
parsePyprojectFields,
|
|
30
30
|
readPackageManifest
|
|
31
31
|
} from '../lib/package-manifest.mjs'
|
|
32
|
+
import { readChangeFiles } from '../../release/lib/change-file.mjs'
|
|
32
33
|
|
|
33
34
|
const execFileAsync = promisify(execFile)
|
|
34
35
|
|
|
@@ -395,6 +396,17 @@ function workspaceLabel(manifest) {
|
|
|
395
396
|
return manifest.ws === '.' ? '<root>' : manifest.ws
|
|
396
397
|
}
|
|
397
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Чи має workspace незрелізні change-файли (намір зафіксовано — bump зробить CI).
|
|
401
|
+
* @param {string} ws workspace
|
|
402
|
+
* @param {string} cwd корінь
|
|
403
|
+
* @returns {Promise<boolean>} результат
|
|
404
|
+
*/
|
|
405
|
+
async function hasPendingChangeFiles(ws, cwd) {
|
|
406
|
+
const files = await readChangeFiles(ws, cwd)
|
|
407
|
+
return files.length > 0
|
|
408
|
+
}
|
|
409
|
+
|
|
398
410
|
/**
|
|
399
411
|
* @param {import('../lib/package-manifest.mjs').PackageManifest} manifest параметр
|
|
400
412
|
* @param {string} Vcurrent параметр
|
|
@@ -407,6 +419,10 @@ function workspaceLabel(manifest) {
|
|
|
407
419
|
async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd) {
|
|
408
420
|
const label = workspaceLabel(manifest)
|
|
409
421
|
const mf = manifestFilePath(manifest.ws, manifest)
|
|
422
|
+
if (await hasPendingChangeFiles(manifest.ws, cwd)) {
|
|
423
|
+
pass(`${label}: є change-файл(и) у .changes/ — bump зробить CI (n-changelog.mdc)`)
|
|
424
|
+
return
|
|
425
|
+
}
|
|
410
426
|
if (!(await isInsideGitRepo(cwd))) {
|
|
411
427
|
return
|
|
412
428
|
}
|
|
@@ -502,6 +518,10 @@ async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVers
|
|
|
502
518
|
async function checkLocalOnlyChangedWorkspace(comparisonRef, manifest, baseLabel, pass, fail, cwd) {
|
|
503
519
|
const label = workspaceLabel(manifest)
|
|
504
520
|
const mf = manifestFilePath(manifest.ws, manifest)
|
|
521
|
+
if (await hasPendingChangeFiles(manifest.ws, cwd)) {
|
|
522
|
+
pass(`${label}: є change-файл(и) у .changes/ — bump зробить CI (n-changelog.mdc)`)
|
|
523
|
+
return
|
|
524
|
+
}
|
|
505
525
|
const Vcurrent = manifest.version
|
|
506
526
|
if (!Vcurrent) {
|
|
507
527
|
fail(`${label}: у ${mf} відсутнє поле version (потрібне для запису в CHANGELOG)`)
|
package/rules/ci4/ci4.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Архітектурна документація продукту — Markdown як джерело істини; рекомендований стек arc42 + Diátaxis + ADR (MADR v4, формат описаний у правилі `adr`) + C4 як набір нотацій; гібридна модель manual + autogen-зон, що регенеруються з accepted ADR; Zed + marksman LSP як viewer без site-generator-а, portable subset (CommonMark + GFM + Mermaid + KaTeX), collapsible engineer-блоки через нативний `<details
|
|
2
|
+
description: Архітектурна документація продукту — Markdown як джерело істини; рекомендований стек arc42 + Diátaxis + ADR (MADR v4, формат описаний у правилі `adr`) + C4 як набір нотацій; гібридна модель manual + autogen-зон, що регенеруються з accepted ADR; Zed + marksman LSP як viewer без site-generator-а, portable subset (CommonMark + GFM + Mermaid + KaTeX), collapsible engineer-блоки через нативний `<details>`; рекомендоване VSCode-розширення `arr.marksman` для контрибʼюторів поза Zed; `.marksman.toml` авто-створюється у корені проєкту з canonical baseline
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '3.
|
|
4
|
+
version: '3.2'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Архітектурна документація проєкту живе у Markdown поряд із кодом. Це не довідник «для людей із порталу архітектора» — це **джерело істини**, з якого LLM-агент і людина читають намір системи перед будь-якою зміною коду. Тому правила нижче — не оформлення, а робочий процес: який стек використовуємо, як зберігаємо рішення, як автоматично перегенеровуємо проекції з ADR і як рендеримо для змішаної аудиторії (менеджери + інженери + ops).
|
|
@@ -208,6 +208,18 @@ User Service відповідає за автентифікацію та про
|
|
|
208
208
|
}
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
+
**`.marksman.toml`** у корені проєкту авто-створюється правилом ci4 при першому `npx @nitra/cursor fix ci4` із canonical baseline ([data/marksman_config/marksman.baseline.toml](./js/data/marksman_config/marksman.baseline.toml)). Ключові опції — `markdown.glfm = true` (потрібен для GFM-alerts/таблиць/todo з portable subset), `[completion] wiki.style = "file-stem"` (ADR slug == ім'я файла, стабільний ідентифікатор у AUTOGEN `sources`/manifest/валідаторі — заголовок змінюється, посилання не ламається), `[code_action] toc.enable = true` (TOC code action для довгих arc42-сторінок). Без явного конфіга marksman використовує `title-slug-ref` і вимкнений GLFM — частина задокументованої навігації працювала б інакше. Ручні правки конфіга не перетираються — `ensureBaselineFile` ідемпотентний.
|
|
212
|
+
|
|
213
|
+
**VSCode-альтернатива.** Контрибʼютори, що працюють у VSCode/Cursor замість Zed, отримують той самий шар навігації через офіційне розширення marksman LSP. Канонічний запис у `.vscode/extensions.json`:
|
|
214
|
+
|
|
215
|
+
```json title=".vscode/extensions.json"
|
|
216
|
+
{
|
|
217
|
+
"recommendations": ["arr.marksman"]
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Канон `recommendations` (substring requirement): [extensions.json.snippet.json](./policy/vscode_extensions/template/extensions.json.snippet.json). Інші marksman-сумісні редактори (Neovim, Helix, Emacs) налаштовують `marksman` як LSP-сервер за документацією свого редактора — поведінка ідентична (cmd+click по `[link](file.md)`/`[[wiki-link]]`, find-references, refactor-перейменування).
|
|
222
|
+
|
|
211
223
|
**Portable-only синтаксис.** Усе, що пишемо в `docs/`, обмежене **CommonMark + GFM + Mermaid у fenced code (` ```mermaid `) + KaTeX (`$...$`) + нативний HTML5 `<details>`**. Заборонено:
|
|
212
224
|
|
|
213
225
|
- pymdownx admonitions (`!!! note`, `??? engineer`, `=== "tab"`)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Workspace-маркер marksman LSP. У корені репо робить весь монорепо одним
|
|
2
|
+
# marksman-workspace — README.md і docs/ перехресно навігуються через
|
|
3
|
+
# [text](file.md) і [[wiki-link]]. Файли .mdc не індексуються — marksman
|
|
4
|
+
# дивиться лише на розширення нижче.
|
|
5
|
+
#
|
|
6
|
+
# Створюється правилом ci4 (npx @nitra/cursor fix ci4) із canonical baseline.
|
|
7
|
+
# Ручні правки не перетираються: повторні прогони ідемпотентні (no-op).
|
|
8
|
+
|
|
9
|
+
[core]
|
|
10
|
+
markdown.file_extensions = ["md", "markdown"]
|
|
11
|
+
|
|
12
|
+
# GitHub-Flavored Markdown (таблиці, todo, alerts, math) — наш portable subset.
|
|
13
|
+
markdown.glfm = true
|
|
14
|
+
|
|
15
|
+
[completion]
|
|
16
|
+
# Стиль резолву [[wiki-link]]:
|
|
17
|
+
# "file-stem" → [[oidc-pkce-flow]] резолвиться у docs/adr/oidc-pkce-flow.md
|
|
18
|
+
# "title-slug-ref" → резолв за slugified H1 заголовком
|
|
19
|
+
# Беремо file-stem: ADR-slug == ім'я файла, стабільний ідентифікатор у
|
|
20
|
+
# AUTOGEN sources, manifest, валідаторі. Заголовок може мінятися — посилання
|
|
21
|
+
# не ламається.
|
|
22
|
+
wiki.style = "file-stem"
|
|
23
|
+
|
|
24
|
+
[code_action]
|
|
25
|
+
# Code action "Insert/Update TOC" — корисно для довгих arc42-сторінок
|
|
26
|
+
# (architecture.md з 12 розділами).
|
|
27
|
+
toc.enable = true
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Концерн `marksman_config` правила ci4 (ci4.mdc): копіює canonical
|
|
3
|
+
* `.marksman.toml` baseline у корінь cwd, якщо файлу ще немає.
|
|
4
|
+
*
|
|
5
|
+
* Marksman LSP читає `.marksman.toml` для визначення workspace-роота,
|
|
6
|
+
* GLFM-флага (GitHub-Flavored Markdown), стилю wiki-links і code actions.
|
|
7
|
+
* Дефолти marksman не вмикають GLFM і використовують `title-slug-ref` —
|
|
8
|
+
* але portable subset з ci4.mdc вимагає GLFM (alerts/таблиці/todo) +
|
|
9
|
+
* `file-stem` (ADR slug == ім'я файла). Без явного конфіга частина
|
|
10
|
+
* marksman-функцій працює інакше, ніж задокументовано у правилі.
|
|
11
|
+
*
|
|
12
|
+
* Idempotent: якщо `.marksman.toml` вже існує (навіть з кастомним вмістом)
|
|
13
|
+
* — не перетирається, тільки рапортується факт існування. Ручні правки
|
|
14
|
+
* користувача зберігаються між прогонами.
|
|
15
|
+
*
|
|
16
|
+
* Файл скопійовано в `cwd`, бо marksman визначає workspace-root за
|
|
17
|
+
* розташуванням свого `.marksman.toml`. У корені репо марксман бачить
|
|
18
|
+
* і docs/, і README.md усіх workspaces одним workspace-ом.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from 'node:fs'
|
|
21
|
+
import { copyFile } from 'node:fs/promises'
|
|
22
|
+
import { dirname, join, relative } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
|
|
25
|
+
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
26
|
+
|
|
27
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
const MARKSMAN_BASELINE_PATH = join(HERE, 'data', 'marksman_config', 'marksman.baseline.toml')
|
|
29
|
+
const MARKSMAN_TARGET_FILENAME = '.marksman.toml'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} [cwd] корінь проєкту (default: `process.cwd()` — CLI-сумісність)
|
|
33
|
+
* @returns {Promise<number>} 0 — OK (створено або вже існує), 1 — baseline-файл пакета зламаний
|
|
34
|
+
*/
|
|
35
|
+
export async function check(cwd = process.cwd()) {
|
|
36
|
+
const reporter = createCheckReporter()
|
|
37
|
+
|
|
38
|
+
if (!existsSync(MARKSMAN_BASELINE_PATH)) {
|
|
39
|
+
reporter.fail(`canonical baseline не знайдено (${MARKSMAN_BASELINE_PATH}) — перевстанови @nitra/cursor`)
|
|
40
|
+
return reporter.getExitCode()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const target = join(cwd, MARKSMAN_TARGET_FILENAME)
|
|
44
|
+
if (existsSync(target)) {
|
|
45
|
+
reporter.pass(`${MARKSMAN_TARGET_FILENAME} існує (${relative(cwd, target)})`)
|
|
46
|
+
return reporter.getExitCode()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await copyFile(MARKSMAN_BASELINE_PATH, target)
|
|
50
|
+
reporter.pass(`${MARKSMAN_TARGET_FILENAME} створено з canonical baseline (${relative(cwd, target)}) (ci4.mdc)`)
|
|
51
|
+
return reporter.getExitCode()
|
|
52
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Перевірка `.vscode/extensions.json` для ci4 (ci4.mdc).
|
|
2
|
+
#
|
|
3
|
+
# Канон надходить через --data: { "template": { "snippet": ... } }
|
|
4
|
+
package ci4.vscode_extensions
|
|
5
|
+
|
|
6
|
+
import rego.v1
|
|
7
|
+
|
|
8
|
+
deny contains msg if {
|
|
9
|
+
some rec in data.template.snippet.recommendations
|
|
10
|
+
not rec in {r | some r in object.get(input, "recommendations", [])}
|
|
11
|
+
msg := sprintf(".vscode/extensions.json: recommendations має містити %q (ci4.mdc)", [rec])
|
|
12
|
+
}
|
|
@@ -359,6 +359,7 @@ async function checkDockerfiles(root, ignorePaths, passFn, failFn) {
|
|
|
359
359
|
* `default.conf.template` (умовне правило — без шаблона цей крок не запускається).
|
|
360
360
|
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
361
361
|
* @param {(msg: string) => void} failFn callback при помилці
|
|
362
|
+
* @param {string} cwd корінь репозиторію
|
|
362
363
|
* @returns {void}
|
|
363
364
|
*/
|
|
364
365
|
function checkVscodeNginx(passFn, failFn, cwd) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `n-cursor change` — пише один change-файл `<ws>/.changes/<timestamp>-<rand>.md`.
|
|
3
|
+
* Замінює ручне редагування CHANGELOG у feature-флоу (n-changelog.mdc v3.0).
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { CHANGES_DIR, newChangeFileName, parseChangeFile, serializeChangeFile } from './lib/change-file.mjs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} params параметри
|
|
12
|
+
* @param {string} params.bump `major|minor|patch`
|
|
13
|
+
* @param {string} params.section `Added|Changed|Fixed|Removed`
|
|
14
|
+
* @param {string} params.message опис
|
|
15
|
+
* @param {string} [params.ws] workspace (за замовчуванням `.`)
|
|
16
|
+
* @param {string} [params.cwd] корінь
|
|
17
|
+
* @returns {Promise<string>} відносний шлях створеного файлу (від ws)
|
|
18
|
+
*/
|
|
19
|
+
export async function writeChange({ bump, section, message, ws = '.', cwd = process.cwd() }) {
|
|
20
|
+
const description = (message ?? '').trim()
|
|
21
|
+
const content = serializeChangeFile({ bump, section, description })
|
|
22
|
+
// Валідація полів: parseChangeFile кидає зрозумілу помилку на невалідних bump/section/порожньому описі.
|
|
23
|
+
parseChangeFile(content)
|
|
24
|
+
|
|
25
|
+
const dir = join(cwd, ws, CHANGES_DIR)
|
|
26
|
+
await mkdir(dir, { recursive: true })
|
|
27
|
+
const name = newChangeFileName()
|
|
28
|
+
await writeFile(join(dir, name), content)
|
|
29
|
+
return join(CHANGES_DIR, name)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string[]} args аргументи CLI (`--bump`, `--section`, `--message`, `--ws`)
|
|
34
|
+
* @returns {Promise<number>} exit-код
|
|
35
|
+
*/
|
|
36
|
+
export async function runChangeCli(args) {
|
|
37
|
+
const get = flag => {
|
|
38
|
+
const i = args.indexOf(flag)
|
|
39
|
+
return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined
|
|
40
|
+
}
|
|
41
|
+
const bump = get('--bump')
|
|
42
|
+
const section = get('--section')
|
|
43
|
+
const message = get('--message')
|
|
44
|
+
const ws = get('--ws') ?? '.'
|
|
45
|
+
if (!bump || !section || !message) {
|
|
46
|
+
console.error('❌ Використання: n-cursor change --bump <major|minor|patch> --section <Added|Changed|Fixed|Removed> --message "<опис>" [--ws <шлях>]')
|
|
47
|
+
return 1
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const rel = await writeChange({ bump, section, message, ws })
|
|
51
|
+
console.log(`✅ ${join(ws, rel)}`)
|
|
52
|
+
return 0
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(`❌ ${error instanceof Error ? error.message : String(error)}`)
|
|
55
|
+
return 1
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
|
|
2
|
+
import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Запускає правило: applies → JS-concerns → policy → mdc-refs (через runStandardRule).
|
|
6
|
+
* Library mode: викликається CLI orchestration через `import + run(ctx)`.
|
|
7
|
+
* @param {import('../../scripts/lib/run-standard-rule.mjs').RuleContext} [ctx] контекст прогону (walkCache тощо)
|
|
8
|
+
* @returns {Promise<number>} 0 — OK, 1 — порушення
|
|
9
|
+
*/
|
|
10
|
+
export function run(ctx) {
|
|
11
|
+
return runStandardRule(import.meta.dirname, ctx)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (isRunAsCli(import.meta.url)) {
|
|
15
|
+
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
|
|
16
|
+
process.exit(await runRuleCli(import.meta.dirname))
|
|
17
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Агрегація change-файлів одного workspace у version-bump + секцію CHANGELOG
|
|
3
|
+
* (Keep a Changelog 1.1.0, новіше зверху). Без побічних ефектів — лише обчислення/рендер;
|
|
4
|
+
* запис на диск і git — у release.mjs.
|
|
5
|
+
*/
|
|
6
|
+
import { VALID_BUMPS, VALID_SECTIONS } from './change-file.mjs'
|
|
7
|
+
|
|
8
|
+
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/
|
|
9
|
+
const CHANGELOG_HEADER = '# Changelog'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} version `x.y.z`
|
|
13
|
+
* @param {string} bump `major|minor|patch`
|
|
14
|
+
* @returns {string} нова версія
|
|
15
|
+
*/
|
|
16
|
+
export function bumpVersion(version, bump) {
|
|
17
|
+
const m = SEMVER_RE.exec(version)
|
|
18
|
+
if (!m) throw new Error(`aggregate: невалідний semver «${version}»`)
|
|
19
|
+
const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])]
|
|
20
|
+
if (bump === 'major') return `${major + 1}.0.0`
|
|
21
|
+
if (bump === 'minor') return `${major}.${minor + 1}.0`
|
|
22
|
+
return `${major}.${minor}.${patch + 1}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string[]} bumps непорожній список
|
|
27
|
+
* @returns {string} найвищий bump (major > minor > patch)
|
|
28
|
+
*/
|
|
29
|
+
export function maxBump(bumps) {
|
|
30
|
+
return VALID_BUMPS.find(level => bumps.includes(level)) ?? 'patch'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} version нова версія
|
|
35
|
+
* @param {string} date `YYYY-MM-DD`
|
|
36
|
+
* @param {Array<{ section: string, description: string }>} entries записи change-файлів
|
|
37
|
+
* @returns {string} markdown-блок секції
|
|
38
|
+
*/
|
|
39
|
+
export function renderChangelogSection(version, date, entries) {
|
|
40
|
+
let out = `## [${version}] - ${date}\n`
|
|
41
|
+
for (const section of VALID_SECTIONS) {
|
|
42
|
+
const bullets = entries.filter(e => e.section === section)
|
|
43
|
+
if (bullets.length === 0) continue
|
|
44
|
+
const bulletLines = bullets.map(b => '- ' + b.description).join('\n')
|
|
45
|
+
out += `\n### ${section}\n\n${bulletLines}\n`
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} existingText наявний CHANGELOG.md (може бути порожнім)
|
|
52
|
+
* @param {string} sectionBlock новий блок версії
|
|
53
|
+
* @returns {string} CHANGELOG із секцією зверху
|
|
54
|
+
*/
|
|
55
|
+
export function prependChangelogSection(existingText, sectionBlock) {
|
|
56
|
+
const text = existingText.trimStart()
|
|
57
|
+
if (!text.startsWith(CHANGELOG_HEADER)) {
|
|
58
|
+
return `${CHANGELOG_HEADER}\n\n${sectionBlock}`
|
|
59
|
+
}
|
|
60
|
+
const nl = text.indexOf('\n')
|
|
61
|
+
const head = text.slice(0, nl === -1 ? text.length : nl)
|
|
62
|
+
const rest = nl === -1 ? '' : text.slice(nl + 1).trimStart()
|
|
63
|
+
return `${head}\n\n${sectionBlock}\n${rest}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {object} params параметри
|
|
68
|
+
* @param {string} params.currentVersion поточна version маніфесту
|
|
69
|
+
* @param {Array<{ file: string, entry: { bump: string, section: string, description: string } }>} params.changeFiles change-файли workspace
|
|
70
|
+
* @param {string} params.date `YYYY-MM-DD`
|
|
71
|
+
* @returns {{ newVersion: string, sectionBlock: string, consumedFiles: string[] } | null} результат або null, якщо змін нема
|
|
72
|
+
*/
|
|
73
|
+
export function aggregateWorkspace({ currentVersion, changeFiles, date }) {
|
|
74
|
+
if (changeFiles.length === 0) return null
|
|
75
|
+
const newVersion = bumpVersion(currentVersion, maxBump(changeFiles.map(c => c.entry.bump)))
|
|
76
|
+
const sectionBlock = renderChangelogSection(
|
|
77
|
+
newVersion,
|
|
78
|
+
date,
|
|
79
|
+
changeFiles.map(c => c.entry)
|
|
80
|
+
)
|
|
81
|
+
return { newVersion, sectionBlock, consumedFiles: changeFiles.map(c => c.file) }
|
|
82
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Один change-файл `<ws>/.changes/<timestamp>-<rand>.md`: YAML-подібний frontmatter
|
|
3
|
+
* із двома ключами (`bump`, `section`) + текст опису. Парсер мінімальний — лише ці два
|
|
4
|
+
* ключі, без зовнішніх залежностей.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomBytes } from 'node:crypto'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** Дозволені semver-бампи, від найбільшого до найменшого (порядок використовується для max). */
|
|
13
|
+
export const VALID_BUMPS = Object.freeze(['major', 'minor', 'patch'])
|
|
14
|
+
|
|
15
|
+
/** Дозволені Keep a Changelog секції (заголовок `### {section}`). */
|
|
16
|
+
export const VALID_SECTIONS = Object.freeze(['Added', 'Changed', 'Fixed', 'Removed'])
|
|
17
|
+
|
|
18
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} block тіло frontmatter (між `---`)
|
|
22
|
+
* @returns {Record<string, string>} пари ключ→значення
|
|
23
|
+
*/
|
|
24
|
+
function parseFrontmatterBlock(block) {
|
|
25
|
+
/** @type {Record<string, string>} */
|
|
26
|
+
const out = {}
|
|
27
|
+
for (const line of block.split('\n')) {
|
|
28
|
+
const idx = line.indexOf(':')
|
|
29
|
+
if (idx === -1) continue
|
|
30
|
+
out[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} text вміст change-файлу
|
|
37
|
+
* @returns {{ bump: string, section: string, description: string }} розпарсений запис
|
|
38
|
+
*/
|
|
39
|
+
export function parseChangeFile(text) {
|
|
40
|
+
const m = FRONTMATTER_RE.exec(text)
|
|
41
|
+
if (!m) throw new Error('change-файл: відсутній frontmatter `---`')
|
|
42
|
+
const fm = parseFrontmatterBlock(m[1])
|
|
43
|
+
const description = m[2].trim()
|
|
44
|
+
if (!VALID_BUMPS.includes(fm.bump)) {
|
|
45
|
+
throw new Error(`change-файл: bump має бути одним із ${VALID_BUMPS.join('|')} (отримано «${fm.bump ?? ''}»)`)
|
|
46
|
+
}
|
|
47
|
+
if (!VALID_SECTIONS.includes(fm.section)) {
|
|
48
|
+
throw new Error(`change-файл: section має бути одним із ${VALID_SECTIONS.join('|')} (отримано «${fm.section ?? ''}»)`)
|
|
49
|
+
}
|
|
50
|
+
if (!description) throw new Error('change-файл: порожній опис')
|
|
51
|
+
return { bump: fm.bump, section: fm.section, description }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {{ bump: string, section: string, description: string }} entry запис
|
|
56
|
+
* @returns {string} вміст change-файлу
|
|
57
|
+
*/
|
|
58
|
+
export function serializeChangeFile(entry) {
|
|
59
|
+
return `---\nbump: ${entry.bump}\nsection: ${entry.section}\n---\n${entry.description}\n`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Підкаталог зі change-файлами всередині workspace. */
|
|
63
|
+
export const CHANGES_DIR = '.changes'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {number} timestamp `Date.now()`
|
|
67
|
+
* @param {string} suffix короткий випадковий суфікс (hex)
|
|
68
|
+
* @returns {string} `<timestamp>-<suffix>.md`
|
|
69
|
+
*/
|
|
70
|
+
export function changeFileName(timestamp, suffix) {
|
|
71
|
+
return `${timestamp}-${suffix}.md`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Унікальне ім'я для нового change-файлу: timestamp (порядок) + rand (анти-колізія
|
|
76
|
+
* для паралельних агентів у різних worktree, що пишуть у ту саму мілісекунду).
|
|
77
|
+
* @returns {string} результат
|
|
78
|
+
*/
|
|
79
|
+
export function newChangeFileName() {
|
|
80
|
+
return changeFileName(Date.now(), randomBytes(3).toString('hex'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} ws шлях workspace (відносно `cwd`)
|
|
85
|
+
* @param {string} [cwd] корінь репозиторію
|
|
86
|
+
* @returns {Promise<Array<{ file: string, entry: { bump: string, section: string, description: string } }>>} розпарсені change-файли
|
|
87
|
+
*/
|
|
88
|
+
export async function readChangeFiles(ws, cwd = process.cwd()) {
|
|
89
|
+
const dir = join(cwd, ws, CHANGES_DIR)
|
|
90
|
+
if (!existsSync(dir)) return []
|
|
91
|
+
const entries = await readdir(dir)
|
|
92
|
+
const names = entries.filter(n => n.endsWith('.md')).toSorted()
|
|
93
|
+
const result = []
|
|
94
|
+
for (const file of names) {
|
|
95
|
+
const text = await readFile(join(dir, file), 'utf8')
|
|
96
|
+
result.push({ file, entry: parseChangeFile(text) })
|
|
97
|
+
}
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback (n-cursor-release-design рішення 3): коли в workspace є релевантні зміни,
|
|
3
|
+
* але жодного change-файлу — синтезуємо один запис із commit-subjects від останнього
|
|
4
|
+
* релізного тегу `<name>@*`. Усі git-виклики через `runGit` (ін'єкція для тестів).
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} cwd робочий каталог
|
|
13
|
+
* @returns {(args: string[]) => Promise<string | null>} тихий git-раннер (null при помилці)
|
|
14
|
+
*/
|
|
15
|
+
export function defaultRunGit(cwd) {
|
|
16
|
+
return async args => {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execFileAsync('git', args, { cwd })
|
|
19
|
+
return stdout
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} name ім'я пакета (для тегу `<name>@*`)
|
|
28
|
+
* @param {string} ws workspace (pathspec для `git log`; `.` → без обмеження шляху)
|
|
29
|
+
* @param {object} [opts] опції
|
|
30
|
+
* @param {(args: string[]) => Promise<string | null>} [opts.runGit] git-раннер
|
|
31
|
+
* @returns {Promise<{ bump: string, section: string, description: string } | null>} синтезований запис або null
|
|
32
|
+
*/
|
|
33
|
+
export async function synthesizeChangeFromCommits(name, ws, opts = {}) {
|
|
34
|
+
const runGit = opts.runGit ?? defaultRunGit(process.cwd())
|
|
35
|
+
const lastTagRaw = await runGit(['describe', '--tags', '--abbrev=0', '--match', `${name}@*`, 'HEAD'])
|
|
36
|
+
const lastTag = lastTagRaw?.trim()
|
|
37
|
+
// Bootstrap: якщо жодного попереднього тегу немає — перший реліз зроблено вручну;
|
|
38
|
+
// fallback-синтез не запускаємо, щоб не подвоїти bump.
|
|
39
|
+
if (!lastTag) return null
|
|
40
|
+
const pathspec = ws === '.' ? [] : ['--', `${ws}/`]
|
|
41
|
+
const logRaw = await runGit(['log', '--no-merges', '--format=%s', `${lastTag}..HEAD`, ...pathspec])
|
|
42
|
+
const subjects = (logRaw ?? '')
|
|
43
|
+
.split('\n')
|
|
44
|
+
.map(s => s.trim())
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
if (subjects.length === 0) return null
|
|
47
|
+
return { bump: 'patch', section: 'Changed', description: subjects.join('; ') }
|
|
48
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `n-cursor release` — агрегує per-workspace change-файли у version-bump + CHANGELOG,
|
|
3
|
+
* комітить, ставить тег `<name>@<version>`, видаляє use-up change-файли. Запускається
|
|
4
|
+
* у CI на `main` (n-cursor-release-design, варіант A). Сам нічого не публікує.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { readFile, rm, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
import { getMonorepoProjectRootDirs, readPackageManifest } from '../changelog/lib/package-manifest.mjs'
|
|
11
|
+
import { aggregateWorkspace, prependChangelogSection } from './lib/aggregate.mjs'
|
|
12
|
+
import { CHANGES_DIR, readChangeFiles } from './lib/change-file.mjs'
|
|
13
|
+
import { defaultRunGit, synthesizeChangeFromCommits } from './lib/fallback.mjs'
|
|
14
|
+
|
|
15
|
+
const SEMVER_LINE_RE = /("version"\s*:\s*")[^"]*(")/
|
|
16
|
+
const PY_VERSION_LINE_RE = /^(version\s*=\s*")[^"]*(")/m
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Записує нову version у маніфест, зберігаючи форматування файлу.
|
|
20
|
+
* @param {string} cwd корінь
|
|
21
|
+
* @param {import('../changelog/lib/package-manifest.mjs').PackageManifest} manifest маніфест
|
|
22
|
+
* @param {string} newVersion нова версія
|
|
23
|
+
* @returns {Promise<void>} результат
|
|
24
|
+
*/
|
|
25
|
+
async function writeManifestVersion(cwd, manifest, newVersion) {
|
|
26
|
+
const path = join(cwd, manifest.ws === '.' ? manifest.manifestRel : `${manifest.ws}/${manifest.manifestRel}`)
|
|
27
|
+
const text = await readFile(path, 'utf8')
|
|
28
|
+
const re = manifest.kind === 'npm' ? SEMVER_LINE_RE : PY_VERSION_LINE_RE
|
|
29
|
+
const replaced = text.replace(re, `$1${newVersion}$2`)
|
|
30
|
+
if (replaced === text) {
|
|
31
|
+
throw new Error(`release: не вдалося оновити version у ${manifest.ws}/${manifest.manifestRel} — патерн version не знайдено`)
|
|
32
|
+
}
|
|
33
|
+
await writeFile(path, replaced)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} cwd корінь
|
|
38
|
+
* @param {string} ws workspace
|
|
39
|
+
* @param {string} sectionBlock новий блок CHANGELOG
|
|
40
|
+
* @returns {Promise<void>} результат
|
|
41
|
+
*/
|
|
42
|
+
async function prependWorkspaceChangelog(cwd, ws, sectionBlock) {
|
|
43
|
+
const path = join(cwd, ws, 'CHANGELOG.md')
|
|
44
|
+
const existing = existsSync(path) ? await readFile(path, 'utf8') : ''
|
|
45
|
+
await writeFile(path, prependChangelogSection(existing, sectionBlock))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Зібрати change-файли workspace (явні + fallback-синтез, якщо явних нема, але є коміти).
|
|
50
|
+
* @param {string} cwd корінь
|
|
51
|
+
* @param {import('../changelog/lib/package-manifest.mjs').PackageManifest} manifest маніфест
|
|
52
|
+
* @param {(args: string[]) => Promise<string | null>} runGit git-раннер
|
|
53
|
+
* @returns {Promise<Array<{ file: string | null, entry: { bump: string, section: string, description: string } }>>} change-файли
|
|
54
|
+
*/
|
|
55
|
+
async function collectChangeFiles(cwd, manifest, runGit) {
|
|
56
|
+
const explicit = await readChangeFiles(manifest.ws, cwd)
|
|
57
|
+
if (explicit.length > 0) return explicit
|
|
58
|
+
if (!manifest.name) return []
|
|
59
|
+
const synthesized = await synthesizeChangeFromCommits(manifest.name, manifest.ws, { runGit })
|
|
60
|
+
if (!synthesized) return []
|
|
61
|
+
console.warn(`⚠️ ${manifest.ws}: немає change-файлів — синтезовано запис із комітів (fallback)`)
|
|
62
|
+
return [{ file: null, entry: synthesized }]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} [opts] опції
|
|
67
|
+
* @param {string} [opts.cwd] корінь
|
|
68
|
+
* @param {string} [opts.date] `YYYY-MM-DD` (за замовчуванням сьогодні)
|
|
69
|
+
* @param {(args: string[]) => Promise<string | null>} [opts.runGit] git-раннер
|
|
70
|
+
* @returns {Promise<Array<{ ws: string, name: string | null, newVersion: string }>>} зрелізовані пакети
|
|
71
|
+
*/
|
|
72
|
+
export async function release(opts = {}) {
|
|
73
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
74
|
+
const date = opts.date ?? new Date().toISOString().slice(0, 10)
|
|
75
|
+
const runGit = opts.runGit ?? defaultRunGit(cwd)
|
|
76
|
+
|
|
77
|
+
const workspaces = await getMonorepoProjectRootDirs(cwd)
|
|
78
|
+
const subWorkspaces = workspaces.filter(w => w !== '.')
|
|
79
|
+
const isMonorepoRoot = subWorkspaces.length > 0
|
|
80
|
+
|
|
81
|
+
/** @type {Array<{ ws: string, name: string | null, newVersion: string }>} */
|
|
82
|
+
const released = []
|
|
83
|
+
const tags = []
|
|
84
|
+
|
|
85
|
+
for (const ws of workspaces) {
|
|
86
|
+
if (ws === '.' && isMonorepoRoot) continue
|
|
87
|
+
const manifest = await readPackageManifest(ws, cwd)
|
|
88
|
+
if (!manifest || !manifest.version) continue
|
|
89
|
+
|
|
90
|
+
const changeFiles = await collectChangeFiles(cwd, manifest, runGit)
|
|
91
|
+
const agg = aggregateWorkspace({ currentVersion: manifest.version, changeFiles, date })
|
|
92
|
+
if (!agg) continue
|
|
93
|
+
|
|
94
|
+
await writeManifestVersion(cwd, manifest, agg.newVersion)
|
|
95
|
+
await prependWorkspaceChangelog(cwd, ws, agg.sectionBlock)
|
|
96
|
+
for (const file of agg.consumedFiles.filter(Boolean)) {
|
|
97
|
+
await rm(join(cwd, ws, CHANGES_DIR, file))
|
|
98
|
+
}
|
|
99
|
+
released.push({ ws, name: manifest.name, newVersion: agg.newVersion })
|
|
100
|
+
if (manifest.name) tags.push(`${manifest.name}@${agg.newVersion}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (released.length > 0) {
|
|
104
|
+
const subject = tags.length > 0 ? tags.join(', ') : released.map(r => `${r.ws}@${r.newVersion}`).join(', ')
|
|
105
|
+
await runGit(['add', '-A'])
|
|
106
|
+
const committed = await runGit(['commit', '-m', `release: ${subject}`])
|
|
107
|
+
if (committed === null) {
|
|
108
|
+
throw new Error('release: git commit не вдався — теги та push скасовано')
|
|
109
|
+
}
|
|
110
|
+
for (const tag of tags) {
|
|
111
|
+
await runGit(['tag', tag])
|
|
112
|
+
}
|
|
113
|
+
await runGit(['push', '--follow-tags'])
|
|
114
|
+
}
|
|
115
|
+
return released
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string[]} _args аргументи CLI (наразі без опцій)
|
|
120
|
+
* @returns {Promise<number>} exit-код
|
|
121
|
+
*/
|
|
122
|
+
export async function runReleaseCli(_args) {
|
|
123
|
+
try {
|
|
124
|
+
const released = await release()
|
|
125
|
+
if (released.length === 0) {
|
|
126
|
+
console.log('release: немає змін для релізу')
|
|
127
|
+
} else {
|
|
128
|
+
for (const r of released) console.log(`✅ ${r.name ?? r.ws}@${r.newVersion}`)
|
|
129
|
+
}
|
|
130
|
+
return 0
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error(`❌ ${error instanceof Error ? error.message : String(error)}`)
|
|
133
|
+
return 1
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -180,7 +180,9 @@ export async function runCoverageSteps(opts = {}) {
|
|
|
180
180
|
return 1
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
// Підсумок «Разом» має сенс лише коли провайдерів ≥2; для єдиного рядка він
|
|
184
|
+
// дублює його значення, тож не додаємо.
|
|
185
|
+
if (rows.length > 1) rows.push(buildTotalsRow(rows))
|
|
184
186
|
const md = renderMarkdown(rows)
|
|
185
187
|
// Stryker disable next-line StringLiteral: equivalent – writeFile(path, str, '') behaves identically to 'utf8' in Node/Bun
|
|
186
188
|
await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
|
|
@@ -145,7 +145,7 @@ function extractFsFunctionName(callee) {
|
|
|
145
145
|
/**
|
|
146
146
|
* Чи файл — JS-тест (`*.test.mjs` / `*.test.js`).
|
|
147
147
|
* @param {string} absPath абсолютний шлях
|
|
148
|
-
* @returns {boolean}
|
|
148
|
+
* @returns {boolean} true якщо файл є тестом
|
|
149
149
|
*/
|
|
150
150
|
function isTestFile(absPath) {
|
|
151
151
|
const name = basename(absPath)
|
|
@@ -187,7 +187,7 @@ function findOffendersInBody(body) {
|
|
|
187
187
|
*/
|
|
188
188
|
function computeLineOffsets(body) {
|
|
189
189
|
const offsets = [0]
|
|
190
|
-
for (let i = 0; i < body.length; i
|
|
190
|
+
for (let i = 0; i < body.length; i++) {
|
|
191
191
|
if (body[i] === '\n') offsets.push(i + 1)
|
|
192
192
|
}
|
|
193
193
|
return offsets
|
|
@@ -202,7 +202,7 @@ function offsetToLineFromCache(offsets, offset) {
|
|
|
202
202
|
let lo = 0
|
|
203
203
|
let hi = offsets.length - 1
|
|
204
204
|
while (lo < hi) {
|
|
205
|
-
const mid = (lo + hi + 1)
|
|
205
|
+
const mid = Math.floor((lo + hi + 1) / 2)
|
|
206
206
|
if (offsets[mid] <= offset) lo = mid
|
|
207
207
|
else hi = mid - 1
|
|
208
208
|
}
|