@nitra/cursor 1.28.0 → 1.28.2

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
@@ -4,6 +4,14 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.28.2] - 2026-05-28
8
+
9
+ ### Changed
10
+
11
+ - **`rules/rust/coverage/coverage.mjs`** — `cargo mutants` запускається з `--jobs N` (дефолт `min(4, cpus/2)`, override через env `CARGO_MUTANTS_JOBS`). Прапорець `--in-place` прибраний — cargo-mutants створює власну sandbox-копію в `target/mutants.<i>/`, що **обов'язкове** для `--jobs > 1`. Ефект: ~7× прискорення Rust mutation testing на 8-ядерних машинах (виміряно на Tauri-проєкті: 8 год → ~65 хв). Edge-case `--in-place` лишається доступним користувачам напряму через `cargo mutants --in-place` (наш runner його більше не передає). Експортовано `resolveJobs(envValue)` і `buildCargoMutantsArgs({ manifestPath, outDir, jobs })` як чисті функції для unit-тестів.
12
+ - **`rules/tauri/js/cargo_mutants_config.mjs#TAURI_KEY_SNIPPETS.exclude_globs`** — додано `src/lib.rs` як Tauri runtime entrypoint (`pub fn run`). Один мутант там тримає весь app shell, тому ділить sandbox-фейл з `src/main.rs`. Дзеркальна правка в `tauri.mdc` (canon-TOML і пояснення семантики).
13
+ - **`rules/rust/rust.mdc`** (`version` 1.1 → 1.2), **`rules/tauri/tauri.mdc`** (`version` 1.3 → 1.4) — актуалізовано опис паралельного запуску cargo-mutants і Tauri exclude_globs.
14
+
7
15
  ## [1.28.0] - 2026-05-27
8
16
 
9
17
  ### BREAKING
@@ -19,6 +27,7 @@
19
27
  ### Added
20
28
 
21
29
  - **`rules/test/js/no-process-chdir.mjs`** — JS concern: сканує `**/*.test.{js,mjs}` і падає на `process.chdir(`. Token-based regex (`/process\.chdir\s*\(/u`) — не зачіпає згадки у JSDoc. 8 unit-тестів.
