@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 +21 -1
- package/README.md +2 -2
- package/bin/n-cursor.js +12 -2
- package/package.json +1 -1
- package/rules/js-bun-redis/js/imports.mjs +1 -5
- package/rules/js-lint/coverage/coverage.mjs +146 -0
- package/rules/js-lint/js-lint.mdc +5 -1
- package/rules/js-run/js/runtime.mjs +2 -9
- package/rules/rust/coverage/coverage.mjs +112 -0
- package/rules/rust/rust.mdc +5 -1
- package/rules/test/coverage/coverage.mjs +163 -0
- package/rules/test/policy/package_json/package_json.rego +17 -0
- package/rules/test/policy/package_json/target.json +5 -0
- package/rules/test/policy/package_json/template/package.json.contains.json +5 -0
- package/rules/test/test.mdc +11 -1
- package/scripts/utils/worktree-fingerprint.mjs +1 -3
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 файлів
|
|
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-канал)
|
|
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-канал)
|
|
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(
|
|
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
|
@@ -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.
|
|
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
|
+
}
|
package/rules/rust/rust.mdc
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/rules/test/test.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: JS-тести (*.test.mjs) живуть у каталозі tests/ поряд із джерельним файлом, а не безпосередньо в тій же директорії
|
|
3
|
-
version: '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 {
|