@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 +6 -0
- package/package.json +1 -1
- package/src/coverage/aggregate.mjs +31 -0
- package/src/coverage/coverage.mjs +20 -94
- package/src/coverage/js-collector.mjs +437 -0
- package/src/lib/resolve-js-root.mjs +60 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
* Канонічна команда
|
|
3
|
-
* тестування
|
|
4
|
-
*
|
|
2
|
+
* Канонічна команда `@7n/test coverage`: збирає метрики покриття + мутаційного
|
|
3
|
+
* тестування через вбудований JS-колектор (`js-collector.mjs`), агрегує та
|
|
4
|
+
* записує COVERAGE.md у корінь проєкту.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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:
|
|
229
|
-
*
|
|
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
|
-
|
|
251
|
-
|
|
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('✗
|
|
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 для
|
|
305
|
-
* @param {{fix?:boolean, changed?:boolean
|
|
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
|
|
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
|
+
}
|