@nitra/cursor 3.19.0 → 3.21.0

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.
Files changed (49) hide show
  1. package/.claude-template/hooks/capture-decisions.sh +1 -1
  2. package/.claude-template/hooks/normalize-decisions.sh +8 -4
  3. package/CHANGELOG.md +33 -0
  4. package/bin/n-cursor.js +53 -0
  5. package/package.json +1 -1
  6. package/rules/adr/adr.mdc +5 -5
  7. package/rules/adr/js/templates/hooks/.gitignore.snippet +1 -0
  8. package/rules/changelog/changelog.mdc +1 -1
  9. package/rules/changelog/js/consistency.mjs +69 -12
  10. package/rules/ci4/ci4.mdc +2 -2
  11. package/rules/docker/docker.mdc +3 -3
  12. package/rules/docker/js/lint.mjs +1 -1
  13. package/rules/docker/lib/docker-hadolint.mjs +27 -55
  14. package/rules/ga/lint/lint.mjs +18 -54
  15. package/rules/js-run/js/runtime.mjs +32 -0
  16. package/rules/js-run/js-run.mdc +6 -0
  17. package/rules/js-run/lib/temporal-scan.mjs +52 -0
  18. package/rules/k8s/lint/lint.mjs +3 -10
  19. package/rules/nginx-default-tpl/js/template.mjs +39 -1
  20. package/rules/nginx-default-tpl/nginx-default-tpl.mdc +3 -1
  21. package/rules/npm-module/js/skill_meta.mjs +12 -0
  22. package/rules/npm-module/npm-module.mdc +1 -1
  23. package/rules/npm-module/policy/npm_publish_yml/target.json +1 -0
  24. package/rules/rego/lint/lint.mjs +10 -55
  25. package/rules/release/change.mjs +34 -5
  26. package/rules/release/lib/change-file.mjs +26 -11
  27. package/rules/text/lint/lint.mjs +11 -40
  28. package/rules/worktree/policy/vscode_settings/target.json +5 -0
  29. package/rules/worktree/policy/vscode_settings/template/settings.json.snippet.json +8 -0
  30. package/rules/worktree/policy/zed_settings/target.json +5 -0
  31. package/rules/worktree/policy/zed_settings/template/settings.json.snippet.json +12 -0
  32. package/rules/worktree/worktree.mdc +52 -0
  33. package/schemas/target.json +5 -0
  34. package/scripts/lib/assert-project-root.mjs +80 -0
  35. package/scripts/lib/ensure-tool.mjs +352 -0
  36. package/scripts/lib/root-notice.mjs +64 -0
  37. package/scripts/lib/run-conftest-batch.mjs +6 -28
  38. package/scripts/lib/run-rule.mjs +61 -5
  39. package/scripts/lib/skill-meta.mjs +16 -2
  40. package/scripts/lib/template.mjs +29 -3
  41. package/scripts/lib/worktree-notice.mjs +121 -73
  42. package/scripts/sync-claude-config.mjs +2 -2
  43. package/skills/fix/SKILL.md +4 -4
  44. package/skills/llm-patch/meta.json +1 -1
  45. package/skills/publish-telegram/meta.json +1 -1
  46. package/skills/start-check/meta.json +1 -1
  47. package/skills/worktree/meta.json +1 -1
  48. package/types/bin/n-cursor.d.ts +1 -1
  49. package/rules/npm-module/policy/npm_publish_yml/npm_publish_yml.rego +0 -87
@@ -199,7 +199,7 @@ if ! printf '%s' "$RESPONSE_TRIMMED" | grep -q '^## '; then
199
199
  exit 0
200
200
  fi
201
201
 
202
- TS=$(date +%Y%m%d-%H%M%S)
202
+ TS=$(date +%y%m%d-%H%M)
203
203
 
204
204
  # Slug із першого `## [ADR|Runbook|Knowledge] <heading>`-рядка відповіді.
205
205
  # Логіка локальна (без додаткового LLM-виклику): використовуємо вже згенерований heading.
@@ -407,11 +407,15 @@ while IFS= read -r op_json; do
407
407
  continue
408
408
  ;;
409
409
  esac
410
- # Keep the draft's `YYYYMMDD-HHMMSS-` prefix on the clean file: the name
410
+ # Keep the draft's timestamp prefix on the clean file: the name
411
411
  # stays anchored to capture time, only the slug part changes between draft
412
- # and clean, and docs/adr/ keeps sorting chronologically. Drafts without a
413
- # timestamp prefix fall back to a bare `<slug>.md`.
412
+ # and clean, and docs/adr/ keeps sorting chronologically. New drafts use
413
+ # `YYMMDD-HHMM-`; older `YYYYMMDD-HHMMSS-` drafts are still supported.
414
+ # Drafts without a timestamp prefix fall back to a bare `<slug>.md`.
414
415
  case "$FILE" in