30
+ - **`rules/test/js/no-relative-fs-path.mjs`** — AST-сканер (`oxc-parser`) для `**/*.test.{js,mjs}`: знаходить виклики FS-функцій з `node:fs`/`node:fs/promises` (`writeFile`, `copyFile`, `mkdir`, `readFile`, `existsSync`, `rename`, `symlink`, `cp`, `*Sync`-варіанти + `writeJson`/`ensureDir`), де path-аргумент — string literal без префікса `/`, `\`, `file:`, `http(s):`, `data:`, чи Windows-disk-letter. Покриває обидва path-arg позиції у `copyFile`/`rename`/`symlink`/`link`/`cp`. Виловив би інцидент 1.28.0 (`tests/check-rule-fixtures.test.mjs`): `copyFile(src, 'default.conf.template')` / `copyFile(src, 'values-dev.ini')` зливали fixture у production tree `npm/`. 17 unit-тестів; на власному репо знайдено 0 порушень серед 106 test files.
22
31
  - **`rules/test/js/vitest-config-pool-forks.mjs`** — JS concern: substring-перевірка `pool: 'forks'` у `vitest.config.js`. Defense-in-depth. 6 unit-тестів.
23
32
  - **`rules/test/js/data/vitest_config/vitest.config.baseline.js`** — canonical baseline тепер містить `pool: 'forks'` з обґрунтуванням race-bug у docstring.
24
33
  - **`rules/test/test.mdc` — секція "Заборона `process.chdir` у тестах"** із описом інциденту, контрактом `withTmpDir`, посиланнями на нові concern'и.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.28.0",
3
+ "version": "1.28.2",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -361,8 +361,8 @@ async function checkDockerfiles(root, ignorePaths, passFn, failFn) {
361
361
  * @param {(msg: string) => void} failFn callback при помилці
362
362
  * @returns {void}
363
363
  */
364
- function checkVscodeNginx(passFn, failFn) {
365
- const extPath = '.vscode/extensions.json'
364
+ function checkVscodeNginx(passFn, failFn, cwd) {
365
+ const extPath = join(cwd, '.vscode/extensions.json')
366
366
  if (existsSync(extPath)) {
367
367
  const violations = runConftestBatch({
368
368
  policyDirRel: 'nginx-default-tpl/vscode_extensions',
@@ -378,7 +378,7 @@ function checkVscodeNginx(passFn, failFn) {
378
378
  failFn('Очікується .vscode/extensions.json з ahmadalli.vscode-nginx-conf (див. nginx-default-tpl.mdc)')
379
379
  }
380
380
 
381
- const setPath = '.vscode/settings.json'
381
+ const setPath = join(cwd, '.vscode/settings.json')
382
382
  if (!existsSync(setPath)) {
383
383
  failFn('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
384
384
  return
@@ -429,7 +429,7 @@ export async function check(cwd = process.cwd()) {
429
429
  }
430
430
 
431
431
  await checkDockerfiles(root, ignorePaths, pass, fail)
432
- checkVscodeNginx(pass, fail)
432
+ checkVscodeNginx(pass, fail, root)
433
433
 
434
434
  return reporter.getExitCode()
435
435
  }
@@ -9,7 +9,7 @@
9
9
  import { spawnSync } from 'node:child_process'
10
10
  import { existsSync } from 'node:fs'
11
11
  import { mkdtemp, readFile, rm } from 'node:fs/promises'
12
- import { tmpdir } from 'node:os'
12
+ import { cpus, tmpdir } from 'node:os'
13
13
  import { join } from 'node:path'
14
14
 
15
15
  import { hasCargoTomlInTree } from '../lib/has-cargo-toml.mjs'
@@ -27,6 +27,32 @@ export function detect(cwd) {
27
27
  return Promise.resolve(hasCargoTomlInTree(cwd, IGNORED_DIR_NAMES))
28
28
  }
29
29
 
30
+ /**
31
+ * Обчислює кількість паралельних воркерів cargo-mutants. Env override через
32
+ * CARGO_MUTANTS_JOBS (валідне ціле >= 1). Fallback — min(4, max(1, cpus/2)):
33
+ * на ≤2 ядрах = 1, на 4 = 2, на 8+ = 4. Стеля 4 — Rust linker bottleneck:
34
+ * вище практичного приросту не дає навіть на 16+ ядрах.
35
+ * @param {string | undefined} envValue значення `process.env.CARGO_MUTANTS_JOBS`
36
+ * @returns {number}
37
+ */
38
+ export function resolveJobs(envValue) {
39
+ if (envValue !== undefined && envValue !== '') {
40
+ const n = Number.parseInt(envValue, 10)
41
+ if (Number.isFinite(n) && n >= 1) return n
42
+ }
43
+ return Math.min(4, Math.max(1, Math.floor(cpus().length / 2)))
44
+ }
45
+
46
+ /**
47
+ * Будує argv для `cargo mutants`. `--in-place` навмисно відсутній: cargo-mutants
48
+ * створює власну sandbox-копію в `target/mutants.<i>/`, що обов'язкове для `--jobs > 1`.
49
+ * @param {{ manifestPath: string, outDir: string, jobs: number }} opts
50
+ * @returns {string[]}
51
+ */
52
+ export function buildCargoMutantsArgs({ manifestPath, outDir, jobs }) {
53
+ return ['mutants', '--jobs', String(jobs), '-o', outDir, '--manifest-path', manifestPath]
54
+ }
55
+
30
56
  const defaultRunner = {
31
57
  runLlvmCov({ manifestPath }) {
32
58
  const r = spawnSync('cargo', ['llvm-cov', '--manifest-path', manifestPath, '--json', '--summary-only'], {
@@ -36,7 +62,8 @@ const defaultRunner = {
36
62
  return { exitCode: r.status ?? 1, stdout: r.stdout?.toString('utf8') ?? '' }
37
63
  },
38
64
  runCargoMutants({ manifestPath, outDir }) {
39
- const r = spawnSync('cargo', ['mutants', '--in-place', '-o', outDir, '--manifest-path', manifestPath], {
65
+ const jobs = resolveJobs(process.env.CARGO_MUTANTS_JOBS)
66
+ const r = spawnSync('cargo', buildCargoMutantsArgs({ manifestPath, outDir, jobs }), {
40
67
  stdio: 'inherit',
41
68
  env: process.env
42
69
  })
@@ -2,7 +2,7 @@
2
2
  description: Перевірка Rust коду
3
3
  globs: "**/{Cargo.toml,Cargo.lock,rustfmt.toml,clippy.toml,.vscode/extensions.json,package.json},**/*.rs"
4
4
  alwaysApply: false
5
- version: '1.1'
5
+ version: '1.2'
6
6
  ---
7
7
 
8
8
  **rustfmt** ([rust-lang/rustfmt](https://github.com/rust-lang/rustfmt)) — форматер; **clippy** ([rust-lang/rust-clippy](https://github.com/rust-lang/rust-clippy)) — лінтер. У скрипті **`lint-rust`** локально йдуть три кроки в одному рядку: `cargo fmt --all` → `cargo clippy --fix --allow-staged --allow-dirty --all-targets --all-features` → фінальний `cargo clippy --all-targets --all-features -- -D warnings`. У CI — без `--fix`: `cargo fmt --all -- --check` і `cargo clippy ... -- -D warnings` (див. `lint-rust.yml`).
@@ -28,4 +28,4 @@ Tauri-проєкт завжди має `src-tauri/Cargo.toml`, тому прав
28
28
 
29
29
  ## Покриття + мутаційне тестування Rust
30
30
 
31
- Покриття + мутаційне тестування Rust постачаються через `n-cursor coverage` (правило `test.mdc`). Реалізація провайдера — у `npm/rules/rust/coverage/coverage.mjs`: `cargo llvm-cov --json --summary-only` + `cargo mutants --in-place`. Бінарники: `cargo install cargo-llvm-cov && cargo install cargo-mutants`.
31
+ Покриття + мутаційне тестування Rust постачаються через `n-cursor coverage` (правило `test.mdc`). Реалізація провайдера — у `npm/rules/rust/coverage/coverage.mjs`: `cargo llvm-cov --json --summary-only` + `cargo mutants --jobs N` (паралельні воркери, дефолт `min(4, cpus/2)`; override через env `CARGO_MUTANTS_JOBS`). Прапорець `--in-place` прибраний — cargo-mutants створює власну sandbox-копію в `target/mutants.<i>/`, що сумісне з `--jobs > 1`. Бінарники: `cargo install cargo-llvm-cov && cargo install cargo-mutants`.
@@ -37,8 +37,11 @@ const TAURI_KEY_SNIPPETS = Object.freeze({
37
37
  exclude_globs: `# Platform bridge / app shell — boundary-файли (тестуються smoke/e2e, не mutation unit).
