@nitra/cursor 1.32.0 → 1.35.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 CHANGED
@@ -1,9 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.35.1] - 2026-05-30
4
+
5
+ ### Added
6
+
7
+ - Нові тести для підвищення coverage: run-dotenv-linter (spawnSync error paths), lint.mjs preflight-OK paths, ua_http_route.mjs (default cwd + readFile chmod-fail), auto-rules.mjs (readdir/JSON catch-блоки), run-shellcheck-errors, run-v8r, sync-claude-config, test-helpers absolute-path guard, rename-yaml-extensions sort, graphql langFromPath, capacitor segmentMinMajor, coverage-classify cache/retry/fallback, npm-module package_structure, changelog consistency, bun-sql-scan BinaryExpression. Lines coverage: ~83.59% → 88.32%.
8
+
9
+ ## [1.35.0] - 2026-05-30
10
+
11
+ ### Added
12
+
13
+ - LLM-класифікатор survived мутантів у n-cursor coverage: для кожного survived Claude Sonnet 4.6 виносить verdict (worth-testing/equivalent/defensive/glue/wrapper) з reasoning + confidence. Allowed gaps виключаються з знаменника mutation score. Cache по git-blob-hash. Graceful skip без API key. Threshold у .n-cursor.json#coverage.classifyConfidenceThreshold (default 1.1 — rollout mode).
14
+
15
+ ## [1.34.1] - 2026-05-30
16
+
17
+ ### Added
18
+
19
+ - Нові тест-файли (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%.
20
+
21
+ ### Changed
22
+
23
+ - coverage: не додавати підсумковий рядок "Разом" коли провайдер один (дублював би єдиний рядок)
24
+
3
25
  Усі помітні зміни цього модуля документуються тут.
4
26
 
5
27
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
28
 
29
+ ## [1.34.0] - 2026-05-30
30
+
31
+ ### Added
32
+
33
+ - Тести для `bun-sql-scan.mjs`: `findPgFormatShimDefinitionInText`, `findPgFormatLikeQueryWrapperInText`, `findUnsafeBunSqlInListMissingEmptyGuardInText`, `findPgListenNotifyUsageInText`
34
+ - Тести для `capacitor/platforms.mjs`: semver edge-cases, `recordCapacitorFromOnePackageJson`, `collectCapacitorDataFromAllPackageJson`, nitra-exceptions via config files
35
+ - Тести для `ga/workflows.mjs`: no .github/workflows dir, .yaml extension, MegaLinter detection, apply-k8s paths trigger
36
+ - Тести для `nginx-default-tpl/template.mjs` і `vue/packages.mjs` через check-rule-fixtures.test.mjs
37
+
38
+ ## [1.33.0] - 2026-05-30
39
+
40
+ ### Added
41
+
42
+ - `n-cursor change` / `n-cursor release` — change-файли `<ws>/.changes/*.md` замість ручного bump/CHANGELOG; реліз агрегує їх у CI, ставить git-тег `<name>@<version>`. Підтримка npm і Python workspace.
43
+
44
+ ### Changed
45
+
46
+ - `n-changelog.mdc` v3.0: feature-флоу кладе change-файл; `fix changelog` приймає change-файл або ручний bump.
47
+
7
48
  ## [1.32.0] - 2026-05-30
8
49
 
9
50
  ### Added
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
  }
