@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.
- package/CHANGELOG.md +7 -0
- package/README.md +3 -0
- package/bin/n.js +4 -0
- package/package.json +49 -0
- package/src/assess-need.mjs +76 -0
- package/src/coverage/coverage.mjs +315 -0
- package/src/coverage-classify/apply.mjs +67 -0
- package/src/coverage-classify/cache.mjs +77 -0
- package/src/coverage-classify/index.mjs +112 -0
- package/src/coverage-classify/prompt.mjs +126 -0
- package/src/coverage-classify/verdict-schema.mjs +35 -0
- package/src/coverage-fix-extract.mjs +122 -0
- package/src/coverage-fix.mjs +117 -0
- package/src/coverage-per-file.mjs +85 -0
- package/src/gen-tests.mjs +143 -0
- package/src/index.js +21 -0
- package/src/lib/pi-client.mjs +61 -0
- package/src/run.mjs +88 -0
- package/src/scripts/lib/changed-files.mjs +77 -0
- package/src/scripts/lib/read-n-cursor-config-lite.mjs +59 -0
- package/src/scripts/utils/lock-cache-dir.mjs +37 -0
- package/src/scripts/utils/with-lock.mjs +149 -0
- package/src/scripts/utils/worktree-fingerprint.mjs +33 -0
|
@@ -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
|
+
}
|