@7n/test 0.14.5 → 0.16.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.
@@ -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
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: coverage-fix.mjs
4
4
  resource: npm/src/coverage-fix.mjs
5
5
  docgen:
6
- crc: db577cf1
6
+ crc: eefeb364
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: coverage-per-file.mjs
4
4
  resource: npm/src/coverage-per-file.mjs
5
5
  docgen:
6
- crc: 388282cc
6
+ crc: 133b47a7
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98