@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 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
- process.exitCode = await run(process.argv.slice(2))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.11.2",
3
+ "version": "0.12.1",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -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',
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: coverage-per-file.mjs
4
4
  resource: npm/src/coverage-per-file.mjs
5
5
  docgen:
6
- crc: d167dff5
6
+ crc: 2b4a460d
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -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: b807d1bc
6
+ crc: f82de3c4
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
9
- issues: judge:inaccurate:0.98
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
- Цей модуль виявляє падаючі юніт-тести, запускаючи `vitest run --reporter=json` для збору помилок з конфігураційного файлу `results.json`. Подалі, він ітерує через виявлені файли, використовуючи текстовий режим pi SDK для отримання виправленого коду, який записується напряму, повторюючи спроби до досягнення `MAX_FIX_ATTEMPTS`. Процес реалізований з механізмом перехоплення помилок (`fail-safe`), щоб не кидати винятків.
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 виявляє падаючі юніт-тести, запускаючи vitest у JSON-режимі та повертаючи список цих файлів разом з повідомленнями про помилки.
21
- buildFixTestsPrompt створює деталізований текстовий промпт, що містить поточний вміст падаючих тест-файлів та відповідні вихідні файли, для інструмента LLM.
22
- fixFailingTests ітерує через падаючі тести, отримує виправлення від LLM за допомогою текстового режиму pi SDK та записує оновлений код напряму у відповідні файли, повторюючи спроби до MAX_FIX_ATTEMPTS.
21
+ getFailingTests запускає vitest у JSON-режимі та повертає список падаючих тест-файлів з повідомленнями про помилки.
22
+ buildFixTestsPrompt створює повний текстовий промпт для LLM, що містить вміст падаючих тест-файлів та їхні помилки.
23
+ buildFixTestsBatch розподіляє падаючі тест-файли на батчі для LLM, враховуючи обмеження промпту.
24
+ fixFailingTests ітерує по падаючих тестах, викликаючи LLM для виправлень та записує виправлений код безпосередньо у файли.
23
25
 
24
26
  ## Публічний API
25
27
 
26
- - getFailingTests — Виконує тестування за допомогою Vitest у форматі JSON і повертає файли тестів з помилками, що не пройшли.
27
- - buildFixTestsPrompt — Створює спеціальне повідомлення (prompt) для виправлення одного файлу, що не пройшов тест, включаючи вміст поточного файлу.
28
- - fixFailingTestsЗнаходить та виправляє помилкові тести, використовуючи текстовий режим SDK pi та пряме записування файлів.
28
+ getFailingTests — Виконує тестування у JSON-форматі та надає список файлів із проваленими тестами разом із повідомленнями про помилки.
29
+ buildFixTestsPrompt — Створює запит для ШІ з вмістом одного файлу із проваленим тестом, щоб ШІ міг відновити його, не звертаючись до файлової системи.
30
+ buildFixTestsBatch Групує файли для виправлення відповідно до заданого обсягу. Менші файли потрапляють у основну групу, а більші у резерв на подальший прохід. Файл, який сам по собі перевищує обсяг, завжди отримує окремий запит з обрізанням.
31
+ fixFailingTests — Визначає та виправляє провалених тестів, використовуючи можливості pi SDK для роботи з текстом та прямий запис у файли. Якщо всі тести вже проходять, він повертається одразу з кількістю 0.
29
32
 
30
33
  ## Гарантії поведінки
31
34
 
32
- - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
35
+ - (специфічних машинно-виведених гарантій немає)
@@ -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: 243806c4
6
+ crc: 0d24dc95
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
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
- const sections = failures.map(({ file, errors }) => {
122
- const absPath = dir ? join(dir, file) : file
123
- const testCode = existsSync(absPath) ? readFileSync(absPath, 'utf8').slice(0, 6000) : '(файл не знайдено)'
124
-
125
- // Heuristically find source file: strip tests/ prefix from path
126
- const sourcePath = inferSourcePath(absPath)
127
- const sourceCode =
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 = opts.callTextFn ?? (prompt => callText(prompt, { model: MODEL, cwd: dir }))
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
- for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS && remaining.length > 0; attempt++) {
272
- if (attempt > 1) {
273
- console.log(`\n🔄 Спроба ${attempt}/${MAX_FIX_ATTEMPTS}: залишилось ${remaining.length} файлів...\n`)
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
- writeFixedFiles(fixed, remaining, dir)
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 ? prompt => callTextFn(prompt, { model: localModel, cwd: dir }) : null
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`)
@@ -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 |
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: pi-client.mjs
4
4
  resource: npm/src/lib/pi-client.mjs
5
5
  docgen:
6
- crc: 0aa22b9a
6
+ crc: e7f3471e
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -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: runtime-probe.mjs
4
4
  resource: npm/src/lib/runtime-probe.mjs
5
5
  docgen:
6
- crc: 31dd4fd6
6
+ crc: 6455ded4
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -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: e08618cc
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-синтаксисі декларацій).
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Programmatic pi client via @earendil-works/pi-coding-agent SDK.
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
- async function getRegistry() {
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 = /"(?:omlx_)?code"\s*:\s*"([^"]+)"/.exec(message)?.[1]
56
- const estimated = /"estimated_bytes"\s*:\s*(\d+)/.exec(message)?.[1]
57
- const limit = /"limit_bytes"\s*:\s*(\d+)/.exec(message)?.[1]
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 passed to pi (e.g. "openai/gpt-4o"); omit for pi default
142
- * @returns {Promise<string>}
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
- return withRetry(async () => {
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(provider, modelId) : null
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
- await session.prompt(prompt)
164
-
165
- const state = session.state
166
- const last = state.messages.at(-1)
167
- if (!last || last.role !== 'assistant') return ''
168
- if (last.stopReason === 'error' || last.stopReason === 'aborted') {
169
- throw new Error(`pi error: ${last.errorMessage ?? last.stopReason}`)
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 project root where files should be written
183
- * @returns {Promise<void>}
225
+ * @param {string} prompt текст завдання для агента
226
+ * @param {string} cwd робоча директорія, куди агент може писати файли
227
+ * @returns {Promise<void>} проміс завершується після виконання агента
184
228
  */
185
- export async function callAgent(prompt, cwd) {
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
- await session.prompt(prompt)
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
+ }
@@ -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
- ` exclude: ['**/node_modules/**'],\n` +
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`