@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 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
- 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.1",
3
+ "version": "0.12.0",
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,7 +3,7 @@ type: JS Module
3
3
  title: fix-tests.mjs
4
4
  resource: npm/src/fix-tests.mjs
5
5
  docgen:
6
- crc: b807d1bc
6
+ crc: f198a4b3
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: gen-tests.mjs
4
4
  resource: npm/src/gen-tests.mjs
5
5
  docgen:
6
- crc: 243806c4
6
+ crc: 531f870e
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,27 @@ 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(` 📦 батч: ${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
- writeFixedFiles(fixed, remaining, dir)
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: 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,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 (the shared machine can't fit the prompt in RAM) is not
13
- * retryable retrying against a fixed RAM ceiling can't succeed, so it
14
- * prints the request body to stdout and terminates the process instead.
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
- async function getRegistry() {
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
- * Prints the request body that triggered a memory-guard rejection to stdout
39
- * and throwsthere's no RAM to retry into. Callers that own the process
40
- * lifecycle (CLI entrypoints) must let this propagate uncaught instead of
41
- * swallowing it like a normal per-file error, so the process exits instead
42
- * of quietly continuing against a RAM ceiling that won't change.
43
- * @param {Error} error the memory-guard error thrown by the model server
44
- * @param {string} requestBody prompt sent to the model
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
- throw new Error(`omlx memory-guard: ${error.message}`)
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 throws via `failOnMemoryGuard` instead of retrying — see its docs.
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 terminates the process
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
- for (let attempt = 1; ; attempt++) {
117
+ let memoryAttempts = 0
118
+ let connAttempts = 0
119
+ for (;;) {
66
120
  try {
67
121
  return await fn()
68
122
  } catch (error) {
69
- if (MEMORY_ERROR_RE.test(error.message ?? '')) failOnMemoryGuard(error, requestBody)
70
- if (attempt >= MAX_ATTEMPTS || !RETRYABLE_ERROR_RE.test(error.message ?? '')) throw error
71
- const delay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS)
72
- const jitter = delay * (0.5 + Math.random() * 0.5)
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 passed to pi (e.g. "openai/gpt-4o"); omit for pi default
85
- * @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>} текстова відповідь моделі
86
171
  */
87
172
  export async function callText(prompt, opts = {}) {
88
- return withRetry(async () => {
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(provider, modelId) : null
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
- await session.prompt(prompt)
107
-
108
- const state = session.state
109
- const last = state.messages.at(-1)
110
- if (!last || last.role !== 'assistant') return ''
111
- if (last.stopReason === 'error' || last.stopReason === 'aborted') {
112
- 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()
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 project root where files should be written
126
- * @returns {Promise<void>}
225
+ * @param {string} prompt текст завдання для агента
226
+ * @param {string} cwd робоча директорія, куди агент може писати файли
227
+ * @returns {Promise<void>} проміс завершується після виконання агента
127
228
  */
128
- export async function callAgent(prompt, cwd) {
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
- await session.prompt(prompt)
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 = spawnSync('node', ['--input-type=module'], {
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 = spawnSync('node', ['--input-type=module'], {
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
  }
@@ -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`