@nitra/cursor 1.16.1 → 1.17.1

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +2 -2
  3. package/bin/n-cursor.js +28 -18
  4. package/package.json +2 -1
  5. package/rules/abie/fix.mjs +3 -3
  6. package/rules/adr/fix.mjs +3 -3
  7. package/rules/adr/js/hooks.mjs +6 -6
  8. package/rules/bun/fix.mjs +3 -3
  9. package/rules/capacitor/fix.mjs +3 -3
  10. package/rules/changelog/fix.mjs +3 -3
  11. package/rules/changelog/js/consistency.mjs +15 -15
  12. package/rules/ci4/fix.mjs +3 -3
  13. package/rules/docker/fix.mjs +3 -3
  14. package/rules/efes/fix.mjs +3 -3
  15. package/rules/feedback/fix.mjs +3 -3
  16. package/rules/ga/fix.mjs +3 -3
  17. package/rules/graphql/fix.mjs +3 -3
  18. package/rules/hasura/fix.mjs +3 -3
  19. package/rules/image-avif/fix.mjs +3 -3
  20. package/rules/image-compress/fix.mjs +3 -3
  21. package/rules/js-bun-db/fix.mjs +3 -3
  22. package/rules/js-bun-redis/fix.mjs +3 -3
  23. package/rules/js-bun-redis/js/imports.mjs +1 -5
  24. package/rules/js-lint/coverage/coverage.mjs +148 -0
  25. package/rules/js-lint/fix.mjs +3 -3
  26. package/rules/js-lint/js-lint.mdc +5 -1
  27. package/rules/js-mssql/fix.mjs +3 -3
  28. package/rules/js-run/fix.mjs +3 -3
  29. package/rules/js-run/js/runtime.mjs +2 -9
  30. package/rules/k8s/fix.mjs +3 -3
  31. package/rules/nginx-default-tpl/fix.mjs +3 -3
  32. package/rules/npm-module/fix.mjs +3 -3
  33. package/rules/php/fix.mjs +3 -3
  34. package/rules/rego/fix.mjs +3 -3
  35. package/rules/rust/coverage/coverage.mjs +111 -0
  36. package/rules/rust/fix.mjs +3 -3
  37. package/rules/rust/lib/has-cargo-toml.mjs +1 -3
  38. package/rules/rust/rust.mdc +5 -1
  39. package/rules/security/fix.mjs +3 -3
  40. package/rules/style-lint/fix.mjs +3 -3
  41. package/rules/style-lint/js/tooling.mjs +1 -1
  42. package/rules/tauri/fix.mjs +3 -3
  43. package/rules/test/coverage/coverage.mjs +165 -0
  44. package/rules/test/fix.mjs +3 -3
  45. package/rules/test/js/location.mjs +1 -1
  46. package/rules/test/policy/package_json/package_json.rego +17 -0
  47. package/rules/test/policy/package_json/target.json +5 -0
  48. package/rules/test/policy/package_json/template/package.json.contains.json +5 -0
  49. package/rules/test/test.mdc +11 -1
  50. package/rules/text/fix.mjs +3 -3
  51. package/rules/vue/fix.mjs +3 -3
  52. package/scripts/lib/run-rule-cli.mjs +11 -0
  53. package/scripts/lib/run-standard-rule.mjs +1 -1
  54. package/scripts/utils/with-lock.mjs +27 -16
  55. package/scripts/utils/worktree-fingerprint.mjs +10 -9
