@7n/test 0.11.0 → 0.11.2

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.2] - 2026-07-04
4
+
5
+ ### Fixed
6
+
7
+ - 🤖 fix(core): кап runtime-probe виходів (shape-summary + tmp-cwd) і bounded backoff на omlx memory-guard замість миттєвого падіння
8
+
9
+ ## [0.11.1] - 2026-07-04
10
+
11
+ ### Changed
12
+
13
+ - 🤖 fix(core): omlx memory-guard помилка тепер друкує тіло запиту й завершує процес замість retry
14
+
3
15
  ## [0.11.0] - 2026-07-03
4
16
 
5
17
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -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: 899f36bc
6
+ crc: 243806c4
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
@@ -19,7 +19,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
19
19
  import { tmpdir } from 'node:os'
20
20
  import { join, relative } from 'node:path'
21
21
  import { env } from 'node:process'
22
- import { callText } from './lib/pi-client.mjs'
22
+ import { callText, MEMORY_ERROR_RE } from './lib/pi-client.mjs'
23
23
  import { findTestRules } from './gen-tests.mjs'
24
24
  import { parseFailingTests } from './coverage-per-file.mjs'
25
25
  import { resolveVitestRun } from './lib/vitest-shim.mjs'
@@ -278,6 +278,9 @@ export async function fixFailingTests(dir, opts = {}) {
278
278
  try {
279
279
  response = await callTextFn(prompt)
280
280
  } catch (error) {
281
+ // memory-guard: не звичайна per-file помилка — RAM-стеля фіксована, продовжувати
282
+ // до наступного файлу немає сенсу. Пробиваємо нагору до CLI, аби процес завершився.
283
+ if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
281
284
  console.error(` ✗ pi помилка: ${error.message}`)
282
285
  break
283
286
  }
package/src/gen-tests.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
17
17
  import { spawnSync } from 'node:child_process'
18
18
  import { join, relative, dirname } from 'node:path'
19
- import { callText } from './lib/pi-client.mjs'
19
+ import { callText, MEMORY_ERROR_RE } from './lib/pi-client.mjs'
20
20
  import { resolveVitestRun } from './lib/vitest-shim.mjs'
21
21
  import { extractExportsWithComplexity } from './classify-exports.mjs'
22
22
  import { analyzeModule } from './lib/ast-analyze.mjs'
@@ -51,7 +51,6 @@ const MOCK_TYPE_RE = /:\s*\w*Mock\b/
51
51
  const FETCH_CALL_RE = /\bfetch\s*\(/
52
52
  const TIME_DEPS_RE = /\bnew\s+Date\b|\bgetHours\b|\bgetDay\b|\bgetMinutes\b|\bDate\.now\b/
53
53
  const VITEST_FAIL_RE = /Failed Tests|FAIL /
54
- const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/i
55
54
  const EXPECTED_LINE_RE = /Expected:\s+"([^"]+)"/
56
55
  const RECEIVED_LINE_RE = /Received:\s+"([^"]+)"/