@@ -0,0 +1,9 @@
1
+ name: n-cursor release
2
+ description: Aggregate .changes/* into version bump + CHANGELOG, tag <name>@<version>, commit back
3
+
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Release (bump + CHANGELOG + tag)
8
+ shell: bash
9
+ run: npx @nitra/cursor release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.32.0",
3
+ "version": "1.35.1",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -52,11 +52,12 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@anthropic-ai/claude-agent-sdk": "^0.3.0",
55
- "@anthropic-ai/sdk": "^0.54.0",
55
+ "@anthropic-ai/sdk": "^0.100.1",
56
56
  "oxc-parser": "^0.128.0",
57
57
  "picomatch": "^4.0.4",
58
58
  "smol-toml": "^1.6.1",
59
- "yaml": "^2.8.3"
59
+ "yaml": "^2.8.3",
60
+ "zod": "^4.4.3"
60
61
  },
61
62
  "engines": {
62
63
  "bun": ">=1.3",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння (npm і Python)
3
- version: '2.6'
3
+ version: '3.1'
4
4
  alwaysApply: true
5
5
  ---
6
6
 
@@ -8,13 +8,13 @@ alwaysApply: true
8
8
 
9
9
  > **Якщо в цій сесії ти змінив(ла) файли в пакетному workspace** (код, rego, правила, скіли, скрипти, конфіги, тести — **не** лише `docs/` / `doc/`) — **не завершуй задачу**, поки не виконаєш **усі три** кроки нижче в **тому ж** наборі змін. Це не «опційно після синку» — це частина PR.
10
10
 
11
- 1. **`version`** у `<ws>/package.json` (або `[project].version` у `pyproject.toml`) **patch +1** відносно `git show HEAD:<ws>/package.json`, якщо ще не піднято.
12
- 2. **`CHANGELOG.md`** того workspace **нова** секція `## [версія] - YYYY-MM-DD` **зверху** (не bullet-и в стару версію).
13
- 3. **`npx @nitra/cursor fix changelog`** репо `@nitra/cursor`: `bun ./npm/bin/n-cursor.js check changelog`) exit **`0`**.
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` вручну навіть для hotfix. Єдиний артефакт зміни — change-файл; `version`/CHANGELOG формує `n-cursor release` у CI на `main` (агрегує change-файли, ставить git-тег `<name>@<version>`). Будь-яка зміна `version` поза CI (drift від бази чи опублікованої) завалює `check changelog` — навіть якщо поряд є change-файл.
13
+ 3. **`npx @nitra/cursor fix changelog`** exit **`0`** (достатньо наявності change-файлу; `version` лишається незмінним).
14
14
 
15
15
  **Тригер шляхів (приклади):** `npm/**`, `packages/foo/**`, будь-який каталог з власним `package.json` / `pyproject.toml`, куди потрапили правки.
16
16
 
17
- **Інверсія (bump не потрібен):** лише `docs/` / `doc/`; синхронізований із `@nitra/cursor` інструментарій (`.cursor/`, `.claude/`); лише `.gitignore`; лише сам релізний крок (`CHANGELOG.md` + `version`). **Корінь монорепо** (воркспейс `.` за наявності підпакетів) не перевіряється взагалі — отже й кореневі `AGENTS.md` / `CLAUDE.md` та bump `@nitra/cursor` у `devDependencies`.
17
+ **Інверсія (change-файл не потрібен):** лише `docs/` / `doc/`; синхронізований із `@nitra/cursor` інструментарій (`.cursor/`, `.claude/`); лише `.gitignore`. **Корінь монорепо** (воркспейс `.` за наявності підпакетів) не перевіряється взагалі — отже й кореневі `AGENTS.md` / `CLAUDE.md` та bump `@nitra/cursor` у `devDependencies`. Окремого «релізного кроку» у feature-флоу немає — `version`/`CHANGELOG.md` змінює лише CI.
18
18
 
19
19
  **Pre-commit (людина):** `hk` у цьому репо також запускає `check changelog` при змінах під `npm/**` — агент не покладайся лише на commit hook; виконай кроки 1–3 **до** фінальної відповіді.
20
20
 
@@ -33,15 +33,14 @@ alwaysApply: true
33
33
 
34
34
  Повний алгоритм — у блоці **STOP** вище; тут лише уточнення.
35
35
 
36
- **Інверсія (за замовчуванням не вимагають bump/CHANGELOG):**
36
+ **Інверсія (за замовчуванням не вимагають change-файлу):**
37
37
 
38
38
  - зміни **лише** під `docs/` або `doc/`;
39
39
  - синхронізований із `@nitra/cursor` інструментарій під `.cursor/` (канонічні правила та скіли) і `.claude/` (ADR-хуки) — це дзеркало tooling-пакета, а не логіка воркспейсу;
40
40
  - будь-які зміни в **корені монорепо** (воркспейс `.` за наявності підпакетів) — корінь веде glue/конфіг/tooling, власного CHANGELOG не має; помітні зміни документують підпакети. Сюди потрапляють і кореневі `AGENTS.md` / `CLAUDE.md`, і bump `@nitra/cursor` у `devDependencies`;
41
- - файли під **`.gitignore`**;
42
- - правки **лише** `CHANGELOG.md` або поля `version` у маніфесті як сам релізний крок.
41
+ - файли під **`.gitignore`**.
43
42
 
44
- **Вимагають bump + нову секцію CHANGELOG** — усі інші зміни в каталозі workspace (код, rego, правила, скіли, конфіги, тести тощо). Виняток `.cursor/` / `.claude/` **не** поширюється на джерело правил у репо `@nitra/cursor` — воно лежить під `npm/`, тож зміни в ньому далі вимагають bump.
43
+ **Вимагають change-файл** — усі інші зміни в каталозі workspace (код, rego, правила, скіли, конфіги, тести тощо). Виняток `.cursor/` / `.claude/` **не** поширюється на джерело правил у репо `@nitra/cursor` — воно лежить під `npm/`, тож зміни в ньому далі вимагають change-файлу.
45
44
 
46
45
  Перевірка програмна (`changelog/js/consistency.mjs`).
47
46
 
@@ -55,8 +54,8 @@ alwaysApply: true
55
54
 
56
55
  **Python:** статичні `project.name` і `project.version` у `pyproject.toml` (або Poetry-секція).
57
56
 
58
- 1. **Локальна `version` ≠ опублікованій** (npm / PyPI): запис у `<ws>/CHANGELOG.md`; для npm також `"CHANGELOG.md"` у `files`.
59
- 2. **Версії збігаються**, але в git є **релевантні** зміни без bump → fail.
57
+ 1. **Локальна `version` ≠ опублікованій** (npm / PyPI): drift поза CI **fail** (ручний bump заборонено; навіть із change-файлом). Відкоти `version`.
58
+ 2. **Версії збігаються**, але в git є **релевантні** зміни без change-файлу → fail. Для npm `"CHANGELOG.md"` має бути в `files` (публікується разом із пакетом).
60
59
  3. **Реєстр недосяжний** — fail-safe pass.
61
60
  4. **Немає релевантних змін** — pass.
62
61
 
@@ -67,7 +66,7 @@ alwaysApply: true
67
66
  1. На **`dev`** local-only не активний (крім незакомічених registry-published).
68
67
  2. На **`main`** — diff від **`origin/main`** (попередній опублікований `main`); без remote — від `HEAD~1`. **`dev` не використовується** як база на `main`.
69
68
  3. На **feature-гілці** — `merge-base` з **`dev`**, якщо є; інакше з **`main`** (репо без `dev`).
70
- 4. Bump + CHANGELOG **раз на PR** / direct-commit на `main`.
69
+ 4. Drift `version` від бази **fail** (ручний bump заборонено). Зміни фіксуй change-файлом; bump зробить CI.
71
70
 
72
71
  Якщо немає git або немає `dev`/`main`/`origin/main` — local-only пропускається.
73
72
 
@@ -1,25 +1,24 @@
1
1
  /**
2
- * Перевіряє, що в кожному workspace із релізно-релевантними змінами підвищена `version`
3
- * у маніфесті (`package.json` або `pyproject.toml`) і в `<ws>/CHANGELOG.md` є запис
4
- * `## [version] - YYYY-MM-DD` (формат Keep a Changelog).
2
+ * Перевіряє, що кожен workspace із релізно-релевантними змінами зафіксував їх через
3
+ * change-файл `<ws>/.changes/*.md` єдиний дозволений артефакт зміни. Bump `version`
4
+ * і генерацію `CHANGELOG.md` робить виключно `n-cursor release` у CI на `main`.
5
+ *
6
+ * Інваріант (на будь-якій гілці): `version` не має відхилятися від бази. Будь-який drift
7
+ * `version` (vs опублікована в реєстрі або vs git-база) — ручний bump поза CI — завалює
8
+ * перевірку, навіть якщо присутній change-файл. Pass лише коли є change-файл, а version
9
+ * не зрушено; зміни без change-файлу — fail.
5
10
  *
6
11
  * Дві моделі бази — на рівні воркспейсу (див. n-changelog.mdc):
7
12
  *
8
13
  * 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.
12
- *
13
- * 2) **local-only** (приватні npm, без `files`, Python без імені/версії для реєстру):
14
+ * статична `project.version`): база = опублікована версія в npm / PyPI.
15
+ * 2) **local-only** (приватні npm без `files`, Python без імені/версії для реєстру):
14
16
  * feature-гілка — `merge-base` з `dev`, інакше з `main`; на `main` — diff від
15
- * `origin/main` (попередній опублікований main) або `HEAD~1` без remote.
17
+ * `origin/main` (або `HEAD~1` без remote).
16
18
  *
17
19
  * Усі `git` і зовнішні виклики — через `execFile` / `fetch`, без shell-інтерполяції.
18
20
  */
