@nitra/cursor 1.35.5 → 1.36.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.36.0] - 2026-05-31
4
+
5
+ ### Added
6
+
7
+ - test rule v2.7: канони Console mocking (vi.spyOn), Sandbox-aware тестів (withTmpDir+git init як default, skipIf STRYKER_MUTATOR_WORKER як виняток для smoke-аудиту) і явний {cwd:dir} у child_process
8
+
9
+ ### Changed
10
+
11
+ - withLock: стан локу спільний для всіх git-worktree через resolveLockCacheDir (git-common-dir) — серіалізація важких команд між worktree, не лише в одному checkout
12
+
3
13
  ## [1.35.5] - 2026-05-30
4
14
 
5
15
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.35.5",
3
+ "version": "1.36.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -117,11 +117,12 @@ export async function release(opts = {}) {
117
117
 
118
118
  /**
119
119
  * @param {string[]} _args аргументи CLI (наразі без опцій)
120
+ * @param {import('./release.mjs').ReleaseOpts} [opts] опції для тестів (cwd, date, runGit)
120
121
  * @returns {Promise<number>} exit-код
121
122
  */
122
- export async function runReleaseCli(_args) {
123
+ export async function runReleaseCli(_args, opts = {}) {
123
124
  try {
124
- const released = await release()
125
+ const released = await release(opts)
125
126
  if (released.length === 0) {
126
127
  console.log('release: немає змін для релізу')
127
128
  } else {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.js (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
3
- version: '2.6'
3
+ version: '2.7'
4
4
  globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
5
5
  alwaysApply: false
6
6
  ---
@@ -81,6 +81,51 @@ Recursive globs ловлять файли всередині `tests/` так с
81
81
 
82
82
  Canonical `vitest.config.js` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root).
83
83
 
84
+ ## Console mocking у тестах
85
+
86
+ `console.log` / `console.error` / `console.warn` — **process-wide** мутації, рівно як `process.cwd()`. Якщо тест перехоплює їх через `const orig = console.log; console.log = (...) => …; try { … } finally { console.log = orig }`, паралельний test файл у `pool: 'forks'` (різний процес) ізольований, але в межах **одного** процесу всі тести у файлі ділять єдиний `console`-об'єкт. Якщо try/finally не виконається (наприклад, асинхронний помилка повз `await`), restore не спрацює і наступні тести втрачають вивід.
87
+
88
+ Тому:
89
+
90
+ - **Канон**: `vi.spyOn(console, 'log').mockReturnValue()` (та аналоги для `error`/`warn`/`info`) + `afterEach(() => vi.restoreAllMocks())`. Vitest сам слідкує за scope mock-у і гарантовано відновлює оригінал між тестами, навіть якщо тест кинув виняток.
91
+ - **Заборонено**: ручний store/restore (`const orig = console.log; console.log = stub`). Виняток — коли тест **необхідно** перехопити вивід **до** того, як завантажиться модуль із top-level-логуванням; у цьому випадку фіксуй pattern explicit-коментарем.
92
+ - Якщо потрібен лог зі stub-ом — `const logs = []; vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')))`.
93
+
94
+ Перевірка — концерн `no-console-store-restore` (`rules/test/js/no-console-store-restore.mjs`): AST-сканер, який ловить присвоєння `console.<method> = …` у `*.test.{js,mjs}`.
95
+
96
+ ## Sandbox-aware тести (Stryker)
97
+
98
+ Stryker за замовчуванням копіює репо у sandbox-каталог (`reports/stryker/.tmp/sandbox-XXX/`), щоб AST-патчити мутантів без зачеплення робочого дерева. У sandbox **немає `.git/`**, і будь-який тест, що звертається до **реального** git-дерева через `import.meta.url + N≥3 рівнів вгору` (типу `const REPO_ROOT = join(import.meta.dirname, '..', '..', '..', '..', '..')`) фактично адресує sandbox-корінь, а не справжній репо. Це ламає `git rev-parse`/`git ls-files`/перевірки `.n-cursor.json`/CHANGELOG-співставлення — Stryker dry-run падає, мутаційний прогон не стартує, `mutation.json` лишається stale.
99
+
100
+ **Канон**: тест не повинен залежати від реального git-дерева через `import.meta.url + N≥3 рівнів вгору`. Якщо тест перевіряє git-логіку — ізолюй **повністю**:
101
+
102
+ ```js
103
+ await withTmpDir(async dir => {
104
+ execFileSync('git', ['init', '-q', '--initial-branch=main'], { cwd: dir })
105
+ await writeFile(join(dir, 'a.sh'), '#!/bin/sh\n', 'utf8')
106
+ execFileSync('git', ['add', '-A'], { cwd: dir })
107
+ expect(listShellScriptPaths(dir)).toEqual(['a.sh'])
108
+ })
109
+ ```
110
+
111
+ Приклад у репо: `rules/text/lint/tests/run-shellcheck.test.mjs` — тест `listShellScriptPaths всередині git-репо`.
112
+
113
+ **Виняток** — top-level smoke-аудит тести, що **за визначенням** перевіряють інваріанти **живого** репо (структура файлів, узгодженість CHANGELOG з `package.json`, кожне правило має fixture). Приклади: `tests/integration-repo-checks.test.mjs`, `tests/check-rule-fixtures.test.mjs`. Такі тести **не дають додаткового coverage у unit-сенсі** — концерни, які вони перевіряють, уже покриті per-rule unit-тестами (`rules/<rule>/js/tests/`). Захищаємо їх `test.skipIf(env.STRYKER_MUTATOR_WORKER)` від запуску всередині Stryker-sandbox:
114
+
115
+ ```js
116
+ import { env } from 'node:process'
117
+
118
+ test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним деревом', async () => {
119
+ // …перевірки на REPO_ROOT
120
+ })
121
+ ```
122
+
123
+ Перевірка — концерн `sandbox-aware-test` (`rules/test/js/sandbox-aware-test.mjs`): сканер `*.test.{js,mjs}`, який знаходить тести з `import.meta.url`-deep-relative навігацією (4+ `..`-рівнів) і вимагає або `withTmpDir` у їхніх `test`-блоках, або `test.skipIf(env.STRYKER_MUTATOR_WORKER)`.
124
+
125
+ ## `child_process` у тестах: явний `cwd`
126
+
127
+ Уже зазначено в `## Заборона process.chdir у тестах`. Підсилення: **кожен** виклик `execFile`/`execFileSync`/`spawn`/`spawnSync` у `*.test.{js,mjs}` приймає або **явний `{ cwd: dir }`** (де `dir` — змінна, не `process.cwd()`), або працює з **абсолютними шляхами** до бінарників/fixture-файлів (наприклад `spawnSync('node', [absoluteFixturePath])`). Це гарантує, що мутаційний test-flow у `pool: 'forks'` не страждає від implicit-`process.cwd()`-stride між тестами в одному воркері.
128
+
84
129
  ## Покриття + мутаційне тестування
85
130
 
86
131
  Канонічна команда — `n-cursor coverage`: збирає метрики покриття (`vitest run --coverage`, `cargo llvm-cov` тощо) і мутаційного тестування (Stryker з vitest-runner + `coverageAnalysis: 'perTest'`, `cargo-mutants`) з усіх активних провайдерів у `.n-cursor.json#rules` і пише `COVERAGE.md` у корінь проєкту. Лок і дедуп — `withLock('coverage', ...)`.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Обчислення директорії стану `withLock` (lock + dedup), СПІЛЬНОЇ для всіх git-worktree.
3
+ *
4
+ * Лок-стан (`lock/owner.json`, `result.json`) має бути один на головний checkout
5
+ * і всі linked-worktree. Інакше важкі команди (eslint/oxlint/jscpd, conftest,
6
+ * hadolint, per-rule fix), запущені в різних worktree, НЕ серіалізуються —
7
+ * кожен worktree має власний `node_modules/.cache/`, локи одне одного не бачать,
8
+ * і паралельний eslint перевантажує CPU/диск на macOS.
9
+ *
10
+ * `git rev-parse --git-common-dir` повертає той самий `.git` головного репо з
11
+ * будь-якого worktree, тож стан кладемо під `<git-common-dir>/n-cursor/<key>`
12
+ * (всередині `.git` — спільне, ніколи не трекається, переживає `bun i`).
13
+ * Поза git-репо (git недоступний / каталог не репо) — fallback на per-checkout
14
+ * `node_modules/.cache/n-cursor/<key>`, як було історично.
15
+ */
16
+ import { spawnSync } from 'node:child_process'
17
+ import { join, resolve } from 'node:path'
18
+
19
+ /**
20
+ * @param {string} key ключ локу (`lint-ga`, `fix-bun`, …)
21
+ * @param {{cwd?:string, spawn?:typeof import('child_process').spawnSync}} [opts] робоча директорія та sync-виклик git (ін'єкція для тестів)
22
+ * @returns {string} абсолютний шлях до директорії стану локу для цього ключа
23
+ */
24
+ export function resolveLockCacheDir(key, opts = {}) {
25
+ const cwd = opts.cwd ?? process.cwd()
26
+ const spawn = opts.spawn ?? spawnSync
27
+
28
+ const r = spawn('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8' })
29
+ const commonDir = r.status === 0 && !r.error ? r.stdout.trim() : ''
30
+
31
+ if (commonDir === '') {
32
+ return join(cwd, 'node_modules/.cache/n-cursor', key)
33
+ }
34
+ // commonDir буває відносним (`.git` з кореня) або абсолютним (linked-worktree):
35
+ // resolve проти cwd дає однаковий абсолютний `<main>/.git` в обох випадках.
36
+ return join(resolve(cwd, commonDir), 'n-cursor', key)
37
+ }
@@ -6,6 +6,7 @@ import * as fs from 'node:fs'
6
6
  import { join } from 'node:path'
7
7
  import * as os from 'node:os'
8
8
  import { setTimeout as sleep } from 'node:timers/promises'
9
+ import { resolveLockCacheDir } from './lock-cache-dir.mjs'
9
10
  import { worktreeFingerprint } from './worktree-fingerprint.mjs'
10
11
 
11
12
  const DEFAULTS = {
@@ -62,7 +63,7 @@ export function shouldDedup(result, fingerprint, ttl) {
62
63
  export async function withLock(key, runFn, opts = {}) {
63
64
  const { ttl, staleThreshold, waitTimeout, pollInterval } = { ...DEFAULTS, ...opts }
64
65
  const getFingerprint = opts.getFingerprint ?? worktreeFingerprint
65
- const cacheDir = opts.cacheDir ?? join(process.cwd(), 'node_modules/.cache/n-cursor', key)
66
+ const cacheDir = opts.cacheDir ?? resolveLockCacheDir(key)
66
67
  const lockDir = join(cacheDir, 'lock')
67
68
  const ownerFile = join(lockDir, 'owner.json')
68
69
  const resultFile = join(cacheDir, 'result.json')