57
56
  const TO_CONTAIN_RE = /to contain '([^='\s]+)=([^']+)'/
@@ -703,13 +702,15 @@ function buildRetryDiagnostics(lastErrors, prevErrorSig, staleCount, label) {
703
702
  * @param {number} attempt current attempt number
704
703
  * @param {number} maxAttempts retry limit
705
704
  * @param {string} label display name for logging
706
- * @returns {{stop: boolean, reset: boolean, lastErrors: string|null}} loop-control state
705
+ * @returns {{stop: boolean, lastErrors: string|null}} loop-control state
707
706
  */
708
707
  function resolveLoopCallFailure(error, attempt, maxAttempts, label) {
708
+ // memory-guard: не звичайна per-file помилка — RAM-стеля фіксована, продовжувати
709
+ // до наступного файлу немає сенсу. Пробиваємо нагору до CLI, аби процес завершився.
710
+ if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
709
711
  console.log(` ${label} ✗ LLM error (спроба ${attempt}): ${error.message}`)
710
- if (attempt >= maxAttempts) return { stop: true, reset: false, lastErrors: null }
711
- if (MEMORY_ERROR_RE.test(error.message)) return { stop: false, reset: true, lastErrors: null }
712
- return { stop: false, reset: false, lastErrors: `LLM error: ${error.message}` }
712
+ if (attempt >= maxAttempts) return { stop: true, lastErrors: null }
713
+ return { stop: false, lastErrors: `LLM error: ${error.message}` }
713
714
  }
714
715
 
715
716
  /**
@@ -760,14 +761,7 @@ async function generateBlockWithLoop(
760
761
  } catch (error) {
761
762
  const failure = resolveLoopCallFailure(error, attempt, maxAttempts, label)
762
763
  if (failure.stop) break
763
- if (failure.reset) {
764
- lastBlock = null
765
- lastErrors = null
766
- prevErrorSig = null
767
- staleCount = 0
768
- } else {
769
- lastErrors = failure.lastErrors
770
- }
764
+ lastErrors = failure.lastErrors
771
765
  continue
772
766
  }
773
767
 
@@ -879,6 +873,7 @@ async function generateSharedHeader(ctx, dir, callTextFn) {
879
873
  if (header) return header
880
874
  console.error(` ✗ cloud не повернув header для ${file}`)
881
875
  } catch (error) {
876
+ if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
882
877
  console.error(` ✗ cloud header error: ${error.message}`)
883
878
  }
884
879
  return null
@@ -1116,6 +1111,7 @@ async function generateOneTest(fileInfo, dir, callTextFn) {
1116
1111
  try {
1117
1112
  response = await callTextFn(prompt, { cwd: dir })
1118
1113
  } catch (error) {
1114
+ if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
1119
1115
  console.error(` ✗ pi помилка для ${fileInfo.file}: ${error.message}`)
1120
1116
  return null
1121
1117
  }
@@ -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: f2c45cdc
6
+ crc: 0aa22b9a
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -8,9 +8,16 @@
8
8
  *
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
- * instead of failing the caller's file on the first hiccup.
11
+ * instead of failing the caller's file on the first hiccup. A memory-guard
12
+ * rejection gets its own longer bounded backoff: oMLX обслуговує один промпт
13
+ * одночасно, тож відмова зазвичай відображає залишкову пам'ять попереднього
14
+ * запиту, яку сервер вивільняє сам за десятки секунд. Лише після
15
+ * `N_PI_MEMORY_RETRY_ATTEMPTS` невдач тіло запиту друкується в stdout і
16
+ * помилка прокидається як термінальна (виклики-власники процесу мають дати
17
+ * їй впасти, не ковтати як звичайну per-file помилку).
12
18
  */
13
19
  import { createAgentSession, SessionManager, ModelRegistry, AuthStorage } from '@earendil-works/pi-coding-agent'
20
+ import { randomInt } from 'node:crypto'
14
21
  import { env } from 'node:process'
15
22
  import { setTimeout as sleep } from 'node:timers/promises'
16
23
 
@@ -25,26 +32,101 @@ async function getRegistry() {
25
32
  }
26
33
 
27
34
  const RETRYABLE_ERROR_RE = /connection error|ECONNREFUSED|ETIMEDOUT|fetch failed|network/i
35
+ /** Matches a local model server (e.g. oMLX) rejecting a prompt for lack of RAM. */
36
+ export const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/i
28
37
  const MAX_ATTEMPTS = Number(env.N_PI_RETRY_ATTEMPTS) || 4
29
38
  const BASE_DELAY_MS = Number(env.N_PI_RETRY_DELAY_MS) || 1500
30
39
  const MAX_DELAY_MS = 15_000
40
+ /** Скільки разів пробуємо промпт, відкинутий memory guard (включно з першою спробою). */
41
+ const MEMORY_MAX_ATTEMPTS = Number(env.N_PI_MEMORY_RETRY_ATTEMPTS) || 3
42
+ /** Базова затримка перед повтором після memory-guard відмови (експоненційно: 15s → 30s → …). */
43
+ const MEMORY_BASE_DELAY_MS = Number(env.N_PI_MEMORY_RETRY_DELAY_MS) || 15_000
44
+ const MEMORY_MAX_DELAY_MS = 60_000
45
+
46
+ /**
47
+ * Витягує структуровані поля oMLX-помилки з тексту повідомлення, якщо тіло
48
+ * 400 (`omlx_code`/`estimated_bytes`/`limit_bytes`) потрапило в message.
49
+ * `estimated_bytes`/`limit_bytes` — байти GPU/Metal-пам'яті сервера,
50
+ * ТІЛЬКИ для діагностики й логів (не «скільки символів промпту зрізати»).
51
+ * @param {string} message текст помилки від SDK/сервера
52
+ * @returns {{omlxCode?: string, estimatedBytes?: number, limitBytes?: number} | null} знайдені поля або `null`
53
+ */
54
+ 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]
58
+ if (!code && !estimated && !limit) return null
59
+ const details = {}
60
+ if (code) details.omlxCode = code
61
+ if (estimated) details.estimatedBytes = Number(estimated)
62
+ if (limit) details.limitBytes = Number(limit)
63
+ return details
64
+ }
65
+
66
+ /**
67
+ * Розпізнає memory-guard відмову і збагачує помилку структурованими полями
68
+ * (`omlxCode`, `estimatedBytes`, `limitBytes`), коли вони доступні в message.
69
+ * @param {Error} error помилка з виклику моделі
70
+ * @returns {boolean} `true`, якщо це memory-guard відмова
71
+ */
72
+ function isMemoryGuardError(error) {
73
+ const details = parseOmlxErrorDetails(error.message ?? '')
74
+ if (details) Object.assign(error, details)
75
+ return error.omlxCode === 'prefill_memory_exceeded' || MEMORY_ERROR_RE.test(error.message ?? '')
76
+ }
77
+
78
+ /**
79
+ * Термінальний вихід після вичерпання memory-retry: друкує тіло запиту в
80
+ * stdout (лише тут — не на проміжних спробах) і кидає помилку далі,
81
+ * зберігаючи структуровані поля й оригінал у `cause`. Виклики-власники
82
+ * процесу (CLI) мають дати їй впасти, не ковтати як per-file помилку.
83
+ * @param {Error} error memory-guard помилка останньої спроби
84
+ * @param {string} requestBody промпт, надісланий моделі
85
+ * @returns {never} завжди кидає
86
+ */
87
+ function failOnMemoryGuard(error, requestBody) {
88
+ console.log('--- omlx memory-guard: тіло запиту ---')
89
+ console.log(requestBody)
90
+ console.log(`✗ omlx memory-guard: ${error.message}`)
91
+ const wrapped = new Error(`omlx memory-guard: ${error.message}`, { cause: error })
92
+ for (const key of ['omlxCode', 'estimatedBytes', 'limitBytes']) {
93
+ if (error[key] !== undefined) wrapped[key] = error[key]
94
+ }
95
+ throw wrapped
96
+ }
31
97
 
32
98
  /**
33
99
  * Retries `fn` with exponential backoff + jitter when it throws a transient
34
100
  * connection-ish error (shared local model server busy/restarting); other
35
- * errors (auth, malformed request) are re-thrown immediately.
101
+ * errors (auth, malformed request) are re-thrown immediately. A memory-guard
102
+ * rejection has its own bounded schedule (`MEMORY_MAX_ATTEMPTS` спроб,
103
+ * затримки від `MEMORY_BASE_DELAY_MS` експоненційно): одно-слотова черга
104
+ * oMLX вивільняє залишкову пам'ять попереднього запиту сама, тож повтор
105
+ * того самого промпту після паузи легітимний. Після вичерпання —
106
+ * `failOnMemoryGuard` (друк тіла + термінальний throw).
36
107
  * @template T
37
108
  * @param {() => Promise<T>} fn operation to retry
109
+ * @param {string} requestBody prompt sent to the model, printed if a memory-guard error exhausts its retries
38
110
  * @returns {Promise<T>} result of the first successful attempt
39
111
  */
40
- async function withRetry(fn) {
41
- for (let attempt = 1; ; attempt++) {
112
+ async function withRetry(fn, requestBody) {
113
+ let memoryAttempts = 0
114
+ let connAttempts = 0
115
+ for (;;) {
42
116
  try {
43
117
  return await fn()
44
118
  } catch (error) {
45
- if (attempt >= MAX_ATTEMPTS || !RETRYABLE_ERROR_RE.test(error.message ?? '')) throw error
46
- const delay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS)
47
- const jitter = delay * (0.5 + Math.random() * 0.5)
119
+ if (isMemoryGuardError(error)) {
120
+ memoryAttempts++
121
+ if (memoryAttempts >= MEMORY_MAX_ATTEMPTS) failOnMemoryGuard(error, requestBody)
122
+ const delay = Math.min(MEMORY_BASE_DELAY_MS * 2 ** (memoryAttempts - 1), MEMORY_MAX_DELAY_MS)
123
+ await sleep(delay * (0.5 + randomInt(5000) / 10000))
124
+ continue
125
+ }
126
+ connAttempts++
127
+ if (connAttempts >= MAX_ATTEMPTS || !RETRYABLE_ERROR_RE.test(error.message ?? '')) throw error
128
+ const delay = Math.min(BASE_DELAY_MS * 2 ** (connAttempts - 1), MAX_DELAY_MS)
129
+ const jitter = delay * (0.5 + randomInt(5000) / 10000)
48
130
  await sleep(jitter)
49
131
  }
50
132
  }
@@ -90,7 +172,7 @@ export async function callText(prompt, opts = {}) {
90
172
  .filter(c => c.type === 'text')
91
173
  .map(c => c.text)
92
174
  .join('')
93
- })
175
+ }, prompt)
94
176
  }
95
177
 
96
178
  /**
@@ -109,5 +191,5 @@ export async function callAgent(prompt, cwd) {
109
191
  })
110
192
 
111
193
  await session.prompt(prompt)
112
- })
194
+ }, prompt)
113
195
  }
@@ -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
  }