@7n/test 0.14.5 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/package.json +2 -2
- package/src/coverage/js-collector.mjs +329 -37
- package/src/coverage/storybook-mutation-llm.mjs +180 -0
- package/src/coverage/storybook-mutation.mjs +361 -0
- package/src/coverage/storybook.mjs +75 -0
- package/src/coverage-per-file.mjs +1 -1
- package/src/docs/coverage-fix.md +1 -1
- package/src/docs/coverage-per-file.md +1 -1
- package/src/docs/gen-stories.md +38 -0
- package/src/docs/index.md +1 -1
- package/src/docs/run.md +1 -1
- package/src/gen-stories.mjs +355 -0
- package/src/lib/docs/index.md +7 -8
- package/src/lib/docs/llm.md +1 -1
- package/src/lib/docs/resolve-js-root.md +31 -0
- package/src/run.mjs +105 -52
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-джерело мутантів для Storybook mutation executor-а (storybook-mutation.mjs) —
|
|
3
|
+
* Mutahunter/Meta-ACH-патерн: LLM лише ПРОПОНУЄ context-aware bug-like мутанти
|
|
4
|
+
* (off-by-one, підмінені fallback-значення, переплутані аргументи — те, чого
|
|
5
|
+
* детерміновані оператори виразити не можуть), а вбиває/милує так само лише
|
|
6
|
+
* реальний browser-mode прогін у executor-і. Жодного LLM-суддівства pass/fail.
|
|
7
|
+
*
|
|
8
|
+
* Кожна пропозиція жорстко валідується перед прийняттям: рядок існує і покритий
|
|
9
|
+
* сторі, `original` — точний підрядок цього рядка, а мутований код парситься
|
|
10
|
+
* (`parseAst`) — синтаксично невалідні пропозиції відкидаються (інакше зламаний
|
|
11
|
+
* компайл рахувався б як killed і дуто підвищував score, патерн LLMorpheus).
|
|
12
|
+
*
|
|
13
|
+
* Graceful degradation: будь-яка помилка LLM-виклику (нема API-ключа, мережа)
|
|
14
|
+
* дає `[]` з одноразовим попередженням — детерміновані мутанти працюють далі.
|
|
15
|
+
* Opt-out: env `N_7N_TEST_NO_LLM_MUTANTS=1`.
|
|
16
|
+
*/
|
|
17
|
+
import { env } from 'node:process'
|
|
18
|
+
import { parseAst } from 'rollup/parseAst'
|
|
19
|
+
import { startChain } from '@7n/llm-lib/chain'
|
|
20
|
+
import { budgetFor } from '@7n/llm-lib/prompt-budget'
|
|
21
|
+
|
|
22
|
+
import { callText } from '../lib/llm.mjs'
|
|
23
|
+
import { extractMutableCode } from './storybook-mutation.mjs'
|
|
24
|
+
|
|
25
|
+
/** LLM-мутанти сортуються ПІСЛЯ детермінованих тірів 1–5 (власна стеля — не конкурують). */
|
|
26
|
+
const LLM_TIER = 6
|
|
27
|
+
const MAX_PROPOSALS = 6
|
|
28
|
+
const JSON_ARRAY_RE = /\[[\s\S]*\]/
|
|
29
|
+
|
|
30
|
+
const SYSTEM_PROMPT = `You are a mutation-testing expert. Propose realistic, bug-like mutants for the JavaScript code below — the kind of subtle bugs a developer could actually introduce.
|
|
31
|
+
|
|
32
|
+
Reply ONLY with a JSON array (no markdown fence):
|
|
33
|
+
[{"line": <number>, "original": "<exact substring of that line>", "replacement": "<text>", "category": "<short-kebab-case>", "reason": "<одне речення українською>"}]
|
|
34
|
+
|
|
35
|
+
Rules:
|
|
36
|
+
- line: 1-based line number as shown in the numbered code
|
|
37
|
+
- original MUST be an EXACT substring of that line (long enough to locate unambiguously)
|
|
38
|
+
- replacement must differ from original and keep the file syntactically valid
|
|
39
|
+
- prefer bugs that simple operator swaps cannot express: off-by-one in slice/index bounds, wrong default or fallback value, swapped call arguments, wrong property name, inverted early-return condition, dropped await
|
|
40
|
+
- do NOT mutate imports, comments, or purely cosmetic strings
|
|
41
|
+
- only mutate lines marked as covered
|
|
42
|
+
- at most ${MAX_PROPOSALS} proposals, most bug-likely first`
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Нумерує рядки коду для промпта (absolute line numbers повного файлу).
|
|
46
|
+
* @param {string} code вміст script-блоку
|
|
47
|
+
* @param {number} startLine 1-based номер першого рядка блоку в повному файлі
|
|
48
|
+
* @returns {string} код із префіксами номерів рядків
|
|
49
|
+
*/
|
|
50
|
+
function numberLines(code, startLine) {
|
|
51
|
+
return code
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map((l, i) => `${startLine + i}: ${l}`)
|
|
54
|
+
.join('\n')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Валідує одну LLM-пропозицію і переводить її у shape мутанта executor-а.
|
|
59
|
+
* @param {{line?: unknown, original?: unknown, replacement?: unknown, category?: unknown}} p пропозиція LLM
|
|
60
|
+
* @param {string[]} sourceLines рядки повного файлу
|
|
61
|
+
* @param {number[]} lineOffsets offset початку кожного рядка у повному файлі
|
|
62
|
+
* @param {Set<number>} coveredLines покриті сторі рядки
|
|
63
|
+
* @returns {{line: number, col: number, mutantType: string, original: string, replacement: string, start: number, end: number, text: string, tier: number} | null} мутант або null (невалідна пропозиція)
|
|
64
|
+
*/
|
|
65
|
+
function validateProposal(p, sourceLines, lineOffsets, coveredLines) {
|
|
66
|
+
if (!p || typeof p !== 'object') return null
|
|
67
|
+
const { line, original, replacement } = p
|
|
68
|
+
if (!Number.isInteger(line) || line < 1 || line > sourceLines.length) return null
|
|
69
|
+
if (!coveredLines.has(line)) return null
|
|
70
|
+
if (typeof original !== 'string' || original.length === 0) return null
|
|
71
|
+
if (typeof replacement !== 'string' || replacement === original) return null
|
|
72
|
+
|
|
73
|
+
const lineText = sourceLines[line - 1]
|
|
74
|
+
const col = lineText.indexOf(original)
|
|
75
|
+
if (col === -1) return null
|
|
76
|
+
|
|
77
|
+
const start = lineOffsets[line - 1] + col
|
|
78
|
+
return {
|
|
79
|
+
line,
|
|
80
|
+
col,
|
|
81
|
+
mutantType: typeof p.category === 'string' && p.category ? `llm:${p.category}` : 'llm:proposed',
|
|
82
|
+
original,
|
|
83
|
+
replacement,
|
|
84
|
+
start,
|
|
85
|
+
end: start + original.length,
|
|
86
|
+
text: replacement,
|
|
87
|
+
tier: LLM_TIER
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Чи мутований файл лишається синтаксично валідним (у скоупі script-блоку).
|
|
93
|
+
* @param {string} file відносний шлях файлу
|
|
94
|
+
* @param {string} source повний вміст файлу
|
|
95
|
+
* @param {{start: number, end: number, text: string}} mutant мутант для приміряння
|
|
96
|
+
* @returns {boolean} true, якщо parseAst бере мутований script-блок
|
|
97
|
+
*/
|
|
98
|
+
function staysParseable(file, source, mutant) {
|
|
99
|
+
const mutated = source.slice(0, mutant.start) + mutant.text + source.slice(mutant.end)
|
|
100
|
+
const extracted = extractMutableCode(file, mutated)
|
|
101
|
+
if (!extracted) return false
|
|
102
|
+
try {
|
|
103
|
+
parseAst(extracted.code)
|
|
104
|
+
return true
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Пропонує LLM-мутанти для одного файлу: один callText-виклик (окремий chain),
|
|
112
|
+
* парс JSON-відповіді, жорстка валідація кожної пропозиції (рядок/підрядок/
|
|
113
|
+
* покриття/синтаксис). Кидає помилки LLM-виклику нагору — graceful-обгортка
|
|
114
|
+
* (warn + []) живе на боці виклику (defaultRunner у js-collector.mjs).
|
|
115
|
+
* @param {object} opts опції
|
|
116
|
+
* @param {string} opts.file відносний шлях файлу
|
|
117
|
+
* @param {string} opts.source повний вміст файлу
|
|
118
|
+
* @param {Set<number>} opts.coveredLines покриті сторі рядки (1-indexed)
|
|
119
|
+
* @param {string} opts.cwd робоча директорія для LLM-session/chain
|
|
120
|
+
* @param {typeof callText} [opts.callTextFn] LLM-колер (ін'єкція для тестів)
|
|
121
|
+
* @param {typeof startChain} [opts.makeChain] фабрика ланцюжка (ін'єкція для тестів)
|
|
122
|
+
* @returns {Promise<Array<object>>} валідні мутанти у shape generateMutants (може бути порожнім)
|
|
123
|
+
*/
|
|
124
|
+
export async function proposeLlmMutants(opts) {
|
|
125
|
+
const { file, source, coveredLines, cwd, callTextFn = callText, makeChain = startChain } = opts
|
|
126
|
+
if (env.N_7N_TEST_NO_LLM_MUTANTS) return []
|
|
127
|
+
|
|
128
|
+
const extracted = extractMutableCode(file, source)
|
|
129
|
+
if (!extracted) return []
|
|
130
|
+
|
|
131
|
+
const sourceLines = source.split('\n')
|
|
132
|
+
const lineOffsets = []
|
|
133
|
+
let offset = 0
|
|
134
|
+
for (const l of sourceLines) {
|
|
135
|
+
lineOffsets.push(offset)
|
|
136
|
+
offset += l.length + 1
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 1-based номер першого рядка script-блоку в повному файлі
|
|
140
|
+
const blockStartLine = source.slice(0, extracted.offset).split('\n').length
|
|
141
|
+
const covered = [...coveredLines].toSorted((a, b) => a - b).join(', ')
|
|
142
|
+
|
|
143
|
+
const prompt = [
|
|
144
|
+
SYSTEM_PROMPT,
|
|
145
|
+
'',
|
|
146
|
+
`## File: ${file}`,
|
|
147
|
+
`Covered lines: ${covered}`,
|
|
148
|
+
'',
|
|
149
|
+
'```js',
|
|
150
|
+
numberLines(extracted.code, blockStartLine),
|
|
151
|
+
'```'
|
|
152
|
+
].join('\n')
|
|
153
|
+
|
|
154
|
+
const chain = makeChain({ kind: 'sb-llm-mutants', unit: file, cwd })
|
|
155
|
+
let text
|
|
156
|
+
let failed = null
|
|
157
|
+
try {
|
|
158
|
+
text = await callTextFn(prompt, { cwd, maxTokens: budgetFor('block').maxTokens, chain })
|
|
159
|
+
} catch (error) {
|
|
160
|
+
failed = String(error.message ?? error).slice(0, 200)
|
|
161
|
+
throw error
|
|
162
|
+
} finally {
|
|
163
|
+
chain.end({ outcome: failed ? 'fail' : 'success', extra: failed ? { error: failed } : {} })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let proposals
|
|
167
|
+
try {
|
|
168
|
+
proposals = JSON.parse(JSON_ARRAY_RE.exec(text)?.[0] ?? '[]')
|
|
169
|
+
} catch {
|
|
170
|
+
return []
|
|
171
|
+
}
|
|
172
|
+
if (!Array.isArray(proposals)) return []
|
|
173
|
+
|
|
174
|
+
const mutants = []
|
|
175
|
+
for (const p of proposals.slice(0, MAX_PROPOSALS)) {
|
|
176
|
+
const m = validateProposal(p, sourceLines, lineOffsets, coveredLines)
|
|
177
|
+
if (m && staysParseable(file, source, m)) mutants.push(m)
|
|
178
|
+
}
|
|
179
|
+
return mutants
|
|
180
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation testing для Storybook-покритого коду (vitest browser mode) — власний
|
|
3
|
+
* mutate→run→restore executor замість Stryker vitest-runner, який не підтримує
|
|
4
|
+
* сучасний Playwright-based browser mode (див. коментар над collectStorybookForRoot
|
|
5
|
+
* у js-collector.mjs).
|
|
6
|
+
*
|
|
7
|
+
* Патерн підтверджений дослідженням спільноти (alexop.dev, Mutahunter, Meta ACH):
|
|
8
|
+
* мутанти генеруються детерміновано, а вбиває/милує ЛИШЕ реальний прогін тестів —
|
|
9
|
+
* жодного LLM-суддівства pass/fail. Цикл на мутант: записати мутований файл →
|
|
10
|
+
* `vitest run --project=storybook [сторі-фільтр]` → KILLED (exit ≠ 0) / SURVIVED
|
|
11
|
+
* (exit 0) / TIMEOUT (kill за таймаутом, рахується як caught — мутант зламав
|
|
12
|
+
* поведінку до зависання) → відновити оригінал у `finally`.
|
|
13
|
+
*
|
|
14
|
+
* Генерація мутантів — AST-based (rollup `parseAst`, ESTree-shaped, вже у deps),
|
|
15
|
+
* без regex-хибняків у рядках/коментарях. Оператори — 5 тірів за ймовірністю
|
|
16
|
+
* виживання (catalog citypaul/alexop): boundary → logical → equality → literals/return
|
|
17
|
+
* → arithmetic. Мутуються лише рядки, покриті сторі (lcov `DA:`), з бюджетом
|
|
18
|
+
* (maxPerFile/maxTotal) — browser-mode прогін коштує секунди на мутант.
|
|
19
|
+
*
|
|
20
|
+
* `.vue` SFC: мутується лише вміст `<script>`/`<script setup>` блоку (template поза
|
|
21
|
+
* скоупом). `lang="ts"`-скрипти, які parseAst не бере, тихо пропускаються (0 мутантів).
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { isAbsolute, join, relative } from 'node:path'
|
|
25
|
+
import { parseAst } from 'rollup/parseAst'
|
|
26
|
+
|
|
27
|
+
/** Дефолтна стеля детермінованих мутантів на файл (тіри з нижчих номерів мають пріоритет). */
|
|
28
|
+
const DEFAULT_MAX_PER_FILE = 8
|
|
29
|
+
/** Дефолтна стеля додаткових (LLM-запропонованих) мутантів на файл — поверх детермінованої. */
|
|
30
|
+
const DEFAULT_MAX_EXTRA_PER_FILE = 3
|
|
31
|
+
/** Дефолтна стеля мутантів на весь прогін (спільна для обох джерел). */
|
|
32
|
+
const DEFAULT_MAX_TOTAL = 32
|
|
33
|
+
/** Мінімальний таймаут одного мутант-прогону (браузер холодний старт). */
|
|
34
|
+
const MIN_TIMEOUT_MS = 30_000
|
|
35
|
+
|
|
36
|
+
const SCRIPT_BLOCK_RE = /<script[^>]*>([\s\S]*?)<\/script>/
|
|
37
|
+
const SCRIPT_LANG_TS_RE = /<script[^>]*\blang\s*=\s*["']ts["'][^>]*>/
|
|
38
|
+
const VUE_FILE_RE = /\.vue$/
|
|
39
|
+
|
|
40
|
+
/** Заміни операторів за тірами: tier 1 boundary, 2 logical, 3 equality, 5 arithmetic. */
|
|
41
|
+
const BINARY_SWAPS = {
|
|
42
|
+
'<': { to: '<=', tier: 1, type: 'ConditionalExpression' },
|
|
43
|
+
'<=': { to: '<', tier: 1, type: 'ConditionalExpression' },
|
|
44
|
+
'>': { to: '>=', tier: 1, type: 'ConditionalExpression' },
|
|
45
|
+
'>=': { to: '>', tier: 1, type: 'ConditionalExpression' },
|
|
46
|
+
'===': { to: '!==', tier: 3, type: 'EqualityOperator' },
|
|
47
|
+
'!==': { to: '===', tier: 3, type: 'EqualityOperator' },
|
|
48
|
+
'==': { to: '!=', tier: 3, type: 'EqualityOperator' },
|
|
49
|
+
'!=': { to: '==', tier: 3, type: 'EqualityOperator' },
|
|
50
|
+
'-': { to: '+', tier: 5, type: 'ArithmeticOperator' },
|
|
51
|
+
'*': { to: '/', tier: 5, type: 'ArithmeticOperator' },
|
|
52
|
+
'/': { to: '*', tier: 5, type: 'ArithmeticOperator' }
|
|
53
|
+
}
|
|
54
|
+
const LOGICAL_SWAPS = {
|
|
55
|
+
'&&': { to: '||', tier: 2, type: 'LogicalOperator' },
|
|
56
|
+
'||': { to: '&&', tier: 2, type: 'LogicalOperator' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Парс lcov.info у мапу «файл → покриті рядки» з `SF:`/`DA:<line>,<hits>` records.
|
|
61
|
+
* Шляхи рібейзяться відносно `baseDir` (lcov типово містить абсолютні шляхи).
|
|
62
|
+
* @param {string} text вміст lcov.info
|
|
63
|
+
* @param {string} baseDir корінь, відносно якого рібейзити шляхи (jsRoot)
|
|
64
|
+
* @returns {Map<string, Set<number>>} relative-шлях → множина покритих (hits > 0) рядків
|
|
65
|
+
*/
|
|
66
|
+
export function parseLcovCoveredLines(text, baseDir) {
|
|
67
|
+
const map = new Map()
|
|
68
|
+
let currentFile = null
|
|
69
|
+
let lines = null
|
|
70
|
+
for (const line of text.split('\n')) {
|
|
71
|
+
if (line.startsWith('SF:')) {
|
|
72
|
+
const raw = line.slice(3).trim()
|
|
73
|
+
currentFile = isAbsolute(raw) ? relative(baseDir, raw) : raw
|
|
74
|
+
lines = new Set()
|
|
75
|
+
} else if (line.startsWith('DA:') && lines) {
|
|
76
|
+
const comma = line.indexOf(',', 3)
|
|
77
|
+
const hits = Number(line.slice(comma + 1))
|
|
78
|
+
if (hits > 0) lines.add(Number(line.slice(3, comma)))
|
|
79
|
+
} else if (line === 'end_of_record' && currentFile) {
|
|
80
|
+
if (lines.size > 0 && !currentFile.startsWith('..')) map.set(currentFile, lines)
|
|
81
|
+
currentFile = null
|
|
82
|
+
lines = null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return map
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Витягує JS-вміст для мутації: для `.vue` — перший `<script>`-блок (без TS),
|
|
90
|
+
* для решти — увесь файл. Експортовано для LLM-джерела мутантів
|
|
91
|
+
* (storybook-mutation-llm.mjs) — той самий скоуп і синтакс-валідація.
|
|
92
|
+
* @param {string} file відносний шлях файлу
|
|
93
|
+
* @param {string} source повний вміст файлу
|
|
94
|
+
* @returns {{code: string, offset: number} | null} код і зсув початку в повному файлі, або null (TS-скрипт / нема script-блоку)
|
|
95
|
+
*/
|
|
96
|
+
export function extractMutableCode(file, source) {
|
|
97
|
+
if (!VUE_FILE_RE.test(file)) return { code: source, offset: 0 }
|
|
98
|
+
if (SCRIPT_LANG_TS_RE.test(source)) return null
|
|
99
|
+
const m = SCRIPT_BLOCK_RE.exec(source)
|
|
100
|
+
if (!m) return null
|
|
101
|
+
return { code: m[1], offset: m.index + m[0].indexOf(m[1]) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Індекс початків рядків для перекладу offset → {line, col} (line 1-indexed, col 0-indexed).
|
|
106
|
+
* @param {string} source повний вміст файлу
|
|
107
|
+
* @returns {number[]} зростаючі offsets початку кожного рядка
|
|
108
|
+
*/
|
|
109
|
+
function buildLineIndex(source) {
|
|
110
|
+
const starts = [0]
|
|
111
|
+
for (let i = 0; i < source.length; i++) {
|
|
112
|
+
if (source[i] === '\n') starts.push(i + 1)
|
|
113
|
+
}
|
|
114
|
+
return starts
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Переводить абсолютний offset у позицію {line, col}.
|
|
119
|
+
* @param {number[]} lineStarts індекс із buildLineIndex
|
|
120
|
+
* @param {number} offset абсолютний offset у файлі
|
|
121
|
+
* @returns {{line: number, col: number}} line 1-indexed, col 0-indexed
|
|
122
|
+
*/
|
|
123
|
+
function offsetToPosition(lineStarts, offset) {
|
|
124
|
+
let lo = 0
|
|
125
|
+
let hi = lineStarts.length - 1
|
|
126
|
+
while (lo < hi) {
|
|
127
|
+
const mid = Math.ceil((lo + hi) / 2)
|
|
128
|
+
if (lineStarts[mid] <= offset) lo = mid
|
|
129
|
+
else hi = mid - 1
|
|
130
|
+
}
|
|
131
|
+
return { line: lo + 1, col: offset - lineStarts[lo] }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Рекурсивний обхід ESTree-вузлів.
|
|
136
|
+
* @param {object} node AST-вузол
|
|
137
|
+
* @param {(node: object) => void} visit колбек для кожного вузла з type
|
|
138
|
+
* @returns {void}
|
|
139
|
+
*/
|
|
140
|
+
function walkAst(node, visit) {
|
|
141
|
+
if (!node || typeof node !== 'object') return
|
|
142
|
+
if (Array.isArray(node)) {
|
|
143
|
+
for (const child of node) walkAst(child, visit)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (typeof node.type === 'string') visit(node)
|
|
147
|
+
for (const key of Object.keys(node)) {
|
|
148
|
+
if (key === 'type' || key === 'start' || key === 'end') continue
|
|
149
|
+
walkAst(node[key], visit)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Мутація оператора binary/logical-виразу: знаходить текст оператора між
|
|
155
|
+
* `left.end` і `right.start` і формує splice-заміну.
|
|
156
|
+
* @param {object} node BinaryExpression/LogicalExpression вузол
|
|
157
|
+
* @param {string} code повний вміст файлу
|
|
158
|
+
* @param {{to: string, tier: number, type: string}} swap правило заміни
|
|
159
|
+
* @returns {{start: number, end: number, text: string, tier: number, mutantType: string} | null} splice або null, якщо оператор не знайдено текстуально
|
|
160
|
+
*/
|
|
161
|
+
function operatorMutation(node, code, swap) {
|
|
162
|
+
const between = code.slice(node.left.end, node.right.start)
|
|
163
|
+
const at = between.indexOf(node.operator)
|
|
164
|
+
if (at === -1) return null
|
|
165
|
+
const start = node.left.end + at
|
|
166
|
+
return { start, end: start + node.operator.length, text: swap.to, tier: swap.tier, mutantType: swap.type }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Генерує детерміновані мутанти для одного файлу (AST-based, лише покриті рядки).
|
|
171
|
+
* Тіри (пріоритет відбору): 1 boundary (`<`↔`<=`…), 2 logical (`&&`↔`||`, зняття `!`),
|
|
172
|
+
* 3 equality (`===`↔`!==`…), 4 literals/return (`true`↔`false`, `return X`→`return null`),
|
|
173
|
+
* 5 arithmetic (`-`↔`+`, `*`↔`/`). `+` свідомо не мутується (string-concat дає шумні
|
|
174
|
+
* псевдо-еквівалентні мутанти).
|
|
175
|
+
* @param {string} file відносний шлях (для .vue-детекції)
|
|
176
|
+
* @param {string} source повний вміст файлу
|
|
177
|
+
* @param {Set<number>} coveredLines покриті сторі рядки (1-indexed, з lcov DA)
|
|
178
|
+
* @returns {Array<{line: number, col: number, mutantType: string, original: string, replacement: string, start: number, end: number, text: string, tier: number}>} мутанти, відсортовані за (tier, line)
|
|
179
|
+
*/
|
|
180
|
+
export function generateMutants(file, source, coveredLines) {
|
|
181
|
+
const extracted = extractMutableCode(file, source)
|
|
182
|
+
if (!extracted) return []
|
|
183
|
+
|
|
184
|
+
let ast
|
|
185
|
+
try {
|
|
186
|
+
ast = parseAst(extracted.code)
|
|
187
|
+
} catch {
|
|
188
|
+
return [] // синтаксис поза parseAst (TS у .js тощо) — тихо без мутантів
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const lineStarts = buildLineIndex(source)
|
|
192
|
+
const splices = []
|
|
193
|
+
|
|
194
|
+
walkAst(ast, node => {
|
|
195
|
+
if (node.type === 'BinaryExpression' && BINARY_SWAPS[node.operator]) {
|
|
196
|
+
const m = operatorMutation(node, extracted.code, BINARY_SWAPS[node.operator])
|
|
197
|
+
if (m) splices.push(m)
|
|
198
|
+
} else if (node.type === 'LogicalExpression' && LOGICAL_SWAPS[node.operator]) {
|
|
199
|
+
const m = operatorMutation(node, extracted.code, LOGICAL_SWAPS[node.operator])
|
|
200
|
+
if (m) splices.push(m)
|
|
201
|
+
} else if (node.type === 'UnaryExpression' && node.operator === '!' && node.prefix) {
|
|
202
|
+
splices.push({ start: node.start, end: node.argument.start, text: '', tier: 2, mutantType: 'BooleanNegation' })
|
|
203
|
+
} else if (node.type === 'Literal' && (node.value === true || node.value === false)) {
|
|
204
|
+
splices.push({
|
|
205
|
+
start: node.start,
|
|
206
|
+
end: node.end,
|
|
207
|
+
text: String(!node.value),
|
|
208
|
+
tier: 4,
|
|
209
|
+
mutantType: 'BooleanLiteral'
|
|
210
|
+
})
|
|
211
|
+
} else if (node.type === 'ReturnStatement' && node.argument && node.argument.raw !== 'null') {
|
|
212
|
+
splices.push({
|
|
213
|
+
start: node.argument.start,
|
|
214
|
+
end: node.argument.end,
|
|
215
|
+
text: 'null',
|
|
216
|
+
tier: 4,
|
|
217
|
+
mutantType: 'ReturnValue'
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const mutants = []
|
|
223
|
+
for (const s of splices) {
|
|
224
|
+
const absStart = extracted.offset + s.start
|
|
225
|
+
const absEnd = extracted.offset + s.end
|
|
226
|
+
const pos = offsetToPosition(lineStarts, absStart)
|
|
227
|
+
if (!coveredLines.has(pos.line)) continue
|
|
228
|
+
mutants.push({
|
|
229
|
+
line: pos.line,
|
|
230
|
+
col: pos.col,
|
|
231
|
+
mutantType: s.mutantType,
|
|
232
|
+
original: source.slice(absStart, absEnd),
|
|
233
|
+
replacement: s.text,
|
|
234
|
+
start: absStart,
|
|
235
|
+
end: absEnd,
|
|
236
|
+
text: s.text,
|
|
237
|
+
tier: s.tier
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
return mutants.toSorted((a, b) => a.tier - b.tier || a.line - b.line || a.col - b.col)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Об'єднує детерміновані мутанти з додатковими (LLM-запропонованими), відкидаючи
|
|
245
|
+
* лише ТОЧНІ дублі (той самий [start, end) і та сама заміна). Перетин діапазонів —
|
|
246
|
+
* не дубль: мутація `5` всередині `return x < 5` і заміна всього аргументу на `null`
|
|
247
|
+
* — різні мутанти, обидва цінні.
|
|
248
|
+
* @param {Array<{start: number, end: number, text: string}>} deterministic детерміновані мутанти (пріоритет)
|
|
249
|
+
* @param {Array<{start: number, end: number, text: string}>} extra додаткові мутанти
|
|
250
|
+
* @returns {Array<object>} deterministic + недубльовані extra
|
|
251
|
+
*/
|
|
252
|
+
function mergeMutants(deterministic, extra) {
|
|
253
|
+
const merged = [...deterministic]
|
|
254
|
+
for (const e of extra) {
|
|
255
|
+
const duplicate = deterministic.some(d => d.start === e.start && d.end === e.end && d.text === e.text)
|
|
256
|
+
if (!duplicate) merged.push(e)
|
|
257
|
+
}
|
|
258
|
+
return merged
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Виконує mutate→run→restore цикл для набору файлів одного Storybook-root.
|
|
263
|
+
*
|
|
264
|
+
* Гарантія відновлення: оригінальний вміст тримається в пам'яті і записується назад
|
|
265
|
+
* у `finally` після КОЖНОГО мутанта — жоден наступний прогін не бачить попередню
|
|
266
|
+
* мутацію, а креш процесу лишає щонайбільше один мутований файл (відновлюється
|
|
267
|
+
* `git checkout`, файл завжди у git — ми мутуємо лише tracked production-код).
|
|
268
|
+
*
|
|
269
|
+
* Класифікація: exit 0 → survived; exit ≠ 0 → killed; null status (kill за
|
|
270
|
+
* таймаутом spawnSync) → timeout, рахується як caught (мутант завісив виконання).
|
|
271
|
+
*
|
|
272
|
+
* `proposeExtraMutants` (опційно) — друге джерело мутантів (LLM, Mutahunter/ACH-патерн):
|
|
273
|
+
* ПРОПОНУЄ додаткові bug-like мутанти, але вбиває/милує так само лише реальний прогін.
|
|
274
|
+
* Додаткові мутанти мають ВЛАСНУ стелю (`maxExtraPerFile`) поверх детермінованої і
|
|
275
|
+
* дедуплікуються проти детермінованих за діапазоном; помилки хука (нема API-ключа
|
|
276
|
+
* тощо) — відповідальність самого хука (тут не ловляться).
|
|
277
|
+
* @param {object} opts опції прогону
|
|
278
|
+
* @param {string} opts.jsRoot абсолютний шлях workspace-кореня
|
|
279
|
+
* @param {string[]} opts.files відносні (до jsRoot) шляхи production-файлів для мутації
|
|
280
|
+
* @param {Map<string, Set<number>>} opts.coveredLines файл → покриті рядки (parseLcovCoveredLines)
|
|
281
|
+
* @param {(args: {cwd: string, storyFilter: string|null, timeoutMs: number}) => number|null} opts.runMutantTest прогін тестів проти мутованого дерева; повертає exit code або null при таймауті
|
|
282
|
+
* @param {(file: string) => string|null} [opts.resolveStoryFilter] відносний шлях сторі-файлу компонента для звуження прогону (null = увесь storybook-проєкт)
|
|
283
|
+
* @param {(file: string, source: string, coveredLines: Set<number>) => Promise<Array<object>>} [opts.proposeExtraMutants] друге джерело мутантів у тому ж shape, що generateMutants
|
|
284
|
+
* @param {number} [opts.timeoutMs] таймаут одного мутант-прогону
|
|
285
|
+
* @param {number} [opts.maxPerFile] стеля детермінованих мутантів на файл
|
|
286
|
+
* @param {number} [opts.maxExtraPerFile] стеля додаткових (LLM) мутантів на файл
|
|
287
|
+
* @param {number} [opts.maxTotal] стеля мутантів на прогін (спільна для обох джерел)
|
|
288
|
+
* @returns {Promise<{caught: number, total: number, survived: Array<{file: string, mutants: Array<{line: number, col: number, mutantType: string, original: string, replacement: string}>, exampleTest: null, recommendationText: null}>}>} результат у shape parseStrykerReport
|
|
289
|
+
*/
|
|
290
|
+
export async function runStorybookMutation(opts) {
|
|
291
|
+
const {
|
|
292
|
+
jsRoot,
|
|
293
|
+
files,
|
|
294
|
+
coveredLines,
|
|
295
|
+
runMutantTest,
|
|
296
|
+
resolveStoryFilter = () => null,
|
|
297
|
+
proposeExtraMutants = null,
|
|
298
|
+
timeoutMs = MIN_TIMEOUT_MS,
|
|
299
|
+
maxPerFile = DEFAULT_MAX_PER_FILE,
|
|
300
|
+
maxExtraPerFile = DEFAULT_MAX_EXTRA_PER_FILE,
|
|
301
|
+
maxTotal = DEFAULT_MAX_TOTAL
|
|
302
|
+
} = opts
|
|
303
|
+
|
|
304
|
+
let caught = 0
|
|
305
|
+
let total = 0
|
|
306
|
+
let budget = maxTotal
|
|
307
|
+
const survived = []
|
|
308
|
+
|
|
309
|
+
for (const file of files) {
|
|
310
|
+
if (budget <= 0) break
|
|
311
|
+
const lines = coveredLines.get(file)
|
|
312
|
+
if (!lines || lines.size === 0) continue
|
|
313
|
+
|
|
314
|
+
const absPath = join(jsRoot, file)
|
|
315
|
+
let source
|
|
316
|
+
try {
|
|
317
|
+
source = readFileSync(absPath, 'utf8')
|
|
318
|
+
} catch {
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const deterministic = generateMutants(file, source, lines).slice(0, maxPerFile)
|
|
323
|
+
const proposed = proposeExtraMutants ? await proposeExtraMutants(file, source, lines) : []
|
|
324
|
+
const mutants = mergeMutants(deterministic, proposed.slice(0, maxExtraPerFile)).slice(0, budget)
|
|
325
|
+
if (mutants.length === 0) continue
|
|
326
|
+
budget -= mutants.length
|
|
327
|
+
|
|
328
|
+
const storyFilter = resolveStoryFilter(file)
|
|
329
|
+
const survivedInFile = []
|
|
330
|
+
|
|
331
|
+
for (const mutant of mutants) {
|
|
332
|
+
const mutated = source.slice(0, mutant.start) + mutant.text + source.slice(mutant.end)
|
|
333
|
+
let status
|
|
334
|
+
try {
|
|
335
|
+
writeFileSync(absPath, mutated, 'utf8')
|
|
336
|
+
status = runMutantTest({ cwd: jsRoot, storyFilter, timeoutMs })
|
|
337
|
+
} finally {
|
|
338
|
+
writeFileSync(absPath, source, 'utf8')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
total += 1
|
|
342
|
+
if (status === 0) {
|
|
343
|
+
survivedInFile.push({
|
|
344
|
+
line: mutant.line,
|
|
345
|
+
col: mutant.col,
|
|
346
|
+
mutantType: mutant.mutantType,
|
|
347
|
+
original: mutant.original,
|
|
348
|
+
replacement: mutant.replacement
|
|
349
|
+
})
|
|
350
|
+
} else {
|
|
351
|
+
caught += 1 // non-zero exit АБО timeout (null) — мутант впійманий
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (survivedInFile.length > 0) {
|
|
356
|
+
survived.push({ file, mutants: survivedInFile, exampleTest: null, recommendationText: null })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { caught, total, survived }
|
|
361
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Детекція Storybook-workspace-ів (Vue/React/... компоненти зі сторі), покриття
|
|
3
|
+
* яких рахує vitest browser mode через `@storybook/addon-vitest` (сучасна заміна
|
|
4
|
+
* legacy `@storybook/test-runner` + `@storybook/addon-coverage`). Root вважається
|
|
5
|
+
* Storybook-workspace лише коли є **обидва** сигнали — тека `.storybook/` (конфіг)
|
|
6
|
+
* і сам `@storybook/addon-vitest` у deps: самої теки замало (legacy Storybook без
|
|
7
|
+
* vitest-інтеграції теж має `.storybook/`), самого addon-у замало (може бути
|
|
8
|
+
* встановлений, але вимкнений/не налаштований).
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** `*.stories.*` файли — не production-код, окремий вимір покриття (Storybook, не JS-рядок). */
|
|
15
|
+
export const STORIES_FILE_RE = /\.stories\.[^.]+$/
|
|
16
|
+
|
|
17
|
+
const IGNORE_DIRS = new Set(['node_modules', 'dist', 'build', 'out', '.git', 'coverage', 'reports', 'docs', 'types'])
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Рекурсивний обхід каталогу з відсіюванням службових директорій (dot-теки, IGNORE_DIRS).
|
|
21
|
+
* @param {string} dir абсолютний шлях
|
|
22
|
+
* @param {(absPath: string) => void} onFile колбек для кожного файлу
|
|
23
|
+
* @returns {Promise<void>}
|
|
24
|
+
*/
|
|
25
|
+
async function walk(dir, onFile) {
|
|
26
|
+
let entries
|
|
27
|
+
try {
|
|
28
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
29
|
+
} catch {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (entry.name.startsWith('.')) continue
|
|
34
|
+
const abs = join(dir, entry.name)
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (!IGNORE_DIRS.has(entry.name)) await walk(abs, onFile)
|
|
37
|
+
} else if (entry.isFile()) {
|
|
38
|
+
onFile(abs)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Чи workspace налаштовано під Storybook coverage через vitest: тека `.storybook/`
|
|
45
|
+
* присутня **і** `@storybook/addon-vitest` декларовано в dependencies/devDependencies.
|
|
46
|
+
* @param {string} jsRoot абсолютний шлях workspace-кореня
|
|
47
|
+
* @returns {Promise<boolean>} true для Storybook-workspace з vitest-інтеграцією
|
|
48
|
+
*/
|
|
49
|
+
export async function isStorybookRoot(jsRoot) {
|
|
50
|
+
if (!existsSync(join(jsRoot, '.storybook'))) return false
|
|
51
|
+
const pkgPath = join(jsRoot, 'package.json')
|
|
52
|
+
if (!existsSync(pkgPath)) return false
|
|
53
|
+
let pkg
|
|
54
|
+
try {
|
|
55
|
+
pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
return (
|
|
60
|
+
Boolean(pkg.dependencies?.['@storybook/addon-vitest']) || Boolean(pkg.devDependencies?.['@storybook/addon-vitest'])
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Чи workspace має хоч один `*.stories.*` файл (`node_modules`/`dist`/… не скануються).
|
|
66
|
+
* @param {string} jsRoot абсолютний шлях workspace-кореня
|
|
67
|
+
* @returns {Promise<boolean>} true, якщо знайдено хоча б один сторі-файл
|
|
68
|
+
*/
|
|
69
|
+
export async function hasStories(jsRoot) {
|
|
70
|
+
let found = false
|
|
71
|
+
await walk(jsRoot, abs => {
|
|
72
|
+
if (STORIES_FILE_RE.test(abs)) found = true
|
|
73
|
+
})
|
|
74
|
+
return found
|
|
75
|
+
}
|
|
@@ -12,7 +12,7 @@ import { join, relative } from 'node:path'
|
|
|
12
12
|
import { env } from 'node:process'
|
|
13
13
|
import { resolveVitestRun } from './lib/vitest-shim.mjs'
|
|
14
14
|
|
|
15
|
-
const TEST_FILE_RE = /\.(test|spec)\.[^.]+$|(?:^|[/\\])tests?[/\\]
|
|
15
|
+
const TEST_FILE_RE = /\.(test|spec)\.[^.]+$|(?:^|[/\\])tests?[/\\]|\.stories\.[^.]+$/
|
|
16
16
|
const VITEST_UNSUPPORTED_TEST_RE = /bun:test|Cannot find package 'bun/i
|
|
17
17
|
const MAX_ERRORS_PER_FILE = 5
|
|
18
18
|
const MAX_ERROR_LINES = 10
|
package/src/docs/coverage-fix.md
CHANGED