@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 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.13.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
- "@nitra/llm-lib": "^1.0.0",
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
- * Канонічна команда `n coverage`: збирає метрики покриття + мутаційного
3
- * тестування з усіх провайдерів, чиє правило активне в `.n-cursor.json#rules`,
4
- * агрегує та записує COVERAGE.md у корінь проєкту.
2
+ * Канонічна команда `@7n/test coverage`: збирає метрики покриття + мутаційного
3
+ * тестування через вбудований JS-колектор (`js-collector.mjs`), агрегує та
4
+ * записує COVERAGE.md у корінь проєкту.
5
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`.
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: discovery провайдерів за `.n-cursor.json#rules`,
229
- * detect+collect для кожного, агрегація, запис COVERAGE.md.
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
- 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
- }
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('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
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 для `n coverage [--fix] [--changed]`.
305
- * @param {{fix?:boolean, changed?:boolean, rulesDir?:string}} [opts]
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, rulesDir: opts.rulesDir }))
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
+ }
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: index.mjs
4
4
  resource: npm/src/coverage-classify/index.mjs
5
5
  docgen:
6
- crc: bc7320ff
6
+ crc: 6f120c02
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -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 '@nitra/llm-lib/model-tiers'
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
- return parseVerdict(text)
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
- return parseVerdict(text)
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
  }
@@ -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 '@nitra/llm-lib/model-tiers'
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
- * Викликає агента через @nitra/llm-lib (SDK-embed, live-output до stdout) —
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-дефолту
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: assess-need.mjs
4
4
  resource: npm/src/assess-need.mjs
5
5
  docgen:
6
- crc: 960f8332
6
+ crc: 20c3d440
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: coverage-fix.mjs
4
4
  resource: npm/src/coverage-fix.mjs
5
5
  docgen:
6
- crc: d0a20c67
6
+ crc: db577cf1
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: fix-tests.mjs
4
4
  resource: npm/src/fix-tests.mjs
5
5
  docgen:
6
- crc: 6597b7fc
6
+ crc: 713e7889
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: gen-tests.mjs
4
4
  resource: npm/src/gen-tests.mjs
5
5
  docgen:
6
- crc: 99d3e9b7
6
+ crc: 0183f9b9
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
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 '@nitra/llm-lib/model-tiers'
24
- import { budgetFor, capText, packBatch } from '@nitra/llm-lib/prompt-budget'
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
- * }} [opts] overrides for callText and pre-fetched failures
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
- for (;;) {
346
- // Спроби рахуються per-file (лише коли файл реально був у батчі),
347
- // тож deferred-черга не з'їдає ліміт файлів, які ще не пробували
348
- const eligible = remaining
349
- .filter(f => (attempts.get(f.file) ?? 0) < MAX_FIX_ATTEMPTS)
350
- // Анти-starvation: відкладені минулого разу файли йдуть першими
351
- .toSorted((a, b) => (prevDeferred.has(b.file) ? 1 : 0) - (prevDeferred.has(a.file) ? 1 : 0))
352
- if (eligible.length === 0) break
353
-
354
- const batch = buildFixTestsBatch(eligible, dir)
355
- if (batch.deferred.length) {
356
- console.log(
357
- ` 📦 батч: ${batch.included.length} файлів, відкладено на наступний прохід: ${batch.deferred.length}`
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
- let response
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 fixed = parseFixedFiles(response)
375
-
376
- if (fixed.length === 0) {
377
- console.error(' ✗ pi не повернула виправлений код')
378
- break
379
- }
380
-
381
- const includedSet = new Set(batch.included)
382
- writeFixedFiles(
383
- fixed,
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 '@nitra/llm-lib/prompt-budget'
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
- const exportsInfo = extractExportsWithComplexity(readSourceSnippet(join(dir, fileInfo.file)))
1163
- if (localFn && exportsInfo.length > 0) {
1164
- await generatePerExport(fileInfo, dir, callTextFn, localFn)
1165
- return
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
- await generateOneTest(fileInfo, dir, callTextFn)
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')
@@ -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: 41c6ec97
6
+ crc: b126a1aa
7
7
  ---
8
8
 
9
9
  ## Огляд
10
10
 
11
- Тонкий адаптер @7n/test над пакетом `@nitra/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.
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
- * Тонкий адаптер @7n/test над @nitra/llm-lib (Ф3 спеки llm-lib-extraction):
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 { MEMORY_ERROR_RE, runOneShot } from '@nitra/llm-lib/one-shot'
14
- import { runAgentSkill } from '@nitra/llm-lib/agent-skill'
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
+ }