19
21
  import { execFile } from 'node:child_process'
20
- import { existsSync } from 'node:fs'
21
- import { readFile } from 'node:fs/promises'
22
- import { join } from 'node:path'
23
22
  import { promisify } from 'node:util'
24
23
 
25
24
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
@@ -29,6 +28,7 @@ import {
29
28
  parsePyprojectFields,
30
29
  readPackageManifest
31
30
  } from '../lib/package-manifest.mjs'
31
+ import { readChangeFiles } from '../../release/lib/change-file.mjs'
32
32
 
33
33
  const execFileAsync = promisify(execFile)
34
34
 
@@ -276,16 +276,6 @@ async function readBaseVersion(baseRef, manifest, cwd) {
276
276
  return parsePyprojectFields(out).version
277
277
  }
278
278
 
279
- /**
280
- * @param {string} text параметр
281
- * @param {string} version параметр
282
- * @returns {boolean} результат
283
- */
284
- function changelogHasVersionEntry(text, version) {
285
- const needle = `## [${version}]`
286
- return text.startsWith(needle) || text.includes(`\n${needle}`)
287
- }
288
-
289
279
  /**
290
280
  * @param {string} name параметр
291
281
  * @returns {Promise<string | null>} результат
@@ -362,31 +352,6 @@ function checkNpmFilesArrayContainsChangelog(manifest, pass, fail) {
362
352
  }
363
353
  }
364
354
 
365
- /**
366
- * @param {string} ws параметр
367
- * @param {string} version параметр
368
- * @param {(msg: string) => void} pass параметр
369
- * @param {(msg: string) => void} fail параметр
370
- * @param {string} cwd робочий каталог
371
- * @returns {Promise<boolean>} результат
372
- */
373
- async function verifyChangelogEntry(ws, version, pass, fail, cwd) {
374
- const label = ws === '.' ? '<root>' : ws
375
- const changelogRel = join(ws, 'CHANGELOG.md')
376
- const changelogAbs = join(cwd, changelogRel)
377
- if (!existsSync(changelogAbs)) {
378
- fail(`${label}: відсутній ${changelogRel} (Keep a Changelog, див. n-changelog.mdc)`)
379
- return false
380
- }
381
- const text = await readFile(changelogAbs, 'utf8')
382
- if (changelogHasVersionEntry(text, version)) {
383
- pass(`${changelogRel}: знайдено запис для версії ${version}`)
384
- return true
385
- }
386
- fail(`${changelogRel}: відсутній запис для ${version} (формат "## [${version}] - YYYY-MM-DD")`)
387
- return false
388
- }
389
-
390
355
  /**
391
356
  * @param {import('../lib/package-manifest.mjs').PackageManifest} manifest параметр
392
357
  * @returns {string} результат
@@ -395,18 +360,49 @@ function workspaceLabel(manifest) {
395
360
  return manifest.ws === '.' ? '<root>' : manifest.ws
396
361
  }
397
362
 
363
+ /**
364
+ * Повідомлення «поклади change-файл» для workspace з релевантними змінами без change-файлу.
365
+ * @param {string} label мітка воркспейсу
366
+ * @param {string} mf шлях до маніфесту
367
+ * @returns {string} текст fail
368
+ */
369
+ function missingChangeFileMessage(label, mf) {
370
+ return (
371
+ `${label}: є релевантні зміни, але немає change-файлу (version у ${mf} не чіпай вручну). ` +
372
+ `Поклади change-файл: npx @nitra/cursor change --bump <major|minor|patch> --section <Added|Changed|Fixed|Removed> --message "<…>"; ` +
373
+ `bump зробить CI на main (n-changelog.mdc)`
374
+ )
375
+ }
376
+
377
+ /**
378
+ * Чи має workspace незрелізні change-файли (намір зафіксовано — bump зробить CI).
379
+ * @param {string} ws workspace
380
+ * @param {string} cwd корінь
381
+ * @returns {Promise<boolean>} результат
382
+ */
383
+ async function hasPendingChangeFiles(ws, cwd) {
384
+ const files = await readChangeFiles(ws, cwd)
385
+ return files.length > 0
386
+ }
387
+
398
388
  /**
399
389
  * @param {import('../lib/package-manifest.mjs').PackageManifest} manifest параметр
400
- * @param {string} Vcurrent параметр
390
+ * @param {string} _Vcurrent параметр (для сумісності сигнатури; bump робить CI)
401
391
  * @param {string[]} subWorkspaces параметр
402
392
  * @param {(msg: string) => void} pass параметр
403
393
  * @param {(msg: string) => void} fail параметр
404
394
  * @param {string} cwd робочий каталог
405
395
  * @returns {Promise<void>} результат
406
396
  */
