@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 +10 -0
- package/package.json +1 -1
- package/rules/release/release.mjs +3 -2
- package/rules/test/test.mdc +46 -1
- package/scripts/utils/lock-cache-dir.mjs +37 -0
- package/scripts/utils/with-lock.mjs +2 -1
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
|
@@ -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 {
|
package/rules/test/test.mdc
CHANGED
|
@@ -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.
|
|
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 ??
|
|
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')
|