@nitra/cursor 1.16.1 → 1.17.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
@@ -4,6 +4,26 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.17.0] - 2026-05-24
8
+
9
+ ### Added
10
+
11
+ - CLI-команда `n-cursor coverage` — оркестратор покриття + мутаційного тестування з discovery провайдерів через `.n-cursor.json#rules`. Канон `scripts.coverage` (контейнер `package.json`) у правилі `test`. Лок — прямий `withLock('coverage', ...)`.
12
+ - Провайдер `js-lint/coverage/` — `bun test --coverage --coverage-reporter=lcov` + `bunx stryker run`; парсить lcov.info і `reports/stryker/mutation.json`.
13
+ - Провайдер `rust/coverage/` — `cargo llvm-cov --json` + `cargo mutants --in-place`; парсить `data[0].totals` і `outcomes.json` (caught = caught + timeout; total = caught + missed; unviable виключено).
14
+ - Policy `test.package_json` з template `package.json.contains.json` — substring-вимога `scripts.coverage` містити `n-cursor coverage`.
15
+
16
+ ### Fixed
17
+
18
+ - `test/coverage/coverage.mjs::loadProvider` — коли правило `test` присутнє у `.n-cursor.json#rules` (як у самому `@nitra/cursor`), оркестратор знаходив власний файл `npm/rules/test/coverage/coverage.mjs` і намагався викликати його як провайдер (`provider.detect is not a function`). `loadProvider` тепер перевіряє, що модуль експортує обидва `detect` і `collect` як функції — інакше silently skip. Regression-тест: `пропускає модулі без detect/collect (наприклад сам оркестратор)`.
19
+
20
+ ### Changed
21
+
22
+ - `test.mdc` 1.1 → 1.2: додано секцію «Покриття + мутаційне тестування» з посиланням на template.
23
+ - `js-lint.mdc` 1.24 → 1.25: додано параграф із посиланням на JS-coverage-провайдер.
24
+ - `rust.mdc` 1.0 → 1.1: додано параграф із посиланням на Rust-coverage-провайдер.
25
+ - `npm/bin/n-cursor.js`: новий `case 'coverage'` + розширений help-string.
26
+
7
27
  ## [1.16.1] - 2026-05-24
8
28
 
9
29
  ### Fixed
@@ -162,7 +182,7 @@
162
182
  - `rules/test/fix/location/check.mjs` — перевіряє **лише `*.test.mjs`**, `*_test.rego` свідомо виключено з область перевірки (зафіксовано у docstring).
163
183
  - Додано test-case у `rules/test/fix/location/tests/check.test.mjs`: `*_test.rego` поряд із полісі НЕ є порушенням.
164
184
  - **Відкат переміщення `*_test.rego`**: 69 файлів, які раніше було помилково перенесено у `policy/<concern>/tests/<name>_test.rego`, повернуто у `policy/<concern>/<name>_test.rego` через `git mv`. Порожні `tests/` піддиректорії під `policy/` видалено.
165
- - **`npx @nitra/cursor fix test`** охоплює лише JS-тести: «✅ Всі 77 файлів *.test.mjs у каталозі tests/». Rego-тести продовжують перевірятись через `conftest verify` у правилі `rego`.
185
+ - **`npx @nitra/cursor fix test`** охоплює лише JS-тести: «✅ Всі 77 файлів \*.test.mjs у каталозі tests/». Rego-тести продовжують перевірятись через `conftest verify` у правилі `rego`.
166
186
 
167
187
  ## [1.13.81] - 2026-05-23
168
188
 
package/README.md CHANGED
@@ -134,9 +134,9 @@ npm/rules/<id>/
134
134
 
135
135
  | Що реалізує | Канал виклику | Куди |
136
136
  | ------------------------- | ---------------------------------------------- | ------------------- |
137
- | JS-діагностика + автофікс | `npx @nitra/cursor fix` (fix-канал) | `js/<concern>/` |
137
+ | JS-діагностика + автофікс | `npx @nitra/cursor fix` (fix-канал) | `js/<concern>/` |
138
138
  | JS-orchestrator лінту | `bun run lint-<id>` через `n-cursor lint-<id>` | `lint/` |
