@7n/test 0.2.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,112 @@
1
+ /**
2
+ * Public API класифікатора: classify(survived, cwd, opts) → verdicts[]
3
+ *
4
+ * Routing через pi SDK (callText):
5
+ * 1. Cache lookup → hit → використати збережений verdict.
6
+ * 2. Cache miss → Tier 1 (N_LOCAL_MIN_MODEL через pi) → parseVerdict.
7
+ * 3. Tier 1 fail → Tier 2 (N_CLOUD_MIN_MODEL через pi) → parseVerdict.
8
+ * 4. Tier 2 fail → conservative fallback worth-testing/confidence=0.
9
+ */
10
+ import { env } from 'node:process'
11
+ import { join } from 'node:path'
12
+
13
+ import { callText } from '../lib/pi-client.mjs'
14
+ import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
15
+ import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
16
+ import { parseVerdict } from './verdict-schema.mjs'
17
+
18
+ const FALLBACK_VERDICT = {
19
+ verdict: 'worth-testing',
20
+ confidence: 0,
21
+ reason: 'LLM-classification unavailable, conservative fallback (treat as worth-testing)'
22
+ }
23
+
24
+ /**
25
+ * Викликає pi через callText з опційним model-id.
26
+ * @param {string} prompt
27
+ * @param {string} model provider/model-id або '' для pi-дефолту
28
+ * @param {string} cwd
29
+ * @returns {Promise<string>}
30
+ */
31
+ function callModel(prompt, model, cwd) {
32
+ return callText(prompt, { cwd, ...(model ? { model } : {}) })
33
+ }
34
+
35
+ /**
36
+ * Два тири: N_LOCAL_MIN_MODEL → N_CLOUD_MIN_MODEL → FALLBACK_VERDICT.
37
+ * @param {{file: string, mutants: object[]}} group
38
+ * @param {object} mutant
39
+ * @param {string} cwd
40
+ * @param {(prompt: string, model: string, cwd: string) => Promise<string>} callModelFn
41
+ * @returns {Promise<object>} verdict
42
+ */
43
+ async function classifyOne(group, mutant, cwd, callModelFn) {
44
+ const prompt = `${SYSTEM_PROMPT}\n\n${buildUserPrompt({ ...mutant, file: group.file }, cwd)}`
45
+ const loc = `${group.file}:${mutant.line}:${mutant.col}`
46
+ const tier1 = env.N_LOCAL_MIN_MODEL ?? ''
47
+ const tier2 = env.N_CLOUD_MIN_MODEL ?? ''
48
+
49
+ try {
50
+ const text = await callModelFn(prompt, tier1, cwd)
51
+ return parseVerdict(text)
52
+ } catch {
53
+ try {
54
+ const text = await callModelFn(prompt, tier2, cwd)
55
+ return parseVerdict(text)
56
+ } catch (error) {
57
+ console.warn(`⚠ coverage classify: ${loc} both tiers failed: ${error.message}`)
58
+ return { ...FALLBACK_VERDICT }
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Класифікує survived мутантів через pi (N_LOCAL_MIN_MODEL → N_CLOUD_MIN_MODEL → fallback).
65
+ * @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived
66
+ * @param {string} cwd
67
+ * @param {{cachePath?: string, callModel?: (prompt: string, model: string, cwd: string) => Promise<string>}} [opts]
68
+ * @returns {Promise<Array<{key: string, verdict: object}>>}
69
+ */
70
+ export async function classify(survived, cwd, opts = {}) {
71
+ const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
72
+ const callModelFn = opts.callModel ?? callModel
73
+ const tier1 = env.N_LOCAL_MIN_MODEL ?? ''
74
+ const tier2 = env.N_CLOUD_MIN_MODEL ?? ''
75
+ const cacheModel = `${tier1 || 'default'}+${tier2 || 'cloud'}`
76
+
77
+ const cache = readCache(cachePath)
78
+ if (cache.model !== cacheModel) {
79
+ cache.entries = {}
80
+ cache.model = cacheModel
81
+ }
82
+
83
+ const verdicts = []
84
+ for (const group of survived) {
85
+ for (const mutant of group.mutants) {
86
+ const lookupKey = `${group.file}:${mutant.line}:${mutant.col}:${mutant.replacement}`
87
+ const cacheKey = deriveCacheKey(join(cwd, group.file), mutant)
88
+
89
+ let verdict = null
90
+ if (cacheKey && cache.entries[cacheKey]) {
91
+ const cached = cache.entries[cacheKey]
92
+ verdict = {
93
+ verdict: cached.verdict,
94
+ confidence: cached.confidence,
95
+ reason: cached.reason,
96
+ ...(cached.suggestedTest ? { suggestedTest: cached.suggestedTest } : {})
97
+ }
98
+ }
99
+ if (!verdict) {
100
+ verdict = await classifyOne(group, mutant, cwd, callModelFn)
101
+ if (cacheKey) {
102
+ cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
103
+ }
104
+ }
105
+
106
+ verdicts.push({ key: lookupKey, verdict })
107
+ }
108
+ }
109
+
110
+ writeCache(cachePath, cache)
111
+ return verdicts
112
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Промпт-builder для coverage-classify.
3
+ * SYSTEM_PROMPT — статичний, кешується через cache_control: ephemeral у API call.
4
+ * buildUserPrompt — асемблює per-mutant контекст (location, source ±10, tests, git).
5
+ */
6
+ import { execFileSync } from 'node:child_process'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { basename, dirname, join } from 'node:path'
9
+
10
+ const CONTEXT_LINES = 10
11
+ const TEST_FILE_MAX_LINES = 2000
12
+
13
+ export const SYSTEM_PROMPT = `You are a mutation testing classifier.
14
+
15
+ For each survived Stryker mutant, classify it into exactly one verdict:
16
+
17
+ - **worth-testing**: pure logic with real branches that should be tested. The mutant
18
+ exposes a missing assertion in a unit test. Recommend a test approach.
19
+ - **equivalent**: the mutated code is behaviorally indistinguishable from the original
20
+ (e.g., both branches produce the same observable output, or the mutant lies on dead
21
+ code). You MUST cite a concrete reason referencing input flow or output equivalence.
22
+ - **defensive**: the branch guards against an impossible state given input contracts
23
+ or type system. You MUST identify the invariant that makes the state unreachable.
24
+ - **glue**: thin CLI entrypoint, factory, or boilerplate (e.g., runStandardRule
25
+ wrapper, fix.mjs stubs). Integration tests via subprocess cover the behavior.
26
+ Name the integration test or pattern.
27
+ - **wrapper**: thin shell around an external tool (spawnSync, fetch, dynamic import).
28
+ The wrapper has no logic worth unit-testing in isolation; behavior comes from the
29
+ wrapped tool. Name the integration test or pattern.
30
+
31
+ Output ONLY a single JSON object matching this schema:
32
+
33
+ \`\`\`
34
+ {
35
+ "verdict": "worth-testing" | "equivalent" | "defensive" | "glue" | "wrapper",
36
+ "confidence": number 0-1,
37
+ "reason": string (20-500 chars; concrete code-level reference, not "seems like"),
38
+ "suggestedTest": string (max 300 chars; required only when verdict is worth-testing)
39
+ }
40
+ \`\`\`
41
+
42
+ Confidence guidance:
43
+ - 0.9+: cite specific code fragment, identifier, or input contract proving the verdict.
44
+ - 0.7-0.9: strong inference from visible code structure.
45
+ - <0.7: ambiguity, lacking context, or unfamiliar pattern. Be honest.
46
+
47
+ Never invent integration test names. If you cannot identify a covering test, use
48
+ worth-testing with low confidence instead of glue/wrapper.
49
+ `
50
+
51
+ /**
52
+ * Витягує describe/test/it title з рядка тексту.
53
+ * @param {string} content повний текст test-файла
54
+ * @returns {string} список "describe: <title>" / "test: <title>" або порожній
55
+ */
56
+ function extractTestTitles(content) {
57
+ const titles = []
58
+ for (const match of content.matchAll(/^[ \t]{0,16}(describe|test|it)\(['"`](.{1,300}?)['"`]/gmu)) {
59
+ titles.push(`${match[1]}: ${match[2]}`)
60
+ }
61
+ return titles.join('\n') || '(no describe/test blocks found)'
62
+ }
63
+
64
+ /**
65
+ * Будує користувацький промпт для класифікації одного мутанта.
66
+ * @param {{file: string, line: number, col: number, mutantType: string, original: string, replacement: string}} mutant параметри мутанта (file — відносний до cwd)
67
+ * @param {string} cwd корінь проєкту
68
+ * @returns {string} user prompt
69
+ */
70
+ export function buildUserPrompt(mutant, cwd) {
71
+ const absPath = join(cwd, mutant.file)
72
+
73
+ // Source context
74
+ let srcContext = '(source file unavailable)'
75
+ if (existsSync(absPath)) {
76
+ const lines = readFileSync(absPath, 'utf8').split('\n')
77
+ const start = Math.max(0, mutant.line - 1 - CONTEXT_LINES)
78
+ const end = Math.min(lines.length, mutant.line + CONTEXT_LINES)
79
+ srcContext = lines
80
+ .slice(start, end)
81
+ .map((l, i) => `${start + i + 1}: ${l}`)
82
+ .join('\n')
83
+ }
84
+
85
+ // Existing tests
86
+ const testPath = join(dirname(absPath), 'tests', `${basename(absPath, '.mjs')}.test.mjs`)
87
+ let existingTests = '(no test file)'
88
+ if (existsSync(testPath)) {
89
+ const content = readFileSync(testPath, 'utf8')
90
+ if (content.split('\n').length > TEST_FILE_MAX_LINES) {
91
+ existingTests = extractTestTitles(content)
92
+ } else {
93
+ existingTests = content
94
+ }
95
+ }
96
+
97
+ // Recent git activity (graceful если нет git або untracked)
98
+ let recentActivity = '(no git history)'
99
+ try {
100
+ const out = execFileSync('git', ['log', '-1', '--format=%ar', '--', absPath], {
101
+ cwd,
102
+ encoding: 'utf8',
103
+ stdio: ['ignore', 'pipe', 'ignore']
104
+ }).trim()
105
+ if (out) recentActivity = out
106
+ } catch {
107
+ // git unavailable or file untracked — keep placeholder
108
+ }
109
+
110
+ return `# Mutant
111
+ File: ${mutant.file}
112
+ Line: ${mutant.line}:${mutant.col}
113
+ Type: ${mutant.mutantType}
114
+ Original code: \`${mutant.original}\`
115
+ Mutated to: \`${mutant.replacement}\`
116
+
117
+ # Source context (±${CONTEXT_LINES} lines)
118
+ ${srcContext}
119
+
120
+ # Existing tests
121
+ ${existingTests}
122
+
123
+ # Recent activity
124
+ File last modified: ${recentActivity}
125
+ `
126
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Zod-схема для verdict-відповіді LLM-класифікатора (coverage-classify).
3
+ * parseVerdict — витяг JSON з raw-text LLM-відповіді + validate.
4
+ *
5
+ * Категорії:
6
+ * - worth-testing: pure logic, real branches — пиши тест
7
+ * - equivalent: мутант поведінково еквівалентний (не killable)
8
+ * - defensive: гілка для impossible state (не killable)
9
+ * - glue: CLI entry / runStandardRule wrapper (integration covers)
10
+ * - wrapper: тонкий spawn/fetch wrapper (integration covers)
11
+ */
12
+ import { z } from 'zod'
13
+
14
+ export const VerdictSchema = z.object({
15
+ verdict: z.enum(['worth-testing', 'equivalent', 'defensive', 'glue', 'wrapper']),
16
+ confidence: z.number().min(0).max(1),
17
+ reason: z.string().min(20).max(500),
18
+ suggestedTest: z.string().max(300).optional()
19
+ })
20
+
21
+ /**
22
+ * Витягує JSON-об'єкт з raw-text LLM-відповіді і валідує через VerdictSchema.
23
+ * @param {string} rawText raw-text відповідь LLM
24
+ * @returns {{verdict: string, confidence: number, reason: string, suggestedTest?: string}} verdict
25
+ * @throws {Error} якщо JSON не знайдено, не парситься, або не відповідає схемі
26
+ */
27
+ export function parseVerdict(rawText) {
28
+ const jsonStart = rawText.indexOf('{')
29
+ const jsonEnd = rawText.lastIndexOf('}')
30
+ if (jsonStart === -1 || jsonEnd === -1) {
31
+ throw new Error('No JSON object found in LLM response')
32
+ }
33
+ const json = JSON.parse(rawText.slice(jsonStart, jsonEnd + 1))
34
+ return VerdictSchema.parse(json)
35
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `n-cursor coverage-fix index|slice` — read-only витяг вцілілих мутантів із
3
+ * `COVERAGE.md` для скілу `n-coverage-fix`.
4
+ *
5
+ * Мотивація: `COVERAGE.md` може важити мегабайти (секція `## Вцілілі мутанти`
6
+ * з JSON-блоком на сотні файлів). Якщо цей файл читає LLM-оркестратор, він
7
+ * спалює сотні тисяч токенів лише на парсинг. Натомість важкий парсинг несе цей
8
+ * скрипт (для JS — мілісекунди, 0 токенів), а агенту віддається рівно потрібна
9
+ * порція:
10
+ * - `index` — крихітний `[{file, mutants}]` для рішення про фан-аут;
11
+ * - `slice --file <path>` — промпт лише для одного файлу (контекст ±3 рядки),
12
+ * рівно під когнітивне навантаження одного субагента.
13
+ *
14
+ * Команда read-only: лише парсить наявний `COVERAGE.md`, нічого не мутує і не
15
+ * перезапускає Stryker (тож не входить у root-guard).
16
+ */
17
+ import { readFile } from 'node:fs/promises'
18
+ import { join } from 'node:path'
19
+
20
+ import { buildFixPrompt } from './coverage-fix.mjs'
21
+
22
+ /** Заголовок секції вцілілих мутантів у COVERAGE.md (контракт із renderMarkdown). */
23
+ const SURVIVED_SECTION = '## Вцілілі мутанти'
24
+
25
+ /**
26
+ * Огорожа json-блоку: ≥3 бектики, далі `json` і решта рядка до `\n`. Довжина
27
+ * захоплюється в групу 1 — renderMarkdown пише 3, але oxfmt підвищує до 4+, коли
28
+ * сам JSON-вміст містить ``` (типово для original/replacement мутантів).
29
+ */
30
+ const FENCE_OPEN_RE = /(`{3,8})json[^\n]{0,200}\n/
31
+
32
+ /**
33
+ * Витягує JSON-масив вцілілих мутантів із тексту COVERAGE.md: знаходить секцію
34
+ * `## Вцілілі мутанти`, перший огороджений ` ```json ` блок під нею і парсить.
35
+ * @param {string} md повний текст COVERAGE.md
36
+ * @returns {import('./coverage-fix.mjs').SurvivedFileGroup[]} групи вцілілих по файлах (порожньо, якщо секції/блоку немає або JSON невалідний)
37
+ */
38
+ export function parseSurvivedBlock(md) {
39
+ const sectionAt = md.indexOf(SURVIVED_SECTION)
40
+ if (sectionAt === -1) return []
41
+ const after = md.slice(sectionAt)
42
+ const open = after.match(FENCE_OPEN_RE)
43
+ if (!open) return []
44
+ const fence = open[1]
45
+ const bodyStart = open.index + open[0].length
46
+ const rest = after.slice(bodyStart)
47
+ // Закриття — рядок із тих самих бектиків. Усередині JSON реальних переводів
48
+ // рядка немає (JSON.stringify екранує їх як `\n`), тож `\n<fence>` унікально
49
+ // позначає кінець блоку навіть якщо значення містять бектики.
50
+ const closeAt = rest.indexOf(`\n${fence}`)
51
+ const json = closeAt === -1 ? rest : rest.slice(0, closeAt)
52
+ try {
53
+ const parsed = JSON.parse(json)
54
+ return Array.isArray(parsed) ? parsed : []
55
+ } catch {
56
+ return []
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Читає `COVERAGE.md` із кореня проєкту і повертає структуровані групи вцілілих.
62
+ * @param {string} cwd корінь проєкту
63
+ * @returns {Promise<import('./coverage-fix.mjs').SurvivedFileGroup[]>} групи вцілілих по файлах
64
+ */
65
+ export async function readSurvived(cwd) {
66
+ let md
67
+ try {
68
+ md = await readFile(join(cwd, 'COVERAGE.md'), 'utf8')
69
+ } catch {
70
+ return []
71
+ }
72
+ return parseSurvivedBlock(md)
73
+ }
74
+
75
+ /**
76
+ * Згортає групи вцілілих у компактний index `[{file, mutants}]`.
77
+ * @param {import('./coverage-fix.mjs').SurvivedFileGroup[]} survived групи вцілілих
78
+ * @returns {Array<{file:string, mutants:number}>} файл → кількість вцілілих мутантів
79
+ */
80
+ export function buildIndex(survived) {
81
+ return survived
82
+ .filter(group => group && typeof group.file === 'string' && Array.isArray(group.mutants))
83
+ .map(group => ({ file: group.file, mutants: group.mutants.length }))
84
+ }
85
+
86
+ const USAGE = 'Usage: n-cursor coverage-fix <index | slice --file <path>>'
87
+
88
+ /**
89
+ * CLI: `index` друкує компактний JSON-масив, `slice --file <path>` — промпт для
90
+ * одного файлу. Обидва read-only (читають лише COVERAGE.md).
91
+ * @param {string[]} args аргументи після `coverage-fix`
92
+ * @param {string} [cwd] корінь проєкту (ін'єкція для тестів)
93
+ * @returns {Promise<number>} exit code
94
+ */
95
+ export async function runCoverageFixCli(args, cwd = process.cwd()) {
96
+ const sub = args[0]
97
+ const survived = await readSurvived(cwd)
98
+
99
+ if (sub === 'index') {
100
+ process.stdout.write(`${JSON.stringify(buildIndex(survived))}\n`)
101
+ return 0
102
+ }
103
+
104
+ if (sub === 'slice') {
105
+ const flagAt = args.indexOf('--file')
106
+ const file = flagAt === -1 ? undefined : args[flagAt + 1]
107
+ if (!file) {
108
+ console.error(USAGE)
109
+ return 1
110
+ }
111
+ const group = survived.find(g => g && g.file === file)
112
+ if (!group) {
113
+ console.error(`✗ Файл не знайдено серед вцілілих мутантів: ${file}`)
114
+ return 1
115
+ }
116
+ process.stdout.write(`${await buildFixPrompt([group], cwd)}\n`)
117
+ return 0
118
+ }
119
+
120
+ console.error(USAGE)
121
+ return 1
122
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `n coverage --fix`: запускає pi-агента для написання тестів
3
+ * по вцілілих мутантах Stryker. Агент отримує список мутантів з контекстом
4
+ * (file, line, оригінальний код, вцілілий варіант, тип мутації) і самостійно
5
+ * знаходить або створює відповідні test-файли.
6
+ *
7
+ * Модель: CLOUD_MAX (складна агентна задача) або N_CURSOR_COVERAGE_FIX_MODEL.
8
+ */
9
+ import { readFile } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+ import { spawnSync } from 'node:child_process'
12
+ import { env } from 'node:process'
13
+
14
+ const MODEL = env.N_CURSOR_COVERAGE_FIX_MODEL ?? env.N_CLOUD_MAX_MODEL ?? ''
15
+
16
+ /**
17
+ * @typedef {{line:number, col:number, mutantType:string, original:string, replacement:string}} MutantDetail
18
+ * @typedef {{file:string, mutants:MutantDetail[], exampleTest:{testFile:string,code:string|null}|null, recommendationText:string|null}} SurvivedFileGroup
19
+ */
20
+
21
+ /**
22
+ * Запускає pi-агента для написання тестів по вцілілих мутантах.
23
+ * @param {SurvivedFileGroup[]} survived вцілілі мутанти, згруповані по файлах
24
+ * @param {string} projectRoot абсолютний шлях до кореня проєкту
25
+ * @param {{ callPi?: (prompt: string, model: string, opts: { cwd: string }) => void }} [opts] ін'єкції для тестів
26
+ * @returns {Promise<void>}
27
+ */
28
+ export async function fixSurvivedMutants(survived, projectRoot, opts = {}) {
29
+ const totalMutants = survived.reduce((s, g) => s + g.mutants.length, 0)
30
+ if (totalMutants === 0) {
31
+ console.log('✓ Всі мутанти вбиті — доповнення тестів не потрібне')
32
+ return
33
+ }
34
+
35
+ const prompt = await buildFixPrompt(survived, projectRoot)
36
+ console.log(`\n🤖 coverage --fix: запускаю агента для ${totalMutants} вцілілих мутантів...\n`)
37
+
38
+ const callPiFn = opts.callPi ?? callPi
39
+ callPiFn(prompt, MODEL, { cwd: projectRoot })
40
+ }
41
+
42
+ /**
43
+ * Викликає pi в агентному режимі з live-output до stdout.
44
+ * @param {string} prompt текст промпта
45
+ * @param {string} model provider/model-id або '' для pi-дефолту
46
+ * @param {{ cwd?: string }} [piOpts] опційні параметри (cwd)
47
+ */
48
+ function callPi(prompt, model, { cwd } = {}) {
49
+ const modelArgs = model ? ['--model', model] : []
50
+ spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session'], {
51
+ cwd,
52
+ stdio: 'inherit',
53
+ timeout: 900_000
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Формує rich-промпт для агента: список вцілілих мутантів згрупований по файлах,
59
+ * з контекстом ±3 рядки навколо кожного мутанта з source-файлу.
60
+ * @param {SurvivedFileGroup[]} survived групи вцілілих мутантів по файлах
61
+ * @param {string} projectRoot корінь проєкту
62
+ * @returns {Promise<string>} текст rich-промпту
63
+ */
64
+ export async function buildFixPrompt(survived, projectRoot) {
65
+ const sections = []
66
+
67
+ for (const { file, mutants, exampleTest } of survived) {
68
+ let srcLines = []
69
+ try {
70
+ const src = await readFile(join(projectRoot, file), 'utf8')
71
+ srcLines = src.split('\n')
72
+ } catch {
73
+ // файл може бути недоступним — пропускаємо контекст, але продовжуємо
74
+ }
75
+
76
+ const mutantDescriptions = mutants
77
+ .map(m => {
78
+ const ctxStart = Math.max(0, m.line - 4)
79
+ const ctxEnd = Math.min(srcLines.length, m.line + 3)
80
+ const context = srcLines
81
+ .slice(ctxStart, ctxEnd)
82
+ .map((l, i) => `${ctxStart + i + 1}: ${l}`)
83
+ .join('\n')
84
+ return [
85
+ ` - Рядок ${m.line}, колонка ${m.col}, тип мутації \`${m.mutantType}\``,
86
+ ` Оригінал: \`${m.original}\``,
87
+ ` Вижив варіант: \`${m.replacement}\``,
88
+ context ? ` Контекст:\n\`\`\`\n${context}\n\`\`\`` : ''
89
+ ]
90
+ .filter(Boolean)
91
+ .join('\n')
92
+ })
93
+ .join('\n')
94
+
95
+ const exampleSection = exampleTest?.code
96
+ ? `\n\nПриклад тесту з \`${exampleTest.testFile}\`:\n\`\`\`js\n${exampleTest.code}\n\`\`\``
97
+ : ''
98
+
99
+ sections.push(`### \`${file}\`${exampleSection}\n${mutantDescriptions}`)
100
+ }
101
+
102
+ return [
103
+ 'Твоє завдання — написати unit-тести, що вбивають наступні вцілілі мутанти Stryker.',
104
+ 'Для кожного мутанта: знайди або створи відповідний test-файл, додай тест-кейс,',
105
+ 'що явно перевіряє цю гілку/умову і провалиться якщо код замінити на "вцілілий варіант".',
106
+ '',
107
+ '## Вцілілі мутанти',
108
+ '',
109
+ ...sections,
110
+ '',
111
+ '## Правила',
112
+ '- Не змінюй source-файли — лише test-файли.',
113
+ '- Використовуй той самий test-фреймворк, що вже в проєкті.',
114
+ '- Запусти `bun test` (або відповідну команду) після кожного файлу — переконайся, що 0 fail.',
115
+ '- Якщо мутант охоплений іншим новим тестом — не дублюй.'
116
+ ].join('\n')
117
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Per-file coverage via vitest + lcov.
3
+ * Runs `bunx vitest run --coverage --coverage.reporter=lcov` and parses lcov.info
4
+ * per source file. Returns relative paths (to dir) with line coverage %.
5
+ */
6
+ import { spawnSync } from 'node:child_process'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { mkdtemp, rm } from 'node:fs/promises'
9
+ import { tmpdir } from 'node:os'
10
+ import { join, relative } from 'node:path'
11
+ import { env } from 'node:process'
12
+
13
+ const TEST_FILE_RE = /\.(test|spec)\.[^.]+$|[/\\]tests?[/\\]/
14
+
15
+ /**
16
+ * @param {string} text lcov.info content
17
+ * @returns {Array<{file: string, pct: number, linesFound: number, linesCovered: number}>}
18
+ */
19
+ function parseLcovPerFile(text) {
20
+ const files = []
21
+ let currentFile = null
22
+ let lf = 0
23
+ let lh = 0
24
+ for (const line of text.split('\n')) {
25
+ if (line.startsWith('SF:')) {
26
+ currentFile = line.slice(3).trim()
27
+ lf = 0
28
+ lh = 0
29
+ } else if (line.startsWith('LF:')) {
30
+ lf = Number(line.slice(3))
31
+ } else if (line.startsWith('LH:')) {
32
+ lh = Number(line.slice(3))
33
+ } else if (line === 'end_of_record' && currentFile) {
34
+ files.push({
35
+ file: currentFile,
36
+ pct: lf === 0 ? 100 : Math.round((lh / lf) * 10000) / 100,
37
+ linesFound: lf,
38
+ linesCovered: lh
39
+ })
40
+ currentFile = null
41
+ }
42
+ }
43
+ return files
44
+ }
45
+
46
+ /**
47
+ * Runs vitest coverage and returns per-file line coverage data.
48
+ * @param {string} dir project root
49
+ * @returns {Promise<Array<{file: string, pct: number, linesFound: number, linesCovered: number}>>}
50
+ */
51
+ export async function measureCoveragePerFile(dir) {
52
+ const lcovDir = await mkdtemp(join(tmpdir(), '7n-cov-'))
53
+ try {
54
+ spawnSync(
55
+ 'bunx',
56
+ [
57
+ 'vitest',
58
+ 'run',
59
+ '--passWithNoTests',
60
+ '--coverage',
61
+ '--coverage.reporter=lcov',
62
+ `--coverage.reportsDirectory=${lcovDir}`
63
+ ],
64
+ { cwd: dir, stdio: 'inherit', env }
65
+ )
66
+ const lcovPath = join(lcovDir, 'lcov.info')
67
+ if (!existsSync(lcovPath)) return []
68
+ const allFiles = parseLcovPerFile(readFileSync(lcovPath, 'utf8'))
69
+ return allFiles
70
+ .map(f => ({ ...f, file: relative(dir, f.file) }))
71
+ .filter(f => !f.file.startsWith('..') && !TEST_FILE_RE.test(f.file))
72
+ } finally {
73
+ await rm(lcovDir, { recursive: true, force: true }).catch(() => {})
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Files below the coverage threshold.
79
+ * @param {Array<{file: string, pct: number}>} files
80
+ * @param {number} [threshold=80]
81
+ * @returns {Array<{file: string, pct: number}>}
82
+ */
83
+ export function getUncoveredFiles(files, threshold = 80) {
84
+ return files.filter(f => f.pct < threshold)
85
+ }