407
- async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd) {
397
+ async function checkPublishedWorkspacePendingGitChanges(manifest, _Vcurrent, subWorkspaces, pass, fail, cwd) {
408
398
  const label = workspaceLabel(manifest)
409
399
  const mf = manifestFilePath(manifest.ws, manifest)
400
+ if (await hasPendingChangeFiles(manifest.ws, cwd)) {
401
+ pass(`${label}: є change-файл(и) у .changes/ — bump зробить CI (n-changelog.mdc)`)
402
+ // Реліз наближається → CHANGELOG має публікуватися разом із пакетом.
403
+ checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
404
+ return
405
+ }
410
406
  if (!(await isInsideGitRepo(cwd))) {
411
407
  return
412
408
  }
@@ -415,42 +411,19 @@ async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subW
415
411
 
416
412
  if (branch === LOCAL_ONLY_SKIP_BRANCH) {
417
413
  if (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd)) {
418
- fail(
419
- `${label}: у registry-published пакеті є незакомічені зміни при version ${Vcurrent}, що вже в реєстрі. ` +
420
- `Підвищ version у ${mf} і додай запис у CHANGELOG.md (n-changelog.mdc)`
421
- )
414
+ fail(missingChangeFileMessage(label, mf))
422
415
  }
423
416
  return
424
417
  }
425
418
 
426
419
  const comparison = await resolveChangelogComparisonPoint(branch, cwd)
427
- if (
428
- comparison &&
429
- (await workspaceHasRelevantChangesAgainstBase(comparison.ref, manifest.ws, subWorkspaces, cwd))
430
- ) {
431
- const Vbase = await readBaseVersion(comparison.ref, manifest, cwd)
432
- const baseLabel = comparison.label
433
- if (Vbase === null) {
434
- pass(
435
- `${label}: новий registry-published воркспейс (на ${baseLabel} відсутній ${mf}) — перевіряємо CHANGELOG для ${Vcurrent}`
436
- )
437
- await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail, cwd)
438
- checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
439
- } else if (Vbase === Vcurrent) {
440
- fail(
441
- `${label}: у цій гілці є зміни в registry-published пакеті, але version у ${mf} ` +
442
- `не підвищено (на ${baseLabel} — ${Vbase}). Bump + запис у CHANGELOG.md обов'язкові (n-changelog.mdc)`
443
- )
444
- } else {
445
- pass(`${label}: version змінено (${Vbase} → ${Vcurrent}) — очікується запис CHANGELOG після bump`)
446
- }
420
+ if (comparison && (await workspaceHasRelevantChangesAgainstBase(comparison.ref, manifest.ws, subWorkspaces, cwd))) {
421
+ fail(missingChangeFileMessage(label, mf))
422
+ return
447
423
  }
448
424
 
449
425
  if (branch === 'main' && (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd))) {
450
- fail(
451
- `${label}: у registry-published пакеті є незакомічені зміни при version ${Vcurrent}, що вже в реєстрі. ` +
452
- `Підвищ version у ${mf} і додай запис у CHANGELOG.md (n-changelog.mdc)`
453
- )
426
+ fail(missingChangeFileMessage(label, mf))
454
427
  }
