@7n/test 0.13.0 → 0.14.1
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 +16 -0
- package/package.json +2 -2
- package/src/coverage/aggregate.mjs +31 -0
- package/src/coverage/coverage.mjs +20 -94
- package/src/coverage/js-collector.mjs +437 -0
- package/src/coverage-classify/docs/index.md +1 -1
- package/src/coverage-classify/index.mjs +39 -13
- package/src/coverage-fix.mjs +2 -2
- package/src/docs/assess-need.md +1 -1
- package/src/docs/coverage-fix.md +1 -1
- package/src/docs/fix-tests.md +1 -1
- package/src/docs/gen-tests.md +1 -1
- package/src/fix-tests.mjs +73 -47
- package/src/gen-tests.mjs +23 -8
- package/src/index.js +13 -0
- package/src/lib/docs/llm.md +2 -2
- package/src/lib/llm.mjs +10 -4
- package/src/lib/resolve-js-root.mjs +60 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.1] - 2026-07-11
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- fix(coverage): самодостатній JS-колектор — без залежності від @nitra/cursor providers
|
|
8
|
+
|
|
9
|
+
## [0.14.0] - 2026-07-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Ланцюжки (chains) @nitra/llm-lib ^1.1.0: mutant-classify — chain per mutant (tier1/tier2 = кроки, cache hit без chain), test-generate — chain per file (header + local/cloud спроби + vitest/length-retry = кроки), test-fix — chain per прогін (batch-виклики = кроки); адаптер callText/callAgent прокидає opts.chain. Аналітика ланцюжків — n-llm-chains-report і вкладка «Ланцюжки» в myllm.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- підняти @7n/llm-lib до ^2.0.2 (rename @nitra/llm-lib → @7n/llm-lib уже застосовано в dacc051; нова мажорна версія без змін API — chains/компресія/body-capture, лише перейменування пакета)
|
|
18
|
+
|
|
3
19
|
## [0.13.0] - 2026-07-05
|
|
4
20
|
|
|
5
21
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7n/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "CLI-утиліта @7n/test",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"7n",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
43
43
|
"@nitra/check-env": "^4.2.1",
|
|
44
|
-
"@
|
|
44
|
+
"@7n/llm-lib": "^2.0.2",
|
|
45
45
|
"@vitest/coverage-v8": "^4.1.9",
|
|
46
46
|
"rollup": "^4.62.2",
|
|
47
47
|
"vitest": "^4.1.9",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Сумування coverage/mutation totals — спільна утиліта для collector-ів
|
|
3
|
+
* (js-collector.mjs) і оркестратора (coverage.mjs). Винесено в окремий модуль,
|
|
4
|
+
* щоб уникнути циклічного імпорту collector ↔ orchestrator.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Сума двох coverage-totals.
|
|
9
|
+
* @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший subtotal
|
|
10
|
+
* @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий subtotal
|
|
11
|
+
* @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сумарні lines/functions
|
|
12
|
+
*/
|
|
13
|
+
export function addCoverage(a, b) {
|
|
14
|
+
return {
|
|
15
|
+
lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
|
|
16
|
+
functions: {
|
|
17
|
+
covered: a.functions.covered + b.functions.covered,
|
|
18
|
+
total: a.functions.total + b.functions.total
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Сума двох mutation-counts.
|
|
25
|
+
* @param {{caught:number,total:number}} a перший subtotal
|
|
26
|
+
* @param {{caught:number,total:number}} b другий subtotal
|
|
27
|
+
* @returns {{caught:number,total:number}} сумарні caught/total
|
|
28
|
+
*/
|
|
29
|
+
export function addMutation(a, b) {
|
|
30
|
+
return { caught: a.caught + b.caught, total: a.total + b.total }
|
|
31
|
+
}
|
|
@@ -1,65 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Канонічна команда
|
|
3
|
-
* тестування
|
|
4
|
-
*
|
|
2
|
+
* Канонічна команда `@7n/test coverage`: збирає метрики покриття + мутаційного
|
|
3
|
+
* тестування через вбудований JS-колектор (`js-collector.mjs`), агрегує та
|
|
4
|
+
* записує COVERAGE.md у корінь проєкту.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `rulesDir` резолвиться з `node_modules/@nitra/cursor/rules` у cwd проєкту.
|
|
12
|
-
* Override — через `opts.rulesDir` у `runCoverageSteps`.
|
|
6
|
+
* Самодостатній — не залежить від `@nitra/cursor` (раніше — provider-discovery
|
|
7
|
+
* за `.n-cursor.json#rules` через `<rulesDir>/<ruleId>/coverage/coverage.mjs`;
|
|
8
|
+
* provider-підсистема видалена з `@nitra/cursor` 2026-07-10, лишаючи оркестратор
|
|
9
|
+
* без жодного провайдера). Наразі підтримується лише JS/TS (`js-collector.mjs`);
|
|
10
|
+
* інші мови (Rust тощо) — не покриті, до появи власного колектора тут.
|
|
13
11
|
*
|
|
14
12
|
* Лок — прямий виклик `withLock('coverage', steps)`.
|
|
15
13
|
*/
|
|
16
|
-
import { existsSync } from 'node:fs'
|
|
17
14
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
18
15
|
import { join } from 'node:path'
|
|
19
|
-
import { pathToFileURL } from 'node:url'
|
|
20
16
|
|
|
21
17
|
import { applyVerdicts } from '../coverage-classify/apply.mjs'
|
|
22
18
|
import { classify } from '../coverage-classify/index.mjs'
|
|
23
19
|
import { collectChangedFilesSince, resolveChangedBase } from '../scripts/lib/changed-files.mjs'
|
|
24
|
-
import { readNCursorConfigLite } from '../scripts/lib/read-n-cursor-config-lite.mjs'
|
|
25
20
|
import { withLock } from '../scripts/utils/with-lock.mjs'
|
|
21
|
+
import { addCoverage, addMutation } from './aggregate.mjs'
|
|
22
|
+
import { collect as collectJs, detect as detectJs } from './js-collector.mjs'
|
|
26
23
|
|
|
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
|
-
}
|
|
24
|
+
export { addCoverage, addMutation }
|
|
63
25
|
|
|
64
26
|
/**
|
|
65
27
|
* Форматує covered/total як `XX.XX% (covered/total)`.
|
|
@@ -166,23 +128,6 @@ export function renderMarkdown(rows, allowedGaps = []) {
|
|
|
166
128
|
return `${lines.join('\n')}\n`
|
|
167
129
|
}
|
|
168
130
|
|
|
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
131
|
/**
|
|
187
132
|
* Будує підсумковий рядок «Разом» через сумування всіх coverage/mutation.
|
|
188
133
|
* @param {Array<{area:string, coverage:object, mutation:object}>} rows рядки провайдерів без totals
|
|
@@ -225,43 +170,24 @@ export function resolveChangedScope(cwd) {
|
|
|
225
170
|
}
|
|
226
171
|
|
|
227
172
|
/**
|
|
228
|
-
* Виконує coverage-pipeline:
|
|
229
|
-
*
|
|
230
|
-
* @param {{cwd?:string, rulesDir?:string, fix?:boolean, changed?:boolean}} [opts]
|
|
173
|
+
* Виконує coverage-pipeline: JS-колектор (detect+collect), агрегація, запис COVERAGE.md.
|
|
174
|
+
* @param {{cwd?:string, fix?:boolean, changed?:boolean}} [opts]
|
|
231
175
|
* @returns {Promise<number>} exit code (0 OK, 1 помилка)
|
|
232
176
|
*/
|
|
233
177
|
export async function runCoverageSteps(opts = {}) {
|
|
234
178
|
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
179
|
const scope = opts.changed ? resolveChangedScope(cwd) : null
|
|
247
180
|
const collectOpts = scope ? { changedFiles: scope.files, base: scope.base } : {}
|
|
248
|
-
const rows = []
|
|
249
181
|
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
}
|
|
182
|
+
const applicable = await detectJs(cwd)
|
|
183
|
+
const rows = applicable ? await collectJs(cwd, collectOpts) : []
|
|
258
184
|
|
|
259
185
|
if (rows.length === 0) {
|
|
260
186
|
if (opts.changed) {
|
|
261
|
-
console.log('✓ coverage --changed: немає змінених файлів у scope
|
|
187
|
+
console.log('✓ coverage --changed: немає змінених файлів у scope — пропускаю')
|
|
262
188
|
return 0
|
|
263
189
|
}
|
|
264
|
-
console.error('✗
|
|
190
|
+
console.error('✗ Coverage: не знайдено тестів (vitest не задекларовано, або жоден workspace не має тестів)')
|
|
265
191
|
return 1
|
|
266
192
|
}
|
|
267
193
|
|
|
@@ -301,15 +227,15 @@ export async function runCoverageSteps(opts = {}) {
|
|
|
301
227
|
}
|
|
302
228
|
|
|
303
229
|
/**
|
|
304
|
-
* CLI entrypoint для
|
|
305
|
-
* @param {{fix?:boolean, changed?:boolean
|
|
230
|
+
* CLI entrypoint для `@7n/test coverage [--fix] [--changed]`.
|
|
231
|
+
* @param {{cwd?:string, fix?:boolean, changed?:boolean}} [opts]
|
|
306
232
|
* @returns {Promise<number>} exit code
|
|
307
233
|
*/
|
|
308
234
|
export async function runCoverageCli(opts = {}) {
|
|
309
235
|
const code = await withLock('coverage', () => runCoverageSteps(opts))
|
|
310
236
|
if (code === 0 && opts.fix) {
|
|
311
237
|
console.log('\n♻️ Повторний coverage після агента…\n')
|
|
312
|
-
return withLock('coverage', () => runCoverageSteps({ fix: false, changed: opts.changed
|
|
238
|
+
return withLock('coverage', () => runCoverageSteps({ cwd: opts.cwd, fix: false, changed: opts.changed }))
|
|
313
239
|
}
|
|
314
240
|
return code
|
|
315
241
|
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Самодостатній JS/TS coverage + mutation-testing колектор: збирає метрики покриття
|
|
3
|
+
* (`vitest run --coverage`) і мутаційного тестування (Stryker з vitest-runner + perTest).
|
|
4
|
+
* Не залежить від `@nitra/cursor` — раніше жив там як rule-провайдер
|
|
5
|
+
* (`rules/js/coverage/coverage.mjs`), видалений разом з усією provider-підсистемою
|
|
6
|
+
* (2026-07-10). Тепер — вбудований collector `@7n/test coverage`.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
11
|
+
import { createRequire } from 'node:module'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { dirname, isAbsolute, join, relative } from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { resolveAllJsRoots } from '../lib/resolve-js-root.mjs'
|
|
16
|
+
import { addCoverage, addMutation } from './aggregate.mjs'
|
|
17
|
+
|
|
18
|
+
const TEST_BLOCK_START = /^\s*(it|test)\(/
|
|
19
|
+
const FILE_EXTENSION = /\.[^.]+$/
|
|
20
|
+
/** JS/TS-розширення — файли, які мутує Stryker і покриває vitest. */
|
|
21
|
+
const JS_FILE = /\.(c|m)?[jt]sx?$/
|
|
22
|
+
/** Тест-файли (`*.test.*` / `*.spec.*`) — НЕ production-код, не йдуть у Stryker `--mutate`. */
|
|
23
|
+
const TEST_FILE = /\.(test|spec)\.[^.]+$/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Звужує список змінених файлів (relative до cwd) до тих, що лежать під `jsRoot`,
|
|
27
|
+
* мають JS/TS-розширення, і рібейзить їх відносно `jsRoot`.
|
|
28
|
+
* @param {string[]} changedFiles relative-до-cwd шляхи змінених файлів
|
|
29
|
+
* @param {string} cwd корінь проєкту
|
|
30
|
+
* @param {string} jsRoot абсолютний шлях workspace-кореня
|
|
31
|
+
* @returns {string[]} JS-файли під jsRoot, шляхи relative до jsRoot
|
|
32
|
+
*/
|
|
33
|
+
export function scopeToRoot(changedFiles, cwd, jsRoot) {
|
|
34
|
+
const out = []
|
|
35
|
+
for (const f of changedFiles) {
|
|
36
|
+
if (!JS_FILE.test(f)) continue
|
|
37
|
+
const rel = relative(jsRoot, join(cwd, f))
|
|
38
|
+
if (rel.startsWith('..') || isAbsolute(rel)) continue
|
|
39
|
+
out.push(rel)
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const VITEST_HINT =
|
|
45
|
+
'js coverage: vitest відсутній у package.json — додай `vitest`, `@vitest/coverage-v8` та `@stryker-mutator/vitest-runner` у devDependencies (див. test.mdc)'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Чи у пакеті встановлено vitest (через dependencies або devDependencies).
|
|
49
|
+
* @param {{dependencies?: Record<string,string>, devDependencies?: Record<string,string>}} pkg package.json
|
|
50
|
+
* @returns {boolean} true, якщо `vitest` декларовано хоча б в одному dep-section
|
|
51
|
+
*/
|
|
52
|
+
function hasVitestDep(pkg) {
|
|
53
|
+
return Boolean(pkg.devDependencies?.vitest) || Boolean(pkg.dependencies?.vitest)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Чи колектор застосовний у поточному cwd. Активується, коли `vitest`
|
|
58
|
+
* декларовано хоча б в одному JS-root АБО у кореневому `package.json`
|
|
59
|
+
* (workspace-проєкт із hoisted node_modules — типовий патерн bun monorepo).
|
|
60
|
+
* Інакше silent skip із hint у stderr (одноразово).
|
|
61
|
+
* @param {string} cwd корінь проєкту
|
|
62
|
+
* @returns {Promise<boolean>} true, якщо проєкт сумісний з vitest-based coverage
|
|
63
|
+
*/
|
|
64
|
+
export async function detect(cwd) {
|
|
65
|
+
const jsRoots = await resolveAllJsRoots(cwd)
|
|
66
|
+
if (jsRoots.length === 0) return false
|
|
67
|
+
for (const jsRoot of jsRoots) {
|
|
68
|
+
const pkgPath = join(jsRoot, 'package.json')
|
|
69
|
+
if (!existsSync(pkgPath)) continue
|
|
70
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
71
|
+
if (hasVitestDep(pkg)) return true
|
|
72
|
+
}
|
|
73
|
+
const rootInJsRoots = jsRoots.includes(cwd)
|
|
74
|
+
if (!rootInJsRoots) {
|
|
75
|
+
const rootPkgPath = join(cwd, 'package.json')
|
|
76
|
+
if (existsSync(rootPkgPath)) {
|
|
77
|
+
const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
|
|
78
|
+
if (hasVitestDep(rootPkg)) return true
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!detect._hinted) {
|
|
82
|
+
console.error(VITEST_HINT)
|
|
83
|
+
detect._hinted = true
|
|
84
|
+
}
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Парс lcov.info: сумує LF/LH (рядки) і FNF/FNH (функції) по всіх records.
|
|
90
|
+
* @param {string} text вміст lcov.info
|
|
91
|
+
* @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} агреговані totals
|
|
92
|
+
*/
|
|
93
|
+
function parseLcov(text) {
|
|
94
|
+
const acc = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
95
|
+
for (const line of text.split('\n')) {
|
|
96
|
+
if (line.startsWith('LF:')) acc.lines.total += Number(line.slice(3))
|
|
97
|
+
else if (line.startsWith('LH:')) acc.lines.covered += Number(line.slice(3))
|
|
98
|
+
else if (line.startsWith('FNF:')) acc.functions.total += Number(line.slice(4))
|
|
99
|
+
else if (line.startsWith('FNH:')) acc.functions.covered += Number(line.slice(4))
|
|
100
|
+
}
|
|
101
|
+
return acc
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Витягує оригінальний фрагмент коду з рядків файлу за позицією мутанта.
|
|
106
|
+
* @param {string[]} fileLines рядки файлу (0-indexed)
|
|
107
|
+
* @param {{start:{line:number,column:number},end:{line:number,column:number}}} loc позиція (рядки 1-indexed)
|
|
108
|
+
* @returns {string} оригінальний текст мутанта
|
|
109
|
+
*/
|
|
110
|
+
function extractOriginal(fileLines, loc) {
|
|
111
|
+
const startLine = loc.start.line - 1
|
|
112
|
+
const endLine = loc.end.line - 1
|
|
113
|
+
if (startLine === endLine) {
|
|
114
|
+
return fileLines[startLine]?.slice(loc.start.column, loc.end.column) ?? ''
|
|
115
|
+
}
|
|
116
|
+
const parts = []
|
|
117
|
+
for (let i = startLine; i <= endLine; i++) {
|
|
118
|
+
const line = fileLines[i] ?? ''
|
|
119
|
+
if (i === startLine) parts.push(line.slice(loc.start.column))
|
|
120
|
+
else if (i === endLine) parts.push(line.slice(0, loc.end.column))
|
|
121
|
+
else parts.push(line)
|
|
122
|
+
}
|
|
123
|
+
return parts.join('\n')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Витягує перший `it(` або `test(` блок з вмісту тест-файлу.
|
|
128
|
+
* Відстежує глибину `{}` для коректного завершення.
|
|
129
|
+
* @param {string} content вміст тест-файлу
|
|
130
|
+
* @returns {string | null} перший тест-блок або null
|
|
131
|
+
*/
|
|
132
|
+
export function extractFirstTestBlock(content) {
|
|
133
|
+
const lines = content.split('\n')
|
|
134
|
+
let startLine = -1
|
|
135
|
+
let depth = 0
|
|
136
|
+
let inBlock = false
|
|
137
|
+
const result = []
|
|
138
|
+
for (const [i, line] of lines.entries()) {
|
|
139
|
+
if (startLine === -1 && TEST_BLOCK_START.test(line)) startLine = i
|
|
140
|
+
if (startLine === -1) continue
|
|
141
|
+
result.push(line)
|
|
142
|
+
for (const ch of line) {
|
|
143
|
+
if (ch === '{') {
|
|
144
|
+
depth++
|
|
145
|
+
inBlock = true
|
|
146
|
+
} else if (ch === '}') depth--
|
|
147
|
+
}
|
|
148
|
+
if (inBlock && depth === 0) break
|
|
149
|
+
}
|
|
150
|
+
return result.length > 0 ? result.join('\n') : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Шукає тест-файл для заданого source-файлу і повертає перший тест-блок як приклад стилю.
|
|
155
|
+
* Кандидати: `<base>.test.js`, `<base>.test.mjs`, `<dir>/tests/<name>.test.js`.
|
|
156
|
+
* @param {string} jsRoot абсолютний шлях до JS-кореня
|
|
157
|
+
* @param {string} filename відносний шлях source-файлу (від jsRoot)
|
|
158
|
+
* @returns {{testFile:string, code:string|null} | null} null — якщо тест-файл не знайдено
|
|
159
|
+
*/
|
|
160
|
+
export function findExampleTest(jsRoot, filename) {
|
|
161
|
+
const base = filename.replace(FILE_EXTENSION, '')
|
|
162
|
+
const candidates = [`${base}.test.js`, `${base}.test.mjs`, `${base}.test.ts`]
|
|
163
|
+
const lastSlash = base.lastIndexOf('/')
|
|
164
|
+
if (lastSlash !== -1) {
|
|
165
|
+
const dir = base.slice(0, lastSlash)
|
|
166
|
+
const name = base.slice(lastSlash + 1)
|
|
167
|
+
candidates.push(`${dir}/tests/${name}.test.js`, `${dir}/tests/${name}.test.mjs`)
|
|
168
|
+
}
|
|
169
|
+
for (const rel of candidates) {
|
|
170
|
+
const full = join(jsRoot, rel)
|
|
171
|
+
if (!existsSync(full)) continue
|
|
172
|
+
const content = readFileSync(full, 'utf8')
|
|
173
|
+
return { testFile: rel, code: extractFirstTestBlock(content) }
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Парс Stryker mutation.json: Killed+Timeout → caught; Survived+NoCoverage → до total.
|
|
180
|
+
* Compile/Runtime помилки виключаються з total.
|
|
181
|
+
* Survived мутанти групуються по файлах з exampleTest.
|
|
182
|
+
* @param {{files:Record<string,{mutants:Array<{status:string,mutatorName?:string,replacement?:string,location?:{start:{line:number,column:number},end:{line:number,column:number}}}>}>}} report Stryker mutation.json
|
|
183
|
+
* @param {string|null} [jsRoot] корінь для читання source-рядків і пошуку тест-файлів
|
|
184
|
+
* @returns {{caught:number,total:number,survived:Array<{file:string,mutants:Array<{line:number,col:number,mutantType:string,original:string,replacement:string}>,exampleTest:{testFile:string,code:string|null}|null,recommendationText:string|null}>}} результат парсу: caught/total та згруповані survived мутанти
|
|
185
|
+
*/
|
|
186
|
+
export function parseStrykerReport(report, jsRoot) {
|
|
187
|
+
let caught = 0
|
|
188
|
+
let total = 0
|
|
189
|
+
/** @type {Map<string, Array<{line:number,col:number,mutantType:string,original:string,replacement:string}>>} */
|
|
190
|
+
const byFile = new Map()
|
|
191
|
+
|
|
192
|
+
for (const [filePath, fileData] of Object.entries(report.files)) {
|
|
193
|
+
let fileLines = null
|
|
194
|
+
for (const mutant of fileData.mutants) {
|
|
195
|
+
if (mutant.status === 'Killed' || mutant.status === 'Timeout') {
|
|
196
|
+
caught += 1
|
|
197
|
+
total += 1
|
|
198
|
+
} else if (mutant.status === 'Survived' || mutant.status === 'NoCoverage') {
|
|
199
|
+
total += 1
|
|
200
|
+
if (mutant.status === 'Survived' && jsRoot && mutant.location) {
|
|
201
|
+
if (!fileLines) {
|
|
202
|
+
try {
|
|
203
|
+
fileLines = readFileSync(join(jsRoot, filePath), 'utf8').split('\n')
|
|
204
|
+
} catch {
|
|
205
|
+
fileLines = []
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!byFile.has(filePath)) byFile.set(filePath, [])
|
|
209
|
+
byFile.get(filePath).push({
|
|
210
|
+
line: mutant.location.start.line,
|
|
211
|
+
col: mutant.location.start.column,
|
|
212
|
+
mutantType: mutant.mutatorName ?? 'Unknown',
|
|
213
|
+
original: extractOriginal(fileLines, mutant.location),
|
|
214
|
+
replacement: mutant.replacement ?? ''
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const survived = []
|
|
222
|
+
for (const [file, mutants] of byFile) {
|
|
223
|
+
survived.push({
|
|
224
|
+
file,
|
|
225
|
+
mutants,
|
|
226
|
+
exampleTest: jsRoot ? findExampleTest(jsRoot, file) : null,
|
|
227
|
+
recommendationText: null
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { caught, total, survived }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Шлях до локально встановленого Stryker core-bin (поряд із плагінами на кшталт
|
|
236
|
+
* `@stryker-mutator/vitest-runner`). Запуск саме його через `node` — не `npx`/`bunx` —
|
|
237
|
+
* дає Stryker побачити локальні плагіни при plugin-discovery.
|
|
238
|
+
* @returns {string | null} абсолютний шлях `bin/stryker.js` або `null`, якщо не встановлено
|
|
239
|
+
*/
|
|
240
|
+
let strykerBinCache
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Резолвить локальний Stryker core bin (мемоізовано).
|
|
244
|
+
* @returns {string | null} абсолютний шлях `bin/stryker.js` або `null`
|
|
245
|
+
*/
|
|
246
|
+
function resolveLocalStrykerBin() {
|
|
247
|
+
if (strykerBinCache !== undefined) return strykerBinCache
|
|
248
|
+
try {
|
|
249
|
+
// `exports` у core НЕ відкриває `./bin/stryker.js`, тож резолвимо package.json
|
|
250
|
+
// (доступний) і беремо шлях bin звідти. Ключ bin зазвичай `stryker`; як запас —
|
|
251
|
+
// перше значення map'и.
|
|
252
|
+
const require = createRequire(import.meta.url)
|
|
253
|
+
const pkgJsonPath = require.resolve('@stryker-mutator/core/package.json')
|
|
254
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
|
255
|
+
const binRel = typeof pkg.bin === 'string' ? pkg.bin : (pkg.bin?.stryker ?? Object.values(pkg.bin ?? {})[0])
|
|
256
|
+
strykerBinCache = binRel ? join(dirname(pkgJsonPath), binRel) : null
|
|
257
|
+
} catch {
|
|
258
|
+
strykerBinCache = null
|
|
259
|
+
}
|
|
260
|
+
return strykerBinCache
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const defaultRunner = {
|
|
264
|
+
runJsCoverage({ cwd, lcovDir, base }) {
|
|
265
|
+
// base !== undefined ⇔ --changed-режим: vitest сам рахує зачеплені змінами тести
|
|
266
|
+
// через граф імпортів. `--changed <base>` порівнює base↔робоче дерево (committed і
|
|
267
|
+
// uncommitted разом); `--changed` без аргументу — uncommitted vs HEAD.
|
|
268
|
+
const changedArgs = base === undefined ? [] : base === null ? ['--changed'] : ['--changed', base]
|
|
269
|
+
const r = spawnSync(
|
|
270
|
+
'bunx',
|
|
271
|
+
[
|
|
272
|
+
'vitest',
|
|
273
|
+
'run',
|
|
274
|
+
'--passWithNoTests',
|
|
275
|
+
'--coverage',
|
|
276
|
+
'--coverage.reporter=lcov',
|
|
277
|
+
`--coverage.reportsDirectory=${lcovDir}`,
|
|
278
|
+
...changedArgs
|
|
279
|
+
],
|
|
280
|
+
{ cwd, stdio: 'inherit', env: process.env }
|
|
281
|
+
)
|
|
282
|
+
return r.status ?? 1
|
|
283
|
+
},
|
|
284
|
+
runStryker({ cwd, mutate }) {
|
|
285
|
+
// Plugin-discovery Stryker (`@stryker-mutator/*`) globиться відносно CORE-install-каталогу
|
|
286
|
+
// (`core/dist/src/di/plugin-loader.js` → `../../../../../@stryker-mutator/*`). Тож core
|
|
287
|
+
// МАЄ вантажитись із проєктного `node_modules`, де поряд лежить `@stryker-mutator/vitest-runner`.
|
|
288
|
+
// `npx`/`bunx` тягнуть core у власний кеш (`_npx/<hash>`, `bunx-temp`) БЕЗ плагінів → воркери
|
|
289
|
+
// падають `Cannot find TestRunner plugin "vitest"`. Тому резолвимо локальний core-bin через
|
|
290
|
+
// `import.meta.url` і запускаємо його через `node`. Fallback на `npx`, якщо не встановлено.
|
|
291
|
+
// mutate (непорожній) ⇔ --changed-режим: мутуємо лише змінені production-файли цього root.
|
|
292
|
+
const mutateArgs = mutate && mutate.length > 0 ? ['--mutate', mutate.join(',')] : []
|
|
293
|
+
const strykerBin = resolveLocalStrykerBin()
|
|
294
|
+
const r = strykerBin
|
|
295
|
+
? spawnSync(strykerBin, ['run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
|
|
296
|
+
: spawnSync('npx', ['@stryker-mutator/core', 'run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
|
|
297
|
+
return r.status ?? 1
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Збирає метрики покриття + мутаційного тестування для **одного** JS-root.
|
|
303
|
+
*
|
|
304
|
+
* Full-режим (`scope === null`): vitest на всьому suite + Stryker на всіх файлах
|
|
305
|
+
* config-глоба. Пропускає workspace без тестів (повертає `null`): vitest пройшов з
|
|
306
|
+
* `--passWithNoTests`, але lcov порожній — нема сенсу запускати Stryker.
|
|
307
|
+
*
|
|
308
|
+
* Changed-режим (`scope = { files, base }`): vitest `--changed <base>` (лише
|
|
309
|
+
* зачеплені тести) + Stryker `--mutate` лише по змінених production-файлах. Тут
|
|
310
|
+
* **не** пропускаємо на порожньому lcov — змінений src без тестів має дати
|
|
311
|
+
* NoCoverage-мутанти (gate впаде, як і має). Якщо змінено лише тест-файли (нема
|
|
312
|
+
* production-src) — Stryker не запускаємо (мутувати нічого), повертаємо лише coverage.
|
|
313
|
+
*
|
|
314
|
+
* Реальні помилки (vitest exit ≠ 0, відсутній mutation.json попри запуск Stryker)
|
|
315
|
+
* кидаються — у multi-root режимі це не маскує справжній збій.
|
|
316
|
+
* @param {string} jsRoot абсолютний шлях до workspace-кореня
|
|
317
|
+
* @param {string} cwd корінь проєкту (для рібейзингу `survived[].file`)
|
|
318
|
+
* @param {{runJsCoverage:Function, runStryker:Function}} runner spawn-ін'єкція
|
|
319
|
+
* @param {{files:string[], base:string|null}|null} [scope] changed-scope (null = full-режим)
|
|
320
|
+
* @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результати або null коли full-режим і workspace без тестів
|
|
321
|
+
*/
|
|
322
|
+
async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
|
|
323
|
+
const wsRel = relative(cwd, jsRoot)
|
|
324
|
+
// У changed-режимі production-файли для мутації = змінені JS цього root без тест-файлів.
|
|
325
|
+
const mutateSrc = scope ? scope.files.filter(f => !TEST_FILE.test(f)) : null
|
|
326
|
+
|
|
327
|
+
// 1. Coverage через vitest run --passWithNoTests --coverage (+ --changed у changed-режимі)
|
|
328
|
+
const lcovDir = await mkdtemp(join(tmpdir(), 'js-cov-'))
|
|
329
|
+
let coverage
|
|
330
|
+
try {
|
|
331
|
+
const code = await runner.runJsCoverage(scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir })
|
|
332
|
+
if (code !== 0) throw new Error(`JS coverage exit ${code}`)
|
|
333
|
+
const lcovPath = join(lcovDir, 'lcov.info')
|
|
334
|
+
coverage = existsSync(lcovPath)
|
|
335
|
+
? parseLcov(await readFile(lcovPath, 'utf8'))
|
|
336
|
+
: { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
337
|
+
} finally {
|
|
338
|
+
await rm(lcovDir, { recursive: true, force: true })
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Full-режим: порожній lcov ⇔ vitest не знайшов тестів → пропускаємо workspace,
|
|
342
|
+
// щоб не ганяти Stryker марно. У changed-режимі НЕ пропускаємо (див. JSDoc).
|
|
343
|
+
if (!scope) {
|
|
344
|
+
const hasTests = coverage.lines.total > 0 || coverage.functions.total > 0
|
|
345
|
+
if (!hasTests) return null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Changed-режим без production-src (змінено лише тест-файли) → мутувати нічого.
|
|
349
|
+
if (scope && mutateSrc.length === 0) {
|
|
350
|
+
return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 2. Mutation через Stryker (у changed-режимі — лише по mutateSrc)
|
|
354
|
+
await runner.runStryker(scope ? { cwd: jsRoot, mutate: mutateSrc } : { cwd: jsRoot })
|
|
355
|
+
const mutationPath = join(jsRoot, 'reports', 'stryker', 'mutation.json')
|
|
356
|
+
if (!existsSync(mutationPath)) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
'js coverage: stryker не залишив mutation.json — ' +
|
|
359
|
+
'переконайся що встановлено canonical stryker.config.mjs (vitest-runner, perTest), ' +
|
|
360
|
+
'або налаштуй його вручну'
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
const mutationReport = JSON.parse(await readFile(mutationPath, 'utf8'))
|
|
364
|
+
const parsed = parseStrykerReport(mutationReport, jsRoot)
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
coverage,
|
|
368
|
+
mutation: { caught: parsed.caught, total: parsed.total },
|
|
369
|
+
survived: parsed.survived.map(group => ({
|
|
370
|
+
...group,
|
|
371
|
+
file: wsRel === '' ? group.file : join(wsRel, group.file),
|
|
372
|
+
exampleTest: group.exampleTest
|
|
373
|
+
? {
|
|
374
|
+
...group.exampleTest,
|
|
375
|
+
testFile: wsRel === '' ? group.exampleTest.testFile : join(wsRel, group.exampleTest.testFile)
|
|
376
|
+
}
|
|
377
|
+
: null
|
|
378
|
+
}))
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Збирає JS-метрики покриття + мутаційного тестування. У monorepo ітерує усі
|
|
384
|
+
* JS-roots з `resolveAllJsRoots()` (включно з glob-патернами `cf/*`), запускає
|
|
385
|
+
* vitest+Stryker у кожному та сумує lcov/mutation через `addCoverage`/`addMutation`.
|
|
386
|
+
* Workspaces без тестів пропускаються (див. `collectOneRoot`).
|
|
387
|
+
* Якщо тестів немає у жодному workspace — повертає `[]`.
|
|
388
|
+
* Шляхи у `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs`
|
|
389
|
+
* знаходив джерела через `join(projectRoot, file)`.
|
|
390
|
+
*
|
|
391
|
+
* Changed-режим (`opts.changedFiles` задано): кожен root отримує лише свої змінені
|
|
392
|
+
* JS-файли (`scopeToRoot`); roots без змінених JS пропускаються повністю (ні vitest,
|
|
393
|
+
* ні Stryker). Якщо змін нема ніде — повертає `[]` без error-логу (оркестратор
|
|
394
|
+
* трактує порожній changed-scope як pass).
|
|
395
|
+
* @param {string} cwd корінь проєкту
|
|
396
|
+
* @param {{runner?: typeof defaultRunner, changedFiles?: string[], base?: string|null}} [opts] runner-ін'єкція + changed-scope
|
|
397
|
+
* @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядок `JS` або `[]` коли тестів/змін нема ніде
|
|
398
|
+
*/
|
|
399
|
+
export async function collect(cwd, opts = {}) {
|
|
400
|
+
const runner = opts.runner ?? defaultRunner
|
|
401
|
+
const changed = Array.isArray(opts.changedFiles)
|
|
402
|
+
const jsRoots = await resolveAllJsRoots(cwd)
|
|
403
|
+
if (jsRoots.length === 0) throw new Error('js coverage: package.json не знайдено')
|
|
404
|
+
|
|
405
|
+
const results = []
|
|
406
|
+
for (const jsRoot of jsRoots) {
|
|
407
|
+
let scope = null
|
|
408
|
+
if (changed) {
|
|
409
|
+
const files = scopeToRoot(opts.changedFiles, cwd, jsRoot)
|
|
410
|
+
if (files.length === 0) continue // root без змінених JS — пропускаємо
|
|
411
|
+
scope = { files, base: opts.base ?? null }
|
|
412
|
+
}
|
|
413
|
+
const r = await collectOneRoot(jsRoot, cwd, runner, scope)
|
|
414
|
+
if (r !== null) results.push(r)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (results.length === 0) {
|
|
418
|
+
// Changed-режим: нема змінених JS у жодному root → тихо порожньо (це pass, не помилка).
|
|
419
|
+
if (changed) return []
|
|
420
|
+
console.error(
|
|
421
|
+
'js coverage: жоден workspace не має тестів ' +
|
|
422
|
+
'(`*.test.{js,mjs}` у `tests/` або поряд із джерелом) — ' +
|
|
423
|
+
'додай тести або запусти `npx @7n/test` для генерації'
|
|
424
|
+
)
|
|
425
|
+
return []
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
429
|
+
let mutation = { caught: 0, total: 0 }
|
|
430
|
+
const survived = []
|
|
431
|
+
for (const r of results) {
|
|
432
|
+
coverage = addCoverage(coverage, r.coverage)
|
|
433
|
+
mutation = addMutation(mutation, r.mutation)
|
|
434
|
+
survived.push(...r.survived)
|
|
435
|
+
}
|
|
436
|
+
return [{ area: 'JS', coverage, mutation, survived }]
|
|
437
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
|
|
12
12
|
import { callText } from '../lib/llm.mjs'
|
|
13
|
-
import { CLOUD_MIN, LOCAL_MIN } from '@
|
|
13
|
+
import { CLOUD_MIN, LOCAL_MIN } from '@7n/llm-lib/model-tiers'
|
|
14
|
+
import { startChain } from '@7n/llm-lib/chain'
|
|
14
15
|
import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
|
|
15
16
|
import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
|
|
16
17
|
import { parseVerdict } from './verdict-schema.mjs'
|
|
@@ -26,52 +27,77 @@ const FALLBACK_VERDICT = {
|
|
|
26
27
|
* @param {string} prompt
|
|
27
28
|
* @param {string} model provider/model-id або '' для pi-дефолту
|
|
28
29
|
* @param {string} cwd
|
|
30
|
+
* @param {{chain?: object}} [callOpts] chain handle поточного мутанта
|
|
29
31
|
* @returns {Promise<string>}
|
|
30
32
|
*/
|
|
31
|
-
function callModel(prompt, model, cwd) {
|
|
32
|
-
return callText(prompt, { cwd, ...(model && { model }) })
|
|
33
|
+
function callModel(prompt, model, cwd, { chain } = {}) {
|
|
34
|
+
return callText(prompt, { cwd, chain, ...(model && { model }) })
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* Два тири: tier1 (local-min) → tier2 (cloud-min) → FALLBACK_VERDICT.
|
|
39
|
+
* Кожен мутант — окремий ланцюжок (kind: mutant-classify): tier1 = крок 1,
|
|
40
|
+
* tier2 = крок 2; fallback-вердикт = outcome:'fail' (LLM не впорався).
|
|
37
41
|
* @param {{file: string, mutants: object[]}} group
|
|
38
42
|
* @param {object} mutant
|
|
39
43
|
* @param {string} cwd
|
|
40
|
-
* @param {(prompt: string, model: string, cwd: string) => Promise<string>} callModelFn
|
|
44
|
+
* @param {(prompt: string, model: string, cwd: string, callOpts?: {chain?: object}) => Promise<string>} callModelFn
|
|
41
45
|
* @param {string} tier1 model-spec першого тиру ('' = pi-дефолт)
|
|
42
46
|
* @param {string} tier2 model-spec другого тиру ('' = pi-дефолт)
|
|
47
|
+
* @param {typeof startChain} makeChain фабрика ланцюжка
|
|
43
48
|
* @returns {Promise<object>} verdict
|
|
44
49
|
*/
|
|
45
|
-
async function classifyOne(group, mutant, cwd, callModelFn, tier1, tier2) {
|
|
50
|
+
async function classifyOne(group, mutant, cwd, callModelFn, tier1, tier2, makeChain) {
|
|
46
51
|
const prompt = `${SYSTEM_PROMPT}\n\n${buildUserPrompt({ ...mutant, file: group.file }, cwd)}`
|
|
47
52
|
const loc = `${group.file}:${mutant.line}:${mutant.col}`
|
|
53
|
+
const chain = makeChain({ kind: 'mutant-classify', unit: loc, cwd })
|
|
48
54
|
|
|
49
55
|
try {
|
|
50
|
-
const text = await callModelFn(prompt, tier1, cwd)
|
|
51
|
-
|
|
56
|
+
const text = await callModelFn(prompt, tier1, cwd, { chain })
|
|
57
|
+
const verdict = parseVerdict(text)
|
|
58
|
+
chain.end({ outcome: 'success', extra: verdictExtra(verdict, mutant) })
|
|
59
|
+
return verdict
|
|
52
60
|
} catch {
|
|
53
61
|
try {
|
|
54
|
-
const text = await callModelFn(prompt, tier2, cwd)
|
|
55
|
-
|
|
62
|
+
const text = await callModelFn(prompt, tier2, cwd, { chain })
|
|
63
|
+
const verdict = parseVerdict(text)
|
|
64
|
+
chain.end({ outcome: 'success', extra: verdictExtra(verdict, mutant) })
|
|
65
|
+
return verdict
|
|
56
66
|
} catch (error) {
|
|
57
67
|
console.warn(`⚠ coverage classify: ${loc} both tiers failed: ${error.message}`)
|
|
68
|
+
chain.end({ outcome: 'fail', extra: { error: String(error.message ?? error).slice(0, 200) } })
|
|
58
69
|
return { ...FALLBACK_VERDICT }
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Extra-поля фінального chain-запису мутанта.
|
|
76
|
+
* @param {{verdict: string, confidence: number}} verdict розпарсений вердикт
|
|
77
|
+
* @param {{replacement?: string}} mutant мутант
|
|
78
|
+
* @returns {object} extra
|
|
79
|
+
*/
|
|
80
|
+
function verdictExtra(verdict, mutant) {
|
|
81
|
+
return {
|
|
82
|
+
verdict: verdict.verdict,
|
|
83
|
+
confidence: verdict.confidence,
|
|
84
|
+
replacement: String(mutant.replacement ?? '').slice(0, 120)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
63
88
|
/**
|
|
64
89
|
* Класифікує survived мутантів через pi (N_LOCAL_MIN_MODEL → N_CLOUD_MIN_MODEL → fallback).
|
|
65
90
|
* @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived
|
|
66
91
|
* @param {string} cwd
|
|
67
|
-
* @param {{cachePath?: string, callModel?: (prompt: string, model: string, cwd: string) => Promise<string>,
|
|
68
|
-
* tier1?: string, tier2?: string}} [opts] `tier1`/`tier2` — явні model-specs (дефолт: LOCAL_MIN/CLOUD_MIN пакета;
|
|
69
|
-
* інжектовні, бо тир-константи фіксуються при імпорті й у тестах не стабляться через env)
|
|
92
|
+
* @param {{cachePath?: string, callModel?: (prompt: string, model: string, cwd: string, callOpts?: {chain?: object}) => Promise<string>,
|
|
93
|
+
* tier1?: string, tier2?: string, startChain?: typeof startChain}} [opts] `tier1`/`tier2` — явні model-specs (дефолт: LOCAL_MIN/CLOUD_MIN пакета;
|
|
94
|
+
* інжектовні, бо тир-константи фіксуються при імпорті й у тестах не стабляться через env); `startChain` — фабрика ланцюжка (інжект для тестів)
|
|
70
95
|
* @returns {Promise<Array<{key: string, verdict: object}>>}
|
|
71
96
|
*/
|
|
72
97
|
export async function classify(survived, cwd, opts = {}) {
|
|
73
98
|
const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
|
|
74
99
|
const callModelFn = opts.callModel ?? callModel
|
|
100
|
+
const makeChain = opts.startChain ?? startChain
|
|
75
101
|
const tier1 = opts.tier1 ?? LOCAL_MIN
|
|
76
102
|
const tier2 = opts.tier2 ?? CLOUD_MIN
|
|
77
103
|
const cacheModel = `${tier1 || 'default'}+${tier2 || 'cloud'}`
|
|
@@ -99,7 +125,7 @@ export async function classify(survived, cwd, opts = {}) {
|
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
127
|
if (!verdict) {
|
|
102
|
-
verdict = await classifyOne(group, mutant, cwd, callModelFn, tier1, tier2)
|
|
128
|
+
verdict = await classifyOne(group, mutant, cwd, callModelFn, tier1, tier2, makeChain)
|
|
103
129
|
if (cacheKey) {
|
|
104
130
|
cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
|
|
105
131
|
}
|
package/src/coverage-fix.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { readFile } from 'node:fs/promises'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { env } from 'node:process'
|
|
12
|
-
import { CLOUD_MAX } from '@
|
|
12
|
+
import { CLOUD_MAX } from '@7n/llm-lib/model-tiers'
|
|
13
13
|
import { callAgent } from './lib/llm.mjs'
|
|
14
14
|
|
|
15
15
|
const MODEL = env.N_CURSOR_COVERAGE_FIX_MODEL ?? CLOUD_MAX
|
|
@@ -41,7 +41,7 @@ export async function fixSurvivedMutants(survived, projectRoot, opts = {}) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Викликає агента через
|
|
44
|
+
* Викликає агента через `@7n/llm-lib` (SDK-embed, live-output до stdout) —
|
|
45
45
|
* заміна колишнього spawnSync pi CLI.
|
|
46
46
|
* @param {string} prompt текст промпта
|
|
47
47
|
* @param {string} model provider/model-id або '' для pi-дефолту
|
package/src/docs/assess-need.md
CHANGED
package/src/docs/coverage-fix.md
CHANGED
package/src/docs/fix-tests.md
CHANGED
package/src/docs/gen-tests.md
CHANGED
package/src/fix-tests.mjs
CHANGED
|
@@ -20,8 +20,9 @@ import { tmpdir } from 'node:os'
|
|
|
20
20
|
import { join, relative } from 'node:path'
|
|
21
21
|
import { env } from 'node:process'
|
|
22
22
|
import { callText, MEMORY_ERROR_RE } from './lib/llm.mjs'
|
|
23
|
-
import { CLOUD_MAX } from '@
|
|
24
|
-
import {
|
|
23
|
+
import { CLOUD_MAX } from '@7n/llm-lib/model-tiers'
|
|
24
|
+
import { startChain } from '@7n/llm-lib/chain'
|
|
25
|
+
import { budgetFor, capText, packBatch } from '@7n/llm-lib/prompt-budget'
|
|
25
26
|
import { findTestRules } from './gen-tests.mjs'
|
|
26
27
|
import { parseFailingTests } from './coverage-per-file.mjs'
|
|
27
28
|
import { resolveVitestRun } from './lib/vitest-shim.mjs'
|
|
@@ -322,17 +323,28 @@ function writeFixedFiles(fixed, remaining, dir) {
|
|
|
322
323
|
* @param {string} dir project root
|
|
323
324
|
* @param {{
|
|
324
325
|
* failures?: Array<{file: string, errors: string[]}>,
|
|
325
|
-
* callTextFn?: (prompt: string, opts?: object) => Promise<string
|
|
326
|
-
*
|
|
326
|
+
* callTextFn?: (prompt: string, opts?: object) => Promise<string>,
|
|
327
|
+
* startChain?: typeof startChain
|
|
328
|
+
* }} [opts] overrides for callText, pre-fetched failures і фабрика ланцюжка (інжект для тестів)
|
|
327
329
|
* @returns {Promise<{count: number, fixed: number, remaining: number}>} fix result summary
|
|
328
330
|
*/
|
|
329
331
|
export async function fixFailingTests(dir, opts = {}) {
|
|
330
|
-
const callTextFn =
|
|
331
|
-
opts.callTextFn ?? (prompt => callText(prompt, { model: MODEL, cwd: dir, maxTokens: budgetFor('fix').maxTokens }))
|
|
332
332
|
const failures = opts.failures ?? (await getFailingTests(dir))
|
|
333
333
|
|
|
334
334
|
if (failures.length === 0) return { count: 0, fixed: 0, remaining: 0 }
|
|
335
335
|
|
|
336
|
+
// Один ланцюжок на прогін: батчі змішують файли, per-file chain неможливий
|
|
337
|
+
// без ламання батчингу; кожен batch-виклик = крок.
|
|
338
|
+
const chain = (opts.startChain ?? startChain)({
|
|
339
|
+
kind: 'test-fix',
|
|
340
|
+
unit: `fix:${failures.length}files`,
|
|
341
|
+
cwd: dir
|
|
342
|
+
})
|
|
343
|
+
let batches = 0
|
|
344
|
+
const callTextFn =
|
|
345
|
+
opts.callTextFn ??
|
|
346
|
+
(prompt => callText(prompt, { model: MODEL, cwd: dir, maxTokens: budgetFor('fix').maxTokens, chain }))
|
|
347
|
+
|
|
336
348
|
console.log(`\n🔧 Виправляю ${failures.length} падаючих test-файлів (pi text mode)...\n`)
|
|
337
349
|
for (const f of failures) {
|
|
338
350
|
console.log(` • ${f.file} (${f.errors.length} помилок)`)
|
|
@@ -342,50 +354,64 @@ export async function fixFailingTests(dir, opts = {}) {
|
|
|
342
354
|
let remaining = failures
|
|
343
355
|
const attempts = new Map()
|
|
344
356
|
let prevDeferred = new Set()
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
357
|
+
try {
|
|
358
|
+
for (;;) {
|
|
359
|
+
// Спроби рахуються per-file (лише коли файл реально був у батчі),
|
|
360
|
+
// тож deferred-черга не з'їдає ліміт файлів, які ще не пробували
|
|
361
|
+
const eligible = remaining
|
|
362
|
+
.filter(f => (attempts.get(f.file) ?? 0) < MAX_FIX_ATTEMPTS)
|
|
363
|
+
// Анти-starvation: відкладені минулого разу файли йдуть першими
|
|
364
|
+
.toSorted((a, b) => (prevDeferred.has(b.file) ? 1 : 0) - (prevDeferred.has(a.file) ? 1 : 0))
|
|
365
|
+
if (eligible.length === 0) break
|
|
366
|
+
|
|
367
|
+
const batch = buildFixTestsBatch(eligible, dir)
|
|
368
|
+
if (batch.deferred.length) {
|
|
369
|
+
console.log(
|
|
370
|
+
` 📦 батч: ${batch.included.length} файлів, відкладено на наступний прохід: ${batch.deferred.length}`
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
for (const file of batch.included) attempts.set(file, (attempts.get(file) ?? 0) + 1)
|
|
374
|
+
prevDeferred = new Set(batch.deferred)
|
|
375
|
+
|
|
376
|
+
let response
|
|
377
|
+
try {
|
|
378
|
+
batches++
|
|
379
|
+
response = await callTextFn(batch.prompt)
|
|
380
|
+
} catch (error) {
|
|
381
|
+
// memory-guard: не звичайна per-file помилка — RAM-стеля фіксована, продовжувати
|
|
382
|
+
// до наступного файлу немає сенсу. Пробиваємо нагору до CLI, аби процес завершився.
|
|
383
|
+
if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
|
|
384
|
+
console.error(` ✗ pi помилка: ${error.message}`)
|
|
385
|
+
break
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const fixed = parseFixedFiles(response)
|
|
389
|
+
|
|
390
|
+
if (fixed.length === 0) {
|
|
391
|
+
console.error(' ✗ pi не повернула виправлений код')
|
|
392
|
+
break
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const includedSet = new Set(batch.included)
|
|
396
|
+
writeFixedFiles(
|
|
397
|
+
fixed,
|
|
398
|
+
eligible.filter(f => includedSet.has(f.file)),
|
|
399
|
+
dir
|
|
358
400
|
)
|
|
359
|
-
}
|
|
360
|
-
for (const file of batch.included) attempts.set(file, (attempts.get(file) ?? 0) + 1)
|
|
361
|
-
prevDeferred = new Set(batch.deferred)
|
|
362
401
|
|
|
363
|
-
|
|
364
|
-
try {
|
|
365
|
-
response = await callTextFn(batch.prompt)
|
|
366
|
-
} catch (error) {
|
|
367
|
-
// memory-guard: не звичайна per-file помилка — RAM-стеля фіксована, продовжувати
|
|
368
|
-
// до наступного файлу немає сенсу. Пробиваємо нагору до CLI, аби процес завершився.
|
|
369
|
-
if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
|
|
370
|
-
console.error(` ✗ pi помилка: ${error.message}`)
|
|
371
|
-
break
|
|
402
|
+
remaining = await getFailingTests(dir)
|
|
372
403
|
}
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
eligible.filter(f => includedSet.has(f.file)),
|
|
385
|
-
dir
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
remaining = await getFailingTests(dir)
|
|
404
|
+
} finally {
|
|
405
|
+
const fixedSoFar = failures.length - remaining.length
|
|
406
|
+
chain.end({
|
|
407
|
+
outcome: remaining.length === 0 ? 'success' : fixedSoFar > 0 ? 'partial' : 'fail',
|
|
408
|
+
extra: {
|
|
409
|
+
files: failures.map(f => f.file),
|
|
410
|
+
batches,
|
|
411
|
+
fixed: fixedSoFar,
|
|
412
|
+
remaining: remaining.length
|
|
413
|
+
}
|
|
414
|
+
})
|
|
389
415
|
}
|
|
390
416
|
|
|
391
417
|
const fixedCount = failures.length - remaining.length
|
package/src/gen-tests.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import { spawnSync } from 'node:child_process'
|
|
|
18
18
|
import { join, relative, dirname } from 'node:path'
|
|
19
19
|
import { callText, MEMORY_ERROR_RE } from './lib/llm.mjs'
|
|
20
20
|
import { env } from 'node:process'
|
|
21
|
-
import { budgetFor } from '@
|
|
21
|
+
import { budgetFor } from '@7n/llm-lib/prompt-budget'
|
|
22
|
+
import { startChain } from '@7n/llm-lib/chain'
|
|
22
23
|
import { resolveVitestRun } from './lib/vitest-shim.mjs'
|
|
23
24
|
import { extractExportsWithComplexity } from './classify-exports.mjs'
|
|
24
25
|
import { analyzeModule } from './lib/ast-analyze.mjs'
|
|
@@ -1149,9 +1150,10 @@ function resolveLocalModel(opts) {
|
|
|
1149
1150
|
* @param {PiCallFn} callTextFn cloud LLM caller
|
|
1150
1151
|
* @param {PiCallFn | null} localFn local LLM caller
|
|
1151
1152
|
* @param {GenerateOneFn | undefined} generateOne custom single-file generator
|
|
1153
|
+
* @param {typeof startChain} [makeChain] фабрика ланцюжка (інжект для тестів)
|
|
1152
1154
|
* @returns {Promise<void>} resolves after generation for this file completes
|
|
1153
1155
|
*/
|
|
1154
|
-
async function generateTestsForFile(fileInfo, dir, callTextFn, localFn, generateOne) {
|
|
1156
|
+
async function generateTestsForFile(fileInfo, dir, callTextFn, localFn, generateOne, makeChain = startChain) {
|
|
1155
1157
|
console.log(` → ${fileInfo.file} (${fileInfo.pct.toFixed(1)}%)`)
|
|
1156
1158
|
|
|
1157
1159
|
if (generateOne) {
|
|
@@ -1159,13 +1161,26 @@ async function generateTestsForFile(fileInfo, dir, callTextFn, localFn, generate
|
|
|
1159
1161
|
return
|
|
1160
1162
|
}
|
|
1161
1163
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
}
|
|
1164
|
+
// Ланцюжок файлу: усі виклики (header, per-export local/cloud спроби,
|
|
1165
|
+
// vitest-retry, length-retry) — кроки одного chain.
|
|
1166
|
+
const chain = makeChain({ kind: 'test-generate', unit: fileInfo.file, cwd: dir })
|
|
1167
|
+
const chainedCloud = (prompt, callOpts = {}) => callTextFn(prompt, { ...callOpts, chain })
|
|
1168
|
+
const chainedLocal = localFn ? (prompt, callOpts = {}) => localFn(prompt, { ...callOpts, chain }) : null
|
|
1169
|
+
let failed = null
|
|
1170
|
+
try {
|
|
1171
|
+
const exportsInfo = extractExportsWithComplexity(readSourceSnippet(join(dir, fileInfo.file)))
|
|
1172
|
+
if (chainedLocal && exportsInfo.length > 0) {
|
|
1173
|
+
await generatePerExport(fileInfo, dir, chainedCloud, chainedLocal)
|
|
1174
|
+
return
|
|
1175
|
+
}
|
|
1167
1176
|
|
|
1168
|
-
|
|
1177
|
+
await generateOneTest(fileInfo, dir, chainedCloud)
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
failed = String(error.message ?? error).slice(0, 200)
|
|
1180
|
+
throw error
|
|
1181
|
+
} finally {
|
|
1182
|
+
chain.end({ outcome: failed ? 'fail' : 'success', extra: failed ? { error: failed } : {} })
|
|
1183
|
+
}
|
|
1169
1184
|
}
|
|
1170
1185
|
|
|
1171
1186
|
/**
|
package/src/index.js
CHANGED
|
@@ -11,9 +11,22 @@ export async function run(args) {
|
|
|
11
11
|
console.log(' Runs coverage analysis, generates missing tests, then mutation testing.')
|
|
12
12
|
console.log(' --no-mutation Skip mutation testing phase.')
|
|
13
13
|
console.log(' Defaults to current directory when no argument is given.')
|
|
14
|
+
console.log('Usage: n coverage [--fix] [--changed]')
|
|
15
|
+
console.log(' Coverage + mutation testing only (no test generation) — writes COVERAGE.md.')
|
|
16
|
+
console.log(' --fix Agent fixes survived mutants, then re-runs coverage.')
|
|
17
|
+
console.log(' --changed Scope to files changed vs merge-base with main — no COVERAGE.md write.')
|
|
14
18
|
return 0
|
|
15
19
|
}
|
|
16
20
|
|
|
21
|
+
if (first === 'coverage') {
|
|
22
|
+
const { runCoverageCli } = await import('./coverage/coverage.mjs')
|
|
23
|
+
return runCoverageCli({
|
|
24
|
+
cwd: getCwd(),
|
|
25
|
+
fix: flags.includes('--fix'),
|
|
26
|
+
changed: flags.includes('--changed')
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
const dir = first ? resolve(first) : getCwd()
|
|
18
31
|
const noMutation = flags.includes('--no-mutation')
|
|
19
32
|
const { runAutoTest } = await import('./run.mjs')
|
package/src/lib/docs/llm.md
CHANGED
|
@@ -3,12 +3,12 @@ type: JS Module
|
|
|
3
3
|
title: llm.mjs
|
|
4
4
|
resource: npm/src/lib/llm.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: b126a1aa
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
## Огляд
|
|
10
10
|
|
|
11
|
-
Тонкий адаптер @7n/test над пакетом `@
|
|
11
|
+
Тонкий адаптер @7n/test над пакетом `@7n/llm-lib` (Ф3 спеки llm-lib-extraction у репо cursor): зберігає звичний контракт `callText`/`callAgent` для внутрішніх колерів (gen-tests, fix-tests, assess-need, coverage-classify, coverage-fix), а транспорт, registry і трасування повністю живуть у пакеті. Замінив колишній `pi-client.mjs` з власним SDK-плюмбінгом, retry/backoff і прямим omlx-HTTP.
|
|
12
12
|
|
|
13
13
|
## Поведінка
|
|
14
14
|
|
package/src/lib/llm.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Тонкий адаптер
|
|
2
|
+
* Тонкий адаптер `@7n/test` над `@7n/llm-lib` (Ф3 спеки llm-lib-extraction):
|
|
3
3
|
* зберігає звичний контракт `callText`/`callAgent` для внутрішніх колерів,
|
|
4
4
|
* але транспорт/registry/trace повністю живуть у пакеті.
|
|
5
5
|
*
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* подвоєння `maxTokens` на обрізаній відповіді (`stopReason: 'length'`):
|
|
11
11
|
* це семантичний повтор без пауз, а не очікування зайнятого сервера.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
14
|
-
import { runAgentSkill } from '@
|
|
13
|
+
import { runOneShot } from '@7n/llm-lib/one-shot'
|
|
14
|
+
import { runAgentSkill } from '@7n/llm-lib/agent-skill'
|
|
15
|
+
|
|
15
16
|
|
|
16
|
-
export { MEMORY_ERROR_RE }
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Стеля відповіді моделі для подвоєння на `stopReason: 'length'` — межа
|
|
@@ -33,6 +33,7 @@ const AGENT_TIMEOUT_MS = 900_000
|
|
|
33
33
|
* @param {string} [opts.model] provider/model-id (напр. "openai/gpt-5.5"); без значення — default pi
|
|
34
34
|
* @param {number} [opts.maxTokens] стеля відповіді для цього виклику; на
|
|
35
35
|
* `stopReason: 'length'` виклик повторюється один раз із подвоєною стелею
|
|
36
|
+
* @param {object} [opts.chain] chain handle (`@7n/llm-lib/chain`) — виклик стає кроком ланцюжка
|
|
36
37
|
* @param {object} [opts.deps] інжекти для тестів (прокидаються у runOneShot)
|
|
37
38
|
* @returns {Promise<string>} текстова відповідь моделі
|
|
38
39
|
*/
|
|
@@ -44,6 +45,7 @@ export async function callText(prompt, opts = {}) {
|
|
|
44
45
|
timeoutMs: 0,
|
|
45
46
|
cwd: opts.cwd,
|
|
46
47
|
caller: '7n-test:text',
|
|
48
|
+
chain: opts.chain ?? null,
|
|
47
49
|
deps: opts.deps
|
|
48
50
|
})
|
|
49
51
|
if (r.error) throw new Error(r.error)
|
|
@@ -66,6 +68,7 @@ export async function callText(prompt, opts = {}) {
|
|
|
66
68
|
* @param {string} cwd робоча директорія, куди агент може писати файли
|
|
67
69
|
* @param {object} [opts] додаткові параметри
|
|
68
70
|
* @param {string} [opts.model] provider/model-id або '' для pi-дефолту
|
|
71
|
+
* @param {object} [opts.chain] chain handle (`@7n/llm-lib/chain`) — виклик стає кроком ланцюжка
|
|
69
72
|
* @param {object} [opts.deps] інжекти для тестів (прокидаються у runAgentSkill)
|
|
70
73
|
* @returns {Promise<void>} проміс завершується після виконання агента
|
|
71
74
|
*/
|
|
@@ -77,7 +80,10 @@ export async function callAgent(prompt, cwd, opts = {}) {
|
|
|
77
80
|
timeoutMs: AGENT_TIMEOUT_MS,
|
|
78
81
|
maxTokens: 0, // без стелі: агент пише цілі тест-файли (паритет зі старим CLI-шляхом)
|
|
79
82
|
caller: 'agent:7n-test',
|
|
83
|
+
chain: opts.chain ?? null,
|
|
80
84
|
deps: opts.deps
|
|
81
85
|
})
|
|
82
86
|
if (r.error) throw new Error(r.error)
|
|
83
87
|
}
|
|
88
|
+
|
|
89
|
+
export {MEMORY_ERROR_RE} from '@7n/llm-lib/one-shot'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Резолвить корінь(и) JS-коду в проєкті: для workspace-projects — усі workspaces
|
|
3
|
+
* (з підтримкою glob-патернів типу `cf/*`), для single-package — корінь cwd.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { glob, readFile } from 'node:fs/promises'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const WORKSPACE_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**']
|
|
10
|
+
const PACKAGE_JSON_SUFFIX_RE = /[/\\]package\.json$/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Розгортає один workspace-патерн у список абсолютних шляхів каталогів з package.json.
|
|
14
|
+
* Літеральні патерни перевіряються через existsSync; glob-патерни — через node:fs/promises#glob.
|
|
15
|
+
* @param {string} cwd корінь проєкту
|
|
16
|
+
* @param {string} pattern workspace-патерн з package.json (наприклад, `app` або `cf/*`)
|
|
17
|
+
* @returns {Promise<string[]>} абсолютні шляхи до workspace-каталогів
|
|
18
|
+
*/
|
|
19
|
+
async function expandWorkspacePattern(cwd, pattern) {
|
|
20
|
+
if (!pattern.includes('*')) {
|
|
21
|
+
const wsPath = join(cwd, pattern)
|
|
22
|
+
return existsSync(join(wsPath, 'package.json')) ? [wsPath] : []
|
|
23
|
+
}
|
|
24
|
+
const results = []
|
|
25
|
+
for await (const rel of glob(`${pattern}/package.json`, { cwd, exclude: WORKSPACE_GLOB_IGNORE })) {
|
|
26
|
+
const wsRel = rel.replace(PACKAGE_JSON_SUFFIX_RE, '')
|
|
27
|
+
results.push(join(cwd, wsRel))
|
|
28
|
+
}
|
|
29
|
+
return results.toSorted()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} cwd корінь проєкту (де кореневий package.json)
|
|
34
|
+
* @returns {Promise<string|null>} абсолютний шлях до JS-root або null без кореневого package.json
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveJsRoot(cwd) {
|
|
37
|
+
const roots = await resolveAllJsRoots(cwd)
|
|
38
|
+
if (roots.length === 0) return null
|
|
39
|
+
return roots[0]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Plural-варіант: повертає всі JS-roots проєкту. Для workspace-projects — кожен
|
|
44
|
+
* workspace з власним `package.json` (з розгортанням glob-патернів); для
|
|
45
|
+
* single-package — `[cwd]`. Порожній масив без кореневого package.json.
|
|
46
|
+
* @param {string} cwd корінь проєкту
|
|
47
|
+
* @returns {Promise<string[]>} абсолютні шляхи до всіх JS-roots
|
|
48
|
+
*/
|
|
49
|
+
export async function resolveAllJsRoots(cwd) {
|
|
50
|
+
const rootPkgPath = join(cwd, 'package.json')
|
|
51
|
+
if (!existsSync(rootPkgPath)) return []
|
|
52
|
+
const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
|
|
53
|
+
const patterns = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
|
|
54
|
+
if (patterns.length === 0) return [cwd]
|
|
55
|
+
const roots = []
|
|
56
|
+
for (const pattern of patterns) {
|
|
57
|
+
roots.push(...(await expandWorkspacePattern(cwd, pattern)))
|
|
58
|
+
}
|
|
59
|
+
return roots.length > 0 ? roots : [cwd]
|
|
60
|
+
}
|