416
+ [0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]-*)
417
+ DEST_SLUG="$(printf '%s' "$FILE" | cut -c1-11)-$SLUG"
418
+ ;;
415
419
  [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
416
420
  DEST_SLUG="$(printf '%s' "$FILE" | cut -c1-15)-$SLUG"
417
421
  ;;
@@ -444,7 +448,7 @@ while IFS= read -r op_json; do
444
448
  ;;
445
449
  esac
446
450
  # Resolve the target clean file. The LLM gives a bare `<slug>.md`, but the
447
- # real file usually carries a `YYYYMMDD-HHMMSS-` prefix. Try, in order:
451
+ # real file usually carries a timestamp prefix. Try, in order:
448
452
  # 1. exact name in docs/adr/,
449
453
  # 2. a rewrite of this batch that produced that slug (SLUG_MAP),
450
454
  # 3. a unique existing clean file whose name ends with `-<slug>.md`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.21.0] - 2026-06-04
4
+
5
+ ### Added
6
+
7
+ - Root-guard для всіх деструктивних точок: CLI-команди fix/lint/coverage/change/release + дефолтний sync хардом перевіряють cwd===git-toplevel (assertCwdIsProjectRoot); worktree-preflight підсилено root-assert (pwd vs toplevel); новий meta.json-флаг requireRoot + injectRootNotice для in-place root-only скілів (n-start-check); skillRequiresRoot як явна похідна ознака захисту; валідація requireRoot у npm-module
8
+ - sync .gitignore: ігнорувати .claude/scheduled_tasks.lock (runtime-lock планувальника)
9
+
10
+ ### Changed
11
+
12
+ - Скорочено timestamp-префікси ADR і change-файлів до YYMMDD-HHMM; додано атомарний suffix для локальних колізій та заборону Temporal у Bun runtime.
13
+
14
+ ### Fixed
15
+
16
+ - n-changelog: directional version-drift — лише version ВПЕРЕД (> опублікованої/git-бази) валить як ручний bump; version ПОЗАДУ (локаль відстала від CI-релізу, ще не git pull) більше не блокує коміт (compareSemverCore/versionIsAhead)
17
+
18
+ ## [3.20.0] - 2026-06-03
19
+
20
+ ### Added
21
+
22
+ - ensure-tool: авто-встановлення зовнішніх CLI-залежностей (hk, conftest, shellcheck, actionlint, dotenv-linter) — brew/scoop/GitHub Release per-platform; hk install після fix; conftest авто-встановлюється перед fix та lint-ga
23
+ - ensureTool: розширено на opa/regal/hadolint/kubeconform/kubescape (brew/scoop/GitHub Release per-platform) + підтримка сирих бінарників (archive:false → download+chmod, без tar). Мігровано call-sites rego-lint (opa/regal), docker (hadolint з docker-fallback), k8s-lint (kubeconform/kubescape). withBinRemovedFromPath виставляє N_CURSOR_NO_AUTO_INSTALL=1.
24
+ - Guard: дефолтний sync (`npx @nitra/cursor` без підкоманди) забороняє запуск із піддиректорії git-репо — STOP до мутацій, замість скаффолда .cursor/.claude/CLAUDE.md/.n-cursor.json не в той каталог
25
+
26
+ ### Changed
27
+
28
+ - worktree-only скіли: bootstrap-виклик npx @nitra/cursor у новоствореному worktree тепер ретраїться при транзитних помилках реєстру/CDN (ETARGET/notarget, ENOTFOUND, ETIMEDOUT, EAI_AGAIN, ECONNRESET, 5xx) кожні 30с до 5 хв (env N_CURSOR_NPX_RETRY_MAX_MIN, ceiling 10 хв); реальний nonzero CLI віддається одразу. worktree-notice додає bun install у дереві (локальна копія усуває гонку з CDN) і shell-обгортку n_cursor_npx; fix-скіл кроки 1/6 використовують її
29
+ - npm-module: npm_publish_yml тепер звіряє ВЕСЬ канонічний сніпет напряму (target.json "check":"template", generic deep-subset) замість bespoke subset-of rego — редагування сніпета одразу змінює enforce, без правок rego й міграторів; масиви (steps) матчаться структурним subset-ом за наявністю (order/key-insensitive, зайві кроки дозволені); legacy publish-only workflow тепер падає check (вимагає release-publish job). Новий режим check:template перевикористовний для будь-якого whole-file концерну зі сніпетом.
30
+ - docker hadolint: прибрано docker-run fallback — hadolint тепер лише нативний бінарник через ensureTool (brew/scoop/GitHub Release). Видалено HADOLINT_IMAGE; оновлено docker.mdc і тести.
31
+
32
+ ### Fixed
33
+
34
+ - nginx-default-tpl: error_log off → error_log /dev/null crit; (error_log off — НЕ валідний nginx, падає під readOnlyRootFilesystem); авто-заміна в шаблонах + оновлено канон .mdc/фікстуру
35
+
3
36
  ## [3.19.0] - 2026-06-03
4
37
 
5
38
  ### Changed
package/bin/n-cursor.js CHANGED
@@ -92,10 +92,12 @@ import {
92
92
  import { detectAutoSkills } from '../scripts/auto-skills.mjs'
93
93
  import { readSkillMetaRaw } from '../scripts/lib/skill-meta.mjs'
94
94
  import { injectWorktreeNotice } from '../scripts/lib/worktree-notice.mjs'
95
+ import { injectRootNotice } from '../scripts/lib/root-notice.mjs'
95
96
  import { runPostToolUseFixCli } from '../scripts/post-tool-use-fix.mjs'
96
97
  import { discoverCheckRulesFromCursorRules } from '../scripts/lib/discover-check-rules-from-cursor.mjs'
97
98
  import { listRuleIds } from '../scripts/lib/list-rule-ids.mjs'
98
99
  import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
100
+ import { assertCwdIsProjectRoot } from '../scripts/lib/assert-project-root.mjs'
99
101
  import { runLintDocker } from '../rules/docker/lint/lint.mjs'
100
102
  import { runLintGaCli } from '../rules/ga/lint/lint.mjs'
101
103
  import { runLintK8s } from '../rules/k8s/lint/lint.mjs'
@@ -110,6 +112,7 @@ import { runWorktreeCli } from '../scripts/worktree-cli.mjs'
110
112
  import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
111
113
  import { runLint } from '../scripts/lint-cli.mjs'
112
114
  import { formatTimingSummary } from '../scripts/lib/timing-summary.mjs'
115
+ import { ensureHkInstall, ensureTool } from '../scripts/lib/ensure-tool.mjs'
113
116
 
114
117
  const PACKAGE_NAME = '@nitra/cursor'
115
118
  const CONFIG_FILE = '.n-cursor.json'
@@ -785,6 +788,9 @@ async function syncSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
785
788
  await mkdir(destDir, { recursive: true })
786
789
  const meta = readSkillMetaRaw(srcDir)
787
790
  const worktree = meta?.worktree === true
791
+ // root-guard для in-place скілів (мутують CWD без worktree-ізоляції).
792
+ // Worktree-скіли root-assert уже мають у worktree-блоці, тож тут лише !worktree.
793
+ const rootOnly = !worktree && meta?.requireRoot === true
788
794
  const entries = await readdir(srcDir, { withFileTypes: true })
789
795
  for (const entry of entries) {
790
796
  // Лише top-level файли скіла. `meta.json` — метадані (не для споживача);
@@ -794,6 +800,7 @@ async function syncSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
794
800
  let content = await readFile(join(srcDir, entry.name), 'utf8')
795
801
  if (entry.name === 'SKILL.md') {
796
802
  content = injectWorktreeNotice(content, worktree)
803
+ content = injectRootNotice(content, rootOnly)
797
804
  }
798
805
  await writeFile(join(destDir, entry.name), content, 'utf8')
799
806
  }
@@ -1184,6 +1191,10 @@ function logRemovedManagedItems(title, basePath, names) {
1184
1191
  * @returns {Promise<void>}
1185
1192
  */
1186
1193
  async function runFixCommand(requestedRules) {
1194
+ const hkBin = ensureTool('hk')
1195
+ ensureHkInstall(hkBin)
1196
+ ensureTool('conftest')
1197
+
1187
1198
  const available = await listRuleIds(BUNDLED_RULES_DIR)
1188
1199
  if (available.length === 0) {
1189
1200
  console.error('❌ Не знайдено жодного правила у пакеті')
@@ -1453,10 +1464,52 @@ async function runSync() {
1453
1464
  }
1454
1465
  }
1455
1466
 
1467
+ /**
1468
+ * Команди, що мутують проєкт у CWD і вимагають кореня репо. `undefined`/`''` —
1469
+ * дефолтний sync; `check` — deprecated-alias `fix`. Решта (read-only `trace`/
1470
+ * `graph`, `--root`-команди `docgen`/`rename-yaml-extensions`, `worktree`,
1471
+ * sub-лінтери) гард не зачіпає.
1472
+ */
1473
+ const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'fix', 'check', 'lint', 'coverage', 'change', 'release'])
1474
+
1475
+ /**
1476
+ * Короткий опис дії для тексту root-guard помилки за іменем команди.
1477
+ * @param {string | undefined} cmd підкоманда CLI (або undefined для дефолтного sync)
1478
+ * @returns {string} фраза «що саме мутує CWD»
1479
+ */
1480
+ function describeRootGuardedAction(cmd) {
1481
+ switch (cmd) {
1482
+ case undefined:
1483
+ case '':
1484
+ return 'Дефолтна синхронізація скаффолдить .cursor/, .claude/, CLAUDE.md, .n-cursor.json і робить bun install у поточному каталозі'
1485
+ case 'fix':
1486
+ case 'check':
1487
+ return '`fix` запускає programmatic-перевірки правил, що переписують конфіги в поточному каталозі'
1488
+ case 'lint':
1489
+ return '`lint` запускає авто-fix лінтерів (oxfmt/eslint --fix/stylelint --fix) у поточному каталозі'
1490
+ case 'coverage':
1491
+ return '`coverage` генерує COVERAGE.md і Stryker-артефакти в поточному каталозі'
1492
+ case 'change':
1493
+ return '`change` пише change-файл у .changes/ поточного каталогу'
1494
+ case 'release':
1495
+ return '`release` бампає version і переписує CHANGELOG у поточному каталозі'
1496
+ default:
1497
+ return 'Команда @nitra/cursor мутує проєкт у поточному каталозі'
1498
+ }
1499
+ }
1500
+
1456
1501
  // CLI: маршрутизація команд
1457
1502
  const [command, ...args] = process.argv.slice(2)
1458
1503
 
1459
1504
  try {
1505
+ // Root-guard до перших мутацій: дефолтний sync скаффолдить .cursor/.claude/CLAUDE.md/
1506
+ // .n-cursor.json + bun install, а fix/lint/coverage/change/release переписують файли в CWD —
1507
+ // усе це ключиться на cwd(). Запуск із піддиректорії git-репо (типово прямий
1508
+ // `bun npm/bin/n-cursor.js` не з кореня) зачепив би не той каталог → STOP. Read-only та
1509
+ // `--root`-команди (trace, graph, docgen, rename-yaml-extensions) не зачіпаємо.
1510
+ if (ROOT_GUARDED_COMMANDS.has(command)) {
1511
+ assertCwdIsProjectRoot(cwd(), describeRootGuardedAction(command))
1512
+ }
1460
1513
  await ensureNitraCursorInRootDevDependencies(cwd())
1461
1514
  switch (command) {
1462
1515
  case 'fix': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.19.0",
3
+ "version": "3.21.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
package/rules/adr/adr.mdc CHANGED
@@ -18,8 +18,8 @@ ADR живуть у єдиному каталозі **`docs/adr/`**. Clean ADR-
18
18
 
19
19
  Є два стани файлу, які відрізняються YAML frontmatter:
20
20
 
21
- - **Draft** — файл з frontmatter `session: …`, `captured: …`, `transcript: …` та timestamp-іменем `YYYYMMDD-HHMMSS-<sid>.md`. Пише `capture-decisions.sh` після кожної сесії.
22
- - **Clean** — файл без frontmatter, з kebab-case-іменем. `normalize-decisions.sh` зберігає timestamp-префікс чернетки → `YYYYMMDD-HHMMSS-<slug>.md` (наприклад `20260518-092807-ланцюжок-запуску-abie.md`); створений руками clean-файл може мати просто `<slug>.md`.
21
+ - **Draft** — файл з frontmatter `session: …`, `captured: …`, `transcript: …` та timestamp-іменем `YYMMDD-HHMM-<sid>.md`. Пише `capture-decisions.sh` після кожної сесії.
22
+ - **Clean** — файл без frontmatter, з kebab-case-іменем. `normalize-decisions.sh` зберігає timestamp-префікс чернетки → `YYMMDD-HHMM-<slug>.md` (наприклад `260518-0928-ланцюжок-запуску-abie.md`); створений руками clean-файл може мати просто `<slug>.md`.
23
23
 
24
24
  `normalize-decisions.sh` ніколи не чіпає clean-файли — крім випадку `merge-into`, коли дописує `## Update YYYY-MM-DD` в кінець наявного clean-файлу.
25
25
 
@@ -50,7 +50,7 @@ LLM повертає масив операцій:
50
50
  | `rewrite` | Чернетка стає окремим clean-файлом MADR v4 minimal: frontmatter знімається, ім'я → `<timestamp>-<slug>.md` (timestamp-префікс чернетки збережено), додаються `**Status:** Accepted`, `**Date:**` з `captured` і canonical MADR headings. | `file`, `slug`, `content` |
51
51
  | `merge-into` | Чернетка повторює тему вже існуючого clean-файлу; дописуємо `## Update YYYY-MM-DD` у кінець `target`. | `file`, `target`, `additions` |
52
52
 
53
- `slug` — kebab-case українською (`ланцюжок-запуску-abie`, `npm-publish-flow`); англійські технічні терміни лишаються англійською без транслітерації. До імені clean-файлу скрипт додає `YYYYMMDD-HHMMSS-` чернетки, тож запис лишається прив'язаним до часу capture, а `docs/adr/` сортується хронологічно. Колізія імен обробляється детермінованим суфіксом `-2`, `-3`.
53
+ `slug` — kebab-case українською (`ланцюжок-запуску-abie`, `npm-publish-flow`); англійські технічні терміни лишаються англійською без транслітерації. До імені clean-файлу скрипт додає `YYMMDD-HHMM-` чернетки, тож запис лишається прив'язаним до часу capture, а `docs/adr/` сортується хронологічно. Колізія імен обробляється детермінованим суфіксом `-2`, `-3`. Старі чернетки з `YYYYMMDD-HHMMSS-` prefix нормалізатор також розпізнає, щоб не ламати наявний inbox.
54
54
 
55
55
  ### Жодних git-операцій
56
56
 
@@ -86,8 +86,8 @@ LLM повертає масив операцій:
86
86
 
87
87
  ```text
88
88
  docs/adr/
89
- ├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
90
- └── YYYYMMDD-HHMMSS-<slug>.md # clean ADR-и (без frontmatter, timestamp-префікс чернетки збережено)
89
+ ├── YYMMDD-HHMM-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
90
+ └── YYMMDD-HHMM-<slug>.md # clean ADR-и (без frontmatter, timestamp-префікс чернетки збережено)
91
91
  .claude/hooks/
92
92
  ├── capture-decisions.sh # auto-synced з пакета
93
93
  ├── normalize-decisions.sh # auto-synced з пакета
@@ -6,3 +6,4 @@ dist/
6
6
  .claude/hooks/*.log
7
7
  .claude/hooks/.normalize-state
8
8
  .claude/hooks/.normalize.lock
9
+ .claude/scheduled_tasks.lock
@@ -8,7 +8,7 @@ alwaysApply: true
8
8
 
9
9
  > **Якщо в цій сесії ти змінив(ла) файли в пакетному workspace** (код, rego, правила, скіли, скрипти, конфіги, тести — **не** лише `docs/` / `doc/`) — **не завершуй задачу**, поки не виконаєш **усі три** кроки нижче в **тому ж** наборі змін. Це не «опційно після синку» — це частина PR.
10
10
 
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 <шлях>]`.
11
+ 1. **Поклади change-файл** `<ws>/.changes/YYMMDD-HHMM.md` з frontmatter `bump:` (`major|minor|patch`) + `section:` (`Added|Changed|Fixed|Removed`) і текстом опису. Команда: `npx @nitra/cursor change --bump <…> --section <…> --message "<…>" [--ws <шлях>]`. Якщо файл за ту саму хвилину вже існує, CLI атомарно створює `YYMMDD-HHMM-2.md`, потім `-3` тощо.
12
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
13
  3. **`npx @nitra/cursor fix changelog`** → exit **`0`** (достатньо наявності change-файлу; `version` лишається незмінним).
14
14
 
@@ -3,10 +3,12 @@
3
3
  * change-файл `<ws>/.changes/*.md` — єдиний дозволений артефакт зміни. Bump `version`
4
4
  * і генерацію `CHANGELOG.md` робить виключно `n-cursor release` у CI на `main`.
5
5
  *
6
- * Інваріант (на будь-якій гілці): `version` не має відхилятися від бази. Будь-який drift
7
- * `version` (vs опублікована в реєстрі або vs git-база) — ручний bump поза CI — завалює
8
- * перевірку, навіть якщо присутній change-файл. Pass лише коли є change-файл, а version
9
- * не зрушено; зміни без change-файлу fail.
6
+ * Інваріант (на будь-якій гілці): `version` у дереві не має **випереджати** базу. Лише
7
+ * drift **уперед** (`version` > опублікованої в реєстрі / > git-бази) — ручний bump поза
8
+ * CI — завалює перевірку, навіть із change-файлом. Version **позаду** бази (локаль відстала
9
+ * від уже опублікованого CI-релізу, ще не зроблено `git pull`)НЕ порушення: це не ручний
10
+ * bump, а git і так не дасть запушити non-fast-forward. Pass лише коли є change-файл, а
11
+ * version не випереджає базу; зміни без change-файлу — fail.
10
12
  *
11
13
  * Дві моделі бази — на рівні воркспейсу (див. n-changelog.mdc):
12
14
  *
@@ -318,6 +320,48 @@ function resolvePublishedVersion(manifest, getPublishedVersion) {
318
320
  return getPublishedVersion(manifest.name, manifest.kind)
319
321
  }
320
322
 
323
+ /** Числове ядро semver (`x.y.z`); хвіст (prerelease/build) ігнорується. */
324
+ const SEMVER_CORE_RE = /^(\d+)\.(\d+)\.(\d+)/
325
+
326
+ /**
327
+ * Парсить числове ядро semver-рядка.
328
+ * @param {unknown} v версія
329
+ * @returns {{ major: number, minor: number, patch: number } | null} ядро або null
330
+ */
331
+ function parseSemverCore(v) {
332
+ const m = typeof v === 'string' ? v.match(SEMVER_CORE_RE) : null
333
+ if (!m) return null
334
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
335
+ }
336
+
337
+ /**
338
+ * Порівнює semver-ядра двох версій.
339
+ * @param {unknown} a перша версія
340
+ * @param {unknown} b друга версія
341
+ * @returns {-1 | 0 | 1 | null} a<b → -1, a==b → 0, a>b → 1; null — нерозпізнано
342
+ */
343
+ function compareSemverCore(a, b) {
344
+ const pa = parseSemverCore(a)
345
+ const pb = parseSemverCore(b)
346
+ if (!pa || !pb) return null
347
+ if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1
348
+ if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1
349
+ if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1
350
+ return 0
351
+ }
352
+
353
+ /**
354
+ * Чи `current` випереджає `base` (ручний bump поза CI). Якщо semver нерозпізнаний
355
+ * (null) — fail-closed на будь-яку нерівність (консервативно, як до directional-фікса).
356
+ * @param {unknown} current версія в дереві
357
+ * @param {unknown} base база (опублікована / git-база)
358
+ * @returns {boolean} true — current попереду base (або нерозпізнано й не рівні)
359
+ */
360
+ function versionIsAhead(current, base) {
361
+ const cmp = compareSemverCore(current, base)
362
+ return cmp === null ? current !== base : cmp > 0
363
+ }
364
+
321
365
  /**
322
366
  * @param {string} name пакет
323
367
  * @param {import('../lib/package-manifest.mjs').PackageKind} [kind] тип пакета
@@ -454,16 +498,28 @@ async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVers
454
498
  pass(`${label}: ${name} — опублікована версія недоступна (мережа/реєстр), перевірку пропущено`)
455
499
  return
456
500
  }
457
- // Drift від опублікованої версії має пріоритет над change-файлом: ручний bump
458
- // заборонено навіть із change-файлом (симетрично з local-only-шляхом).
459
- if (Vpublished !== Vcurrent) {
501
+ // Лише drift УПЕРЕД (version > опублікованої) ручний bump поза CI; має пріоритет
502
+ // над change-файлом (симетрично з local-only-шляхом). Версія ПОЗАДУ реєстру — локаль
503
+ // відстала від уже опублікованого релізу, не порушення (нижче).
504
+ if (versionIsAhead(Vcurrent, Vpublished)) {
460
505
  fail(
461
- `${label}: version у ${mf} (${Vcurrent}) розходиться з опублікованою (${Vpublished}) — ` +
462
- `ручний bump заборонено. Відкоти version і поклади change-файл ` +
506
+ `${label}: version у ${mf} (${Vcurrent}) випереджає опубліковану (${Vpublished}) — ` +
507
+ `ручний bump поза CI заборонено. Відкоти version і поклади change-файл ` +
463
508
  `(npx @nitra/cursor change …); bump зробить CI на main (n-changelog.mdc)`
464
509
  )
465
510
  return
466
511
  }
512
+ if (compareSemverCore(Vcurrent, Vpublished) < 0) {
513
+ // Локаль ПОЗАДУ реєстру: CI вже опублікував новішу версію й закомітив bump назад,
514
+ // а ти ще не зробив `git pull`. Це не ручний bump (git не дасть запушити
515
+ // non-fast-forward), тож коміт не блокуємо — лише вимагаємо change-файл на наявні зміни.
516
+ pass(
517
+ `${label}: version у ${mf} (${Vcurrent}) позаду опублікованої (${Vpublished}) — ` +
518
+ `локаль відстала від реєстру (зроби git pull); це не ручний bump`
519
+ )
520
+ await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd)
521
+ return
522
+ }
467
523
  pass(`${label}: ${name}@${Vcurrent} збігається з реєстром — перевіряємо git на незрелізні зміни`)
468
524
  await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd)
469
525
  }
@@ -480,10 +536,11 @@ async function checkLocalOnlyChangedWorkspace(comparisonRef, manifest, baseLabel
480
536
  const label = workspaceLabel(manifest)
481
537
  const mf = manifestFilePath(manifest.ws, manifest)
482
538
  const Vcurrent = manifest.version
483
- // Drift version від бази має пріоритет над change-файлом: ручний bump заборонено
484
- // навіть якщо change-файл присутній (симетрично з published-шляхом).
539
+ // Лише drift УПЕРЕД (version > бази) має пріоритет над change-файлом: ручний bump
540
+ // заборонено навіть із change-файлом (симетрично з published-шляхом). Version позаду
541
+ // бази (гілка відстала від base-ref) — не порушення, не блокуємо.
485
542
  const Vbase = await readBaseVersion(comparisonRef, manifest, cwd)
486
- if (Vbase !== null && Vcurrent !== null && Vbase !== Vcurrent) {
543
+ if (Vbase !== null && Vcurrent !== null && versionIsAhead(Vcurrent, Vbase)) {
487
544
  fail(
488
545
  `${label}: version у ${mf} змінено поза CI (${Vbase} → ${Vcurrent}) — ручний bump заборонено (на ${baseLabel} — ${Vbase}). ` +
489
546
  `Відкоти version і поклади change-файл (npx @nitra/cursor change …); bump зробить CI (n-changelog.mdc)`
package/rules/ci4/ci4.mdc CHANGED
@@ -66,7 +66,7 @@ RAG витягує **фрагменти**, не цілі документи. Т
66
66
  docs/
67
67
  ├── adr/
68
68
  │ ├── <slug>.md # clean ADR-и (MADR v4 minimal, без frontmatter)
69
- │ ├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
69
+ │ ├── YYMMDD-HHMM-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
70
70
  │ └── index.md # autogen — список accepted ADR за датою
71
71
  ├── explanation/ # Diátaxis: чому і що
72
72
  │ ├── overview.md # Capability Map (autogen)
@@ -86,7 +86,7 @@ docs/
86
86
 
87
87
  ## ADR як вхід проекцій
88
88
 
89
- Регенератор працює **тільки з clean ADR** (`docs/adr/<slug>.md`, без frontmatter, з рядком `**Status:** Accepted`). Drafts (`YYYYMMDD-HHMMSS-*.md`) ігноруються — їх перетворить у clean ADR `normalize-decisions.sh` із правила `adr`.
89
+ Регенератор працює **тільки з clean ADR** (`docs/adr/<slug>.md`, без frontmatter, з рядком `**Status:** Accepted`). Drafts (`YYMMDD-HHMM-*.md`) ігноруються — їх перетворить у clean ADR `normalize-decisions.sh` із правила `adr`.
90
90
 
91
91
  Поля, які регенератор читає з clean ADR:
92
92
 
@@ -181,7 +181,7 @@ CLI **`hadolint`** приймає лише **явні шляхи** (`[DOCKERFILE
181
181
 
182
182
  **Область lint-docker (вужча, ніж `check docker`):** лише файли з іменем **`Dockerfile`** та **`*.Dockerfile`** (суфікс **`.dockerfile`** без урахування регістру, наприклад **`api.Dockerfile`**). Файли **`Dockerfile.prod`**, **`Containerfile`** тощо **не** входять у **`lint-docker`**; їх ловить **`check docker`** (`rules/docker/fix.mjs`).
183
183
 
184
- Обхід: **`walkDir`** з тими самими пропусками каталогів, що й **`rules/docker/fix.mjs`**. Виклик **`hadolint`**: **`PATH`**, інакше **`docker run`** — спільна логіка **`npm/rules/docker/js/lint/docker-hadolint.mjs`**.
184
+ Обхід: **`walkDir`** з тими самими пропусками каталогів, що й **`rules/docker/fix.mjs`**. Виклик **`hadolint`** як **нативного бінарника** через **`ensureTool`** (PATH кеш → авто-install brew/scoop/GitHub Release; **без** `docker run`) — спільна логіка **`npm/rules/docker/lib/docker-hadolint.mjs`**.
185
185
 
186
186
  - Канон `package.json#scripts.lint-docker`: [package.json.snippet.json](./policy/package_json/template/package.json.snippet.json)
187
187
 
@@ -191,14 +191,14 @@ CLI **`hadolint`** приймає лише **явні шляхи** (`[DOCKERFILE
191
191
 
192
192
  - Канон: [lint-docker.yml.snippet.yml](./policy/lint_docker_yml/template/lint-docker.yml.snippet.yml)
193
193
 
194
- Узгоджуй версію hadolint **v2.12.0** з **`HADOLINT_IMAGE`** у **`npm/rules/docker/js/lint/docker-hadolint.mjs`**.
194
+ Локально hadolint авто-встановлюється через **`ensureTool`** (latest, без піну версії). У CI встанови його кроком із workflow-сніпета (curl-download бінарника — без `docker run`).
195
195
 
196
196
  Кореневий скрипт **`lint`** (див. **`n-bun.mdc`**) **обов'язково** містить **`bun run lint-docker`**, коли в проєкті підключено правило **`docker`**.
197
197
 
198
198
  ## Запуск
199
199
 
200
200
  1. **`bun run lint-docker`** — **`run-docker.mjs`**: **`Dockerfile`** та **`*.Dockerfile`** (див. **`lint-docker`**); у CI встанови hadolint (приклад у workflow).
201
- 2. **`npx @nitra/cursor fix docker`** — **`rules/docker/fix.mjs`**, виклик hadolint як у **`docker-hadolint.mjs`** (**`PATH`** або **`docker run`** з **`hadolint/hadolint:v2.12.0`**).
201
+ 2. **`npx @nitra/cursor fix docker`** — **`rules/docker/fix.mjs`**, виклик hadolint як у **`docker-hadolint.mjs`** (нативний бінарник через **`ensureTool`**; **без** `docker run`).
202
202
  3. Кореневий **`.hadolint.yaml`**: вимкнення правил, trusted registries — [документація](https://github.com/hadolint/hadolint#configure). Щоб не додавати **`# hadolint ignore=DL3007`** у кожному **`FROM`** з **`:latest`**, у корені репозиторію задати глобально:
203
203
 
204
204
  ```yaml title=".hadolint.yaml"
@@ -29,7 +29,7 @@
29
29
  * `USER` у Dockerfile — перевірка non-root для нього пропускається.
30
30
  *
31
31
  * Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
32
- * тощо. Спочатку hadolint з PATH, інакше docker run з образом hadolint/hadolint.
32
+ * тощо. hadolint нативний бінарник через `ensureTool` (PATH/кеш/авто-install; без docker run).
33
33
  * Кореневий .hadolint.yaml підхоплюється hadolint автоматично.
34
34
  */
35
35
  import { readFile } from 'node:fs/promises'
@@ -1,20 +1,18 @@
1
1
  /**
2
2
  * Спільна логіка виклику hadolint для шляхів до Dockerfile (див. docker.mdc).
3
3
  *
4
- * Відносні шляхи з прямими слешами для контейнера; спочатку hadolint з PATH,
5
- * інакше docker run з образом HADOLINT_IMAGE. Використовується `./check.mjs`
6
- * (check-docker) та `../../lint/lint.mjs` (run-docker).
4
+ * Відносні шляхи з прямими слешами; hadolint резолвиться через `ensureTool`
5
+ * (PATH кеш авто-install brew/scoop/GitHub Release per-platform). Docker-fallback
6
+ * прибрано hadolint ставиться як **нативний бінарник**, без `docker run`.
7
+ * Використовується `./check.mjs` (check-docker) та `../../lint/lint.mjs` (run-docker).
7
8
  */
8
9
  import { spawnSync } from 'node:child_process'
9
10
  import { relative, sep } from 'node:path'
10
11
 
11
- import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
12
-
13
- /** Тег образу для резервного запуску (узгоджуй з docker.mdc). */
14
- export const HADOLINT_IMAGE = 'hadolint/hadolint:v2.12.0'
12
+ import { ensureTool } from '../../../scripts/lib/ensure-tool.mjs'
15
13
 
16
14
  /**
17
- * Відносний шлях від root з прямими слешами (hadolint у контейнері).
15
+ * Відносний шлях від root з прямими слешами (стабільний вивід незалежно від OS).
18
16
  * @param {string} root корінь
19
17
  * @param {string} absPath абсолютний шлях
20
18
  * @returns {string} відносний шлях з прямими слешами
@@ -24,65 +22,39 @@ export function posixRel(root, absPath) {
24
22
  }
25
23
 
26
24
  /**
27
- * Запуск hadolint: спочатку PATH, інакше Docker.
25
+ * Запуск hadolint як нативного бінарника. hadolint резолвиться через `ensureTool`
26
+ * (PATH → кеш → авто-install); якщо авто-install відключено (`N_CURSOR_NO_AUTO_INSTALL`)
27
+ * чи не вдався — повертаємо `ok: false` з підказкою (без `docker run`).
28
28
  * @param {string} root корінь репозиторію
29
29
  * @param {string} absPath абсолютний шлях до Dockerfile
30
- * @returns {{ ok: boolean, stdout: string, stderr: string, via: string }} результат перевірки hadolint та канал запуску
30
+ * @returns {{ ok: boolean, stdout: string, stderr: string, via: string }} результат перевірки hadolint
31
31
  */
32
32
  export function lintDockerfileWithHadolint(root, absPath) {
33
33
  const rel = posixRel(root, absPath)
34
- const hadolintPath = resolveCmd('hadolint')
35
- if (hadolintPath) {
36
- const local = spawnSync(hadolintPath, [rel], {
37
- cwd: root,
38
- encoding: 'utf8',
39
- maxBuffer: 10 * 1024 * 1024
40
- })
41
- const ok = local.status === 0
42
- return {
43
- ok,
44
- stdout: local.stdout ?? '',
45
- stderr: local.stderr ?? '',
46
- via: 'hadolint'
47
- }
48
- }
49
-
50
- const dockerPath = resolveCmd('docker')
51
- if (!dockerPath) {
34
+ let hadolintPath
35
+ try {
36
+ hadolintPath = ensureTool('hadolint')
37
+ } catch (error) {
52
38
  return {
53
39
  ok: false,
54
40
  stdout: '',
55
41
  stderr:
56
- 'Не знайдено hadolint у PATH і не знайдено docker у PATH. ' +
57
- 'Встанови hadolint (наприклад brew install hadolint) або Docker (див. docker.mdc).',
58
- via: 'docker'
42
+ `Не вдалося отримати hadolint (${error.message}). ` +
43
+ 'Встанови: brew install hadolint (macOS) / scoop install hadolint (Windows) / ' +
44
+ 'https://github.com/hadolint/hadolint/releases (Linux).',
45
+ via: 'hadolint'
59
46
  }
60
47
  }
61
48
 
62
- const docker = spawnSync(
63
- dockerPath,
64
- ['run', '--rm', '-v', `${root}:/workdir`, '-w', '/workdir', HADOLINT_IMAGE, rel],
65
- {
66
- cwd: root,
67
- encoding: 'utf8',
68
- maxBuffer: 10 * 1024 * 1024
69
- }
70
- )
71
- if (docker.error) {
72
- return {
73
- ok: false,
74
- stdout: '',
75
- stderr:
76
- `Не знайдено hadolint у PATH і не вдалося запустити Docker (${docker.error.message}). ` +
77
- `Встанови hadolint (наприклад brew install hadolint) або Docker (див. docker.mdc).`,
78
- via: 'docker'
79
- }
80
- }
81
- const ok = docker.status === 0
49
+ const local = spawnSync(hadolintPath, [rel], {
50
+ cwd: root,
51
+ encoding: 'utf8',
52
+ maxBuffer: 10 * 1024 * 1024
53
+ })
82
54
  return {
83
- ok,
84
- stdout: docker.stdout ?? '',
85
- stderr: docker.stderr ?? '',
86
- via: 'docker'
55
+ ok: local.status === 0,
56
+ stdout: local.stdout ?? '',
57
+ stderr: local.stderr ?? '',
58
+ via: 'hadolint'
87
59
  }
88
60
  }