139
- | Rego-діагностика | `npx @nitra/cursor fix` (fix-канал) | `policy/<concern>/` |
139
+ | Rego-діагностика | `npx @nitra/cursor fix` (fix-канал) | `policy/<concern>/` |
140
140
 
141
141
  `js/` і `policy/` обидва живлять fix-канал (`npx @nitra/cursor fix` запускає і JS-checks, і rego-policies), але **розділені за технологією**: JS у `js/`, rego у `policy/`. `lint/` тримає лише JS, що оркеструє `bun run lint-<id>`.
142
142
 
package/bin/n-cursor.js CHANGED
@@ -1231,7 +1231,9 @@ try {
1231
1231
  }
1232
1232
  case 'check': {
1233
1233
  // Backward-compatibility alias. Перейменовано на `fix` у 1.13.84 (узгоджено з ім'ям файла `rules/<id>/fix.mjs`).
1234
- console.warn(`⚠️ Команда \`check\` deprecated — використовуйте \`fix\` (\`npx ${PACKAGE_NAME} fix [<rule>...]\`)`)
1234
+ console.warn(
1235
+ `⚠️ Команда \`check\` deprecated — використовуйте \`fix\` (\`npx ${PACKAGE_NAME} fix [<rule>...]\`)`
1236
+ )
1235
1237
  await runFixCommand(args)
1236
1238
 
1237
1239
  break
@@ -1283,6 +1285,14 @@ try {
1283
1285
 
1284
1286
  break
1285
1287
  }
1288
+ case 'coverage': {
1289
+ // n-cursor coverage — оркестратор покриття + мутаційного тестування з discovery
1290
+ // провайдерів через .n-cursor.json#rules (test.mdc).
1291
+ const { runCoverageCli } = await import('../rules/test/coverage/coverage.mjs')
1292
+ process.exitCode = await runCoverageCli()
1293
+
1294
+ break
1295
+ }
1286
1296
  case 'skill': {
1287
1297
  process.exitCode = runSkillsCli(args)
1288
1298
 
@@ -1297,7 +1307,7 @@ try {
1297
1307
  default: {
1298
1308
  console.error(`❌ Невідома команда: ${command}`)
1299
1309
  console.error(
1300
- ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, stop-hook, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, skill`
1310
+ ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, stop-hook, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, skill`
1301
1311
  )
1302
1312
  process.exitCode = 1
1303
1313
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.16.1",
3
+ "version": "1.17.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -16,11 +16,7 @@ import { join, relative } from 'node:path'
16
16
 
17
17
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
18
18
  import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
19
- import {
20
- findRedisImportsInText,
21
- isRedisScanSourceFile,
22
- shouldSkipFileForRedisScan
23
- } from '../lib/redis-imports.mjs'
19
+ import { findRedisImportsInText, isRedisScanSourceFile, shouldSkipFileForRedisScan } from '../lib/redis-imports.mjs'
24
20
  import { walkDir } from '../../../scripts/utils/walkDir.mjs'
25
21
 
26
22
  /**
@@ -0,0 +1,146 @@
1
+ /**
2
+ * JS-провайдер для `n-cursor coverage`: збирає метрики покриття (`bun test --coverage`)
3
+ * і мутаційного тестування (Stryker) для JS/TS коду. Активується через `js-lint`
4
+ * правило в `.n-cursor.json#rules`; реальна applies-логіка — у `detect(cwd)`.
5
+ *
6
+ * Контракт провайдера — у docs/superpowers/specs/2026-05-24-coverage-rule-design.md.
7
+ */
8
+ import { existsSync } from 'node:fs'
9
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+
13
+ /**
14
+ * Резолвить cwd, у якому стоять JS-тести. Workspace-проєкти — перший workspace
15
+ * (mlmail: app/), single-package — корінь.
16
+ * @param {string} cwd корінь проєкту
17
+ * @returns {Promise<string|null>} абсолютний шлях або null якщо package.json відсутній
18
+ */
19
+ async function resolveJsRoot(cwd) {
20
+ const rootPkgPath = join(cwd, 'package.json')
21
+ if (!existsSync(rootPkgPath)) return null
22
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
23
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
24
+ if (workspaces.length > 0) {
25
+ const wsPath = join(cwd, workspaces[0])
26
+ if (existsSync(join(wsPath, 'package.json'))) return wsPath
27
+ }
28
+ return cwd
29
+ }
30
+
31
+ /**
32
+ * Чи `scripts` містить coverage-сумісну команду.
33
+ * @param {Record<string, string> | undefined} scripts
34
+ * @returns {boolean}
35
+ */
36
+ function hasCoverageScript(scripts) {
37
+ if (!scripts || typeof scripts !== 'object') return false
38
+ if (typeof scripts['test:coverage'] === 'string' && scripts['test:coverage'].length > 0) return true
39
+ if (typeof scripts.test === 'string' && scripts.test.includes('--coverage')) return true
40
+ return false
41
+ }
42
+
43
+ /**
44
+ * Чи провайдер застосовний у поточному cwd.
45
+ * @param {string} cwd
46
+ * @returns {Promise<boolean>}
47
+ */
48
+ export async function detect(cwd) {
49
+ const jsRoot = await resolveJsRoot(cwd)
50
+ if (jsRoot === null) return false
51
+ const pkgPath = join(jsRoot, 'package.json')
52
+ if (!existsSync(pkgPath)) return false
53
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
54
+ return hasCoverageScript(pkg.scripts)
55
+ }
56
+
57
+ /**
58
+ * Парс lcov.info: сумує LF/LH (рядки) і FNF/FNH (функції) по всіх records.
59
+ * @param {string} text
60
+ * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}}
61
+ */
62
+ function parseLcov(text) {
63
+ const acc = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
64
+ for (const line of text.split('\n')) {
65
+ if (line.startsWith('LF:')) acc.lines.total += Number(line.slice(3))
66
+ else if (line.startsWith('LH:')) acc.lines.covered += Number(line.slice(3))
67
+ else if (line.startsWith('FNF:')) acc.functions.total += Number(line.slice(4))
68
+ else if (line.startsWith('FNH:')) acc.functions.covered += Number(line.slice(4))
69
+ }
70
+ return acc
71
+ }
72
+
73
+ /**
74
+ * Парс Stryker mutation.json: Killed+Timeout → caught; Survived+NoCoverage → до total.
75
+ * Compile/Runtime errors виключаються з total.
76
+ * @param {{files:Record<string,{mutants:Array<{status:string}>}>}} report
77
+ * @returns {{caught:number,total:number}}
78
+ */
79
+ function parseStrykerReport(report) {
80
+ let caught = 0
81
+ let total = 0
82
+ for (const file of Object.values(report.files)) {
83
+ for (const mutant of file.mutants) {
84
+ if (mutant.status === 'Killed' || mutant.status === 'Timeout') {
85
+ caught += 1
86
+ total += 1
87
+ } else if (mutant.status === 'Survived' || mutant.status === 'NoCoverage') {
88
+ total += 1
89
+ }
90
+ }
91
+ }
92
+ return { caught, total }
93
+ }
94
+
95
+ /**
96
+ * Default runner — спавнить реальні bun-команди. Замінюється у тестах.
97
+ */
98
+ const defaultRunner = {
99
+ async runJsCoverage({ cwd, lcovDir }) {
100
+ const proc = Bun.spawn(['bun', 'run', 'test:coverage', '--coverage-reporter=lcov', `--coverage-dir=${lcovDir}`], {
101
+ cwd,
102
+ stdout: 'inherit',
103
+ stderr: 'inherit'
104
+ })
105
+ return proc.exited
106
+ },
107
+ async runStryker({ cwd }) {
108
+ const proc = Bun.spawn(['bunx', 'stryker', 'run'], { cwd, stdout: 'inherit', stderr: 'inherit' })
109
+ return proc.exited
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Збирає JS-метрики покриття + мутаційного тестування.
115
+ * @param {string} cwd корінь проєкту
116
+ * @param {{runner?: typeof defaultRunner}} [opts] runner-ін'єкція для тестів
117
+ * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}}>>}
118
+ */
119
+ export async function collect(cwd, opts = {}) {
120
+ const runner = opts.runner ?? defaultRunner
121
+ const jsRoot = await resolveJsRoot(cwd)
122
+ if (jsRoot === null) throw new Error('js-lint coverage: package.json не знайдено')
123
+
124
+ // 1. Coverage через bun test --coverage
125
+ const lcovDir = await mkdtemp(join(tmpdir(), 'js-lint-cov-'))
126
+ let coverage
127
+ try {
128
+ const code = await runner.runJsCoverage({ cwd: jsRoot, lcovDir })
129
+ if (code !== 0) throw new Error(`JS coverage exit ${code}`)
130
+ coverage = parseLcov(await readFile(join(lcovDir, 'lcov.info'), 'utf8'))
131
+ } finally {
132
+ await rm(lcovDir, { recursive: true, force: true })
133
+ }
134
+
135
+ // 2. Mutation через Stryker
136
+ await runner.runStryker({ cwd: jsRoot })
137
+ let mutationReport
138
+ try {
139
+ mutationReport = JSON.parse(await readFile(join(jsRoot, 'reports', 'stryker', 'mutation.json'), 'utf8'))
140
+ } catch {
141
+ throw new Error('js-lint coverage: stryker не залишив mutation.json — перевір stryker.config.mjs у проєкті')
142
+ }
143
+ const mutation = parseStrykerReport(mutationReport)
144
+
145
+ return [{ area: 'JS', coverage, mutation }]
146
+ }
@@ -2,7 +2,7 @@
2
2
  description: Перевірка JavaScript коду
3
3
  globs: "**/{.oxlintrc.json,eslint.config.js,.jscpd.json,knip.json,package.json},**/*.{js,mjs,cjs,jsx,ts,tsx}"
4
4
  alwaysApply: false
5
- version: '1.24'
5
+ version: '1.25'
6
6
  ---
7
7
 
8
8
  **oxlint**, **ESLint**, **jscpd**, **knip**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`**, **`bunx knip`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.9.2`** (з **3.8.0** правило `no-restricted-syntax` забороняє `for...in`; з **3.9.2** у `getConfig` вбудовано ignore для **`**/adr/**`** — ADR-документи не валідуються ESLint, локально цей glob додавати не потрібно; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd/knip не додавай без потреби монорепо.
@@ -200,3 +200,7 @@ for (const item of arr) {
200
200
  ## Тести
201
201
 
202
202
  Проєкт має бути покритий unit-тестами (**Bun test**). Код: синтаксис Node **24+**, **top level await** (узгоджено з `engines.node` у `package.json`).
203
+
204
+ ## Покриття + мутаційне тестування JS
205
+
206
+ Покриття + мутаційне тестування JS постачаються через `n-cursor coverage` (правило `test.mdc`). Реалізація провайдера — у `npm/rules/js-lint/coverage/coverage.mjs`: `bun test --coverage --coverage-reporter=lcov` + `bunx stryker run`. Stryker конфігурується в `stryker.config.mjs` у JS-корені (single-package або `workspaces[0]`).
@@ -36,11 +36,7 @@ import { existsSync, statSync } from 'node:fs'
36
36
  import { readFile } from 'node:fs/promises'
37
37
  import { join, relative } from 'node:path'
38
38
 
39
- import {
40
- findBunyanImportsInText,
41
- isBunyanScanSourceFile,
42
- shouldSkipFileForBunyanScan
43
- } from '../lib/bunyan-imports.mjs'
39
+ import { findBunyanImportsInText, isBunyanScanSourceFile, shouldSkipFileForBunyanScan } from '../lib/bunyan-imports.mjs'
44
40
  import { findUncheckedProcessEnvInText, isCheckEnvScanSourceFile } from '../lib/check-env-scan.mjs'
45
41
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
46
42
  import { runConftestBatch } from '../../../scripts/lib/run-conftest-batch.mjs'
@@ -52,10 +48,7 @@ import {
52
48
  resolveConnDirFromPackageJson
53
49
  } from '../lib/conn-imports-scan.mjs'
54
50
  import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
55
- import {
56
- findPromiseSetTimeoutInText,
57
- isPromiseSetTimeoutScanSourceFile
58
- } from '../lib/promise-settimeout-scan.mjs'
51
+ import { findPromiseSetTimeoutInText, isPromiseSetTimeoutScanSourceFile } from '../lib/promise-settimeout-scan.mjs'
59
52
  import { walkDir } from '../../../scripts/utils/walkDir.mjs'
60
53
  import { getMonorepoPackageRootDirs } from '../../../scripts/lib/workspaces.mjs'
61
54
 
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Rust-провайдер для `n-cursor coverage`: збирає метрики покриття (`cargo llvm-cov`)
3
+ * і мутаційного тестування (`cargo-mutants`) для Rust-коду. Активується через
4
+ * правило `rust` у `.n-cursor.json#rules`; applies-логіка — у `detect(cwd)`
5
+ * (наявність Cargo.toml у cwd або workspace-підкаталозі).
6
+ *
7
+ * Контракт провайдера — у docs/superpowers/specs/2026-05-24-coverage-rule-design.md.
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+
14
+ import { hasCargoTomlInTree } from '../lib/has-cargo-toml.mjs'
15
+
16
+ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo', 'target'])
17
+
18
+ /**
19
+ * Чи провайдер застосовний у поточному cwd.
20
+ * @param {string} cwd
21
+ * @returns {Promise<boolean>}
22
+ */
23
+ export async function detect(cwd) {
24
+ if (existsSync(join(cwd, 'Cargo.toml'))) return true
25
+ return hasCargoTomlInTree(cwd, IGNORED_DIR_NAMES)
26
+ }
27
+
28
+ /**
29
+ * Знайти Cargo.toml: cwd/Cargo.toml або в одному з workspace-підкаталогів.
30
+ * @param {string} cwd
31
+ * @returns {Promise<string>} абсолютний шлях до Cargo.toml
32
+ */
33
+ async function resolveCargoManifest(cwd) {
34
+ const rootManifest = join(cwd, 'Cargo.toml')
35
+ if (existsSync(rootManifest)) return rootManifest
36
+
37
+ const rootPkgPath = join(cwd, 'package.json')
38
+ if (existsSync(rootPkgPath)) {
39
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
40
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
41
+ for (const ws of workspaces) {
42
+ const tauriManifest = join(cwd, ws, 'src-tauri', 'Cargo.toml')
43
+ if (existsSync(tauriManifest)) return tauriManifest
44
+ const flatManifest = join(cwd, ws, 'Cargo.toml')
45
+ if (existsSync(flatManifest)) return flatManifest
46
+ }
47
+ }
48
+
49
+ throw new Error('rust coverage: Cargo.toml не знайдено (cwd + workspaces)')
50
+ }
51
+
52
+ const defaultRunner = {
53
+ async runLlvmCov({ manifestPath }) {
54
+ const proc = Bun.spawn(['cargo', 'llvm-cov', '--manifest-path', manifestPath, '--json', '--summary-only'], {
55
+ stdout: 'pipe',
56
+ stderr: 'inherit'
57
+ })
58
+ const stdout = await new Response(proc.stdout).text()
59
+ const exitCode = await proc.exited
60
+ return { exitCode, stdout }
61
+ },
62
+ async runCargoMutants({ manifestPath, outDir }) {
63
+ const proc = Bun.spawn(['cargo', 'mutants', '--in-place', '-o', outDir, '--manifest-path', manifestPath], {
64
+ stdout: 'inherit',
65
+ stderr: 'inherit'
66
+ })
67
+ return proc.exited
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Збирає Rust-метрики покриття + мутаційного тестування.
73
+ * @param {string} cwd корінь проєкту
74
+ * @param {{runner?: typeof defaultRunner}} [opts]
75
+ * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}}>>}
76
+ */
77
+ export async function collect(cwd, opts = {}) {
78
+ const runner = opts.runner ?? defaultRunner
79
+ const manifestPath = await resolveCargoManifest(cwd)
80
+
81
+ // 1. Coverage через cargo llvm-cov
82
+ const { exitCode: llvmCode, stdout: llvmJson } = await runner.runLlvmCov({ manifestPath })
83
+ if (llvmCode !== 0) {
84
+ throw new Error('rust coverage: cargo llvm-cov упав — встанови: cargo install cargo-llvm-cov')
85
+ }
86
+ const totals = JSON.parse(llvmJson).data[0].totals
87
+ const coverage = {
88
+ lines: { covered: totals.lines.covered, total: totals.lines.count },
89
+ functions: { covered: totals.functions.covered, total: totals.functions.count }
90
+ }
91
+
92
+ // 2. Mutation через cargo mutants
93
+ const outDir = await mkdtemp(join(tmpdir(), 'rust-mutants-'))
94
+ let mutation
95
+ try {
96
+ // cargo-mutants exit ≠ 0 коли є missed — це нормально, не помилка.
97
+ // Реальний крах — відсутній outcomes.json.
98
+ await runner.runCargoMutants({ manifestPath, outDir })
99
+ let outcomes
100
+ try {
101
+ outcomes = JSON.parse(await readFile(join(outDir, 'mutants.out', 'outcomes.json'), 'utf8'))
102
+ } catch {
103
+ throw new Error('rust coverage: cargo mutants не залишив outcomes.json — встанови: cargo install cargo-mutants')
104
+ }
105
+ const caught = (outcomes.caught ?? 0) + (outcomes.timeout ?? 0)
106
+ mutation = { caught, total: caught + (outcomes.missed ?? 0) }
107
+ } finally {
108
+ await rm(outDir, { recursive: true, force: true })
109
+ }
110
+
111
+ return [{ area: 'Rust', coverage, mutation }]
112
+ }
@@ -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.0'
5
+ version: '1.1'
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`).
@@ -25,3 +25,7 @@ Tauri-проєкт завжди має `src-tauri/Cargo.toml`, тому прав
25
25
  - `tauri` — `tauri-apps.tauri-vscode` (див. **tauri.mdc**).
26
26
 
27
27
  Обидва правила перевіряють `.vscode/extensions.json` за `contains`-семантикою; конкурентного запису немає.
28
+
29
+ ## Покриття + мутаційне тестування Rust
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`.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Канонічна команда `n-cursor coverage`: збирає метрики покриття + мутаційного
3
+ * тестування з усіх провайдерів, чиє правило активне в `.n-cursor.json#rules`,
4
+ * агрегує та записує COVERAGE.md у корінь проєкту.
5
+ *
6
+ * Discovery провайдерів — за `.n-cursor.json#rules`: для кожного `ruleId` зі
7
+ * списку шукаємо `npm/rules/<ruleId>/coverage/coverage.mjs` і динамічно
8
+ * імпортуємо. Якщо файлу немає — провайдер для цього правила відсутній (skip
9
+ * silently, не помилка).
10
+ *
11
+ * Лок — прямий виклик `withLock('coverage', steps)`. Один CLI-консумер, один
12
+ * callsite — спільна точка входу не виноситься (YAGNI, див. C4 у
13
+ * specs/2026-05-24-coverage-rule-design.md).
14
+ */
15
+ import { existsSync } from 'node:fs'
16
+ import { writeFile } from 'node:fs/promises'
17
+ import { dirname, join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
21
+ import { withLock } from '../../../scripts/utils/with-lock.mjs'
22
+
23
+ /** Корінь `npm/rules/` — `<rules>/test/coverage` → `<rules>` */
24
+ const RULES_DIR = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
25
+
26
+ /**
27
+ * Сума двох coverage-totals.
28
+ * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a
29
+ * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b
30
+ * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}}
31
+ */
32
+ export function addCoverage(a, b) {
33
+ return {
34
+ lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
35
+ functions: {
36
+ covered: a.functions.covered + b.functions.covered,
37
+ total: a.functions.total + b.functions.total
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Сума двох mutation-counts.
44
+ * @param {{caught:number,total:number}} a
45
+ * @param {{caught:number,total:number}} b
46
+ * @returns {{caught:number,total:number}}
47
+ */
48
+ export function addMutation(a, b) {
49
+ return { caught: a.caught + b.caught, total: a.total + b.total }
50
+ }
51
+
52
+ /**
53
+ * Форматує covered/total як `XX.XX% (covered/total)`.
54
+ * @param {{covered:number,total:number}} metric
55
+ * @returns {string}
56
+ */
57
+ export function formatCoverage({ covered, total }) {
58
+ const percent = total === 0 ? '—' : `${((covered / total) * 100).toFixed(2)}%`
59
+ return `${percent} (${covered}/${total})`
60
+ }
61
+
62
+ /**
63
+ * Форматує мутаційний score як `XX.XX%`.
64
+ * @param {{caught:number,total:number}} metric
65
+ * @returns {string}
66
+ */
67
+ export function formatScore({ caught, total }) {
68
+ return total === 0 ? '—' : `${((caught / total) * 100).toFixed(2)}%`
69
+ }
70
+
71
+ /**
72
+ * Рендерить таблицю покриття + мутаційного тестування як Markdown.
73
+ * Без timestamp, щоб git diff рухався лише при зміні метрик.
74
+ * @param {Array<{area:string, coverage:{lines:{covered:number,total:number},functions:{covered:number,total:number}}, mutation:{caught:number,total:number}}>} rows
75
+ * @returns {string}
76
+ */
77
+ export function renderMarkdown(rows) {
78
+ const lines = [
79
+ '# Coverage',
80
+ '',
81
+ '| Область | Рядки | Функції | Вбито мутацій | Score |',
82
+ '| --- | --- | --- | --- | --- |'
83
+ ]
84
+ for (const row of rows) {
85
+ lines.push(
86
+ `| ${row.area} | ${formatCoverage(row.coverage.lines)} | ${formatCoverage(row.coverage.functions)} | ` +
87
+ `${row.mutation.caught}/${row.mutation.total} | ${formatScore(row.mutation)} |`
88
+ )
89
+ }
90
+ return `${lines.join('\n')}\n`
91
+ }
92
+
93
+ /**
94
+ * Завантажує provider-модуль з `<rulesDir>/<ruleId>/coverage/coverage.mjs`.
95
+ * Повертає null коли:
96
+ * - файлу немає (rule без coverage-провайдера),
97
+ * - файл існує, але не експортує `detect` + `collect` як функції (наприклад,
98
+ * `rules/test/coverage/coverage.mjs` — сам оркестратор, не провайдер).
99
+ * @param {string} rulesDir
100
+ * @param {string} ruleId
101
+ * @returns {Promise<{detect:Function, collect:Function}|null>}
102
+ */
103
+ async function loadProvider(rulesDir, ruleId) {
104
+ const providerPath = join(rulesDir, ruleId, 'coverage', 'coverage.mjs')
105
+ if (!existsSync(providerPath)) return null
106
+ const mod = await import(providerPath)
107
+ if (typeof mod.detect !== 'function' || typeof mod.collect !== 'function') return null
108
+ return mod
109
+ }
110
+
111
+ /**
112
+ * Будує підсумковий рядок «Разом» через сумування всіх coverage/mutation.
113
+ * @param {Array<{area:string, coverage:object, mutation:object}>} rows
114
+ * @returns {{area:string, coverage:object, mutation:{caught:number,total:number}}}
115
+ */
116
+ function buildTotalsRow(rows) {
117
+ const totalCoverage = rows.reduce((acc, row) => addCoverage(acc, row.coverage), {
118
+ lines: { covered: 0, total: 0 },
119
+ functions: { covered: 0, total: 0 }
120
+ })
121
+ const totalMutation = rows.reduce((acc, row) => addMutation(acc, row.mutation), { caught: 0, total: 0 })
122
+ return { area: '**Разом**', coverage: totalCoverage, mutation: totalMutation }
123
+ }
124
+
125
+ /**
126
+ * Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
127
+ * detect+collect для кожного, агрегація, запис COVERAGE.md.
128
+ * @param {{cwd?:string, rulesDir?:string}} [opts] ін'єкція для тестів
129
+ * @returns {Promise<number>} exit code (0 OK, 1 коли жоден провайдер не дав даних)
130
+ */
131
+ export async function runCoverageSteps(opts = {}) {
132
+ const cwd = opts.cwd ?? process.cwd()
133
+ const rulesDir = opts.rulesDir ?? RULES_DIR
134
+ const config = await readNCursorConfigLite(cwd)
135
+ const rows = []
136
+
137
+ for (const ruleId of config.rules) {
138
+ if (config.disableRules.includes(ruleId)) continue
139
+ const provider = await loadProvider(rulesDir, ruleId)
140
+ if (!provider) continue
141
+ if (!(await provider.detect(cwd))) continue
142
+ console.log(`→ ${ruleId} coverage…`)
143
+ rows.push(...(await provider.collect(cwd)))
144
+ }
145
+
146
+ if (rows.length === 0) {
147
+ console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
148
+ return 1
149
+ }
150
+
151
+ rows.push(buildTotalsRow(rows))
152
+ const md = renderMarkdown(rows)
153
+ await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
154
+ console.log('✓ COVERAGE.md')
155
+ return 0
156
+ }
157
+
158
+ // Один оркестратор, один callsite — `withLock` викликається напряму, без спільної
159
+ // точки входу. Канонічне обмеження «не імпортуй withLock у lint.mjs/fix.mjs напряму»
160
+ // (scripts.mdc § withLock) націлене на дедуплікацію preamble серед багатьох файлів —
161
+ // для одного coverage-консумера не релевантне (див. C4 у
162
+ // specs/2026-05-24-coverage-rule-design.md).
163
+ export const runCoverageCli = () => withLock('coverage', runCoverageSteps)
@@ -0,0 +1,17 @@
1
+ # Перевірка `package.json` для правила test (test.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "contains": ... } }
4
+ # Структура --data сформована з template/package.json.contains.json.
5
+ # Перевіряємо substring-вимоги до scripts.coverage:
6
+ # рядок має містити "n-cursor coverage" (локальні розширення дозволені).
7
+ package test.package_json
8
+
9
+ import rego.v1
10
+
11
+ deny contains msg if {
12
+ some script_name, needles in data.template.contains.scripts
13
+ actual := object.get(object.get(input, "scripts", {}), script_name, "")
14
+ some needle in needles
15
+ not contains(actual, needle)
16
+ msg := sprintf("package.json: scripts.%s має містити %q (test.mdc)", [script_name, needle])
17
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": "package.json", "required": true },
4
+ "missingMessage": "package.json не існує — створи зі scripts.coverage (test.mdc)"
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "scripts": {
3
+ "coverage": ["n-cursor coverage"]
4
+ }
5
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: JS-тести (*.test.mjs) живуть у каталозі tests/ поряд із джерельним файлом, а не безпосередньо в тій же директорії
3
- version: '1.1'
3
+ version: '1.2'
4
4
  alwaysApply: true
5
5
  ---
6
6
 
@@ -58,3 +58,13 @@ Recursive globs ловлять файли всередині `tests/` так с
58
58
  `*_test.rego` перевіркою **не охоплюються** — вони не переміщуються.
59
59
 
60
60
  Пропускаються: `node_modules`, `.git`, `dist`, `build`, `.venv`, `venv`, шляхи з `.n-cursor.json:ignore`.
61
+
62
+ ## Покриття + мутаційне тестування
63
+
64
+ Канонічна команда — `n-cursor coverage`: збирає метрики покриття (`bun test --coverage`, `cargo llvm-cov` тощо) і мутаційного тестування (Stryker, `cargo-mutants`) з усіх активних провайдерів у `.n-cursor.json#rules` і пише `COVERAGE.md` у корінь проєкту. Лок і дедуп — `withLock('coverage', ...)`.
65
+
66
+ Провайдери живуть у `npm/rules/<rule>/coverage/coverage.mjs` (постачаються правилами мови/рантайму: `js-lint`, `rust`, у майбутньому `python` тощо). Оркестратор — у `npm/rules/test/coverage/coverage.mjs`.
67
+
68
+ У `package.json` (корінь) має бути `scripts.coverage` із викликом `n-cursor coverage`:
69
+
70
+ Канон `scripts.coverage` (substring requirement): [package.json.contains.json](./policy/package_json/template/package.json.contains.json)
@@ -21,9 +21,7 @@ export function worktreeFingerprint(spawn = spawnSync) {
21
21
  // повертаються у `"..."` формі, і `git hash-object` не знаходить файл → throw → fingerprint=null.
22
22
  const untrackedRaw = git(['ls-files', '-z', '--others', '--exclude-standard'])
23
23
  const untrackedFiles = untrackedRaw.split('\0').filter(Boolean)
24
- const pairs = untrackedFiles
25
- .map(f => `${f}:${git(['hash-object', f]).trim()}`)
26
- .sort()
24
+ const pairs = untrackedFiles.map(f => `${f}:${git(['hash-object', f]).trim()}`).sort()
27
25
  const raw = [commitHash, diffText, ...pairs].join('\n')
28
26
  return createHash('sha256').update(raw).digest('hex')
29
27
  } catch {