@7n/test 0.7.2 → 0.9.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 +12 -0
- package/package.json +1 -1
- package/src/assess-need.mjs +52 -10
- package/src/run.mjs +28 -26
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0] - 2026-06-28
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- В нормальному циклі `assessNeed` (LLM-оцінка) повністю прибрано: coverage % < порогу є достатнім сигналом для генерації тестів. У bootstrap-режимі тепер використовується тільки `quickClassify` (локально, без LLM). Таким чином нульова кількість LLM-викликів до `assessNeed` у будь-якому сценарії.
|
|
8
|
+
|
|
9
|
+
## [0.8.0] - 2026-06-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `assessNeed` тепер запускає локальні JS-евристики перед LLM-викликом: файли-реекспорти (`export { } from`) класифікуються як `needsTests:false`, файли з функціями та гілками — як `needsTests:true`, без будь-яких витрат на API. LLM викликається тільки для неоднозначних файлів. Нова публічна функція `quickClassify(content)` доступна для тестування/розширення.
|
|
14
|
+
|
|
3
15
|
## [0.7.2] - 2026-06-27
|
|
4
16
|
|
|
5
17
|
### Fixed
|
package/package.json
CHANGED
package/src/assess-need.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LLM assessment via pi SDK: does an uncovered file actually need unit tests?
|
|
3
3
|
* Uses callText() (no tools) for structured JSON replies.
|
|
4
|
+
*
|
|
5
|
+
* Quick local heuristics run first — LLM is called only for ambiguous files.
|
|
4
6
|
*/
|
|
5
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
6
8
|
import { join } from 'node:path'
|
|
@@ -25,17 +27,60 @@ needsTests: true when:
|
|
|
25
27
|
- Business logic with conditions or non-trivial contracts
|
|
26
28
|
- Pure functions that can be unit-tested cheaply`
|
|
27
29
|
|
|
30
|
+
// Lines that are purely module wiring — no testable logic
|
|
31
|
+
const WIRING_RE = /^(import\b|export\s+(?:\{[^}]*\}|\*|type\b|interface\b|enum\b))/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Strip JS/TS comments for heuristic analysis.
|
|
35
|
+
* @param {string} src
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function stripComments(src) {
|
|
39
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, '')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fast local classifier — no I/O, no LLM.
|
|
44
|
+
* Returns a result object for obvious cases, or null when ambiguous.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} content file source
|
|
47
|
+
* @returns {{ needsTests: boolean, reason: string } | null}
|
|
48
|
+
*/
|
|
49
|
+
export function quickClassify(content) {
|
|
50
|
+
const stripped = stripComments(content)
|
|
51
|
+
const lines = stripped.split('\n').map(l => l.trim()).filter(Boolean)
|
|
52
|
+
|
|
53
|
+
// All lines are imports/re-exports → no testable logic
|
|
54
|
+
if (lines.length > 0 && lines.every(l => WIRING_RE.test(l))) {
|
|
55
|
+
return { needsTests: false, reason: 'лише імпорти/реекспорти без логіки' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Has control flow AND function bodies → obviously needs tests
|
|
59
|
+
const hasBranches = /\bif\s*\(|\bswitch\s*\(/.test(stripped)
|
|
60
|
+
const hasFunctions = /\bfunction\s*\w*\s*\(|=>\s*\{/.test(stripped)
|
|
61
|
+
if (hasBranches && hasFunctions) {
|
|
62
|
+
return { needsTests: true, reason: 'містить функції з розгалуженнями' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
28
68
|
/**
|
|
29
69
|
* @param {{file: string, pct: number}} fileInfo
|
|
30
70
|
* @param {string} dir project root
|
|
31
|
-
* @param {Function}
|
|
71
|
+
* @param {Function} callTextFn
|
|
32
72
|
* @returns {Promise<{file: string, pct: number, needsTests: boolean, reason: string}>}
|
|
33
73
|
*/
|
|
34
74
|
async function assessOne(fileInfo, dir, callTextFn) {
|
|
35
75
|
const absPath = join(dir, fileInfo.file)
|
|
36
76
|
if (!existsSync(absPath)) return { ...fileInfo, needsTests: false, reason: 'файл недоступний' }
|
|
37
77
|
|
|
38
|
-
|
|
78
|
+
const rawContent = readFileSync(absPath, 'utf8')
|
|
79
|
+
|
|
80
|
+
const quick = quickClassify(rawContent)
|
|
81
|
+
if (quick !== null) return { ...fileInfo, ...quick }
|
|
82
|
+
|
|
83
|
+
let content = rawContent
|
|
39
84
|
if (content.length > MAX_CONTENT_BYTES) content = content.slice(0, MAX_CONTENT_BYTES) + '\n...(truncated)'
|
|
40
85
|
|
|
41
86
|
const prompt =
|
|
@@ -48,23 +93,20 @@ async function assessOne(fileInfo, dir, callTextFn) {
|
|
|
48
93
|
const match = text.match(/\{[\s\S]*?"needsTests"[\s\S]*?\}/)
|
|
49
94
|
const parsed = JSON.parse(match?.[0] ?? '{}')
|
|
50
95
|
return {
|
|
51
|
-
|
|
52
|
-
pct: fileInfo.pct,
|
|
96
|
+
...fileInfo,
|
|
53
97
|
needsTests: parsed.needsTests !== false,
|
|
54
98
|
reason: typeof parsed.reason === 'string' ? parsed.reason : ''
|
|
55
99
|
}
|
|
56
100
|
} catch {
|
|
57
|
-
return {
|
|
58
|
-
file: fileInfo.file,
|
|
59
|
-
pct: fileInfo.pct,
|
|
60
|
-
needsTests: true,
|
|
61
|
-
reason: 'оцінка не вдалась — вважаємо що потрібні тести'
|
|
62
|
-
}
|
|
101
|
+
return { ...fileInfo, needsTests: true, reason: 'оцінка не вдалась — вважаємо що потрібні тести' }
|
|
63
102
|
}
|
|
64
103
|
}
|
|
65
104
|
|
|
66
105
|
/**
|
|
67
106
|
* Assess a list of uncovered files: do they need tests?
|
|
107
|
+
* Obvious cases (re-exports, functions-with-branches) are resolved locally.
|
|
108
|
+
* Only ambiguous files trigger an LLM call.
|
|
109
|
+
*
|
|
68
110
|
* @param {Array<{file: string, pct: number}>} files
|
|
69
111
|
* @param {string} dir project root
|
|
70
112
|
* @param {{ callText?: Function }} [opts]
|
package/src/run.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Main auto-test loop: coverage →
|
|
2
|
+
* Main auto-test loop: coverage → gen-tests → loop until max → mutation + fix.
|
|
3
3
|
*
|
|
4
4
|
* Phase 1 (loop, max 5 iterations):
|
|
5
5
|
* 1. Measure per-file line coverage via vitest+lcov.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
6
|
+
* 2. Generate tests for all files below threshold — coverage % is the signal.
|
|
7
|
+
* 3. Bootstrap: when no tests exist, scan sources and quickClassify locally.
|
|
8
8
|
* 4. Repeat until coverage maxes out or no improvement.
|
|
9
9
|
*
|
|
10
10
|
* Phase 2:
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
* 6. Auto-fix survived mutants via pi agent.
|
|
13
13
|
*/
|
|
14
14
|
import { measureCoveragePerFile, getUncoveredFiles, findSourceFiles } from './coverage-per-file.mjs'
|
|
15
|
-
import {
|
|
15
|
+
import { quickClassify } from './assess-need.mjs'
|
|
16
16
|
import { generateTests } from './gen-tests.mjs'
|
|
17
17
|
import { fixFailingTests } from './fix-tests.mjs'
|
|
18
18
|
import { runCoverageSteps } from './coverage/coverage.mjs'
|
|
19
19
|
import { withLock } from './scripts/utils/with-lock.mjs'
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
20
22
|
|
|
21
23
|
const MAX_ITERATIONS = 5
|
|
22
24
|
const COVERAGE_THRESHOLD = 80
|
|
@@ -53,17 +55,26 @@ export async function runAutoTest(dir, opts = {}) {
|
|
|
53
55
|
console.log('⚠ Vitest coverage не повернула даних — перевір налаштування vitest.')
|
|
54
56
|
break
|
|
55
57
|
}
|
|
56
|
-
console.log(`\n── Bootstrap: ${sourceFiles.length}
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
console.log(`\n── Bootstrap: ${sourceFiles.length} джерел — класифікую локально ──\n`)
|
|
59
|
+
|
|
60
|
+
const bootstrapFiles = sourceFiles
|
|
61
|
+
.map(f => {
|
|
62
|
+
try {
|
|
63
|
+
const q = quickClassify(readFileSync(join(dir, f), 'utf8'))
|
|
64
|
+
if (q?.needsTests === false) return null
|
|
65
|
+
} catch { /* include on read error */ }
|
|
66
|
+
return { file: f, pct: 0 }
|
|
67
|
+
})
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
|
|
70
|
+
if (bootstrapFiles.length === 0) {
|
|
71
|
+
console.log('✓ Жоден файл не потребує unit-тестів (реекспорти/типи).')
|
|
62
72
|
break
|
|
63
73
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
|
|
75
|
+
console.log(`\n→ Bootstrap-тести для (${bootstrapFiles.length}):`)
|
|
76
|
+
for (const f of bootstrapFiles) console.log(` • ${f.file}`)
|
|
77
|
+
await generateTests(bootstrapFiles, dir)
|
|
67
78
|
continue
|
|
68
79
|
}
|
|
69
80
|
|
|
@@ -84,21 +95,12 @@ export async function runAutoTest(dir, opts = {}) {
|
|
|
84
95
|
}
|
|
85
96
|
prevUncoveredCount = uncovered.length
|
|
86
97
|
|
|
87
|
-
console.log(`\n
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (needsTests.length === 0) {
|
|
92
|
-
console.log('✓ LLM вирішила: жоден непокритий файл не потребує unit-тестів.')
|
|
93
|
-
break
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
console.log(`\n→ Потребують тестів (${needsTests.length}):`)
|
|
97
|
-
for (const f of needsTests) {
|
|
98
|
-
console.log(` • ${f.file} (${f.pct.toFixed(1)}%) — ${f.reason}`)
|
|
98
|
+
console.log(`\n→ Генерую тести для ${uncovered.length} файлів:`)
|
|
99
|
+
for (const f of uncovered) {
|
|
100
|
+
console.log(` • ${f.file} (${f.pct.toFixed(1)}%)`)
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
await generateTests(
|
|
103
|
+
await generateTests(uncovered, dir)
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
if (opts.noMutation) {
|