38
38
  # Якщо у bridge-файлі з'являється pure/business logic — винеси її у platform-neutral
39
39
  # модуль (src/auth/oauth.rs, src/gmail/message.rs, ...) і тестуй mutation-testing там.
40
+ # src/lib.rs (Tauri pub fn run) — runtime entrypoint, що запускає весь app shell:
41
+ # один мутант там тримає весь Tauri runtime, тому ділить sandbox-фейл з src/main.rs.
40
42
  exclude_globs = [
41
43
  "src/main.rs",
44
+ "src/lib.rs",
42
45
  "src/**/android.rs",
43
46
  "src/**/ios.rs",
44
47
  "src/**/mobile.rs",
@@ -2,7 +2,7 @@
2
2
  description: Tauri
3
3
  globs: "**/src-tauri/**,**/tauri.conf.json"
4
4
  alwaysApply: false
5
- version: '1.3'
5
+ version: '1.4'
6
6
  ---
7
7
 
8
8
  У `.vscode/extensions.json` `recommendations` має містити `tauri-apps.tauri-vscode`:
@@ -36,6 +36,7 @@ additional_cargo_test_args = ["--lib", "--tests"]
36
36
 
37
37
  exclude_globs = [
38
38
  "src/main.rs",
39
+ "src/lib.rs",
39
40
  "src/**/android.rs",
40
41
  "src/**/ios.rs",
41
42
  "src/**/mobile.rs",
@@ -49,6 +50,7 @@ exclude_globs = [
49
50
  Семантика (фіксована між Tauri-проєктами):
50
51
 
51
52
  - **`src/main.rs`** — binary shell entrypoint: запускає Tauri runtime, реєструє plugins/handlers і повертає управління циклу подій. Тестується smoke/e2e (запуск бінарника), не mutation unit;
53
+ - **`src/lib.rs`** — Tauri runtime entrypoint (`pub fn run`): піднімає весь app shell. Один мутант там тримає весь Tauri runtime, тому ділить sandbox-фейл з `src/main.rs` і тестується smoke/e2e, а не mutation unit;
52
54
  - **`*android.rs`, `*ios.rs`, `*mobile.rs`** — mobile plugin bridge / platform glue: тонка обгортка над JNI/Objective-C виклики, mapping platform errors, виклики Tauri AppHandle і native API;
53
55
  - **`*macos.rs`, `*windows.rs`, `*linux.rs`, `*desktop.rs`** — desktop platform bridge / OS integration glue: opener/window APIs, OS-specific I/O, mapping platform errors.
54
56
 
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Заборона **relative-path** аргументів у FS-функціях усередині тестів.
3
+ *
4
+ * Контекст (test.mdc, секція "Заборона `process.chdir` у тестах"):
5
+ * Після видалення `withTmpCwd` усі тести отримують `dir` параметром і мають
6
+ * будувати **абсолютні** шляхи через `join(dir, …)`. Якщо хтось забуде префікс
7
+ * і напише `writeFile('foo.json', …)` чи `copyFile(src, 'foo.json')` —
8
+ * relative-path резолвиться у `process.cwd()` (= `npm/`), що зливає тестову
9
+ * фікстуру у production tree. Інцидент v1.28.0: `tests/check-rule-fixtures.test.mjs`
10
+ * залишив `copyFile(src, 'values-dev.ini')` і `copyFile(src, 'default.conf.template')` —
11
+ * створило файли `npm/values-dev.ini` і `npm/default.conf.template`.
12
+ *
13
+ * Сканер AST-based (oxc-parser): знаходить виклики `node:fs`/`node:fs/promises`
14
+ * функцій із **string literal** аргументом-шляхом, який НЕ починається з:
15
+ * - `/`, `\\` — POSIX/Windows absolute;
16
+ * - `file:`/`http`/`data:` — URL-схема (передається до `new URL(...)`);
17
+ * - `${…}` (template-literal з виразом) і `\`…\${dir}\`` патерни — обчислений шлях;
18
+ * - `:` для Windows-літер диску `C:\…` (рідко в тестах, але legit).
19
+ * Виклики, чий path-аргумент — НЕ literal (CallExpression `join(...)`, BinaryExpression,
20
+ * Identifier, MemberExpression) — пропускаємо: припускаємо що це абсолютний шлях.
21
+ *
22
+ * Скани: `**\/*.test.{js,mjs}` з загальними `walkDir` skip + `.n-cursor.json#ignore`.
23
+ */
24
+ import { readFile } from 'node:fs/promises'
25
+ import { basename, relative } from 'node:path'
26
+
27
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
28
+ import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
29
+ import { parseProgramOrNull, walkAstWithAncestors } from '../../../scripts/utils/ast-scan-utils.mjs'
30
+ import { walkDir } from '../../../scripts/utils/walkDir.mjs'
31
+
32
+ /**
33
+ * FS-функції з `node:fs` / `node:fs/promises` / sync-API, які приймають path
34
+ * у фіксованих позиціях. Map: ім'я функції → масив 0-індексованих позицій
35
+ * path-аргументів (1-й, 2-й, або обидва — як у `copyFile/rename/symlink/link`).
36
+ */
37
+ const FS_PATH_ARG_POSITIONS = new Map([
38
+ ['writeFile', [0]],
39
+ ['writeFileSync', [0]],
40
+ ['readFile', [0]],
41
+ ['readFileSync', [0]],
42
+ ['appendFile', [0]],
43
+ ['appendFileSync', [0]],
44
+ ['mkdir', [0]],
45
+ ['mkdirSync', [0]],
46
+ ['rmdir', [0]],
47
+ ['rmdirSync', [0]],
48
+ ['rm', [0]],
49
+ ['rmSync', [0]],
50
+ ['unlink', [0]],
51
+ ['unlinkSync', [0]],
52
+ ['access', [0]],
53
+ ['accessSync', [0]],
54
+ ['stat', [0]],
55
+ ['statSync', [0]],
56
+ ['lstat', [0]],
57
+ ['lstatSync', [0]],
58
+ ['chmod', [0]],
59
+ ['chmodSync', [0]],
60
+ ['chown', [0]],
61
+ ['chownSync', [0]],
62
+ ['truncate', [0]],
63
+ ['truncateSync', [0]],
64
+ ['existsSync', [0]],
65
+ ['readdir', [0]],
66
+ ['readdirSync', [0]],
67
+ ['copyFile', [0, 1]],
68
+ ['copyFileSync', [0, 1]],
69
+ ['rename', [0, 1]],
70
+ ['renameSync', [0, 1]],
71
+ ['symlink', [0, 1]],
72
+ ['symlinkSync', [0, 1]],
73
+ ['link', [0, 1]],
74
+ ['linkSync', [0, 1]],
75
+ ['cp', [0, 1]],
76
+ ['cpSync', [0, 1]],
77
+ // test-helpers абсолютні-only форми (зайвий захист)
78
+ ['writeJson', [0]],
79
+ ['ensureDir', [0]]
80
+ ])
81
+
82
+ /**
83
+ * Префікси абсолютних шляхів або очевидно-обчислених. Якщо literal починається з
84
+ * одного з них — це OK (тест свідомо передає absolute чи URL).
85
+ */
86
+ const ABSOLUTE_PREFIXES = ['/', '\\', 'file:', 'http:', 'https:', 'data:']
87
+
88
+ /**
89
+ * Чи string literal — relative path (тобто баг). Перевіряє лише string-літерали
90
+ * та template literals без виразів. Виклики `join(...)` / `resolve(...)` /
91
+ * перемінні з ${} — пропускаємо (припускаємо absolute).
92
+ * @param {object} arg AST node аргументу
93
+ * @returns {string|null} relative-path значення (для меседжа), або null якщо OK
94
+ */
95
+ function extractRelativeLiteralPath(arg) {
96
+ if (!arg) return null
97
+ if (arg.type === 'Literal' && typeof arg.value === 'string') {
98
+ return isRelativeString(arg.value) ? arg.value : null
99
+ }
100
+ if (arg.type === 'TemplateLiteral' && arg.expressions.length === 0) {
101
+ const raw = arg.quasis.map(q => q.value.cooked).join('')
102
+ return isRelativeString(raw) ? raw : null
103
+ }
104
+ // Не string-literal — не аналізуємо (припускаємо обчислений absolute через join/resolve).
105
+ return null
106
+ }
107
+
108
+ /**
109
+ * Чи рядок виглядає як relative path. Порожній рядок — false (це не path).
110
+ * Windows-disk-letter `C:\…` — absolute, бо містить `:` між літерою і `\`.
111
+ * @param {string} s рядок-шлях
112
+ * @returns {boolean} true якщо relative
113
+ */
114
+ function isRelativeString(s) {
115
+ if (!s) return false
116
+ for (const prefix of ABSOLUTE_PREFIXES) {
117
+ if (s.startsWith(prefix)) return false
118
+ }
119
+ // Windows drive letter, наприклад `C:\foo` або `C:/foo`.
120
+ if (/^[A-Za-z]:[\\/]/u.test(s)) return false
121
+ return true
122
+ }
123
+
124
+ /**
125
+ * Витягує ім'я FS-функції з callee:
126
+ * - `writeFile(…)` → "writeFile" (Identifier callee)
127
+ * - `fs.writeFile(…)` чи `fsp.writeFile(…)` → "writeFile" (MemberExpression)
128
+ * - `await fs.promises.writeFile(…)` → "writeFile"
129
+ * Повертає null для будь-якого іншого виклику.
130
+ * @param {object} callee AST callee node
131
+ * @returns {string|null} ім'я FS-функції або null
132
+ */
133
+ function extractFsFunctionName(callee) {
134
+ if (!callee) return null
135
+ if (callee.type === 'Identifier') {
136
+ return FS_PATH_ARG_POSITIONS.has(callee.name) ? callee.name : null
137
+ }
138
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.property?.type === 'Identifier') {
139
+ const name = callee.property.name
140
+ return FS_PATH_ARG_POSITIONS.has(name) ? name : null
141
+ }
142
+ return null
143
+ }
144
+
145
+ /**
146
+ * Чи файл — JS-тест (`*.test.mjs` / `*.test.js`).
147
+ * @param {string} absPath абсолютний шлях
148
+ * @returns {boolean}
149
+ */
150
+ function isTestFile(absPath) {
151
+ const name = basename(absPath)
152
+ return name.endsWith('.test.mjs') || name.endsWith('.test.js')
153
+ }
154
+
155
+ /**
156
+ * Знаходить порушення у одному тестовому файлі.
157
+ * @param {string} body вміст тесту
158
+ * @returns {Array<{line: number, fn: string, path: string, argPos: number}>} порушення
159
+ */
160
+ function findOffendersInBody(body) {
161
+ const program = parseProgramOrNull(body, 'test.mjs')
162
+ if (!program) return []
163
+ const offenders = []
164
+ const lineOffsets = computeLineOffsets(body)
165
+ walkAstWithAncestors(program, [], node => {
166
+ if (node?.type !== 'CallExpression') return
167
+ const fnName = extractFsFunctionName(node.callee)
168
+ if (!fnName) return
169
+ const positions = FS_PATH_ARG_POSITIONS.get(fnName)
170
+ for (const pos of positions) {
171
+ const arg = node.arguments?.[pos]
172
+ const relPath = extractRelativeLiteralPath(arg)
173
+ if (relPath !== null) {
174
+ const start = arg?.start ?? node.start ?? 0
175
+ const line = offsetToLineFromCache(lineOffsets, start)
176
+ offenders.push({ line, fn: fnName, path: relPath, argPos: pos })
177
+ }
178
+ }
179
+ })
180
+ return offenders
181
+ }
182
+
183
+ /**
184
+ * Кешований offset→line: бінарний пошук по newline-offsets.
185
+ * @param {string} body source
186
+ * @returns {number[]} offsets newline char positions
187
+ */
188
+ function computeLineOffsets(body) {
189
+ const offsets = [0]
190
+ for (let i = 0; i < body.length; i += 1) {
191
+ if (body[i] === '\n') offsets.push(i + 1)
192
+ }
193
+ return offsets
194
+ }
195
+
196
+ /**
197
+ * @param {number[]} offsets newline-offsets
198
+ * @param {number} offset 0-індекс символу
199
+ * @returns {number} 1-індексований рядок
200
+ */
201
+ function offsetToLineFromCache(offsets, offset) {
202
+ let lo = 0
203
+ let hi = offsets.length - 1
204
+ while (lo < hi) {
205
+ const mid = (lo + hi + 1) >>> 1
206
+ if (offsets[mid] <= offset) lo = mid
207
+ else hi = mid - 1
208
+ }
209
+ return lo + 1
210
+ }
211
+
212
+ /**
213
+ * Перевіряє, що жоден `*.test.{mjs,js}` файл не передає relative-path як 1-й
214
+ * (або для `copyFile`/`rename`/`symlink`/`link`/`cp` — 1-й і 2-й) аргумент
215
+ * у FS-функцію з `node:fs` / `node:fs/promises`.
216
+ * @param {string} [cwdParam] корінь репозиторію
217
+ * @returns {Promise<number>} 0 — чисто, 1 — є порушення
218
+ */
219
+ export async function check(cwdParam = process.cwd()) {
220
+ const reporter = createCheckReporter()
221
+ const { pass, fail } = reporter
222
+
223
+ const cwd = cwdParam
224
+ const ignorePaths = await loadCursorIgnorePaths(cwd)
225
+
226
+ /** @type {string[]} */
227
+ const testFiles = []
228
+ await walkDir(
229
+ cwd,
230
+ absPath => {
231
+ if (isTestFile(absPath)) testFiles.push(absPath)
232
+ },
233
+ ignorePaths
234
+ )
235
+
236
+ /** @type {Array<{file: string, line: number, fn: string, path: string, argPos: number}>} */
237
+ const offenders = []
238
+ for (const absPath of testFiles) {
239
+ const body = await readFile(absPath, 'utf8')
240
+ const found = findOffendersInBody(body)
241
+ for (const o of found) {
242
+ offenders.push({ file: relative(cwd, absPath), ...o })
243
+ }
244
+ }
245
+
246
+ if (offenders.length === 0) {
247
+ pass(`Жоден з ${testFiles.length} тестових файлів не передає relative-path у FS-функції (test.mdc)`)
248
+ return reporter.getExitCode()
249
+ }
250
+
251
+ for (const { file, line, fn, path, argPos } of offenders) {
252
+ const which = argPos === 0 ? '1-й аргумент' : `${argPos + 1}-й аргумент`
253
+ fail(
254
+ `${file}:${line}: ${fn}() — ${which} '${path}' relative; використовуй join(dir, …) (test.mdc, no-relative-fs-path)`
255
+ )
256
+ }
257
+
258
+ return reporter.getExitCode()
259
+ }
@@ -73,9 +73,13 @@ Recursive globs ловлять файли всередині `tests/` так с
73
73
  - Concern-функції правил — `await check(dir)`, `await applies(dir)`, `await fix(dir)`; усі production функції приймають перший параметр `cwd = process.cwd()` (default зберігає CLI-сумісність).
74
74
  - `vitest.config.js` додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
75
75
 
76
- Це **обов'язково** і для тестів пакета `@nitra/cursor`, і для кожного проєкту-споживача. Перевіряє concern `no-process-chdir` (`rules/test/js/no-process-chdir.mjs`): сканує `**/*.test.{js,mjs}` і падає з ❌ на будь-яке вживання `process.chdir(`.
76
+ Це **обов'язково** і для тестів пакета `@nitra/cursor`, і для кожного проєкту-споживача. Триплет перевірок:
77
77
 
78
- Канон `vitest.config.js` (substring requirement `pool: 'forks'`): [vitest.config.snippet.js](./policy/vitest_config/template/vitest.config.snippet.js)
78
+ - **`no-process-chdir`** (`rules/test/js/no-process-chdir.mjs`) — сканує `**/*.test.{js,mjs}` і падає з ❌ на будь-яке вживання `process.chdir(`.
79
+ - **`no-relative-fs-path`** (`rules/test/js/no-relative-fs-path.mjs`) — AST-сканер (`oxc-parser`): знаходить виклики FS-функцій із `node:fs`/`node:fs/promises` (`writeFile`, `copyFile`, `mkdir`, `readFile`, `existsSync`, `rename`, `symlink`, `cp`, … включно з `*Sync`-варіантами та `writeJson`/`ensureDir`-хелперами), де path-аргумент — це **string literal** без префікса `/`, `\`, `file:`, `http(s):`, `data:`, чи Windows-disk-letter `C:\`. Виклики `copyFile`/`rename`/`symlink`/`link`/`cp` перевіряють обидва path-аргументи. Виклики з обчисленим path (`join(dir, …)`, змінна, template-literal з виразом) пропускаються. Виловив би інцидент v1.28.0 у `tests/check-rule-fixtures.test.mjs` (`copyFile(src, 'default.conf.template')` → файл у production tree).
80
+ - **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.js`. Defense-in-depth.
81
+
82
+ Canonical `vitest.config.js` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root).
79
83
 
80
84
  ## Покриття + мутаційне тестування
81
85