@7n/test 0.11.2 → 0.12.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 +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 +14 -11
- package/src/docs/gen-tests.md +1 -1
- package/src/fix-tests.mjs +124 -34
- package/src/gen-tests.mjs +8 -5
- package/src/lib/docs/index.md +1 -0
- package/src/lib/docs/pi-client.md +1 -1
- package/src/lib/docs/prompt-budget.md +34 -0
- package/src/lib/docs/runtime-probe.md +1 -1
- package/src/lib/docs/vitest-shim.md +3 -3
- package/src/lib/pi-client.mjs +78 -30
- package/src/lib/prompt-budget.mjs +131 -0
- package/src/lib/vitest-shim.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.1] - 2026-07-04
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- test(core): моки node:fs під vitest-shim і актуалізація shouldDedup-очікування
|
|
8
|
+
|
|
9
|
+
## [0.12.0] - 2026-07-04
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 🤖 feat(core): спільний prompt-budget (бюджет символів + per-call maxTokens за типом задачі через streamFn-обгортку), батчинг fix-tests під бюджет з анти-starvation, повтор на обрізану генерацію (stopReason length)
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- 🤖 fix(core): coverage-скан виключає `**/*.d.ts` — vitest 4 прибрав дефолтний exclude, і v8-remap падав із RolldownError на TS-синтаксисі декларацій у цільових проєктах (CLI-прапорець у coverage-per-file та exclude у shim-конфігу)
|
|
18
|
+
- CLI більше не висить після завершення роботи: явний process.exit у bin (незакриті pi-сесії тримали event loop) і session.dispose() у finally для callText/callAgent
|
|
19
|
+
|
|
3
20
|
## [0.11.2] - 2026-07-04
|
|
4
21
|
|
|
5
22
|
### Fixed
|
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
|
@@ -3,30 +3,33 @@ type: JS Module
|
|
|
3
3
|
title: fix-tests.mjs
|
|
4
4
|
resource: npm/src/fix-tests.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: f82de3c4
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
tier: local-min-retry
|
|
9
|
+
score: 90
|
|
10
|
+
issues: internal-name:callText,judge:inaccurate:0.99
|
|
10
11
|
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
12
|
---
|
|
12
13
|
|
|
13
14
|
## Огляд
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
Виявляє падаючі unit-тести та автоматично їх виправляє з використанням pi SDK (text mode). Процес починається з запуску `vitest run --reporter=json` за допомогою `getFailingTests`, що створює перелік файлів з помилками. Потім функція `fixFailingTests` для кожного файлу читає його вміст та вміст відповідного source-файлу. Використовуючи `callText`, інструмент отримує виправлений код, який потім записується напряму через `writeFileSync` у тест-файл, що усуває проблеми точного матчу рядків, характерні для Edit-інструментів. Всі операції спираються на конфігурацію, описану в `results.json`.
|
|
16
17
|
|
|
17
18
|
## Поведінка
|
|
18
19
|
|
|
19
20
|
Поведінка:
|
|
20
|
-
getFailingTests
|
|
21
|
-
buildFixTestsPrompt створює
|
|
22
|
-
|
|
21
|
+
getFailingTests запускає vitest у JSON-режимі та повертає список падаючих тест-файлів з повідомленнями про помилки.
|
|
22
|
+
buildFixTestsPrompt створює повний текстовий промпт для LLM, що містить вміст падаючих тест-файлів та їхні помилки.
|
|
23
|
+
buildFixTestsBatch розподіляє падаючі тест-файли на батчі для LLM, враховуючи обмеження промпту.
|
|
24
|
+
fixFailingTests ітерує по падаючих тестах, викликаючи LLM для виправлень та записує виправлений код безпосередньо у файли.
|
|
23
25
|
|
|
24
26
|
## Публічний API
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
getFailingTests — Виконує тестування у JSON-форматі та надає список файлів із проваленими тестами разом із повідомленнями про помилки.
|
|
29
|
+
buildFixTestsPrompt — Створює запит для ШІ з вмістом одного файлу із проваленим тестом, щоб ШІ міг відновити його, не звертаючись до файлової системи.
|
|
30
|
+
buildFixTestsBatch — Групує файли для виправлення відповідно до заданого обсягу. Менші файли потрапляють у основну групу, а більші — у резерв на подальший прохід. Файл, який сам по собі перевищує обсяг, завжди отримує окремий запит з обрізанням.
|
|
31
|
+
fixFailingTests — Визначає та виправляє провалених тестів, використовуючи можливості pi SDK для роботи з текстом та прямий запис у файли. Якщо всі тести вже проходять, він повертається одразу з кількістю 0.
|
|
29
32
|
|
|
30
33
|
## Гарантії поведінки
|
|
31
34
|
|
|
32
|
-
-
|
|
35
|
+
- (специфічних машинно-виведених гарантій немає)
|
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,29 @@ 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(
|
|
356
|
+
` 📦 батч: ${batch.included.length} файлів, відкладено на наступний прохід: ${batch.deferred.length}`
|
|
357
|
+
)
|
|
274
358
|
}
|
|
359
|
+
for (const file of batch.included) attempts.set(file, (attempts.get(file) ?? 0) + 1)
|
|
360
|
+
prevDeferred = new Set(batch.deferred)
|
|
275
361
|
|
|
276
|
-
const prompt = buildFixTestsPrompt(remaining, dir)
|
|
277
362
|
let response
|
|
278
363
|
try {
|
|
279
|
-
response = await callTextFn(prompt)
|
|
364
|
+
response = await callTextFn(batch.prompt)
|
|
280
365
|
} catch (error) {
|
|
281
366
|
// memory-guard: не звичайна per-file помилка — RAM-стеля фіксована, продовжувати
|
|
282
367
|
// до наступного файлу немає сенсу. Пробиваємо нагору до CLI, аби процес завершився.
|
|
@@ -292,7 +377,12 @@ export async function fixFailingTests(dir, opts = {}) {
|
|
|
292
377
|
break
|
|
293
378
|
}
|
|
294
379
|
|
|
295
|
-
|
|
380
|
+
const includedSet = new Set(batch.included)
|
|
381
|
+
writeFixedFiles(
|
|
382
|
+
fixed,
|
|
383
|
+
eligible.filter(f => includedSet.has(f.file)),
|
|
384
|
+
dir
|
|
385
|
+
)
|
|
296
386
|
|
|
297
387
|
remaining = await getFailingTests(dir)
|
|
298
388
|
}
|
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,9 @@ 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
|
|
1185
|
+
const localFn = localModel
|
|
1186
|
+
? (prompt, opts = {}) => callTextFn(prompt, { ...opts, model: localModel, cwd: dir })
|
|
1187
|
+
: null
|
|
1185
1188
|
|
|
1186
1189
|
const mode = localFn ? `per-export (local:${localModel} + cloud)` : 'single-file (cloud)'
|
|
1187
1190
|
console.log(`\n🤖 Генерую тести для ${files.length} файлів [${mode}]...\n`)
|
package/src/lib/docs/index.md
CHANGED
|
@@ -10,5 +10,6 @@ resource: npm/src/lib/
|
|
|
10
10
|
| ------------------------------------- | --------- |
|
|
11
11
|
| [ast-analyze.mjs](ast-analyze.md) | JS Module |
|
|
12
12
|
| [pi-client.mjs](pi-client.md) | JS Module |
|
|
13
|
+
| [prompt-budget.mjs](prompt-budget.md) | JS Module |
|
|
13
14
|
| [runtime-probe.mjs](runtime-probe.md) | JS Module |
|
|
14
15
|
| [vitest-shim.mjs](vitest-shim.md) | JS Module |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: prompt-budget.mjs
|
|
4
|
+
resource: npm/src/lib/prompt-budget.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 010265bd
|
|
7
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
+
score: 100
|
|
9
|
+
issues: judge:inaccurate:0.99
|
|
10
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Огляд
|
|
14
|
+
|
|
15
|
+
Слугує єдиною точкою правди для визначення ліміту символів промпту та стелі відповіді (`maxTokens`) залежно від типу задачі (наприклад, `gen-tests`, `fix-tests`). Це механізм захисту від надмірного розширення промпту. Надає функції для внутрішнього обрізання тексту (`fitToBudget`), що виключає низькопріоритетні частини промпту, щоб він вмістився у встановлений ліміт, а також для ефективного групування цілих одиниць вмісту (`packBatch`) для подальшої пакетної обробки.
|
|
16
|
+
|
|
17
|
+
## Поведінка
|
|
18
|
+
|
|
19
|
+
Поведінка
|
|
20
|
+
budgetFor повертає встановлений ліміт символів для промпту та максимальну кількість токенів для заданого типу LLM-задачі.
|
|
21
|
+
capText обрізає вхідний текст до заданого максимального розміру, зберігаючи його структуру з головною та хвостовою частинами, розділеними маркером.
|
|
22
|
+
fitToBudget збирає частини промпту, обрізаючи або відкидаючи нижчопріоритетні частини доти, доки сумарний обсяг не вкладеться у заданий ліміт символів, при цьому найвищий пріоритет завжди захищено.
|
|
23
|
+
packBatch групує одиниці (файли) у батчи, сортуючи їх за розміром, щоб вмістити якомога більше у межах заданого бюджету; одиниці, що не вмістилися, відкладаються для наступного проходу.
|
|
24
|
+
|
|
25
|
+
## Публічний API
|
|
26
|
+
|
|
27
|
+
budgetFor — надає бюджет, необхідний для певної категорії завдання.
|
|
28
|
+
capText — безпечно укорочує текст, зберігаючи його початок, маркер та кінець.
|
|
29
|
+
fitToBudget — розміщує блоки у межах заданого бюджету: спочатку зменшує вміст, а потім відкидає найнижчі за пріоритетом, поки обсяг не втиснеться. Найважливіший елемент завжди залишається повним.
|
|
30
|
+
packBatch — згруповує файли для обробки: спочатку обирає найменші для оптимізації кількості виправлень за один раз. Якщо елемент занадто великий, він відкладається для окремої обробки, де застосовується жорсткіше обрізання.
|
|
31
|
+
|
|
32
|
+
## Гарантії поведінки
|
|
33
|
+
|
|
34
|
+
- Read-only: не виконує операцій запису (ФС/БД).
|
|
@@ -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,7 +9,7 @@
|
|
|
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 gets its own longer bounded backoff: oMLX
|
|
12
|
+
* rejection gets its own longer bounded backoff: oMLX (local model server)
|
|
13
13
|
* одночасно, тож відмова зазвичай відображає залишкову пам'ять попереднього
|
|
14
14
|
* запиту, яку сервер вивільняє сам за десятки секунд. Лише після
|
|
15
15
|
* `N_PI_MEMORY_RETRY_ATTEMPTS` невдач тіло запиту друкується в stdout і
|
|
@@ -23,15 +23,19 @@ import { setTimeout as sleep } from 'node:timers/promises'
|
|
|
23
23
|
|
|
24
24
|
let _registry = null
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
26
|
+
* Повертає або ініціалізує спільний model registry для поточного процесу.
|
|
27
|
+
* @returns {object} спільний registry
|
|
27
28
|
*/
|
|
28
|
-
|
|
29
|
+
function getRegistry() {
|
|
29
30
|
if (_registry) return _registry
|
|
30
31
|
_registry = ModelRegistry.create(AuthStorage.create())
|
|
31
32
|
return _registry
|
|
32
33
|
}
|
|
33
34
|
|
|
34
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+)/
|
|
35
39
|
/** Matches a local model server (e.g. oMLX) rejecting a prompt for lack of RAM. */
|
|
36
40
|
export const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/i
|
|
37
41
|
const MAX_ATTEMPTS = Number(env.N_PI_RETRY_ATTEMPTS) || 4
|
|
@@ -52,9 +56,9 @@ const MEMORY_MAX_DELAY_MS = 60_000
|
|
|
52
56
|
* @returns {{omlxCode?: string, estimatedBytes?: number, limitBytes?: number} | null} знайдені поля або `null`
|
|
53
57
|
*/
|
|
54
58
|
function parseOmlxErrorDetails(message) {
|
|
55
|
-
const code =
|
|
56
|
-
const estimated =
|
|
57
|
-
const limit =
|
|
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]
|
|
58
62
|
if (!code && !estimated && !limit) return null
|
|
59
63
|
const details = {}
|
|
60
64
|
if (code) details.omlxCode = code
|
|
@@ -132,17 +136,41 @@ async function withRetry(fn, requestBody) {
|
|
|
132
136
|
}
|
|
133
137
|
}
|
|
134
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
|
+
|
|
135
161
|
/**
|
|
136
162
|
* Sends a single prompt to pi in text mode (no tools) and returns the response.
|
|
137
163
|
* Reads auth/model config from ~/.pi/ same as the CLI.
|
|
138
|
-
* @param {string} prompt
|
|
139
|
-
* @param {object} [opts]
|
|
140
|
-
* @param {string} [opts.cwd]
|
|
141
|
-
* @param {string} [opts.model] provider/model-id
|
|
142
|
-
* @
|
|
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>} текстова відповідь моделі
|
|
143
171
|
*/
|
|
144
172
|
export async function callText(prompt, opts = {}) {
|
|
145
|
-
|
|
173
|
+
const result = await withRetry(async () => {
|
|
146
174
|
const cwd = opts.cwd ?? process.cwd()
|
|
147
175
|
const sessionOpts = {
|
|
148
176
|
tools: [],
|
|
@@ -154,35 +182,51 @@ export async function callText(prompt, opts = {}) {
|
|
|
154
182
|
const slashIdx = opts.model.indexOf('/')
|
|
155
183
|
const provider = slashIdx === -1 ? null : opts.model.slice(0, slashIdx)
|
|
156
184
|
const modelId = slashIdx === -1 ? opts.model : opts.model.slice(slashIdx + 1)
|
|
157
|
-
const resolved = provider ? registry.find
|
|
185
|
+
const resolved = provider ? Reflect.apply(registry.find, registry, [provider, modelId]) : null
|
|
158
186
|
sessionOpts.modelRegistry = registry
|
|
159
187
|
sessionOpts.model = resolved ?? opts.model
|
|
160
188
|
}
|
|
161
189
|
const { session } = await createAgentSession(sessionOpts)
|
|
190
|
+
if (opts.maxTokens) applyMaxTokens(session, opts.maxTokens)
|
|
191
|
+
try {
|
|
192
|
+
await session.prompt(prompt)
|
|
162
193
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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()
|
|
170
209
|
}
|
|
171
|
-
return last.content
|
|
172
|
-
.filter(c => c.type === 'text')
|
|
173
|
-
.map(c => c.text)
|
|
174
|
-
.join('')
|
|
175
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
|
|
176
220
|
}
|
|
177
221
|
|
|
178
222
|
/**
|
|
179
223
|
* Sends a prompt to pi in agent mode with full coding tools (read/write/bash/edit).
|
|
180
224
|
* The agent writes test files directly — no need to parse output.
|
|
181
|
-
* @param {string} prompt
|
|
182
|
-
* @param {string} cwd
|
|
183
|
-
* @returns {Promise<void>}
|
|
225
|
+
* @param {string} prompt текст завдання для агента
|
|
226
|
+
* @param {string} cwd робоча директорія, куди агент може писати файли
|
|
227
|
+
* @returns {Promise<void>} проміс завершується після виконання агента
|
|
184
228
|
*/
|
|
185
|
-
export
|
|
229
|
+
export function callAgent(prompt, cwd) {
|
|
186
230
|
return withRetry(async () => {
|
|
187
231
|
const { session } = await createAgentSession({
|
|
188
232
|
tools: ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls'],
|
|
@@ -190,6 +234,10 @@ export async function callAgent(prompt, cwd) {
|
|
|
190
234
|
cwd
|
|
191
235
|
})
|
|
192
236
|
|
|
193
|
-
|
|
237
|
+
try {
|
|
238
|
+
await session.prompt(prompt)
|
|
239
|
+
} finally {
|
|
240
|
+
session.dispose()
|
|
241
|
+
}
|
|
194
242
|
}, prompt)
|
|
195
243
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
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 {
|
|
100
|
+
text: parts
|
|
101
|
+
.filter(p => p.kept)
|
|
102
|
+
.map(p => p.out)
|
|
103
|
+
.join('\n'),
|
|
104
|
+
dropped
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Пакує одиниці (файли) у бюджет: найменші першими, щоб максимізувати
|
|
110
|
+
* кількість виправлень за один виклик. Одиниця, що сама-одна перевищує
|
|
111
|
+
* бюджет, потрапляє в `deferred` — колер робить для неї соло-виклик із
|
|
112
|
+
* жорсткішим внутрішнім обрізанням (`fitToBudget`), а не мовчазний skip.
|
|
113
|
+
* @param {Array<{key: string, size: number}>} items одиниці з розмірами
|
|
114
|
+
* @param {number} maxChars бюджет символів на батч
|
|
115
|
+
* @returns {{included: string[], deferred: string[]}} ключі включених і відкладених одиниць
|
|
116
|
+
*/
|
|
117
|
+
export function packBatch(items, maxChars) {
|
|
118
|
+
const sorted = items.toSorted((a, b) => a.size - b.size)
|
|
119
|
+
const included = []
|
|
120
|
+
const deferred = []
|
|
121
|
+
let used = 0
|
|
122
|
+
for (const item of sorted) {
|
|
123
|
+
if (item.size + (included.length === 0 ? 0 : used) <= maxChars) {
|
|
124
|
+
included.push(item.key)
|
|
125
|
+
used += item.size
|
|
126
|
+
} else {
|
|
127
|
+
deferred.push(item.key)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { included, deferred }
|
|
131
|
+
}
|
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`
|