@7n/test 0.14.0 → 0.14.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.1] - 2026-07-11
4
+
5
+ ### Changed
6
+
7
+ - fix(coverage): самодостатній JS-колектор — без залежності від @nitra/cursor providers
8
+
3
9
  ## [0.14.0] - 2026-07-11
4
10
 
5
11
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Сумування coverage/mutation totals — спільна утиліта для collector-ів
3
+ * (js-collector.mjs) і оркестратора (coverage.mjs). Винесено в окремий модуль,
4
+ * щоб уникнути циклічного імпорту collector ↔ orchestrator.
5
+ */
6
+
7
+ /**
8
+ * Сума двох coverage-totals.
9
+ * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший subtotal
10
+ * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий subtotal
11
+ * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сумарні lines/functions
12
+ */
13
+ export function addCoverage(a, b) {
14
+ return {
15
+ lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
16
+ functions: {
17
+ covered: a.functions.covered + b.functions.covered,
18
+ total: a.functions.total + b.functions.total
19
+ }
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Сума двох mutation-counts.
25
+ * @param {{caught:number,total:number}} a перший subtotal
26
+ * @param {{caught:number,total:number}} b другий subtotal
27
+ * @returns {{caught:number,total:number}} сумарні caught/total
28
+ */
29
+ export function addMutation(a, b) {
30
+ return { caught: a.caught + b.caught, total: a.total + b.total }
31
+ }
@@ -1,65 +1,27 @@
1
1
  /**
2
- * Канонічна команда `n coverage`: збирає метрики покриття + мутаційного
3
- * тестування з усіх провайдерів, чиє правило активне в `.n-cursor.json#rules`,
4
- * агрегує та записує COVERAGE.md у корінь проєкту.
2
+ * Канонічна команда `@7n/test coverage`: збирає метрики покриття + мутаційного
3
+ * тестування через вбудований JS-колектор (`js-collector.mjs`), агрегує та
4
+ * записує COVERAGE.md у корінь проєкту.
5
5
  *
6
- * Discovery провайдерів за `.n-cursor.json#rules`: для кожного `ruleId` зі
7
- * списку шукаємо `<rulesDir>/<ruleId>/coverage/coverage.mjs` і динамічно
8
- * імпортуємо. Якщо файлу немає провайдер для цього правила відсутній (skip
9
- * silently, не помилка).
10
- *
11
- * `rulesDir` резолвиться з `node_modules/@nitra/cursor/rules` у cwd проєкту.
12
- * Override — через `opts.rulesDir` у `runCoverageSteps`.
6
+ * Самодостатнійне залежить від `@nitra/cursor` (раніше provider-discovery
7
+ * за `.n-cursor.json#rules` через `<rulesDir>/<ruleId>/coverage/coverage.mjs`;
8
+ * provider-підсистема видалена з `@nitra/cursor` 2026-07-10, лишаючи оркестратор
9
+ * без жодного провайдера). Наразі підтримується лише JS/TS (`js-collector.mjs`);
10
+ * інші мови (Rust тощо) — не покриті, до появи власного колектора тут.
13
11
  *
14
12
  * Лок — прямий виклик `withLock('coverage', steps)`.
15
13
  */
16
- import { existsSync } from 'node:fs'
17
14
  import { readFile, writeFile } from 'node:fs/promises'
18
15
  import { join } from 'node:path'
19
- import { pathToFileURL } from 'node:url'
20
16
 
21
17
  import { applyVerdicts } from '../coverage-classify/apply.mjs'
22
18
  import { classify } from '../coverage-classify/index.mjs'
23
19
  import { collectChangedFilesSince, resolveChangedBase } from '../scripts/lib/changed-files.mjs'
24
- import { readNCursorConfigLite } from '../scripts/lib/read-n-cursor-config-lite.mjs'
25
20
  import { withLock } from '../scripts/utils/with-lock.mjs'
21
+ import { addCoverage, addMutation } from './aggregate.mjs'
22
+ import { collect as collectJs, detect as detectJs } from './js-collector.mjs'
26
23
 
27
- /**
28
- * Знаходить rulesDir @nitra/cursor у node_modules проєкту.
29
- * @param {string} cwd корінь проєкту
30
- * @returns {string|null} абсолютний шлях до rules/ або null
31
- */
32
- function resolveDefaultRulesDir(cwd) {
33
- const localPath = join(cwd, 'node_modules', '@nitra', 'cursor', 'rules')
34
- if (existsSync(localPath)) return localPath
35
- return null
36
- }
37
-
38
- /**
39
- * Сума двох coverage-totals.
40
- * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший subtotal
41
- * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий subtotal
42
- * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сумарні lines/functions
43
- */
44
- export function addCoverage(a, b) {
45
- return {
46
- lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
47
- functions: {
48
- covered: a.functions.covered + b.functions.covered,
49
- total: a.functions.total + b.functions.total
50
- }
51
- }
52
- }
53
-
54
- /**
55
- * Сума двох mutation-counts.
56
- * @param {{caught:number,total:number}} a перший subtotal
57
- * @param {{caught:number,total:number}} b другий subtotal
58
- * @returns {{caught:number,total:number}} сумарні caught/total
59
- */
60
- export function addMutation(a, b) {
61
- return { caught: a.caught + b.caught, total: a.total + b.total }
62
- }
24
+ export { addCoverage, addMutation }
63
25
 
64
26
  /**
65
27
  * Форматує covered/total як `XX.XX% (covered/total)`.
@@ -166,23 +128,6 @@ export function renderMarkdown(rows, allowedGaps = []) {
166
128
  return `${lines.join('\n')}\n`
167
129
  }
168
130
 
169
- /**
170
- * Завантажує provider-модуль з `<rulesDir>/<ruleId>/coverage/coverage.mjs`.
171
- * Повертає null коли:
172
- * - файлу немає (rule без coverage-провайдера),
173
- * - файл існує, але не експортує `detect` + `collect` як функції.
174
- * @param {string} rulesDir корінь rules/ (з @nitra/cursor або override)
175
- * @param {string} ruleId id правила з `.n-cursor.json#rules`
176
- * @returns {Promise<{detect:(cwd:string)=>Promise<boolean>, collect:(cwd:string)=>Promise<Array<object>>}|null>} provider-модуль або null
177
- */
178
- async function loadProvider(rulesDir, ruleId) {
179
- const providerPath = join(rulesDir, ruleId, 'coverage', 'coverage.mjs')
180
- if (!existsSync(providerPath)) return null
181
- const mod = await import(pathToFileURL(providerPath).href)
182
- if (typeof mod.detect !== 'function' || typeof mod.collect !== 'function') return null
183
- return mod
184
- }
185
-
186
131
  /**
187
132
  * Будує підсумковий рядок «Разом» через сумування всіх coverage/mutation.
188
133
  * @param {Array<{area:string, coverage:object, mutation:object}>} rows рядки провайдерів без totals
@@ -225,43 +170,24 @@ export function resolveChangedScope(cwd) {
225
170
  }
226
171
 
227
172
  /**
228
- * Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
229
- * detect+collect для кожного, агрегація, запис COVERAGE.md.
230
- * @param {{cwd?:string, rulesDir?:string, fix?:boolean, changed?:boolean}} [opts]
173
+ * Виконує coverage-pipeline: JS-колектор (detect+collect), агрегація, запис COVERAGE.md.
174
+ * @param {{cwd?:string, fix?:boolean, changed?:boolean}} [opts]
231
175
  * @returns {Promise<number>} exit code (0 OK, 1 помилка)
232
176
  */
233
177
  export async function runCoverageSteps(opts = {}) {
234
178
  const cwd = opts.cwd ?? process.cwd()
235
- const rulesDir = opts.rulesDir ?? resolveDefaultRulesDir(cwd)
236
-
237
- if (!rulesDir) {
238
- console.error(
239
- '✗ Не знайдено @nitra/cursor у node_modules. ' +
240
- 'Встанови @nitra/cursor як devDependency або передай rulesDir через opts.'
241
- )
242
- return 1
243
- }
244
-
245
- const config = await readNCursorConfigLite(cwd)
246
179
  const scope = opts.changed ? resolveChangedScope(cwd) : null
247
180
  const collectOpts = scope ? { changedFiles: scope.files, base: scope.base } : {}
248
- const rows = []
249
181
 
250
- for (const ruleId of config.rules) {
251
- if (config.disableRules.includes(ruleId)) continue
252
- const provider = await loadProvider(rulesDir, ruleId)
253
- if (!provider) continue
254
- if (!(await provider.detect(cwd))) continue
255
- console.log(`→ ${ruleId} coverage…`)
256
- rows.push(...(await provider.collect(cwd, collectOpts)))
257
- }
182
+ const applicable = await detectJs(cwd)
183
+ const rows = applicable ? await collectJs(cwd, collectOpts) : []
258
184
 
259
185
  if (rows.length === 0) {
260
186
  if (opts.changed) {
261
- console.log('✓ coverage --changed: немає змінених файлів у scope провайдерів — пропускаю')
187
+ console.log('✓ coverage --changed: немає змінених файлів у scope — пропускаю')
262
188
  return 0
263
189
  }
264
- console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
190
+ console.error('✗ Coverage: не знайдено тестів (vitest не задекларовано, або жоден workspace не має тестів)')
265
191
  return 1
266
192
  }
267
193
 
@@ -301,15 +227,15 @@ export async function runCoverageSteps(opts = {}) {
301
227
  }
302
228
 
303
229
  /**
304
- * CLI entrypoint для `n coverage [--fix] [--changed]`.
305
- * @param {{fix?:boolean, changed?:boolean, rulesDir?:string}} [opts]
230
+ * CLI entrypoint для `@7n/test coverage [--fix] [--changed]`.
231
+ * @param {{cwd?:string, fix?:boolean, changed?:boolean}} [opts]
306
232
  * @returns {Promise<number>} exit code
307
233
  */
308
234
  export async function runCoverageCli(opts = {}) {
309
235
  const code = await withLock('coverage', () => runCoverageSteps(opts))
310
236
  if (code === 0 && opts.fix) {
311
237
  console.log('\n♻️ Повторний coverage після агента…\n')
312
- return withLock('coverage', () => runCoverageSteps({ fix: false, changed: opts.changed, rulesDir: opts.rulesDir }))
238
+ return withLock('coverage', () => runCoverageSteps({ cwd: opts.cwd, fix: false, changed: opts.changed }))
313
239
  }
314
240
  return code
315
241
  }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Самодостатній JS/TS coverage + mutation-testing колектор: збирає метрики покриття
3
+ * (`vitest run --coverage`) і мутаційного тестування (Stryker з vitest-runner + perTest).
4
+ * Не залежить від `@nitra/cursor` — раніше жив там як rule-провайдер
5
+ * (`rules/js/coverage/coverage.mjs`), видалений разом з усією provider-підсистемою
6
+ * (2026-07-10). Тепер — вбудований collector `@7n/test coverage`.
7
+ */
8
+ import { spawnSync } from 'node:child_process'
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
11
+ import { createRequire } from 'node:module'
12
+ import { tmpdir } from 'node:os'
13
+ import { dirname, isAbsolute, join, relative } from 'node:path'
14
+
15
+ import { resolveAllJsRoots } from '../lib/resolve-js-root.mjs'
16
+ import { addCoverage, addMutation } from './aggregate.mjs'
17
+
18
+ const TEST_BLOCK_START = /^\s*(it|test)\(/
19
+ const FILE_EXTENSION = /\.[^.]+$/
20
+ /** JS/TS-розширення — файли, які мутує Stryker і покриває vitest. */
21
+ const JS_FILE = /\.(c|m)?[jt]sx?$/
22
+ /** Тест-файли (`*.test.*` / `*.spec.*`) — НЕ production-код, не йдуть у Stryker `--mutate`. */
23
+ const TEST_FILE = /\.(test|spec)\.[^.]+$/
24
+
25
+ /**
26
+ * Звужує список змінених файлів (relative до cwd) до тих, що лежать під `jsRoot`,
27
+ * мають JS/TS-розширення, і рібейзить їх відносно `jsRoot`.
28
+ * @param {string[]} changedFiles relative-до-cwd шляхи змінених файлів
29
+ * @param {string} cwd корінь проєкту
30
+ * @param {string} jsRoot абсолютний шлях workspace-кореня
31
+ * @returns {string[]} JS-файли під jsRoot, шляхи relative до jsRoot
32
+ */
33
+ export function scopeToRoot(changedFiles, cwd, jsRoot) {
34
+ const out = []
35
+ for (const f of changedFiles) {
36
+ if (!JS_FILE.test(f)) continue
37
+ const rel = relative(jsRoot, join(cwd, f))
38
+ if (rel.startsWith('..') || isAbsolute(rel)) continue
39
+ out.push(rel)
40
+ }
41
+ return out
42
+ }
43
+
44
+ const VITEST_HINT =
45
+ 'js coverage: vitest відсутній у package.json — додай `vitest`, `@vitest/coverage-v8` та `@stryker-mutator/vitest-runner` у devDependencies (див. test.mdc)'
46
+
47
+ /**
48
+ * Чи у пакеті встановлено vitest (через dependencies або devDependencies).
49
+ * @param {{dependencies?: Record<string,string>, devDependencies?: Record<string,string>}} pkg package.json
50
+ * @returns {boolean} true, якщо `vitest` декларовано хоча б в одному dep-section
51
+ */
52
+ function hasVitestDep(pkg) {
53
+ return Boolean(pkg.devDependencies?.vitest) || Boolean(pkg.dependencies?.vitest)
54
+ }
55
+
56
+ /**
57
+ * Чи колектор застосовний у поточному cwd. Активується, коли `vitest`
58
+ * декларовано хоча б в одному JS-root АБО у кореневому `package.json`
59
+ * (workspace-проєкт із hoisted node_modules — типовий патерн bun monorepo).
60
+ * Інакше silent skip із hint у stderr (одноразово).
61
+ * @param {string} cwd корінь проєкту
62
+ * @returns {Promise<boolean>} true, якщо проєкт сумісний з vitest-based coverage
63
+ */
64
+ export async function detect(cwd) {
65
+ const jsRoots = await resolveAllJsRoots(cwd)
66
+ if (jsRoots.length === 0) return false
67
+ for (const jsRoot of jsRoots) {
68
+ const pkgPath = join(jsRoot, 'package.json')
69
+ if (!existsSync(pkgPath)) continue
70
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
71
+ if (hasVitestDep(pkg)) return true
72
+ }
73
+ const rootInJsRoots = jsRoots.includes(cwd)
74
+ if (!rootInJsRoots) {
75
+ const rootPkgPath = join(cwd, 'package.json')
76
+ if (existsSync(rootPkgPath)) {
77
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
78
+ if (hasVitestDep(rootPkg)) return true
79
+ }
80
+ }
81
+ if (!detect._hinted) {
82
+ console.error(VITEST_HINT)
83
+ detect._hinted = true
84
+ }
85
+ return false
86
+ }
87
+
88
+ /**
89
+ * Парс lcov.info: сумує LF/LH (рядки) і FNF/FNH (функції) по всіх records.
90
+ * @param {string} text вміст lcov.info
91
+ * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} агреговані totals
92
+ */
93
+ function parseLcov(text) {
94
+ const acc = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
95
+ for (const line of text.split('\n')) {
96
+ if (line.startsWith('LF:')) acc.lines.total += Number(line.slice(3))
97
+ else if (line.startsWith('LH:')) acc.lines.covered += Number(line.slice(3))
98
+ else if (line.startsWith('FNF:')) acc.functions.total += Number(line.slice(4))
99
+ else if (line.startsWith('FNH:')) acc.functions.covered += Number(line.slice(4))
100
+ }
101
+ return acc
102
+ }
103
+
104
+ /**
105
+ * Витягує оригінальний фрагмент коду з рядків файлу за позицією мутанта.
106
+ * @param {string[]} fileLines рядки файлу (0-indexed)
107
+ * @param {{start:{line:number,column:number},end:{line:number,column:number}}} loc позиція (рядки 1-indexed)
108
+ * @returns {string} оригінальний текст мутанта
109
+ */
110
+ function extractOriginal(fileLines, loc) {
111
+ const startLine = loc.start.line - 1
112
+ const endLine = loc.end.line - 1
113
+ if (startLine === endLine) {
114
+ return fileLines[startLine]?.slice(loc.start.column, loc.end.column) ?? ''
115
+ }
116
+ const parts = []
117
+ for (let i = startLine; i <= endLine; i++) {
118
+ const line = fileLines[i] ?? ''
119
+ if (i === startLine) parts.push(line.slice(loc.start.column))
120
+ else if (i === endLine) parts.push(line.slice(0, loc.end.column))
121
+ else parts.push(line)
122
+ }
123
+ return parts.join('\n')
124
+ }
125
+
126
+ /**
127
+ * Витягує перший `it(` або `test(` блок з вмісту тест-файлу.
128
+ * Відстежує глибину `{}` для коректного завершення.
129
+ * @param {string} content вміст тест-файлу
130
+ * @returns {string | null} перший тест-блок або null
131
+ */
132
+ export function extractFirstTestBlock(content) {
133
+ const lines = content.split('\n')
134
+ let startLine = -1
135
+ let depth = 0
136
+ let inBlock = false
137
+ const result = []
138
+ for (const [i, line] of lines.entries()) {
139
+ if (startLine === -1 && TEST_BLOCK_START.test(line)) startLine = i
140
+ if (startLine === -1) continue
141
+ result.push(line)
142
+ for (const ch of line) {
143
+ if (ch === '{') {
144
+ depth++
145
+ inBlock = true
146
+ } else if (ch === '}') depth--
147
+ }
148
+ if (inBlock && depth === 0) break
149
+ }
150
+ return result.length > 0 ? result.join('\n') : null
151
+ }
152
+
153
+ /**
154
+ * Шукає тест-файл для заданого source-файлу і повертає перший тест-блок як приклад стилю.
155
+ * Кандидати: `<base>.test.js`, `<base>.test.mjs`, `<dir>/tests/<name>.test.js`.
156
+ * @param {string} jsRoot абсолютний шлях до JS-кореня
157
+ * @param {string} filename відносний шлях source-файлу (від jsRoot)
158
+ * @returns {{testFile:string, code:string|null} | null} null — якщо тест-файл не знайдено
159
+ */
160
+ export function findExampleTest(jsRoot, filename) {
161
+ const base = filename.replace(FILE_EXTENSION, '')
162
+ const candidates = [`${base}.test.js`, `${base}.test.mjs`, `${base}.test.ts`]
163
+ const lastSlash = base.lastIndexOf('/')
164
+ if (lastSlash !== -1) {
165
+ const dir = base.slice(0, lastSlash)
166
+ const name = base.slice(lastSlash + 1)
167
+ candidates.push(`${dir}/tests/${name}.test.js`, `${dir}/tests/${name}.test.mjs`)
168
+ }
169
+ for (const rel of candidates) {
170
+ const full = join(jsRoot, rel)
171
+ if (!existsSync(full)) continue
172
+ const content = readFileSync(full, 'utf8')
173
+ return { testFile: rel, code: extractFirstTestBlock(content) }
174
+ }
175
+ return null
176
+ }
177
+
178
+ /**
179
+ * Парс Stryker mutation.json: Killed+Timeout → caught; Survived+NoCoverage → до total.
180
+ * Compile/Runtime помилки виключаються з total.
181
+ * Survived мутанти групуються по файлах з exampleTest.
182
+ * @param {{files:Record<string,{mutants:Array<{status:string,mutatorName?:string,replacement?:string,location?:{start:{line:number,column:number},end:{line:number,column:number}}}>}>}} report Stryker mutation.json
183
+ * @param {string|null} [jsRoot] корінь для читання source-рядків і пошуку тест-файлів
184
+ * @returns {{caught:number,total:number,survived:Array<{file:string,mutants:Array<{line:number,col:number,mutantType:string,original:string,replacement:string}>,exampleTest:{testFile:string,code:string|null}|null,recommendationText:string|null}>}} результат парсу: caught/total та згруповані survived мутанти
185
+ */
186
+ export function parseStrykerReport(report, jsRoot) {
187
+ let caught = 0
188
+ let total = 0
189
+ /** @type {Map<string, Array<{line:number,col:number,mutantType:string,original:string,replacement:string}>>} */
190
+ const byFile = new Map()
191
+
192
+ for (const [filePath, fileData] of Object.entries(report.files)) {
193
+ let fileLines = null
194
+ for (const mutant of fileData.mutants) {
195
+ if (mutant.status === 'Killed' || mutant.status === 'Timeout') {
196
+ caught += 1
197
+ total += 1
198
+ } else if (mutant.status === 'Survived' || mutant.status === 'NoCoverage') {
199
+ total += 1
200
+ if (mutant.status === 'Survived' && jsRoot && mutant.location) {
201
+ if (!fileLines) {
202
+ try {
203
+ fileLines = readFileSync(join(jsRoot, filePath), 'utf8').split('\n')
204
+ } catch {
205
+ fileLines = []
206
+ }
207
+ }
208
+ if (!byFile.has(filePath)) byFile.set(filePath, [])
209
+ byFile.get(filePath).push({
210
+ line: mutant.location.start.line,
211
+ col: mutant.location.start.column,
212
+ mutantType: mutant.mutatorName ?? 'Unknown',
213
+ original: extractOriginal(fileLines, mutant.location),
214
+ replacement: mutant.replacement ?? ''
215
+ })
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ const survived = []
222
+ for (const [file, mutants] of byFile) {
223
+ survived.push({
224
+ file,
225
+ mutants,
226
+ exampleTest: jsRoot ? findExampleTest(jsRoot, file) : null,
227
+ recommendationText: null
228
+ })
229
+ }
230
+
231
+ return { caught, total, survived }
232
+ }
233
+
234
+ /**
235
+ * Шлях до локально встановленого Stryker core-bin (поряд із плагінами на кшталт
236
+ * `@stryker-mutator/vitest-runner`). Запуск саме його через `node` — не `npx`/`bunx` —
237
+ * дає Stryker побачити локальні плагіни при plugin-discovery.
238
+ * @returns {string | null} абсолютний шлях `bin/stryker.js` або `null`, якщо не встановлено
239
+ */
240
+ let strykerBinCache
241
+
242
+ /**
243
+ * Резолвить локальний Stryker core bin (мемоізовано).
244
+ * @returns {string | null} абсолютний шлях `bin/stryker.js` або `null`
245
+ */
246
+ function resolveLocalStrykerBin() {
247
+ if (strykerBinCache !== undefined) return strykerBinCache
248
+ try {
249
+ // `exports` у core НЕ відкриває `./bin/stryker.js`, тож резолвимо package.json
250
+ // (доступний) і беремо шлях bin звідти. Ключ bin зазвичай `stryker`; як запас —
251
+ // перше значення map'и.
252
+ const require = createRequire(import.meta.url)
253
+ const pkgJsonPath = require.resolve('@stryker-mutator/core/package.json')
254
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
255
+ const binRel = typeof pkg.bin === 'string' ? pkg.bin : (pkg.bin?.stryker ?? Object.values(pkg.bin ?? {})[0])
256
+ strykerBinCache = binRel ? join(dirname(pkgJsonPath), binRel) : null
257
+ } catch {
258
+ strykerBinCache = null
259
+ }
260
+ return strykerBinCache
261
+ }
262
+
263
+ const defaultRunner = {
264
+ runJsCoverage({ cwd, lcovDir, base }) {
265
+ // base !== undefined ⇔ --changed-режим: vitest сам рахує зачеплені змінами тести
266
+ // через граф імпортів. `--changed <base>` порівнює base↔робоче дерево (committed і
267
+ // uncommitted разом); `--changed` без аргументу — uncommitted vs HEAD.
268
+ const changedArgs = base === undefined ? [] : base === null ? ['--changed'] : ['--changed', base]
269
+ const r = spawnSync(
270
+ 'bunx',
271
+ [
272
+ 'vitest',
273
+ 'run',
274
+ '--passWithNoTests',
275
+ '--coverage',
276
+ '--coverage.reporter=lcov',
277
+ `--coverage.reportsDirectory=${lcovDir}`,
278
+ ...changedArgs
279
+ ],
280
+ { cwd, stdio: 'inherit', env: process.env }
281
+ )
282
+ return r.status ?? 1
283
+ },
284
+ runStryker({ cwd, mutate }) {
285
+ // Plugin-discovery Stryker (`@stryker-mutator/*`) globиться відносно CORE-install-каталогу
286
+ // (`core/dist/src/di/plugin-loader.js` → `../../../../../@stryker-mutator/*`). Тож core
287
+ // МАЄ вантажитись із проєктного `node_modules`, де поряд лежить `@stryker-mutator/vitest-runner`.
288
+ // `npx`/`bunx` тягнуть core у власний кеш (`_npx/<hash>`, `bunx-temp`) БЕЗ плагінів → воркери
289
+ // падають `Cannot find TestRunner plugin "vitest"`. Тому резолвимо локальний core-bin через
290
+ // `import.meta.url` і запускаємо його через `node`. Fallback на `npx`, якщо не встановлено.
291
+ // mutate (непорожній) ⇔ --changed-режим: мутуємо лише змінені production-файли цього root.
292
+ const mutateArgs = mutate && mutate.length > 0 ? ['--mutate', mutate.join(',')] : []
293
+ const strykerBin = resolveLocalStrykerBin()
294
+ const r = strykerBin
295
+ ? spawnSync(strykerBin, ['run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
296
+ : spawnSync('npx', ['@stryker-mutator/core', 'run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
297
+ return r.status ?? 1
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Збирає метрики покриття + мутаційного тестування для **одного** JS-root.
303
+ *
304
+ * Full-режим (`scope === null`): vitest на всьому suite + Stryker на всіх файлах
305
+ * config-глоба. Пропускає workspace без тестів (повертає `null`): vitest пройшов з
306
+ * `--passWithNoTests`, але lcov порожній — нема сенсу запускати Stryker.
307
+ *
308
+ * Changed-режим (`scope = { files, base }`): vitest `--changed <base>` (лише
309
+ * зачеплені тести) + Stryker `--mutate` лише по змінених production-файлах. Тут
310
+ * **не** пропускаємо на порожньому lcov — змінений src без тестів має дати
311
+ * NoCoverage-мутанти (gate впаде, як і має). Якщо змінено лише тест-файли (нема
312
+ * production-src) — Stryker не запускаємо (мутувати нічого), повертаємо лише coverage.
313
+ *
314
+ * Реальні помилки (vitest exit ≠ 0, відсутній mutation.json попри запуск Stryker)
315
+ * кидаються — у multi-root режимі це не маскує справжній збій.
316
+ * @param {string} jsRoot абсолютний шлях до workspace-кореня
317
+ * @param {string} cwd корінь проєкту (для рібейзингу `survived[].file`)
318
+ * @param {{runJsCoverage:Function, runStryker:Function}} runner spawn-ін'єкція
319
+ * @param {{files:string[], base:string|null}|null} [scope] changed-scope (null = full-режим)
320
+ * @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результати або null коли full-режим і workspace без тестів
321
+ */
322
+ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
323
+ const wsRel = relative(cwd, jsRoot)
324
+ // У changed-режимі production-файли для мутації = змінені JS цього root без тест-файлів.
325
+ const mutateSrc = scope ? scope.files.filter(f => !TEST_FILE.test(f)) : null
326
+
327
+ // 1. Coverage через vitest run --passWithNoTests --coverage (+ --changed у changed-режимі)
328
+ const lcovDir = await mkdtemp(join(tmpdir(), 'js-cov-'))
329
+ let coverage
330
+ try {
331
+ const code = await runner.runJsCoverage(scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir })
332
+ if (code !== 0) throw new Error(`JS coverage exit ${code}`)
333
+ const lcovPath = join(lcovDir, 'lcov.info')
334
+ coverage = existsSync(lcovPath)
335
+ ? parseLcov(await readFile(lcovPath, 'utf8'))
336
+ : { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
337
+ } finally {
338
+ await rm(lcovDir, { recursive: true, force: true })
339
+ }
340
+
341
+ // Full-режим: порожній lcov ⇔ vitest не знайшов тестів → пропускаємо workspace,
342
+ // щоб не ганяти Stryker марно. У changed-режимі НЕ пропускаємо (див. JSDoc).
343
+ if (!scope) {
344
+ const hasTests = coverage.lines.total > 0 || coverage.functions.total > 0
345
+ if (!hasTests) return null
346
+ }
347
+
348
+ // Changed-режим без production-src (змінено лише тест-файли) → мутувати нічого.
349
+ if (scope && mutateSrc.length === 0) {
350
+ return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
351
+ }
352
+
353
+ // 2. Mutation через Stryker (у changed-режимі — лише по mutateSrc)
354
+ await runner.runStryker(scope ? { cwd: jsRoot, mutate: mutateSrc } : { cwd: jsRoot })
355
+ const mutationPath = join(jsRoot, 'reports', 'stryker', 'mutation.json')
356
+ if (!existsSync(mutationPath)) {
357
+ throw new Error(
358
+ 'js coverage: stryker не залишив mutation.json — ' +
359
+ 'переконайся що встановлено canonical stryker.config.mjs (vitest-runner, perTest), ' +
360
+ 'або налаштуй його вручну'
361
+ )
362
+ }
363
+ const mutationReport = JSON.parse(await readFile(mutationPath, 'utf8'))
364
+ const parsed = parseStrykerReport(mutationReport, jsRoot)
365
+
366
+ return {
367
+ coverage,
368
+ mutation: { caught: parsed.caught, total: parsed.total },
369
+ survived: parsed.survived.map(group => ({
370
+ ...group,
371
+ file: wsRel === '' ? group.file : join(wsRel, group.file),
372
+ exampleTest: group.exampleTest
373
+ ? {
374
+ ...group.exampleTest,
375
+ testFile: wsRel === '' ? group.exampleTest.testFile : join(wsRel, group.exampleTest.testFile)
376
+ }
377
+ : null
378
+ }))
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Збирає JS-метрики покриття + мутаційного тестування. У monorepo ітерує усі
384
+ * JS-roots з `resolveAllJsRoots()` (включно з glob-патернами `cf/*`), запускає
385
+ * vitest+Stryker у кожному та сумує lcov/mutation через `addCoverage`/`addMutation`.
386
+ * Workspaces без тестів пропускаються (див. `collectOneRoot`).
387
+ * Якщо тестів немає у жодному workspace — повертає `[]`.
388
+ * Шляхи у `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs`
389
+ * знаходив джерела через `join(projectRoot, file)`.
390
+ *
391
+ * Changed-режим (`opts.changedFiles` задано): кожен root отримує лише свої змінені
392
+ * JS-файли (`scopeToRoot`); roots без змінених JS пропускаються повністю (ні vitest,
393
+ * ні Stryker). Якщо змін нема ніде — повертає `[]` без error-логу (оркестратор
394
+ * трактує порожній changed-scope як pass).
395
+ * @param {string} cwd корінь проєкту
396
+ * @param {{runner?: typeof defaultRunner, changedFiles?: string[], base?: string|null}} [opts] runner-ін'єкція + changed-scope
397
+ * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядок `JS` або `[]` коли тестів/змін нема ніде
398
+ */
399
+ export async function collect(cwd, opts = {}) {
400
+ const runner = opts.runner ?? defaultRunner
401
+ const changed = Array.isArray(opts.changedFiles)
402
+ const jsRoots = await resolveAllJsRoots(cwd)
403
+ if (jsRoots.length === 0) throw new Error('js coverage: package.json не знайдено')
404
+
405
+ const results = []
406
+ for (const jsRoot of jsRoots) {
407
+ let scope = null
408
+ if (changed) {
409
+ const files = scopeToRoot(opts.changedFiles, cwd, jsRoot)
410
+ if (files.length === 0) continue // root без змінених JS — пропускаємо
411
+ scope = { files, base: opts.base ?? null }
412
+ }
413
+ const r = await collectOneRoot(jsRoot, cwd, runner, scope)
414
+ if (r !== null) results.push(r)
415
+ }
416
+
417
+ if (results.length === 0) {
418
+ // Changed-режим: нема змінених JS у жодному root → тихо порожньо (це pass, не помилка).
419
+ if (changed) return []
420
+ console.error(
421
+ 'js coverage: жоден workspace не має тестів ' +
422
+ '(`*.test.{js,mjs}` у `tests/` або поряд із джерелом) — ' +
423
+ 'додай тести або запусти `npx @7n/test` для генерації'
424
+ )
425
+ return []
426
+ }
427
+
428
+ let coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
429
+ let mutation = { caught: 0, total: 0 }
430
+ const survived = []
431
+ for (const r of results) {
432
+ coverage = addCoverage(coverage, r.coverage)
433
+ mutation = addMutation(mutation, r.mutation)
434
+ survived.push(...r.survived)
435
+ }
436
+ return [{ area: 'JS', coverage, mutation, survived }]
437
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Резолвить корінь(и) JS-коду в проєкті: для workspace-projects — усі workspaces
3
+ * (з підтримкою glob-патернів типу `cf/*`), для single-package — корінь cwd.
4
+ */
5
+ import { existsSync } from 'node:fs'
6
+ import { glob, readFile } from 'node:fs/promises'
7
+ import { join } from 'node:path'
8
+
9
+ const WORKSPACE_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**']
10
+ const PACKAGE_JSON_SUFFIX_RE = /[/\\]package\.json$/
11
+
12
+ /**
13
+ * Розгортає один workspace-патерн у список абсолютних шляхів каталогів з package.json.
14
+ * Літеральні патерни перевіряються через existsSync; glob-патерни — через node:fs/promises#glob.
15
+ * @param {string} cwd корінь проєкту
16
+ * @param {string} pattern workspace-патерн з package.json (наприклад, `app` або `cf/*`)
17
+ * @returns {Promise<string[]>} абсолютні шляхи до workspace-каталогів
18
+ */
19
+ async function expandWorkspacePattern(cwd, pattern) {
20
+ if (!pattern.includes('*')) {
21
+ const wsPath = join(cwd, pattern)
22
+ return existsSync(join(wsPath, 'package.json')) ? [wsPath] : []
23
+ }
24
+ const results = []
25
+ for await (const rel of glob(`${pattern}/package.json`, { cwd, exclude: WORKSPACE_GLOB_IGNORE })) {
26
+ const wsRel = rel.replace(PACKAGE_JSON_SUFFIX_RE, '')
27
+ results.push(join(cwd, wsRel))
28
+ }
29
+ return results.toSorted()
30
+ }
31
+
32
+ /**
33
+ * @param {string} cwd корінь проєкту (де кореневий package.json)
34
+ * @returns {Promise<string|null>} абсолютний шлях до JS-root або null без кореневого package.json
35
+ */
36
+ export async function resolveJsRoot(cwd) {
37
+ const roots = await resolveAllJsRoots(cwd)
38
+ if (roots.length === 0) return null
39
+ return roots[0]
40
+ }
41
+
42
+ /**
43
+ * Plural-варіант: повертає всі JS-roots проєкту. Для workspace-projects — кожен
44
+ * workspace з власним `package.json` (з розгортанням glob-патернів); для
45
+ * single-package — `[cwd]`. Порожній масив без кореневого package.json.
46
+ * @param {string} cwd корінь проєкту
47
+ * @returns {Promise<string[]>} абсолютні шляхи до всіх JS-roots
48
+ */
49
+ export async function resolveAllJsRoots(cwd) {
50
+ const rootPkgPath = join(cwd, 'package.json')
51
+ if (!existsSync(rootPkgPath)) return []
52
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
53
+ const patterns = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
54
+ if (patterns.length === 0) return [cwd]
55
+ const roots = []
56
+ for (const pattern of patterns) {
57
+ roots.push(...(await expandWorkspacePattern(cwd, pattern)))
58
+ }
59
+ return roots.length > 0 ? roots : [cwd]
60
+ }