@7n/test 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/bin/7n-test.js +4 -1
- package/package.json +1 -1
- package/src/coverage-per-file.mjs +2 -0
- package/src/docs/coverage-per-file.md +1 -1
- package/src/docs/fix-tests.md +1 -1
- package/src/docs/gen-tests.md +1 -1
- package/src/fix-tests.mjs +118 -34
- package/src/gen-tests.mjs +6 -5
- package/src/lib/docs/runtime-probe.md +1 -1
- package/src/lib/docs/vitest-shim.md +3 -3
- package/src/lib/pi-client.mjs +150 -45
- package/src/lib/prompt-budget.mjs +125 -0
- package/src/lib/runtime-probe.mjs +139 -15
- package/src/lib/vitest-shim.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0] - 2026-07-04
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 🤖 feat(core): спільний prompt-budget (бюджет символів + per-call maxTokens за типом задачі через streamFn-обгортку), батчинг fix-tests під бюджет з анти-starvation, повтор на обрізану генерацію (stopReason length)
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- 🤖 fix(core): coverage-скан виключає `**/*.d.ts` — vitest 4 прибрав дефолтний exclude, і v8-remap падав із RolldownError на TS-синтаксисі декларацій у цільових проєктах (CLI-прапорець у coverage-per-file та exclude у shim-конфігу)
|
|
12
|
+
- CLI більше не висить після завершення роботи: явний process.exit у bin (незакриті pi-сесії тримали event loop) і session.dispose() у finally для callText/callAgent
|
|
13
|
+
|
|
14
|
+
## [0.11.2] - 2026-07-04
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- 🤖 fix(core): кап runtime-probe виходів (shape-summary + tmp-cwd) і bounded backoff на omlx memory-guard замість миттєвого падіння
|
|
19
|
+
|
|
3
20
|
## [0.11.1] - 2026-07-04
|
|
4
21
|
|
|
5
22
|
### Changed
|
package/bin/7n-test.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { run } from '../src/index.js'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Незакриті pi-сесії (createAgentSession) тримають event loop, тож без явного
|
|
5
|
+
// process.exit процес висить після завершення роботи навіть з кодом 0.
|
|
6
|
+
// eslint-disable-next-line n/no-process-exit
|
|
7
|
+
process.exit(await run(process.argv.slice(2)))
|
package/package.json
CHANGED
|
@@ -112,6 +112,8 @@ export async function measureCoveragePerFile(dir) {
|
|
|
112
112
|
'--coverage.reporter=lcov',
|
|
113
113
|
// vitest 4 прибрав coverage.all — явний include, щоб файли без тестів лишались у lcov (0%)
|
|
114
114
|
'--coverage.include=**/*.{js,mjs,ts,vue}',
|
|
115
|
+
// vitest 4 прибрав дефолтний exclude **/*.d.ts — без нього v8-remap падає на TS-синтаксисі декларацій
|
|
116
|
+
'--coverage.exclude=**/*.d.ts',
|
|
115
117
|
`--coverage.reportsDirectory=${lcovDir}`,
|
|
116
118
|
'--reporter=verbose',
|
|
117
119
|
'--reporter=json',
|
package/src/docs/fix-tests.md
CHANGED
package/src/docs/gen-tests.md
CHANGED
package/src/fix-tests.mjs
CHANGED
|
@@ -20,6 +20,7 @@ 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/pi-client.mjs'
|
|
23
|
+
import { budgetFor, capText, packBatch } from './lib/prompt-budget.mjs'
|
|
23
24
|
import { findTestRules } from './gen-tests.mjs'
|
|
24
25
|
import { parseFailingTests } from './coverage-per-file.mjs'
|
|
25
26
|
import { resolveVitestRun } from './lib/vitest-shim.mjs'
|
|
@@ -108,6 +109,44 @@ function extractCode(text) {
|
|
|
108
109
|
return text.slice(bodyStart + 1, end).trim()
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Читає складники секції одного падаючого файлу для промпту.
|
|
114
|
+
* @param {{file: string, errors: string[]}} failure падаючий тест-файл
|
|
115
|
+
* @param {string} [dir] project root
|
|
116
|
+
* @returns {{file: string, errBlock: string, testCode: string, sourceCode: string|null}} сирі частини секції
|
|
117
|
+
*/
|
|
118
|
+
function readFailureParts({ file, errors }, dir) {
|
|
119
|
+
const absPath = dir ? join(dir, file) : file
|
|
120
|
+
const testCode = existsSync(absPath) ? readFileSync(absPath, 'utf8').slice(0, 6000) : '(файл не знайдено)'
|
|
121
|
+
// Heuristically find source file: strip tests/ prefix from path
|
|
122
|
+
const sourcePath = inferSourcePath(absPath)
|
|
123
|
+
const sourceCode =
|
|
124
|
+
sourcePath && existsSync(sourcePath) ? readFileSync(sourcePath, 'utf8').slice(0, MAX_SRC_BYTES) : null
|
|
125
|
+
return { file, errBlock: errors.join('\n\n'), testCode, sourceCode }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Складає секцію файлу з готових частин.
|
|
130
|
+
* @param {{file: string, errBlock: string, testCode: string, sourceCode: string|null}} parts частини з `readFailureParts`
|
|
131
|
+
* @returns {string} markdown-секція файлу
|
|
132
|
+
*/
|
|
133
|
+
function buildFileSection({ file, errBlock, testCode, sourceCode }) {
|
|
134
|
+
return [
|
|
135
|
+
`### \`${file}\``,
|
|
136
|
+
'',
|
|
137
|
+
'**Помилки:**',
|
|
138
|
+
'```',
|
|
139
|
+
errBlock,
|
|
140
|
+
'```',
|
|
141
|
+
'',
|
|
142
|
+
'**Поточний тест-файл:**',
|
|
143
|
+
'```js',
|
|
144
|
+
testCode,
|
|
145
|
+
'```',
|
|
146
|
+
...(sourceCode ? ['', '**Source (для довідки, не міняй):**', '```js', sourceCode, '```'] : [])
|
|
147
|
+
].join('\n')
|
|
148
|
+
}
|
|
149
|
+
|
|
111
150
|
/**
|
|
112
151
|
* Builds a prompt to fix one failing test file.
|
|
113
152
|
* Includes current file content so the LLM doesn't need file-read tools.
|
|
@@ -117,34 +156,17 @@ function extractCode(text) {
|
|
|
117
156
|
*/
|
|
118
157
|
export function buildFixTestsPrompt(failures, dir) {
|
|
119
158
|
const testRules = dir ? findTestRules(dir) : null
|
|
159
|
+
const sections = failures.map(f => buildFileSection(readFailureParts(f, dir)))
|
|
160
|
+
return buildPromptShell(sections, testRules)
|
|
161
|
+
}
|
|
120
162
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
sourcePath && existsSync(sourcePath) ? readFileSync(sourcePath, 'utf8').slice(0, MAX_SRC_BYTES) : null
|
|
129
|
-
|
|
130
|
-
const errBlock = errors.join('\n\n')
|
|
131
|
-
|
|
132
|
-
return [
|
|
133
|
-
`### \`${file}\``,
|
|
134
|
-
'',
|
|
135
|
-
'**Помилки:**',
|
|
136
|
-
'```',
|
|
137
|
-
errBlock,
|
|
138
|
-
'```',
|
|
139
|
-
'',
|
|
140
|
-
'**Поточний тест-файл:**',
|
|
141
|
-
'```js',
|
|
142
|
-
testCode,
|
|
143
|
-
'```',
|
|
144
|
-
...(sourceCode ? ['', '**Source (для довідки, не міняй):**', '```js', sourceCode, '```'] : [])
|
|
145
|
-
].join('\n')
|
|
146
|
-
})
|
|
147
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Обгортає секції файлів спільними правилами й інструкцією формату відповіді.
|
|
165
|
+
* @param {string[]} sections markdown-секції падаючих файлів
|
|
166
|
+
* @param {string|null} testRules конвенції тестів проєкту
|
|
167
|
+
* @returns {string} повний текст промпту
|
|
168
|
+
*/
|
|
169
|
+
function buildPromptShell(sections, testRules) {
|
|
148
170
|
return [
|
|
149
171
|
'Виправ падаючі unit-тести. Поверни ПОВНИЙ вміст КОЖНОГО виправленого тест-файлу.',
|
|
150
172
|
'',
|
|
@@ -176,6 +198,54 @@ export function buildFixTestsPrompt(failures, dir) {
|
|
|
176
198
|
].join('\n')
|
|
177
199
|
}
|
|
178
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Складає промпт під бюджет `budgetFor('fix')`: скільки файлів влазить —
|
|
203
|
+
* стільки в батч (найменші першими), решта — у `deferred` на наступний
|
|
204
|
+
* прохід. Файл, що сам-один перевищує бюджет, отримує соло-промпт із
|
|
205
|
+
* внутрішнім обрізанням (`fitToBudget`: спершу ріжеться source-довідка,
|
|
206
|
+
* потім vitest-помилки; сам тест-код захищений) — ніколи не скипається.
|
|
207
|
+
* @param {Array<{file: string, errors: string[]}>} failures падаючі тест-файли
|
|
208
|
+
* @param {string} [dir] project root
|
|
209
|
+
* @returns {{prompt: string, included: string[], deferred: string[]}} промпт + розподіл файлів
|
|
210
|
+
*/
|
|
211
|
+
export function buildFixTestsBatch(failures, dir) {
|
|
212
|
+
const testRules = dir ? findTestRules(dir) : null
|
|
213
|
+
const shellOverhead = buildPromptShell([], testRules).length
|
|
214
|
+
const sectionBudget = Math.max(1000, budgetFor('fix').maxPromptChars - shellOverhead)
|
|
215
|
+
|
|
216
|
+
const parts = failures.map(f => {
|
|
217
|
+
const p = readFailureParts(f, dir)
|
|
218
|
+
return { ...p, section: buildFileSection(p) }
|
|
219
|
+
})
|
|
220
|
+
const { included, deferred } = packBatch(
|
|
221
|
+
parts.map(p => ({ key: p.file, size: p.section.length })),
|
|
222
|
+
sectionBudget
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if (included.length === 0) {
|
|
226
|
+
// Соло-режим: навіть найменший файл не влазить — жорстко обрізаємо
|
|
227
|
+
// нутрощі секції (source геть, помилки до чверті бюджету, тест-код —
|
|
228
|
+
// найцінніше — до половини), замість мовчазного skip
|
|
229
|
+
const smallest = parts.toSorted((a, b) => a.section.length - b.section.length)[0]
|
|
230
|
+
console.log(` ⚠ ${smallest.file}: секція завелика (${smallest.section.length} симв.) — соло-виклик з обрізанням`)
|
|
231
|
+
const section = buildFileSection({
|
|
232
|
+
file: smallest.file,
|
|
233
|
+
errBlock: capText(smallest.errBlock, Math.floor(sectionBudget / 4)),
|
|
234
|
+
testCode: capText(smallest.testCode, Math.floor(sectionBudget / 2)),
|
|
235
|
+
sourceCode: null
|
|
236
|
+
})
|
|
237
|
+
return {
|
|
238
|
+
prompt: buildPromptShell([section], testRules),
|
|
239
|
+
included: [smallest.file],
|
|
240
|
+
deferred: failures.map(f => f.file).filter(f => f !== smallest.file)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const includedSet = new Set(included)
|
|
245
|
+
const sections = parts.filter(p => includedSet.has(p.file)).map(p => p.section)
|
|
246
|
+
return { prompt: buildPromptShell(sections, testRules), included, deferred }
|
|
247
|
+
}
|
|
248
|
+
|
|
179
249
|
/**
|
|
180
250
|
* Parses fenced JavaScript blocks with file markers from LLM response.
|
|
181
251
|
* @param {string} text LLM response text.
|
|
@@ -256,7 +326,8 @@ function writeFixedFiles(fixed, remaining, dir) {
|
|
|
256
326
|
* @returns {Promise<{count: number, fixed: number, remaining: number}>} fix result summary
|
|
257
327
|
*/
|
|
258
328
|
export async function fixFailingTests(dir, opts = {}) {
|
|
259
|
-
const callTextFn =
|
|
329
|
+
const callTextFn =
|
|
330
|
+
opts.callTextFn ?? (prompt => callText(prompt, { model: MODEL, cwd: dir, maxTokens: budgetFor('fix').maxTokens }))
|
|
260
331
|
const failures = opts.failures ?? (await getFailingTests(dir))
|
|
261
332
|
|
|
262
333
|
if (failures.length === 0) return { count: 0, fixed: 0, remaining: 0 }
|
|
@@ -268,15 +339,27 @@ export async function fixFailingTests(dir, opts = {}) {
|
|
|
268
339
|
console.log()
|
|
269
340
|
|
|
270
341
|
let remaining = failures
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
342
|
+
const attempts = new Map()
|
|
343
|
+
let prevDeferred = new Set()
|
|
344
|
+
for (;;) {
|
|
345
|
+
// Спроби рахуються per-file (лише коли файл реально був у батчі),
|
|
346
|
+
// тож deferred-черга не з'їдає ліміт файлів, які ще не пробували
|
|
347
|
+
const eligible = remaining
|
|
348
|
+
.filter(f => (attempts.get(f.file) ?? 0) < MAX_FIX_ATTEMPTS)
|
|
349
|
+
// Анти-starvation: відкладені минулого разу файли йдуть першими
|
|
350
|
+
.toSorted((a, b) => (prevDeferred.has(b.file) ? 1 : 0) - (prevDeferred.has(a.file) ? 1 : 0))
|
|
351
|
+
if (eligible.length === 0) break
|
|
352
|
+
|
|
353
|
+
const batch = buildFixTestsBatch(eligible, dir)
|
|
354
|
+
if (batch.deferred.length) {
|
|
355
|
+
console.log(` 📦 батч: ${batch.included.length} файлів, відкладено на наступний прохід: ${batch.deferred.length}`)
|
|
274
356
|
}
|
|
357
|
+
for (const file of batch.included) attempts.set(file, (attempts.get(file) ?? 0) + 1)
|
|
358
|
+
prevDeferred = new Set(batch.deferred)
|
|
275
359
|
|
|
276
|
-
const prompt = buildFixTestsPrompt(remaining, dir)
|
|
277
360
|
let response
|
|
278
361
|
try {
|
|
279
|
-
response = await callTextFn(prompt)
|
|
362
|
+
response = await callTextFn(batch.prompt)
|
|
280
363
|
} catch (error) {
|
|
281
364
|
// memory-guard: не звичайна per-file помилка — RAM-стеля фіксована, продовжувати
|
|
282
365
|
// до наступного файлу немає сенсу. Пробиваємо нагору до CLI, аби процес завершився.
|
|
@@ -292,7 +375,8 @@ export async function fixFailingTests(dir, opts = {}) {
|
|
|
292
375
|
break
|
|
293
376
|
}
|
|
294
377
|
|
|
295
|
-
|
|
378
|
+
const includedSet = new Set(batch.included)
|
|
379
|
+
writeFixedFiles(fixed, eligible.filter(f => includedSet.has(f.file)), dir)
|
|
296
380
|
|
|
297
381
|
remaining = await getFailingTests(dir)
|
|
298
382
|
}
|
package/src/gen-tests.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
|
|
|
17
17
|
import { spawnSync } from 'node:child_process'
|
|
18
18
|
import { join, relative, dirname } from 'node:path'
|
|
19
19
|
import { callText, MEMORY_ERROR_RE } from './lib/pi-client.mjs'
|
|
20
|
+
import { budgetFor } from './lib/prompt-budget.mjs'
|
|
20
21
|
import { resolveVitestRun } from './lib/vitest-shim.mjs'
|
|
21
22
|
import { extractExportsWithComplexity } from './classify-exports.mjs'
|
|
22
23
|
import { analyzeModule } from './lib/ast-analyze.mjs'
|
|
@@ -867,7 +868,7 @@ async function generateSharedHeader(ctx, dir, callTextFn) {
|
|
|
867
868
|
try {
|
|
868
869
|
const headerResp = await callTextFn(
|
|
869
870
|
buildHeaderPrompt({ file, testFilePath, importPath, hasSideEffects, content, exports, testRules, astInfo }),
|
|
870
|
-
{ cwd: dir }
|
|
871
|
+
{ cwd: dir, maxTokens: budgetFor('header').maxTokens }
|
|
871
872
|
)
|
|
872
873
|
const header = extractCode(headerResp)
|
|
873
874
|
if (header) return header
|
|
@@ -892,7 +893,7 @@ async function generateLocalBlock(opts) {
|
|
|
892
893
|
const result = await generateBlockWithLoop(
|
|
893
894
|
blockPrompt,
|
|
894
895
|
callLocalFn,
|
|
895
|
-
{},
|
|
896
|
+
{ maxTokens: budgetFor('block').maxTokens },
|
|
896
897
|
header,
|
|
897
898
|
dir,
|
|
898
899
|
testDir,
|
|
@@ -920,7 +921,7 @@ async function generateCloudBlock(opts) {
|
|
|
920
921
|
const result = await generateBlockWithLoop(
|
|
921
922
|
blockPrompt,
|
|
922
923
|
callTextFn,
|
|
923
|
-
{ cwd: dir },
|
|
924
|
+
{ cwd: dir, maxTokens: budgetFor('block').maxTokens },
|
|
924
925
|
header,
|
|
925
926
|
dir,
|
|
926
927
|
testDir,
|
|
@@ -1109,7 +1110,7 @@ async function generateOneTest(fileInfo, dir, callTextFn) {
|
|
|
1109
1110
|
const prompt = buildSingleFilePrompt(fileInfo, dir)
|
|
1110
1111
|
let response
|
|
1111
1112
|
try {
|
|
1112
|
-
response = await callTextFn(prompt, { cwd: dir })
|
|
1113
|
+
response = await callTextFn(prompt, { cwd: dir, maxTokens: budgetFor('single-file').maxTokens })
|
|
1113
1114
|
} catch (error) {
|
|
1114
1115
|
if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
|
|
1115
1116
|
console.error(` ✗ pi помилка для ${fileInfo.file}: ${error.message}`)
|
|
@@ -1181,7 +1182,7 @@ export async function generateTests(files, dir, opts = {}) {
|
|
|
1181
1182
|
|
|
1182
1183
|
const callTextFn = opts.callText ?? callText
|
|
1183
1184
|
const localModel = resolveLocalModel(opts)
|
|
1184
|
-
const localFn = localModel ? prompt => callTextFn(prompt, { model: localModel, cwd: dir }) : null
|
|
1185
|
+
const localFn = localModel ? (prompt, opts = {}) => callTextFn(prompt, { ...opts, model: localModel, cwd: dir }) : null
|
|
1185
1186
|
|
|
1186
1187
|
const mode = localFn ? `per-export (local:${localModel} + cloud)` : 'single-file (cloud)'
|
|
1187
1188
|
console.log(`\n🤖 Генерую тести для ${files.length} файлів [${mode}]...\n`)
|
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: vitest-shim.mjs
|
|
4
4
|
resource: npm/src/lib/vitest-shim.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 144cb026
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
score: 100
|
|
9
9
|
issues: judge:inaccurate:0.97
|
|
@@ -18,7 +18,7 @@ docgen:
|
|
|
18
18
|
|
|
19
19
|
VITEST_BIN — Абсолютний шлях до вбудованого виконуваного файлу Vitest.
|
|
20
20
|
VITEST_SHIM_CONFIG — Абсолютний шлях до тимчасового файлу конфігурації Vitest, що забезпечує роботу збірки без встановлення Vitest у цільовому проекті.
|
|
21
|
-
ensureVitestShim — Гарантує існування файлу конфігурації Vitest, записуючи мінімальний конфіг у тимчасову директорію, ігноруючи шляхи до `node_modules`.
|
|
21
|
+
ensureVitestShim — Гарантує існування файлу конфігурації Vitest, записуючи мінімальний конфіг у тимчасову директорію, ігноруючи шляхи до `node_modules` та декларації типів `**/*.d.ts`.
|
|
22
22
|
|
|
23
23
|
## Публічний API
|
|
24
24
|
|
|
@@ -28,4 +28,4 @@ ensureVitestShim — Гарантує існування файлу конфіг
|
|
|
28
28
|
|
|
29
29
|
## Гарантії поведінки
|
|
30
30
|
|
|
31
|
-
- Свідомо пропускає шляхи: `node_modules
|
|
31
|
+
- Свідомо пропускає шляхи: `node_modules` і декларації типів `**/*.d.ts` (vitest 4 прибрав дефолтний exclude — без нього v8-remap падає на TS-синтаксисі декларацій).
|
package/src/lib/pi-client.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Programmatic pi client via
|
|
2
|
+
* Programmatic pi client via `@earendil-works/pi-coding-agent` SDK.
|
|
3
3
|
* Replaces spawnSync('pi', ...) with direct in-process calls.
|
|
4
4
|
*
|
|
5
5
|
* Two modes:
|
|
@@ -9,83 +9,168 @@
|
|
|
9
9
|
* Both retry transient connection failures (e.g. a shared local model server
|
|
10
10
|
* that's momentarily busy under concurrent load) with exponential backoff
|
|
11
11
|
* instead of failing the caller's file on the first hiccup. A memory-guard
|
|
12
|
-
* rejection
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* rejection gets its own longer bounded backoff: oMLX (local model server)
|
|
13
|
+
* одночасно, тож відмова зазвичай відображає залишкову пам'ять попереднього
|
|
14
|
+
* запиту, яку сервер вивільняє сам за десятки секунд. Лише після
|
|
15
|
+
* `N_PI_MEMORY_RETRY_ATTEMPTS` невдач тіло запиту друкується в stdout і
|
|
16
|
+
* помилка прокидається як термінальна (виклики-власники процесу мають дати
|
|
17
|
+
* їй впасти, не ковтати як звичайну per-file помилку).
|
|
15
18
|
*/
|
|
16
19
|
import { createAgentSession, SessionManager, ModelRegistry, AuthStorage } from '@earendil-works/pi-coding-agent'
|
|
20
|
+
import { randomInt } from 'node:crypto'
|
|
17
21
|
import { env } from 'node:process'
|
|
18
22
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
19
23
|
|
|
20
24
|
let _registry = null
|
|
21
25
|
/**
|
|
22
|
-
*
|
|
26
|
+
* Повертає або ініціалізує спільний model registry для поточного процесу.
|
|
27
|
+
* @returns {object} спільний registry
|
|
23
28
|
*/
|
|
24
|
-
|
|
29
|
+
function getRegistry() {
|
|
25
30
|
if (_registry) return _registry
|
|
26
31
|
_registry = ModelRegistry.create(AuthStorage.create())
|
|
27
32
|
return _registry
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
const RETRYABLE_ERROR_RE = /connection error|ECONNREFUSED|ETIMEDOUT|fetch failed|network/i
|
|
36
|
+
const OMLX_CODE_RE = /"(?:omlx_)?code"\s*:\s*"([^"]+)"/
|
|
37
|
+
const OMLX_ESTIMATED_BYTES_RE = /"estimated_bytes"\s*:\s*(\d+)/
|
|
38
|
+
const OMLX_LIMIT_BYTES_RE = /"limit_bytes"\s*:\s*(\d+)/
|
|
31
39
|
/** Matches a local model server (e.g. oMLX) rejecting a prompt for lack of RAM. */
|
|
32
40
|
export const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/i
|
|
33
41
|
const MAX_ATTEMPTS = Number(env.N_PI_RETRY_ATTEMPTS) || 4
|
|
34
42
|
const BASE_DELAY_MS = Number(env.N_PI_RETRY_DELAY_MS) || 1500
|
|
35
43
|
const MAX_DELAY_MS = 15_000
|
|
44
|
+
/** Скільки разів пробуємо промпт, відкинутий memory guard (включно з першою спробою). */
|
|
45
|
+
const MEMORY_MAX_ATTEMPTS = Number(env.N_PI_MEMORY_RETRY_ATTEMPTS) || 3
|
|
46
|
+
/** Базова затримка перед повтором після memory-guard відмови (експоненційно: 15s → 30s → …). */
|
|
47
|
+
const MEMORY_BASE_DELAY_MS = Number(env.N_PI_MEMORY_RETRY_DELAY_MS) || 15_000
|
|
48
|
+
const MEMORY_MAX_DELAY_MS = 60_000
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Витягує структуровані поля oMLX-помилки з тексту повідомлення, якщо тіло
|
|
52
|
+
* 400 (`omlx_code`/`estimated_bytes`/`limit_bytes`) потрапило в message.
|
|
53
|
+
* `estimated_bytes`/`limit_bytes` — байти GPU/Metal-пам'яті сервера,
|
|
54
|
+
* ТІЛЬКИ для діагностики й логів (не «скільки символів промпту зрізати»).
|
|
55
|
+
* @param {string} message текст помилки від SDK/сервера
|
|
56
|
+
* @returns {{omlxCode?: string, estimatedBytes?: number, limitBytes?: number} | null} знайдені поля або `null`
|
|
57
|
+
*/
|
|
58
|
+
function parseOmlxErrorDetails(message) {
|
|
59
|
+
const code = OMLX_CODE_RE.exec(message)?.[1]
|
|
60
|
+
const estimated = OMLX_ESTIMATED_BYTES_RE.exec(message)?.[1]
|
|
61
|
+
const limit = OMLX_LIMIT_BYTES_RE.exec(message)?.[1]
|
|
62
|
+
if (!code && !estimated && !limit) return null
|
|
63
|
+
const details = {}
|
|
64
|
+
if (code) details.omlxCode = code
|
|
65
|
+
if (estimated) details.estimatedBytes = Number(estimated)
|
|
66
|
+
if (limit) details.limitBytes = Number(limit)
|
|
67
|
+
return details
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Розпізнає memory-guard відмову і збагачує помилку структурованими полями
|
|
72
|
+
* (`omlxCode`, `estimatedBytes`, `limitBytes`), коли вони доступні в message.
|
|
73
|
+
* @param {Error} error помилка з виклику моделі
|
|
74
|
+
* @returns {boolean} `true`, якщо це memory-guard відмова
|
|
75
|
+
*/
|
|
76
|
+
function isMemoryGuardError(error) {
|
|
77
|
+
const details = parseOmlxErrorDetails(error.message ?? '')
|
|
78
|
+
if (details) Object.assign(error, details)
|
|
79
|
+
return error.omlxCode === 'prefill_memory_exceeded' || MEMORY_ERROR_RE.test(error.message ?? '')
|
|
80
|
+
}
|
|
36
81
|
|
|
37
82
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* @param {
|
|
44
|
-
* @
|
|
45
|
-
* @returns {never} always throws
|
|
83
|
+
* Термінальний вихід після вичерпання memory-retry: друкує тіло запиту в
|
|
84
|
+
* stdout (лише тут — не на проміжних спробах) і кидає помилку далі,
|
|
85
|
+
* зберігаючи структуровані поля й оригінал у `cause`. Виклики-власники
|
|
86
|
+
* процесу (CLI) мають дати їй впасти, не ковтати як per-file помилку.
|
|
87
|
+
* @param {Error} error memory-guard помилка останньої спроби
|
|
88
|
+
* @param {string} requestBody промпт, надісланий моделі
|
|
89
|
+
* @returns {never} завжди кидає
|
|
46
90
|
*/
|
|
47
91
|
function failOnMemoryGuard(error, requestBody) {
|
|
48
92
|
console.log('--- omlx memory-guard: тіло запиту ---')
|
|
49
93
|
console.log(requestBody)
|
|
50
94
|
console.log(`✗ omlx memory-guard: ${error.message}`)
|
|
51
|
-
|
|
95
|
+
const wrapped = new Error(`omlx memory-guard: ${error.message}`, { cause: error })
|
|
96
|
+
for (const key of ['omlxCode', 'estimatedBytes', 'limitBytes']) {
|
|
97
|
+
if (error[key] !== undefined) wrapped[key] = error[key]
|
|
98
|
+
}
|
|
99
|
+
throw wrapped
|
|
52
100
|
}
|
|
53
101
|
|
|
54
102
|
/**
|
|
55
103
|
* Retries `fn` with exponential backoff + jitter when it throws a transient
|
|
56
104
|
* connection-ish error (shared local model server busy/restarting); other
|
|
57
105
|
* errors (auth, malformed request) are re-thrown immediately. A memory-guard
|
|
58
|
-
* rejection
|
|
106
|
+
* rejection has its own bounded schedule (`MEMORY_MAX_ATTEMPTS` спроб,
|
|
107
|
+
* затримки від `MEMORY_BASE_DELAY_MS` експоненційно): одно-слотова черга
|
|
108
|
+
* oMLX вивільняє залишкову пам'ять попереднього запиту сама, тож повтор
|
|
109
|
+
* того самого промпту після паузи легітимний. Після вичерпання —
|
|
110
|
+
* `failOnMemoryGuard` (друк тіла + термінальний throw).
|
|
59
111
|
* @template T
|
|
60
112
|
* @param {() => Promise<T>} fn operation to retry
|
|
61
|
-
* @param {string} requestBody prompt sent to the model, printed if a memory-guard error
|
|
113
|
+
* @param {string} requestBody prompt sent to the model, printed if a memory-guard error exhausts its retries
|
|
62
114
|
* @returns {Promise<T>} result of the first successful attempt
|
|
63
115
|
*/
|
|
64
116
|
async function withRetry(fn, requestBody) {
|
|
65
|
-
|
|
117
|
+
let memoryAttempts = 0
|
|
118
|
+
let connAttempts = 0
|
|
119
|
+
for (;;) {
|
|
66
120
|
try {
|
|
67
121
|
return await fn()
|
|
68
122
|
} catch (error) {
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
123
|
+
if (isMemoryGuardError(error)) {
|
|
124
|
+
memoryAttempts++
|
|
125
|
+
if (memoryAttempts >= MEMORY_MAX_ATTEMPTS) failOnMemoryGuard(error, requestBody)
|
|
126
|
+
const delay = Math.min(MEMORY_BASE_DELAY_MS * 2 ** (memoryAttempts - 1), MEMORY_MAX_DELAY_MS)
|
|
127
|
+
await sleep(delay * (0.5 + randomInt(5000) / 10000))
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
connAttempts++
|
|
131
|
+
if (connAttempts >= MAX_ATTEMPTS || !RETRYABLE_ERROR_RE.test(error.message ?? '')) throw error
|
|
132
|
+
const delay = Math.min(BASE_DELAY_MS * 2 ** (connAttempts - 1), MAX_DELAY_MS)
|
|
133
|
+
const jitter = delay * (0.5 + randomInt(5000) / 10000)
|
|
73
134
|
await sleep(jitter)
|
|
74
135
|
}
|
|
75
136
|
}
|
|
76
137
|
}
|
|
77
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Стеля відповіді моделі для подвоєння на `stopReason: 'length'` — межа
|
|
141
|
+
* `maxTokens` реєстру для локальної моделі (див. `~/.pi/agent/models.json`).
|
|
142
|
+
*/
|
|
143
|
+
const MAX_TOKENS_CEILING = 32_768
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Обгортає `session.agent.streamFn`, додаючи per-call `maxTokens` у
|
|
147
|
+
* options LLM-виклику. SDK не має публічного per-call параметра
|
|
148
|
+
* (спайк: `session.prompt(text, options)` не прокидає options у loop
|
|
149
|
+
* config), але `options.maxTokens` у streamFn перекриває дефолт моделі —
|
|
150
|
+
* перевірено на дроті через myllm-проксі (`max_completion_tokens`
|
|
151
|
+
* змінюється).
|
|
152
|
+
* @param {object} session сесія з `createAgentSession`
|
|
153
|
+
* @param {number} maxTokens стеля відповіді для всіх викликів цієї сесії
|
|
154
|
+
* @returns {void}
|
|
155
|
+
*/
|
|
156
|
+
function applyMaxTokens(session, maxTokens) {
|
|
157
|
+
const orig = session.agent.streamFn
|
|
158
|
+
session.agent.streamFn = (model, context, options) => orig(model, context, { ...options, maxTokens })
|
|
159
|
+
}
|
|
160
|
+
|
|
78
161
|
/**
|
|
79
162
|
* Sends a single prompt to pi in text mode (no tools) and returns the response.
|
|
80
163
|
* Reads auth/model config from ~/.pi/ same as the CLI.
|
|
81
|
-
* @param {string} prompt
|
|
82
|
-
* @param {object} [opts]
|
|
83
|
-
* @param {string} [opts.cwd]
|
|
84
|
-
* @param {string} [opts.model] provider/model-id
|
|
85
|
-
* @
|
|
164
|
+
* @param {string} prompt текст запиту для моделі
|
|
165
|
+
* @param {object} [opts] додаткові параметри виклику
|
|
166
|
+
* @param {string} [opts.cwd] робоча директорія для session
|
|
167
|
+
* @param {string} [opts.model] provider/model-id для pi (наприклад, "openai/gpt-4o"); без значення — default pi
|
|
168
|
+
* @param {number} [opts.maxTokens] стеля відповіді для цього виклику (перекриває дефолт моделі);
|
|
169
|
+
* на `stopReason: 'length'` (обрізана генерація) виклик повторюється один раз із подвоєною стелею
|
|
170
|
+
* @returns {Promise<string>} текстова відповідь моделі
|
|
86
171
|
*/
|
|
87
172
|
export async function callText(prompt, opts = {}) {
|
|
88
|
-
|
|
173
|
+
const result = await withRetry(async () => {
|
|
89
174
|
const cwd = opts.cwd ?? process.cwd()
|
|
90
175
|
const sessionOpts = {
|
|
91
176
|
tools: [],
|
|
@@ -97,35 +182,51 @@ export async function callText(prompt, opts = {}) {
|
|
|
97
182
|
const slashIdx = opts.model.indexOf('/')
|
|
98
183
|
const provider = slashIdx === -1 ? null : opts.model.slice(0, slashIdx)
|
|
99
184
|
const modelId = slashIdx === -1 ? opts.model : opts.model.slice(slashIdx + 1)
|
|
100
|
-
const resolved = provider ? registry.find
|
|
185
|
+
const resolved = provider ? Reflect.apply(registry.find, registry, [provider, modelId]) : null
|
|
101
186
|
sessionOpts.modelRegistry = registry
|
|
102
187
|
sessionOpts.model = resolved ?? opts.model
|
|
103
188
|
}
|
|
104
189
|
const { session } = await createAgentSession(sessionOpts)
|
|
190
|
+
if (opts.maxTokens) applyMaxTokens(session, opts.maxTokens)
|
|
191
|
+
try {
|
|
192
|
+
await session.prompt(prompt)
|
|
105
193
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
194
|
+
const state = session.state
|
|
195
|
+
const last = state.messages.at(-1)
|
|
196
|
+
if (!last || last.role !== 'assistant') return { text: '', truncated: false }
|
|
197
|
+
if (last.stopReason === 'error' || last.stopReason === 'aborted') {
|
|
198
|
+
throw new Error(`pi error: ${last.errorMessage ?? last.stopReason}`)
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
text: last.content
|
|
202
|
+
.filter(c => c.type === 'text')
|
|
203
|
+
.map(c => c.text)
|
|
204
|
+
.join(''),
|
|
205
|
+
truncated: last.stopReason === 'length'
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
session.dispose()
|
|
113
209
|
}
|
|
114
|
-
return last.content
|
|
115
|
-
.filter(c => c.type === 'text')
|
|
116
|
-
.map(c => c.text)
|
|
117
|
-
.join('')
|
|
118
210
|
}, prompt)
|
|
211
|
+
|
|
212
|
+
// Обрізана генерація зі зниженою стелею — не палимо retry-цикли колера
|
|
213
|
+
// на «invalid block», а один раз повторюємо з подвоєною стелею.
|
|
214
|
+
if (result.truncated && opts.maxTokens && opts.maxTokens < MAX_TOKENS_CEILING && !opts._lengthRetried) {
|
|
215
|
+
const doubled = Math.min(opts.maxTokens * 2, MAX_TOKENS_CEILING)
|
|
216
|
+
console.log(` ⚠ відповідь обрізана (stopReason: length) — повтор із maxTokens ${opts.maxTokens} → ${doubled}`)
|
|
217
|
+
return callText(prompt, { ...opts, maxTokens: doubled, _lengthRetried: true })
|
|
218
|
+
}
|
|
219
|
+
return result.text
|
|
119
220
|
}
|
|
120
221
|
|
|
121
222
|
/**
|
|
122
223
|
* Sends a prompt to pi in agent mode with full coding tools (read/write/bash/edit).
|
|
123
224
|
* The agent writes test files directly — no need to parse output.
|
|
124
|
-
* @param {string} prompt
|
|
125
|
-
* @param {string} cwd
|
|
126
|
-
* @returns {Promise<void>}
|
|
225
|
+
* @param {string} prompt текст завдання для агента
|
|
226
|
+
* @param {string} cwd робоча директорія, куди агент може писати файли
|
|
227
|
+
* @returns {Promise<void>} проміс завершується після виконання агента
|
|
127
228
|
*/
|
|
128
|
-
export
|
|
229
|
+
export function callAgent(prompt, cwd) {
|
|
129
230
|
return withRetry(async () => {
|
|
130
231
|
const { session } = await createAgentSession({
|
|
131
232
|
tools: ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls'],
|
|
@@ -133,6 +234,10 @@ export async function callAgent(prompt, cwd) {
|
|
|
133
234
|
cwd
|
|
134
235
|
})
|
|
135
236
|
|
|
136
|
-
|
|
237
|
+
try {
|
|
238
|
+
await session.prompt(prompt)
|
|
239
|
+
} finally {
|
|
240
|
+
session.dispose()
|
|
241
|
+
}
|
|
137
242
|
}, prompt)
|
|
138
243
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Спільний бюджет LLM-промптів (фаза 2 спеки
|
|
3
|
+
* `docs/specs/2026-07-04-omlx-prompt-budget.md`, Д3): єдина точка правди
|
|
4
|
+
* для ліміту символів промпту та стелі відповіді (`maxTokens`) за типом
|
|
5
|
+
* задачі. Захищає від класу проблем «безлімітна секція роздула промпт до
|
|
6
|
+
* memory guard» — кожен колер (gen-tests, fix-tests) бере бюджет звідси
|
|
7
|
+
* замість власних розрізнених констант.
|
|
8
|
+
*
|
|
9
|
+
* Дві незалежні утиліти:
|
|
10
|
+
* - `fitToBudget` — внутрішньопромптове обрізання: дропає/вкорочує
|
|
11
|
+
* низькопріоритетні chunks, поки промпт не влізе в ліміт;
|
|
12
|
+
* - `packBatch` — батчинг цілих одиниць (файлів): скільки влазить зараз,
|
|
13
|
+
* решта — у наступний прохід.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Бюджети за типом задачі: ліміт символів промпту і стеля відповіді. */
|
|
17
|
+
const BUDGETS = {
|
|
18
|
+
header: { maxPromptChars: 8000, maxTokens: 2048 },
|
|
19
|
+
block: { maxPromptChars: 40_000, maxTokens: 8192 },
|
|
20
|
+
'single-file': { maxPromptChars: 60_000, maxTokens: 16_384 },
|
|
21
|
+
fix: { maxPromptChars: 60_000, maxTokens: 16_384 }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Частка голови chunk-а, що лишається при обрізанні (решта — хвіст + маркер). */
|
|
25
|
+
const TRUNCATE_HEAD_RATIO = 0.7
|
|
26
|
+
/** Мінімальний розмір, до якого має сенс обрізати chunk (менше — просто дроп). */
|
|
27
|
+
const MIN_TRUNCATED_CHARS = 400
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Повертає бюджет для типу задачі.
|
|
31
|
+
* @param {'header'|'block'|'single-file'|'fix'} taskKind тип LLM-задачі
|
|
32
|
+
* @returns {{maxPromptChars: number, maxTokens: number}} копія бюджету
|
|
33
|
+
*/
|
|
34
|
+
export function budgetFor(taskKind) {
|
|
35
|
+
const budget = BUDGETS[taskKind]
|
|
36
|
+
if (!budget) throw new Error(`prompt-budget: невідомий taskKind "${taskKind}"`)
|
|
37
|
+
return { ...budget }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Символьно-безпечне обрізання середини: голова + маркер + хвіст.
|
|
42
|
+
* @param {string} text вихідний текст
|
|
43
|
+
* @param {number} maxChars цільовий розмір
|
|
44
|
+
* @returns {string} обрізаний текст із маркером
|
|
45
|
+
*/
|
|
46
|
+
export function capText(text, maxChars) {
|
|
47
|
+
return truncateMiddle(text, maxChars)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Внутрішня реалізація `capText` (окреме ім'я — щоб `fitToBudget` не
|
|
52
|
+
* залежав від публічного контракту).
|
|
53
|
+
* @param {string} text вихідний текст
|
|
54
|
+
* @param {number} maxChars цільовий розмір
|
|
55
|
+
* @returns {string} обрізаний текст із маркером
|
|
56
|
+
*/
|
|
57
|
+
function truncateMiddle(text, maxChars) {
|
|
58
|
+
const chars = [...text]
|
|
59
|
+
if (chars.length <= maxChars) return text
|
|
60
|
+
const head = Math.floor(maxChars * TRUNCATE_HEAD_RATIO)
|
|
61
|
+
const tail = Math.max(0, maxChars - head)
|
|
62
|
+
const dropped = chars.length - head - tail
|
|
63
|
+
return `${chars.slice(0, head).join('')}\n...[обрізано ${dropped} символів]...\n${chars.slice(chars.length - tail).join('')}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Вкладає chunks у бюджет: спершу обрізає, потім дропає найнижчі
|
|
68
|
+
* пріоритети, поки сумарний текст не влізе. Chunk із НАЙВИЩИМ
|
|
69
|
+
* пріоритетом (сама задача / останній user-запит) захищений — його
|
|
70
|
+
* текст не ріжеться і не дропається ніколи.
|
|
71
|
+
* @param {Array<{text: string, priority: number, label?: string}>} chunks частини промпту; нижчий priority ріжеться першим
|
|
72
|
+
* @param {number} maxChars бюджет символів на весь результат
|
|
73
|
+
* @returns {{text: string, dropped: string[]}} зібраний промпт + мітки скорочених/викинутих частин
|
|
74
|
+
*/
|
|
75
|
+
export function fitToBudget(chunks, maxChars) {
|
|
76
|
+
const parts = chunks.map((c, i) => ({ ...c, label: c.label ?? `chunk#${i}`, kept: true, out: c.text }))
|
|
77
|
+
const maxPriority = Math.max(...parts.map(p => p.priority))
|
|
78
|
+
const total = () => parts.filter(p => p.kept).reduce((sum, p) => sum + p.out.length + 1, 0)
|
|
79
|
+
const dropped = []
|
|
80
|
+
|
|
81
|
+
const candidates = parts.filter(p => p.priority < maxPriority).toSorted((a, b) => a.priority - b.priority)
|
|
82
|
+
// Прохід 1: обрізати кандидатів (від найнижчого пріоритету)
|
|
83
|
+
for (const part of candidates) {
|
|
84
|
+
if (total() <= maxChars) break
|
|
85
|
+
const overflow = total() - maxChars
|
|
86
|
+
const target = Math.max(MIN_TRUNCATED_CHARS, part.out.length - overflow)
|
|
87
|
+
if (part.out.length > target) {
|
|
88
|
+
part.out = truncateMiddle(part.out, target)
|
|
89
|
+
dropped.push(`${part.label} (обрізано)`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Прохід 2: дропнути кандидатів цілком, якщо обрізання не вистачило
|
|
93
|
+
for (const part of candidates) {
|
|
94
|
+
if (total() <= maxChars) break
|
|
95
|
+
part.kept = false
|
|
96
|
+
dropped.push(`${part.label} (видалено)`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { text: parts.filter(p => p.kept).map(p => p.out).join('\n'), dropped }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Пакує одиниці (файли) у бюджет: найменші першими, щоб максимізувати
|
|
104
|
+
* кількість виправлень за один виклик. Одиниця, що сама-одна перевищує
|
|
105
|
+
* бюджет, потрапляє в `deferred` — колер робить для неї соло-виклик із
|
|
106
|
+
* жорсткішим внутрішнім обрізанням (`fitToBudget`), а не мовчазний skip.
|
|
107
|
+
* @param {Array<{key: string, size: number}>} items одиниці з розмірами
|
|
108
|
+
* @param {number} maxChars бюджет символів на батч
|
|
109
|
+
* @returns {{included: string[], deferred: string[]}} ключі включених і відкладених одиниць
|
|
110
|
+
*/
|
|
111
|
+
export function packBatch(items, maxChars) {
|
|
112
|
+
const sorted = items.toSorted((a, b) => a.size - b.size)
|
|
113
|
+
const included = []
|
|
114
|
+
const deferred = []
|
|
115
|
+
let used = 0
|
|
116
|
+
for (const item of sorted) {
|
|
117
|
+
if (item.size + (included.length === 0 ? 0 : used) <= maxChars) {
|
|
118
|
+
included.push(item.key)
|
|
119
|
+
used += item.size
|
|
120
|
+
} else {
|
|
121
|
+
deferred.push(item.key)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { included, deferred }
|
|
125
|
+
}
|
|
@@ -9,10 +9,26 @@
|
|
|
9
9
|
* 4. probeHelpers — extracts non-exported helper functions from source and calls them
|
|
10
10
|
* with generic param combos to reveal their actual output shapes.
|
|
11
11
|
*/
|
|
12
|
-
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
13
13
|
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
14
16
|
|
|
15
17
|
const PROBE_TIMEOUT_MS = 10_000
|
|
18
|
+
/**
|
|
19
|
+
* Кап на серіалізований probe-вихід (символів). Довший вихід замінюється
|
|
20
|
+
* shape-summary — стислим описом форми замість значення: гігантський дамп
|
|
21
|
+
* (напр. функція, що читає файл проєкту) інакше роздуває LLM-промпт до
|
|
22
|
+
* сотень тисяч символів і впирається в memory guard моделі, а обрізаний
|
|
23
|
+
* JSON модель копіює в expected як сміття.
|
|
24
|
+
*/
|
|
25
|
+
const PROBE_OUTPUT_MAX_CHARS = 600
|
|
26
|
+
/** Максимум probe-рядків на один export — багато середніх виходів теж не мають роздувати промпт. */
|
|
27
|
+
const PROBE_MAX_ENTRIES_PER_EXPORT = 12
|
|
28
|
+
/** Глибина рекурсії shape-summary. */
|
|
29
|
+
const SHAPE_MAX_DEPTH = 4
|
|
30
|
+
/** Скільки ключів об'єкта показує shape-summary до «…». */
|
|
31
|
+
const SHAPE_MAX_KEYS = 8
|
|
16
32
|
|
|
17
33
|
/** Generic argument combos to try when probing async/fetch functions. */
|
|
18
34
|
const FETCH_ARG_COMBOS = [
|
|
@@ -70,6 +86,97 @@ const PROBE_INPUTS = [
|
|
|
70
86
|
['42', '42']
|
|
71
87
|
]
|
|
72
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Рекурсивно описує форму значення без самих даних.
|
|
91
|
+
* @param {unknown} value розпарсене JSON-значення
|
|
92
|
+
* @param {number} [depth] залишкова глибина рекурсії; за замовчуванням `SHAPE_MAX_DEPTH`
|
|
93
|
+
* @returns {string} стислий опис форми, напр. `Array(34) of {file: string, mutants: Array(12)}`
|
|
94
|
+
*/
|
|
95
|
+
export function describeShape(value, depth = SHAPE_MAX_DEPTH) {
|
|
96
|
+
if (value === null) return 'null'
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
if (value.length === 0) return 'Array(0)'
|
|
99
|
+
if (depth <= 0) return `Array(${value.length})`
|
|
100
|
+
return `Array(${value.length}) of ${describeShape(value[0], depth - 1)}`
|
|
101
|
+
}
|
|
102
|
+
const type = typeof value
|
|
103
|
+
if (type === 'object') {
|
|
104
|
+
const keys = Object.keys(value)
|
|
105
|
+
if (depth <= 0 || keys.length === 0) return 'Object'
|
|
106
|
+
const shown = keys.slice(0, SHAPE_MAX_KEYS)
|
|
107
|
+
const inner =
|
|
108
|
+
depth > 1 ? shown.map(k => `${k}: ${describeShape(value[k], depth - 1)}`).join(', ') : shown.join(', ')
|
|
109
|
+
return `{${inner}${keys.length > shown.length ? ', …' : ''}}`
|
|
110
|
+
}
|
|
111
|
+
if (type === 'string') return 'string'
|
|
112
|
+
return type
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Обмежує серіалізований probe-вихід: до `PROBE_OUTPUT_MAX_CHARS` — без змін,
|
|
117
|
+
* довший — shape-summary замість значення (модель бачить структуру для
|
|
118
|
+
* asserts на форму, але не тягне дамп у промпт і не копіює його в expected).
|
|
119
|
+
* @param {string} serialized JSON-серіалізований вихід probe
|
|
120
|
+
* @returns {string} оригінал або `[shape-summary, ~N chars] <форма>`
|
|
121
|
+
*/
|
|
122
|
+
export function capProbeOutput(serialized) {
|
|
123
|
+
if (typeof serialized !== 'string' || serialized.length <= PROBE_OUTPUT_MAX_CHARS) return serialized
|
|
124
|
+
let shape
|
|
125
|
+
try {
|
|
126
|
+
shape = describeShape(JSON.parse(serialized))
|
|
127
|
+
} catch {
|
|
128
|
+
shape = 'string'
|
|
129
|
+
}
|
|
130
|
+
const summary = `[shape-summary, ~${serialized.length} chars] ${shape}`
|
|
131
|
+
return summary.length > PROBE_OUTPUT_MAX_CHARS ? `${summary.slice(0, PROBE_OUTPUT_MAX_CHARS - 1)}…` : summary
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Застосовує кап виходів і ліміт кількості рядків до результатів `probeModule`.
|
|
136
|
+
* @param {Record<string, Array<{input: string, output: string}> | {constant: string}>} results сирі результати з дочірнього процесу
|
|
137
|
+
* @returns {typeof results} результати з capped-виходами
|
|
138
|
+
*/
|
|
139
|
+
function capModuleResults(results) {
|
|
140
|
+
const capped = {}
|
|
141
|
+
for (const [name, section] of Object.entries(results)) {
|
|
142
|
+
if (Array.isArray(section)) {
|
|
143
|
+
capped[name] = section
|
|
144
|
+
.slice(0, PROBE_MAX_ENTRIES_PER_EXPORT)
|
|
145
|
+
.map(entry => ({ ...entry, output: capProbeOutput(entry.output) }))
|
|
146
|
+
} else if (section && typeof section === 'object' && 'constant' in section) {
|
|
147
|
+
capped[name] = { constant: capProbeOutput(section.constant) }
|
|
148
|
+
} else {
|
|
149
|
+
capped[name] = section
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return capped
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Запускає node з probe-скриптом у порожній тимчасовій cwd: відносні
|
|
157
|
+
* I/O-читання probe-ованих функцій (напр. `readFile('COVERAGE.md')` при
|
|
158
|
+
* порожньому аргументі шляху) не бачать файлів проєкту — вихід
|
|
159
|
+
* детермінований між машинами і не може випадково затягнути великий
|
|
160
|
+
* файл репозиторію у LLM-промпт.
|
|
161
|
+
* @param {string} script код для виконання
|
|
162
|
+
* @param {number} [timeout] ліміт очікування в мс; за замовчуванням `PROBE_TIMEOUT_MS`
|
|
163
|
+
* @returns {import('node:child_process').SpawnSyncReturns<string>} результат spawnSync
|
|
164
|
+
*/
|
|
165
|
+
function spawnProbe(script, timeout = PROBE_TIMEOUT_MS) {
|
|
166
|
+
const cwd = mkdtempSync(join(tmpdir(), 'n-probe-'))
|
|
167
|
+
try {
|
|
168
|
+
return spawnSync('node', ['--input-type=module'], {
|
|
169
|
+
input: script,
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
timeout,
|
|
172
|
+
env: { ...process.env },
|
|
173
|
+
cwd
|
|
174
|
+
})
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
73
180
|
/**
|
|
74
181
|
* Пробує експорти модуля у дочірньому процесі й повертає фактичні виходи.
|
|
75
182
|
* @param {string} absFilePath абсолютний шлях до джерела модуля
|
|
@@ -122,18 +229,13 @@ for (const name of names) {
|
|
|
122
229
|
process.stdout.write(JSON.stringify(results))
|
|
123
230
|
`
|
|
124
231
|
|
|
125
|
-
const proc =
|
|
126
|
-
input: script,
|
|
127
|
-
encoding: 'utf8',
|
|
128
|
-
timeout: PROBE_TIMEOUT_MS,
|
|
129
|
-
env: { ...process.env }
|
|
130
|
-
})
|
|
232
|
+
const proc = spawnProbe(script)
|
|
131
233
|
|
|
132
234
|
if (!proc.stdout) return {}
|
|
133
235
|
try {
|
|
134
236
|
const parsed = JSON.parse(proc.stdout)
|
|
135
237
|
if (parsed.__importError) return {}
|
|
136
|
-
return parsed
|
|
238
|
+
return capModuleResults(parsed)
|
|
137
239
|
} catch {
|
|
138
240
|
return {}
|
|
139
241
|
}
|
|
@@ -150,12 +252,7 @@ process.stdout.write(JSON.stringify(results))
|
|
|
150
252
|
* @returns {Record<string, unknown> | null} розпарсений JSON або `null` при помилці
|
|
151
253
|
*/
|
|
152
254
|
function runProbeScript(script, timeout = PROBE_TIMEOUT_MS) {
|
|
153
|
-
const proc =
|
|
154
|
-
input: script,
|
|
155
|
-
encoding: 'utf8',
|
|
156
|
-
timeout,
|
|
157
|
-
env: { ...process.env }
|
|
158
|
-
})
|
|
255
|
+
const proc = spawnProbe(script, timeout)
|
|
159
256
|
if (!proc.stdout) return null
|
|
160
257
|
try {
|
|
161
258
|
const parsed = JSON.parse(proc.stdout)
|
|
@@ -211,6 +308,33 @@ process.stdout.write(JSON.stringify(results))
|
|
|
211
308
|
return runProbeScript(script) ?? {}
|
|
212
309
|
}
|
|
213
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Застосовує кап до результатів `probeHelpers`: завеликий `result`
|
|
313
|
+
* замінюється shape-summary рядком, кількість комбо на helper обмежується.
|
|
314
|
+
* @param {Record<string, Array<{params: Record<string, unknown>, result: unknown}>>} results сирі результати helper-probe
|
|
315
|
+
* @returns {typeof results} результати з capped-виходами
|
|
316
|
+
*/
|
|
317
|
+
function capHelperResults(results) {
|
|
318
|
+
const capped = {}
|
|
319
|
+
for (const [name, entries] of Object.entries(results)) {
|
|
320
|
+
if (!Array.isArray(entries)) {
|
|
321
|
+
capped[name] = entries
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
capped[name] = entries.slice(0, PROBE_MAX_ENTRIES_PER_EXPORT).map(entry => {
|
|
325
|
+
let serialized
|
|
326
|
+
try {
|
|
327
|
+
serialized = JSON.stringify(entry.result)
|
|
328
|
+
} catch {
|
|
329
|
+
return entry
|
|
330
|
+
}
|
|
331
|
+
if (typeof serialized !== 'string' || serialized.length <= PROBE_OUTPUT_MAX_CHARS) return entry
|
|
332
|
+
return { ...entry, result: capProbeOutput(serialized) }
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
return capped
|
|
336
|
+
}
|
|
337
|
+
|
|
214
338
|
// ---------------------------------------------------------------------------
|
|
215
339
|
// Probe 3: time-variant detection
|
|
216
340
|
// ---------------------------------------------------------------------------
|
|
@@ -386,5 +510,5 @@ for (const name of ${names}) {
|
|
|
386
510
|
}
|
|
387
511
|
process.stdout.write(JSON.stringify(results))
|
|
388
512
|
`
|
|
389
|
-
return runProbeScript(script) ?? {}
|
|
513
|
+
return capHelperResults(runProbeScript(script) ?? {})
|
|
390
514
|
}
|
package/src/lib/vitest-shim.mjs
CHANGED
|
@@ -51,7 +51,8 @@ export function ensureVitestShim() {
|
|
|
51
51
|
` include: ['**/*.{js,mjs,ts,vue}'],\n` +
|
|
52
52
|
// Exclude test files that use non-vitest runners (bun:test, jest) from
|
|
53
53
|
// the coverage scan so they don't cause import errors or skewed data.
|
|
54
|
-
|
|
54
|
+
// *.d.ts: vitest 4 прибрав дефолтний exclude — v8-remap падає на TS-синтаксисі декларацій.
|
|
55
|
+
` exclude: ['**/node_modules/**', '**/*.d.ts'],\n` +
|
|
55
56
|
` },\n` +
|
|
56
57
|
` },\n` +
|
|
57
58
|
`})\n`
|