455
428
  }
456
429
 
@@ -481,14 +454,18 @@ async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVers
481
454
  pass(`${label}: ${name} — опублікована версія недоступна (мережа/реєстр), перевірку пропущено`)
482
455
  return
483
456
  }
484
- if (Vpublished === Vcurrent) {
485
- pass(`${label}: ${name}@${Vcurrent} збігається з реєстром перевіряємо git на незрелізні зміни`)
486
- await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd)
457
+ // Drift від опублікованої версії має пріоритет над change-файлом: ручний bump
458
+ // заборонено навіть із change-файлом (симетрично з local-only-шляхом).
459
+ if (Vpublished !== Vcurrent) {
460
+ fail(
461
+ `${label}: version у ${mf} (${Vcurrent}) розходиться з опублікованою (${Vpublished}) — ` +
462
+ `ручний bump заборонено. Відкоти version і поклади change-файл ` +
463
+ `(npx @nitra/cursor change …); bump зробить CI на main (n-changelog.mdc)`
464
+ )
487
465
  return
488
466
  }
489
- pass(`${label}: ${name} — нова локальна версія (${Vpublished} → ${Vcurrent})`)
490
- await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail, cwd)
491
- checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
467
+ pass(`${label}: ${name}@${Vcurrent} збігається з реєстром перевіряємо git на незрелізні зміни`)
468
+ await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd)
492
469
  }