@@ -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,148 @@
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 { spawnSync } from 'node:child_process'
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
+ /**
15
+ * Резолвить cwd, у якому стоять JS-тести. Workspace-проєкти — перший workspace
16
+ * (наприклад: app/), single-package — корінь.
17
+ * @param {string} cwd корінь проєкту
18
+ * @returns {Promise<string|null>} абсолютний шлях до JS-root або null без package.json
19
+ */
20
+ async function resolveJsRoot(cwd) {
21
+ const rootPkgPath = join(cwd, 'package.json')
22
+ if (!existsSync(rootPkgPath)) return null
23
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
24
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
25
+ if (workspaces.length > 0) {
26
+ const wsPath = join(cwd, workspaces[0])
27
+ if (existsSync(join(wsPath, 'package.json'))) return wsPath
28
+ }
29
+ return cwd
30
+ }
31
+
32
+ /**
33
+ * Чи `scripts` містить coverage-сумісну команду.
34
+ * @param {Record<string, string> | undefined} scripts секція scripts з package.json
35
+ * @returns {boolean} true, якщо є test:coverage або test з --coverage
36
+ */
37
+ function hasCoverageScript(scripts) {
38
+ if (!scripts || typeof scripts !== 'object') return false
39
+ if (typeof scripts['test:coverage'] === 'string' && scripts['test:coverage'].length > 0) return true
40
+ if (typeof scripts.test === 'string' && scripts.test.includes('--coverage')) return true
41
+ return false
42
+ }
43
+
44
+ /**
45
+ * Чи провайдер застосовний у поточному cwd.
46
+ * @param {string} cwd корінь проєкту
47
+ * @returns {Promise<boolean>} true, якщо знайдено coverage-сумісний test-скрипт
48
+ */
49
+ export async function detect(cwd) {
50
+ const jsRoot = await resolveJsRoot(cwd)
51
+ if (jsRoot === null) return false
52
+ const pkgPath = join(jsRoot, 'package.json')
53
+ if (!existsSync(pkgPath)) return false
54
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
55
+ return hasCoverageScript(pkg.scripts)
56
+ }
57
+
58
+ /**
59
+ * Парс lcov.info: сумує LF/LH (рядки) і FNF/FNH (функції) по всіх records.
60
+ * @param {string} text вміст lcov.info
61
+ * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} агреговані totals
62
+ */
63
+ function parseLcov(text) {
64
+ const acc = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
65
+ for (const line of text.split('\n')) {
66
+ if (line.startsWith('LF:')) acc.lines.total += Number(line.slice(3))
67
+ else if (line.startsWith('LH:')) acc.lines.covered += Number(line.slice(3))
68
+ else if (line.startsWith('FNF:')) acc.functions.total += Number(line.slice(4))
69
+ else if (line.startsWith('FNH:')) acc.functions.covered += Number(line.slice(4))
70
+ }
71
+ return acc
72
+ }
73
+
74
+ /**
75
+ * Парс Stryker mutation.json: Killed+Timeout → caught; Survived+NoCoverage → до total.
76
+ * Compile/Runtime errors виключаються з total.
77
+ * @param {{files:Record<string,{mutants:Array<{status:string}>}>}} report розпарсений mutation.json
78
+ * @returns {{caught:number,total:number}} агрегований mutation score
79
+ */
80
+ function parseStrykerReport(report) {
81
+ let caught = 0
82
+ let total = 0
83
+ for (const file of Object.values(report.files)) {
84
+ for (const mutant of file.mutants) {
85
+ if (mutant.status === 'Killed' || mutant.status === 'Timeout') {
86
+ caught += 1
87
+ total += 1
88
+ } else if (mutant.status === 'Survived' || mutant.status === 'NoCoverage') {
89
+ total += 1
90
+ }
91
+ }
92
+ }
93
+ return { caught, total }
94
+ }
95
+
96
+ /**
97
+ * Default runner — спавнить реальні bun-команди через `node:child_process.spawnSync`
98
+ * (працює і в Node-runtime через shebang `n-cursor`, і в Bun). Замінюється у тестах.
99
+ */
100
+ const defaultRunner = {
101
+ runJsCoverage({ cwd, lcovDir }) {
102
+ const r = spawnSync('bun', ['run', 'test:coverage', '--coverage-reporter=lcov', `--coverage-dir=${lcovDir}`], {
103
+ cwd,
104
+ stdio: 'inherit',
105
+ env: process.env
106
+ })
107
+ return r.status ?? 1
108
+ },
109
+ runStryker({ cwd }) {
110
+ const r = spawnSync('bunx', ['stryker', 'run'], { cwd, stdio: 'inherit', env: process.env })
111
+ return r.status ?? 1
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Збирає JS-метрики покриття + мутаційного тестування.
117
+ * @param {string} cwd корінь проєкту
118
+ * @param {{runner?: typeof defaultRunner}} [opts] runner-ін'єкція для тестів
119
+ * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}}>>} рядки для COVERAGE.md
120
+ */
121
+ export async function collect(cwd, opts = {}) {
122
+ const runner = opts.runner ?? defaultRunner
123
+ const jsRoot = await resolveJsRoot(cwd)
124
+ if (jsRoot === null) throw new Error('js-lint coverage: package.json не знайдено')
125
+
126
+ // 1. Coverage через bun test --coverage
127
+ const lcovDir = await mkdtemp(join(tmpdir(), 'js-lint-cov-'))
128
+ let coverage
129
+ try {
130
+ const code = await runner.runJsCoverage({ cwd: jsRoot, lcovDir })
131
+ if (code !== 0) throw new Error(`JS coverage exit ${code}`)
132
+ coverage = parseLcov(await readFile(join(lcovDir, 'lcov.info'), 'utf8'))
133
+ } finally {
134
+ await rm(lcovDir, { recursive: true, force: true })
135
+ }
136
+
137
+ // 2. Mutation через Stryker
138
+ await runner.runStryker({ cwd: jsRoot })
139
+ let mutationReport
140
+ try {
141
+ mutationReport = JSON.parse(await readFile(join(jsRoot, 'reports', 'stryker', 'mutation.json'), 'utf8'))
142
+ } catch {
143
+ throw new Error('js-lint coverage: stryker не залишив mutation.json — перевір stryker.config.mjs у проєкті')
144
+ }
145
+ const mutation = parseStrykerReport(mutationReport)
146
+
147
+ return [{ area: 'JS', coverage, mutation }]
148
+ }
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -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]`).
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -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
 
package/rules/k8s/fix.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
package/rules/php/fix.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -0,0 +1,111 @@
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 { spawnSync } from 'node:child_process'
10
+ import { existsSync } from 'node:fs'
11
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+
15
+ import { hasCargoTomlInTree } from '../lib/has-cargo-toml.mjs'
16
+
17
+ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo', 'target'])
18
+
19
+ /**
20
+ * Чи провайдер застосовний у поточному cwd.
21
+ * @param {string} cwd корінь проєкту
22
+ * @returns {Promise<boolean>} true, якщо знайдено Cargo.toml у cwd або workspace-піддереві
23
+ */
24
+ export function detect(cwd) {
25
+ if (existsSync(join(cwd, 'Cargo.toml'))) return Promise.resolve(true)
26
+ return Promise.resolve(hasCargoTomlInTree(cwd, IGNORED_DIR_NAMES))
27
+ }
28
+
29
+ /**
30
+ * Знайти Cargo.toml: cwd/Cargo.toml або в одному з workspace-підкаталогів.
31
+ * @param {string} cwd корінь проєкту
32
+ * @returns {Promise<string>} абсолютний шлях до Cargo.toml
33
+ */
34
+ async function resolveCargoManifest(cwd) {
35
+ const rootManifest = join(cwd, 'Cargo.toml')
36
+ if (existsSync(rootManifest)) return rootManifest
37
+
38
+ const rootPkgPath = join(cwd, 'package.json')
39
+ if (existsSync(rootPkgPath)) {
40
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
41
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
42
+ for (const ws of workspaces) {
43
+ const tauriManifest = join(cwd, ws, 'src-tauri', 'Cargo.toml')
44
+ if (existsSync(tauriManifest)) return tauriManifest
45
+ const flatManifest = join(cwd, ws, 'Cargo.toml')
46
+ if (existsSync(flatManifest)) return flatManifest
47
+ }
48
+ }
49
+
50
+ throw new Error('rust coverage: Cargo.toml не знайдено (cwd + workspaces)')
51
+ }
52
+
53
+ const defaultRunner = {
54
+ runLlvmCov({ manifestPath }) {
55
+ const r = spawnSync('cargo', ['llvm-cov', '--manifest-path', manifestPath, '--json', '--summary-only'], {
56
+ stdio: ['inherit', 'pipe', 'inherit'],
57
+ env: process.env
58
+ })
59
+ return { exitCode: r.status ?? 1, stdout: r.stdout?.toString('utf8') ?? '' }
60
+ },
61
+ runCargoMutants({ manifestPath, outDir }) {
62
+ const r = spawnSync('cargo', ['mutants', '--in-place', '-o', outDir, '--manifest-path', manifestPath], {
63
+ stdio: 'inherit',
64
+ env: process.env
65
+ })
66
+ return r.status ?? 1
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Збирає Rust-метрики покриття + мутаційного тестування.
72
+ * @param {string} cwd корінь проєкту
73
+ * @param {{runner?: typeof defaultRunner}} [opts] ін'єкція runner-а для тестів
74
+ * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}}>>} рядки для COVERAGE.md
75
+ */
76
+ export async function collect(cwd, opts = {}) {
77
+ const runner = opts.runner ?? defaultRunner
78
+ const manifestPath = await resolveCargoManifest(cwd)
79
+
80
+ // 1. Coverage через cargo llvm-cov
81
+ const { exitCode: llvmCode, stdout: llvmJson } = await runner.runLlvmCov({ manifestPath })
82
+ if (llvmCode !== 0) {
83
+ throw new Error('rust coverage: cargo llvm-cov упав — встанови: cargo install cargo-llvm-cov')
84
+ }
85
+ const totals = JSON.parse(llvmJson).data[0].totals
86
+ const coverage = {
87
+ lines: { covered: totals.lines.covered, total: totals.lines.count },
88
+ functions: { covered: totals.functions.covered, total: totals.functions.count }
89
+ }
90
+
91
+ // 2. Mutation через cargo mutants
92
+ const outDir = await mkdtemp(join(tmpdir(), 'rust-mutants-'))
93
+ let mutation
94
+ try {
95
+ // cargo-mutants exit ≠ 0 коли є missed — це нормально, не помилка.
96
+ // Реальний крах — відсутній outcomes.json.
97
+ await runner.runCargoMutants({ manifestPath, outDir })
98
+ let outcomes
99
+ try {
100
+ outcomes = JSON.parse(await readFile(join(outDir, 'mutants.out', 'outcomes.json'), 'utf8'))
101
+ } catch {
102
+ throw new Error('rust coverage: cargo mutants не залишив outcomes.json — встанови: cargo install cargo-mutants')
103
+ }
104
+ const caught = (outcomes.caught ?? 0) + (outcomes.timeout ?? 0)
105
+ mutation = { caught, total: caught + (outcomes.missed ?? 0) }
106
+ } finally {
107
+ await rm(outDir, { recursive: true, force: true })
108
+ }
109
+
110
+ return [{ area: 'Rust', coverage, mutation }]
111
+ }
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -29,9 +29,7 @@ export function hasCargoTomlInTree(root, ignoredDirNames) {
29
29
  }
30
30
  for (const entry of entries) {
31
31
  if (entry.isFile() && entry.name === 'Cargo.toml') return true
32
- if (entry.isDirectory() && !ignoredDirNames.has(entry.name)) {
33
- if (walk(join(dir, entry.name))) return true
34
- }
32
+ if (entry.isDirectory() && !ignoredDirNames.has(entry.name) && walk(join(dir, entry.name))) return true
35
33
  }
36
34
  return false
37
35
  }
@@ -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`.
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -25,7 +25,7 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
25
25
  * Альтернатива полю `stylelint` у `package.json` — зовнішній файл конфігу. Якщо
26
26
  * поля немає і файлу немає, фейлимося; якщо є хоч щось — пропускаємо. Поле
27
27
  * `stylelint.extends == "@nitra/stylelint-config"` сам формат — у Rego.
28
- * @param {import('../../../../scripts/lib/check-reporter.mjs').CheckReporter} reporter репортер
28
+ * @param {import('../../../scripts/lib/check-reporter.mjs').CheckReporter} reporter репортер
29
29
  */
30
30
  async function checkStylelintConfigPresence(reporter) {
31
31
  const { pass, fail } = reporter
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }