@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0] - 2026-06-26
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Міграція на чистий pi SDK: видалено прямі HTTP-виклики oMLX (`omlx.mjs`, `llm.mjs`, `models.mjs`, `omlx-trace.mjs`), весь LLM-трафік тепер іде через `@earendil-works/pi-coding-agent` via `callText` у `pi-client.mjs`. Додано опційний параметр `model` до `callText`. Моделі tier1/tier2 беруться з `N_LOCAL_MIN_MODEL`/`N_CLOUD_MIN_MODEL`.
|
package/README.md
ADDED
package/bin/n.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@7n/test",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "CLI-утиліта @7n/test",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"7n",
|
|
7
|
+
"bun",
|
|
8
|
+
"cli"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/nitra/7n-test#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/nitra/7n-test/issues"
|
|
13
|
+
},
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"author": "vitaliytv@nitralabs.com",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/nitra/7n-test.git"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"n": "bin/n.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"src",
|
|
26
|
+
"!src/**/*.test.mjs",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"README.md",
|
|
29
|
+
"types"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"main": "./src/index.js",
|
|
33
|
+
"types": "./types/index.d.ts",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"start": "bun ./bin/n.js",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "^0.80.2",
|
|
43
|
+
"zod": "^3.23.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"bun": ">=1.3",
|
|
47
|
+
"node": ">=24"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM assessment via pi SDK: does an uncovered file actually need unit tests?
|
|
3
|
+
* Uses callText() (no tools) for structured JSON replies.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { callText } from './lib/pi-client.mjs'
|
|
8
|
+
|
|
9
|
+
const MAX_CONTENT_BYTES = 6000
|
|
10
|
+
|
|
11
|
+
const SYSTEM_PROMPT = `You are a test-need classifier for JS/TS source files.
|
|
12
|
+
|
|
13
|
+
Given a source file with low test coverage, decide if unit tests are worthwhile.
|
|
14
|
+
|
|
15
|
+
Reply ONLY with a JSON object (no markdown fence):
|
|
16
|
+
{"needsTests": true|false, "reason": "one sentence in Ukrainian"}
|
|
17
|
+
|
|
18
|
+
needsTests: false when:
|
|
19
|
+
- File only contains types, interfaces, constants, or re-exports with no logic
|
|
20
|
+
- Thin config or index file that just wires up other modules
|
|
21
|
+
- Behavior is fully covered by integration/e2e tests (name them)
|
|
22
|
+
|
|
23
|
+
needsTests: true when:
|
|
24
|
+
- File contains utility functions, parsers, transformers with branches
|
|
25
|
+
- Business logic with conditions or non-trivial contracts
|
|
26
|
+
- Pure functions that can be unit-tested cheaply`
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{file: string, pct: number}} fileInfo
|
|
30
|
+
* @param {string} dir project root
|
|
31
|
+
* @param {Function} [callTextFn] injectable for tests
|
|
32
|
+
* @returns {Promise<{file: string, pct: number, needsTests: boolean, reason: string}>}
|
|
33
|
+
*/
|
|
34
|
+
async function assessOne(fileInfo, dir, callTextFn) {
|
|
35
|
+
const absPath = join(dir, fileInfo.file)
|
|
36
|
+
if (!existsSync(absPath)) return { ...fileInfo, needsTests: false, reason: 'файл недоступний' }
|
|
37
|
+
|
|
38
|
+
let content = readFileSync(absPath, 'utf8')
|
|
39
|
+
if (content.length > MAX_CONTENT_BYTES) content = content.slice(0, MAX_CONTENT_BYTES) + '\n...(truncated)'
|
|
40
|
+
|
|
41
|
+
const prompt =
|
|
42
|
+
`${SYSTEM_PROMPT}\n\n` +
|
|
43
|
+
`## File: ${fileInfo.file} (current coverage: ${fileInfo.pct.toFixed(1)}%)\n\n` +
|
|
44
|
+
`\`\`\`\n${content}\n\`\`\``
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const text = await callTextFn(prompt, { cwd: dir })
|
|
48
|
+
const match = text.match(/\{[\s\S]*?"needsTests"[\s\S]*?\}/)
|
|
49
|
+
const parsed = JSON.parse(match?.[0] ?? '{}')
|
|
50
|
+
return {
|
|
51
|
+
file: fileInfo.file,
|
|
52
|
+
pct: fileInfo.pct,
|
|
53
|
+
needsTests: parsed.needsTests !== false,
|
|
54
|
+
reason: typeof parsed.reason === 'string' ? parsed.reason : ''
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
return {
|
|
58
|
+
file: fileInfo.file,
|
|
59
|
+
pct: fileInfo.pct,
|
|
60
|
+
needsTests: true,
|
|
61
|
+
reason: 'оцінка не вдалась — вважаємо що потрібні тести'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Assess a list of uncovered files: do they need tests?
|
|
68
|
+
* @param {Array<{file: string, pct: number}>} files
|
|
69
|
+
* @param {string} dir project root
|
|
70
|
+
* @param {{ callText?: Function }} [opts]
|
|
71
|
+
* @returns {Promise<Array<{file: string, pct: number, needsTests: boolean, reason: string}>>}
|
|
72
|
+
*/
|
|
73
|
+
export async function assessNeed(files, dir, opts = {}) {
|
|
74
|
+
const callTextFn = opts.callText ?? callText
|
|
75
|
+
return Promise.all(files.map(f => assessOne(f, dir, callTextFn)))
|
|
76
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Канонічна команда `n coverage`: збирає метрики покриття + мутаційного
|
|
3
|
+
* тестування з усіх провайдерів, чиє правило активне в `.n-cursor.json#rules`,
|
|
4
|
+
* агрегує та записує COVERAGE.md у корінь проєкту.
|
|
5
|
+
*
|
|
6
|
+
* Discovery провайдерів — за `.n-cursor.json#rules`: для кожного `ruleId` зі
|
|
7
|
+
* списку шукаємо `<rulesDir>/<ruleId>/coverage/coverage.mjs` і динамічно
|
|
8
|
+
* імпортуємо. Якщо файлу немає — провайдер для цього правила відсутній (skip
|
|
9
|
+
* silently, не помилка).
|
|
10
|
+
*
|
|
11
|
+
* `rulesDir` резолвиться з `node_modules/@nitra/cursor/rules` у cwd проєкту.
|
|
12
|
+
* Override — через `opts.rulesDir` у `runCoverageSteps`.
|
|
13
|
+
*
|
|
14
|
+
* Лок — прямий виклик `withLock('coverage', steps)`.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { pathToFileURL } from 'node:url'
|
|
20
|
+
|
|
21
|
+
import { applyVerdicts } from '../coverage-classify/apply.mjs'
|
|
22
|
+
import { classify } from '../coverage-classify/index.mjs'
|
|
23
|
+
import { collectChangedFilesSince, resolveChangedBase } from '../scripts/lib/changed-files.mjs'
|
|
24
|
+
import { readNCursorConfigLite } from '../scripts/lib/read-n-cursor-config-lite.mjs'
|
|
25
|
+
import { withLock } from '../scripts/utils/with-lock.mjs'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Знаходить rulesDir @nitra/cursor у node_modules проєкту.
|
|
29
|
+
* @param {string} cwd корінь проєкту
|
|
30
|
+
* @returns {string|null} абсолютний шлях до rules/ або null
|
|
31
|
+
*/
|
|
32
|
+
function resolveDefaultRulesDir(cwd) {
|
|
33
|
+
const localPath = join(cwd, 'node_modules', '@nitra', 'cursor', 'rules')
|
|
34
|
+
if (existsSync(localPath)) return localPath
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Сума двох coverage-totals.
|
|
40
|
+
* @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший subtotal
|
|
41
|
+
* @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий subtotal
|
|
42
|
+
* @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сумарні lines/functions
|
|
43
|
+
*/
|
|
44
|
+
export function addCoverage(a, b) {
|
|
45
|
+
return {
|
|
46
|
+
lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
|
|
47
|
+
functions: {
|
|
48
|
+
covered: a.functions.covered + b.functions.covered,
|
|
49
|
+
total: a.functions.total + b.functions.total
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Сума двох mutation-counts.
|
|
56
|
+
* @param {{caught:number,total:number}} a перший subtotal
|
|
57
|
+
* @param {{caught:number,total:number}} b другий subtotal
|
|
58
|
+
* @returns {{caught:number,total:number}} сумарні caught/total
|
|
59
|
+
*/
|
|
60
|
+
export function addMutation(a, b) {
|
|
61
|
+
return { caught: a.caught + b.caught, total: a.total + b.total }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Форматує covered/total як `XX.XX% (covered/total)`.
|
|
66
|
+
* @param {{covered:number,total:number}} metric метрика lines або functions
|
|
67
|
+
* @returns {string} відформатований рядок для таблиці COVERAGE.md
|
|
68
|
+
*/
|
|
69
|
+
export function formatCoverage({ covered, total }) {
|
|
70
|
+
const percent = total === 0 ? '—' : `${((covered / total) * 100).toFixed(2)}%`
|
|
71
|
+
return `${percent} (${covered}/${total})`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Форматує мутаційний score як `XX.XX%`.
|
|
76
|
+
* @param {{caught:number,total:number}} metric агрегований mutation score
|
|
77
|
+
* @returns {string} відформатований score або прочерк
|
|
78
|
+
*/
|
|
79
|
+
export function formatScore({ caught, total }) {
|
|
80
|
+
return total === 0 ? '—' : `${((caught / total) * 100).toFixed(2)}%`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Рендерить таблицю покриття + мутаційного тестування як Markdown.
|
|
85
|
+
* Якщо будь-який рядок містить непустий `survived`, додає секцію
|
|
86
|
+
* `## Вцілілі мутанти` з JSON-блоком для coverage-fix.
|
|
87
|
+
* Якщо `allowedGaps` непустий, додає секцію `## Allowed gaps` з таблицею
|
|
88
|
+
* verdict/confidence/reason для кожного LLM-класифікованого мутанта.
|
|
89
|
+
* Без timestamp, щоб git diff рухався лише при зміні метрик.
|
|
90
|
+
* @param {Array<{area:string, coverage:{lines:{covered:number,total:number},functions:{covered:number,total:number}}, mutation:{caught:number,total:number}, survived?: Array<{file:string,line:number,col:number,mutantType:string,original:string,replacement:string}>}>} rows рядки провайдерів
|
|
91
|
+
* @param {Array<{file:string, mutant:{line:number,col:number,mutantType:string,original:string,replacement:string}, verdict:{verdict:string,confidence:number,reason:string}}>} [allowedGaps] мутанти виключені класифікатором
|
|
92
|
+
* @returns {string} Markdown з заголовком `# Coverage`
|
|
93
|
+
*/
|
|
94
|
+
export function renderMarkdown(rows, allowedGaps = []) {
|
|
95
|
+
const lines = [
|
|
96
|
+
'# Coverage',
|
|
97
|
+
'',
|
|
98
|
+
'| Область | Рядки | Функції | Вбито мутацій | Score |',
|
|
99
|
+
'| --- | --- | --- | --- | --- |'
|
|
100
|
+
]
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
lines.push(
|
|
103
|
+
`| ${row.area} | ${formatCoverage(row.coverage.lines)} | ${formatCoverage(row.coverage.functions)} | ` +
|
|
104
|
+
`${row.mutation.caught}/${row.mutation.total} | ${formatScore(row.mutation)} |`
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const allSurvived = rows.flatMap(r => r.survived ?? [])
|
|
109
|
+
if (allSurvived.length > 0) {
|
|
110
|
+
lines.push('', '## Вцілілі мутанти', '', '```json', JSON.stringify(allSurvived, null, 2), '```')
|
|
111
|
+
// Зрозуміла для людини таблиця
|
|
112
|
+
for (const group of allSurvived) {
|
|
113
|
+
lines.push('', `### ${group.file}`, '', '| Рядок | Оригінал | Заміна | Тип |', '| --- | --- | --- | --- |')
|
|
114
|
+
for (const m of group.mutants) {
|
|
115
|
+
lines.push(`| ${m.line} | \`${m.original}\` | \`${m.replacement}\` | ${m.mutantType} |`)
|
|
116
|
+
}
|
|
117
|
+
if (group.exampleTest) {
|
|
118
|
+
lines.push(
|
|
119
|
+
'',
|
|
120
|
+
`**Приклад тесту** (\`${group.exampleTest.testFile}\`):`,
|
|
121
|
+
'',
|
|
122
|
+
'```js',
|
|
123
|
+
group.exampleTest.code ?? '',
|
|
124
|
+
'```'
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
if (group.recommendationText) {
|
|
128
|
+
lines.push('', '**Що треба протестувати:**', '', group.recommendationText)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (allowedGaps.length > 0) {
|
|
134
|
+
// Group allowed gaps by file
|
|
135
|
+
const gapsByFile = new Map()
|
|
136
|
+
for (const gap of allowedGaps) {
|
|
137
|
+
if (!gapsByFile.has(gap.file)) gapsByFile.set(gap.file, [])
|
|
138
|
+
gapsByFile.get(gap.file).push(gap)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
lines.push(
|
|
142
|
+
'',
|
|
143
|
+
'## Allowed gaps',
|
|
144
|
+
'',
|
|
145
|
+
`> LLM-класифікатор виключив ${allowedGaps.length} survived мутант(ів) зі знаменника mutation score.`,
|
|
146
|
+
'> Категорії: equivalent (поведінково еквівалентний), defensive (impossible state), glue/wrapper (integration test покриває).'
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
for (const [file, gaps] of gapsByFile) {
|
|
150
|
+
lines.push(
|
|
151
|
+
'',
|
|
152
|
+
`### ${file}`,
|
|
153
|
+
'',
|
|
154
|
+
'| Line | Mutant | Verdict | Confidence | Reason |',
|
|
155
|
+
'| --- | --- | --- | --- | --- |'
|
|
156
|
+
)
|
|
157
|
+
for (const { mutant, verdict } of gaps) {
|
|
158
|
+
const sanitizedReason = verdict.reason.replaceAll('|', String.raw`\|`).replaceAll('\n', ' ')
|
|
159
|
+
lines.push(
|
|
160
|
+
`| ${mutant.line} | \`${mutant.original}\` → \`${mutant.replacement}\` | ${verdict.verdict} | ${verdict.confidence.toFixed(2)} | ${sanitizedReason} |`
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return `${lines.join('\n')}\n`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Завантажує provider-модуль з `<rulesDir>/<ruleId>/coverage/coverage.mjs`.
|
|
171
|
+
* Повертає null коли:
|
|
172
|
+
* - файлу немає (rule без coverage-провайдера),
|
|
173
|
+
* - файл існує, але не експортує `detect` + `collect` як функції.
|
|
174
|
+
* @param {string} rulesDir корінь rules/ (з @nitra/cursor або override)
|
|
175
|
+
* @param {string} ruleId id правила з `.n-cursor.json#rules`
|
|
176
|
+
* @returns {Promise<{detect:(cwd:string)=>Promise<boolean>, collect:(cwd:string)=>Promise<Array<object>>}|null>} provider-модуль або null
|
|
177
|
+
*/
|
|
178
|
+
async function loadProvider(rulesDir, ruleId) {
|
|
179
|
+
const providerPath = join(rulesDir, ruleId, 'coverage', 'coverage.mjs')
|
|
180
|
+
if (!existsSync(providerPath)) return null
|
|
181
|
+
const mod = await import(pathToFileURL(providerPath).href)
|
|
182
|
+
if (typeof mod.detect !== 'function' || typeof mod.collect !== 'function') return null
|
|
183
|
+
return mod
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Будує підсумковий рядок «Разом» через сумування всіх coverage/mutation.
|
|
188
|
+
* @param {Array<{area:string, coverage:object, mutation:object}>} rows рядки провайдерів без totals
|
|
189
|
+
* @returns {{area:string, coverage:object, mutation:{caught:number,total:number}}} агрегований рядок «Разом»
|
|
190
|
+
*/
|
|
191
|
+
function buildTotalsRow(rows) {
|
|
192
|
+
let totalCoverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
193
|
+
let totalMutation = { caught: 0, total: 0 }
|
|
194
|
+
for (const row of rows) {
|
|
195
|
+
totalCoverage = addCoverage(totalCoverage, row.coverage)
|
|
196
|
+
totalMutation = addMutation(totalMutation, row.mutation)
|
|
197
|
+
}
|
|
198
|
+
return { area: '**Разом**', coverage: totalCoverage, mutation: totalMutation }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Читає `.n-cursor.json#coverage.classifyConfidenceThreshold` (default 1.1 — rollout mode).
|
|
203
|
+
* @param {string} cwd корінь проєкту
|
|
204
|
+
* @returns {Promise<number>} threshold у [0, 1.1]
|
|
205
|
+
*/
|
|
206
|
+
async function readClassifyThreshold(cwd) {
|
|
207
|
+
try {
|
|
208
|
+
const raw = await readFile(join(cwd, '.n-cursor.json'), 'utf8')
|
|
209
|
+
const parsed = JSON.parse(raw)
|
|
210
|
+
const t = parsed?.coverage?.classifyConfidenceThreshold
|
|
211
|
+
return typeof t === 'number' && Number.isFinite(t) ? t : 1.1
|
|
212
|
+
} catch {
|
|
213
|
+
return 1.1
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Резолвить scope змінених файлів для `--changed`-режиму.
|
|
219
|
+
* @param {string} cwd корінь проєкту
|
|
220
|
+
* @returns {{base: string|null, files: string[]}} base-ref і relative-posix список змінених файлів
|
|
221
|
+
*/
|
|
222
|
+
export function resolveChangedScope(cwd) {
|
|
223
|
+
const base = resolveChangedBase(cwd)
|
|
224
|
+
return { base, files: collectChangedFilesSince(base, cwd) }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
|
|
229
|
+
* detect+collect для кожного, агрегація, запис COVERAGE.md.
|
|
230
|
+
* @param {{cwd?:string, rulesDir?:string, fix?:boolean, changed?:boolean}} [opts]
|
|
231
|
+
* @returns {Promise<number>} exit code (0 OK, 1 помилка)
|
|
232
|
+
*/
|
|
233
|
+
export async function runCoverageSteps(opts = {}) {
|
|
234
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
235
|
+
const rulesDir = opts.rulesDir ?? resolveDefaultRulesDir(cwd)
|
|
236
|
+
|
|
237
|
+
if (!rulesDir) {
|
|
238
|
+
console.error(
|
|
239
|
+
'✗ Не знайдено @nitra/cursor у node_modules. ' +
|
|
240
|
+
'Встанови @nitra/cursor як devDependency або передай rulesDir через opts.'
|
|
241
|
+
)
|
|
242
|
+
return 1
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const config = await readNCursorConfigLite(cwd)
|
|
246
|
+
const scope = opts.changed ? resolveChangedScope(cwd) : null
|
|
247
|
+
const collectOpts = scope ? { changedFiles: scope.files, base: scope.base } : {}
|
|
248
|
+
const rows = []
|
|
249
|
+
|
|
250
|
+
for (const ruleId of config.rules) {
|
|
251
|
+
if (config.disableRules.includes(ruleId)) continue
|
|
252
|
+
const provider = await loadProvider(rulesDir, ruleId)
|
|
253
|
+
if (!provider) continue
|
|
254
|
+
if (!(await provider.detect(cwd))) continue
|
|
255
|
+
console.log(`→ ${ruleId} coverage…`)
|
|
256
|
+
rows.push(...(await provider.collect(cwd, collectOpts)))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (rows.length === 0) {
|
|
260
|
+
if (opts.changed) {
|
|
261
|
+
console.log('✓ coverage --changed: немає змінених файлів у scope провайдерів — пропускаю')
|
|
262
|
+
return 0
|
|
263
|
+
}
|
|
264
|
+
console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
|
|
265
|
+
return 1
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (opts.changed) {
|
|
269
|
+
console.log('✓ coverage --changed: змінені файли перевірено')
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// LLM-класифікація survived мутантів (graceful skip без API key)
|
|
274
|
+
const allSurvived = rows.flatMap(r => r.survived ?? [])
|
|
275
|
+
let augmentedRows = rows
|
|
276
|
+
let allowedGaps = []
|
|
277
|
+
if (allSurvived.length > 0) {
|
|
278
|
+
const verdicts = await classify(allSurvived, cwd)
|
|
279
|
+
if (verdicts.length > 0) {
|
|
280
|
+
const threshold = await readClassifyThreshold(cwd)
|
|
281
|
+
const applied = applyVerdicts(rows, verdicts, threshold)
|
|
282
|
+
augmentedRows = applied.rows
|
|
283
|
+
allowedGaps = applied.allowedGaps
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (augmentedRows.filter(r => r.area !== '**Разом**').length > 1) {
|
|
288
|
+
augmentedRows.push(buildTotalsRow(augmentedRows.filter(r => r.area !== '**Разом**')))
|
|
289
|
+
}
|
|
290
|
+
const md = renderMarkdown(augmentedRows, allowedGaps)
|
|
291
|
+
// Stryker disable next-line StringLiteral: equivalent – writeFile(path, str, '') behaves identically to 'utf8' in Node/Bun
|
|
292
|
+
await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
|
|
293
|
+
console.log('✓ COVERAGE.md')
|
|
294
|
+
|
|
295
|
+
if (opts.fix) {
|
|
296
|
+
const { fixSurvivedMutants } = await import(new URL('../coverage-fix.mjs', import.meta.url).href)
|
|
297
|
+
await fixSurvivedMutants(allSurvived, cwd)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return 0
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* CLI entrypoint для `n coverage [--fix] [--changed]`.
|
|
305
|
+
* @param {{fix?:boolean, changed?:boolean, rulesDir?:string}} [opts]
|
|
306
|
+
* @returns {Promise<number>} exit code
|
|
307
|
+
*/
|
|
308
|
+
export async function runCoverageCli(opts = {}) {
|
|
309
|
+
const code = await withLock('coverage', () => runCoverageSteps(opts))
|
|
310
|
+
if (code === 0 && opts.fix) {
|
|
311
|
+
console.log('\n♻️ Повторний coverage після агента…\n')
|
|
312
|
+
return withLock('coverage', () => runCoverageSteps({ fix: false, changed: opts.changed, rulesDir: opts.rulesDir }))
|
|
313
|
+
}
|
|
314
|
+
return code
|
|
315
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Застосовує verdicts до coverage rows: фільтрує survived мутантів,
|
|
3
|
+
* декрементує mutation.total на кількість allowed-gaps, повертає окремий
|
|
4
|
+
* список allowedGaps для рендеру в COVERAGE.md.
|
|
5
|
+
*
|
|
6
|
+
* Skip rule: verdict ∈ {equivalent,defensive,glue,wrapper} AND confidence ≥ threshold.
|
|
7
|
+
* Решта (включно з worth-testing і low-confidence skip-verdicts) залишаються в survived.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SKIP_VERDICTS = new Set(['equivalent', 'defensive', 'glue', 'wrapper'])
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Чи verdict кваліфікує мутанта як allowed-gap (виключити з Killable).
|
|
14
|
+
* @param {{verdict: string, confidence: number}} verdict verdict-об'єкт
|
|
15
|
+
* @param {number} threshold confidence threshold (наприклад 0.7)
|
|
16
|
+
* @returns {boolean} true якщо мутант — allowed gap
|
|
17
|
+
*/
|
|
18
|
+
export function isAllowedGap(verdict, threshold) {
|
|
19
|
+
return SKIP_VERDICTS.has(verdict.verdict) && verdict.confidence >= threshold
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Застосовує verdicts до coverage rows. Фільтрує `survived` за isAllowedGap,
|
|
24
|
+
* зменшує `mutation.total` на скільки мутантів стало allowed-gap.
|
|
25
|
+
* Не мутує вхідні дані.
|
|
26
|
+
* @param {Array<{area: string, coverage: object, mutation: {caught: number, total: number}, survived?: Array<{file: string, mutants: Array<object>, exampleTest?: object|null, recommendationText?: string|null}>}>} rows вхідні рядки
|
|
27
|
+
* @param {Array<{key: string, verdict: {verdict: string, confidence: number, reason: string}}>} verdicts класифіковані verdict-и
|
|
28
|
+
* @param {number} threshold confidence threshold для allowed-gap
|
|
29
|
+
* @returns {{rows: Array<object>, allowedGaps: Array<{file: string, mutant: object, verdict: object}>}} augmented rows + список allowed-gaps
|
|
30
|
+
*/
|
|
31
|
+
export function applyVerdicts(rows, verdicts, threshold) {
|
|
32
|
+
const verdictByKey = new Map()
|
|
33
|
+
for (const { key, verdict } of verdicts) verdictByKey.set(key, verdict)
|
|
34
|
+
|
|
35
|
+
const allowedGaps = []
|
|
36
|
+
|
|
37
|
+
const augmentedRows = rows.map(row => {
|
|
38
|
+
const survived = row.survived ?? []
|
|
39
|
+
let skippedCount = 0
|
|
40
|
+
const remainingSurvived = []
|
|
41
|
+
|
|
42
|
+
for (const group of survived) {
|
|
43
|
+
const remainingMutants = []
|
|
44
|
+
for (const mutant of group.mutants) {
|
|
45
|
+
const key = `${group.file}:${mutant.line}:${mutant.col}:${mutant.replacement}`
|
|
46
|
+
const verdict = verdictByKey.get(key)
|
|
47
|
+
if (verdict && isAllowedGap(verdict, threshold)) {
|
|
48
|
+
allowedGaps.push({ file: group.file, mutant, verdict })
|
|
49
|
+
skippedCount += 1
|
|
50
|
+
} else {
|
|
51
|
+
remainingMutants.push(mutant)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (remainingMutants.length > 0) {
|
|
55
|
+
remainingSurvived.push({ ...group, mutants: remainingMutants })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
...row,
|
|
61
|
+
survived: remainingSurvived,
|
|
62
|
+
mutation: { ...row.mutation, total: row.mutation.total - skippedCount }
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return { rows: augmentedRows, allowedGaps }
|
|
67
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-hash-keyed cache для coverage-classify verdicts.
|
|
3
|
+
*
|
|
4
|
+
* Cache key = `<blob-hash>:<line>:<col>:<base64url(replacement)>`.
|
|
5
|
+
* Blob hash рахуємо через `git hash-object <file>` (детерміновано на working tree)
|
|
6
|
+
* з fallback на sha1(readFile) якщо git недоступний.
|
|
7
|
+
*
|
|
8
|
+
* Cache schema:
|
|
9
|
+
* { version: 1, model: string|null, entries: Record<key, { verdict, confidence, reason, suggestedTest?, classifiedAt }> }
|
|
10
|
+
*
|
|
11
|
+
* Інвалідація: будь-яка зміна source → новий blob-hash → cache miss → re-classify.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from 'node:child_process'
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { dirname } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const CACHE_VERSION = 1
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Хеш контенту файла (sha1, 40 hex chars). Спочатку `git hash-object`,
|
|
22
|
+
* інакше sha1 контенту.
|
|
23
|
+
* @param {string} filePath абсолютний шлях до файла
|
|
24
|
+
* @returns {string | null} 40-char hex hash або null якщо файл недоступний
|
|
25
|
+
*/
|
|
26
|
+
export function deriveBlobHash(filePath) {
|
|
27
|
+
if (!existsSync(filePath)) return null
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync('git', ['hash-object', filePath], { encoding: 'utf8' }).trim()
|
|
30
|
+
} catch {
|
|
31
|
+
const content = readFileSync(filePath)
|
|
32
|
+
return createHash('sha256').update(content).digest('hex')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cache-ключ для конкретного мутанта в конкретному стані файла.
|
|
38
|
+
* @param {string} filePath абсолютний шлях до source файла
|
|
39
|
+
* @param {{line: number, col: number, replacement: string}} mutant параметри мутанта
|
|
40
|
+
* @returns {string | null} ключ або null якщо файл недоступний
|
|
41
|
+
*/
|
|
42
|
+
export function deriveCacheKey(filePath, mutant) {
|
|
43
|
+
const blobHash = deriveBlobHash(filePath)
|
|
44
|
+
if (!blobHash) return null
|
|
45
|
+
const replacement = Buffer.from(mutant.replacement, 'utf8').toString('base64url')
|
|
46
|
+
return `${blobHash}:${mutant.line}:${mutant.col}:${replacement}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Читає cache з диска. При будь-якій проблемі (file absent, corrupt JSON,
|
|
51
|
+
* schema/version mismatch, entries не object) — повертає empty cache.
|
|
52
|
+
* @param {string} cachePath абсолютний шлях до cache.json
|
|
53
|
+
* @returns {{version: number, model: string|null, entries: Record<string, object>}} cache
|
|
54
|
+
*/
|
|
55
|
+
export function readCache(cachePath) {
|
|
56
|
+
const empty = { version: CACHE_VERSION, model: null, entries: {} }
|
|
57
|
+
if (!existsSync(cachePath)) return empty
|
|
58
|
+
try {
|
|
59
|
+
const data = JSON.parse(readFileSync(cachePath, 'utf8'))
|
|
60
|
+
if (data?.version !== CACHE_VERSION) return empty
|
|
61
|
+
if (!data.entries || typeof data.entries !== 'object' || Array.isArray(data.entries)) return empty
|
|
62
|
+
return data
|
|
63
|
+
} catch {
|
|
64
|
+
return empty
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Записує cache на диск. Створює батьківські директорії.
|
|
70
|
+
* @param {string} cachePath абсолютний шлях
|
|
71
|
+
* @param {{version: number, model: string|null, entries: Record<string, object>}} cache cache-об'єкт
|
|
72
|
+
* @returns {void}
|
|
73
|
+
*/
|
|
74
|
+
export function writeCache(cachePath, cache) {
|
|
75
|
+
mkdirSync(dirname(cachePath), { recursive: true })
|
|
76
|
+
writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf8')
|
|
77
|
+
}
|