493
470
 
494
471
  /**
@@ -503,26 +480,21 @@ async function checkLocalOnlyChangedWorkspace(comparisonRef, manifest, baseLabel
503
480
  const label = workspaceLabel(manifest)
504
481
  const mf = manifestFilePath(manifest.ws, manifest)
505
482
  const Vcurrent = manifest.version
506
- if (!Vcurrent) {
507
- fail(`${label}: у ${mf} відсутнє поле version (потрібне для запису в CHANGELOG)`)
508
- return
509
- }
483
+ // Drift version від бази має пріоритет над change-файлом: ручний bump заборонено
484
+ // навіть якщо change-файл присутній (симетрично з published-шляхом).
510
485
  const Vbase = await readBaseVersion(comparisonRef, manifest, cwd)
511
- if (Vbase === null) {
512
- pass(`${label}: новий воркспейс (на ${baseLabel} відсутній ${mf}) — перевіряємо CHANGELOG для ${Vcurrent}`)
513
- if (!(await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail, cwd))) return
514
- checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
515
- return
516
- }
517
- if (Vbase === Vcurrent) {
486
+ if (Vbase !== null && Vcurrent !== null && Vbase !== Vcurrent) {
518
487
  fail(
519
- `${label}: у цій гілці є зміни, але version у ${mf} не підвищено (на ${baseLabel} — ${Vbase}). Bump + запис у CHANGELOG.md обов'язкові на PR`
488
+ `${label}: version у ${mf} змінено поза CI (${Vbase} ${Vcurrent}) ручний bump заборонено (на ${baseLabel} — ${Vbase}). ` +
489
+ `Відкоти version і поклади change-файл (npx @nitra/cursor change …); bump зробить CI (n-changelog.mdc)`
520
490
  )
521
491
  return
522
492
  }
523
- pass(`${label}: version підвищено (${Vbase} ${Vcurrent})`)
524
- if (!(await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail, cwd))) return
525
- checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
493
+ if (await hasPendingChangeFiles(manifest.ws, cwd)) {
494
+ pass(`${label}: є change-файл(и) у .changes/ bump зробить CI (n-changelog.mdc)`)
495
+ return
496
+ }
497
+ fail(missingChangeFileMessage(label, mf))
526
498
  }
527
499
 
528
500
  /**
@@ -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
+ }