@7n/test 0.9.0 → 0.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +4 -2
  3. package/src/assess-need.mjs +4 -1
  4. package/src/classify-exports.mjs +108 -0
  5. package/src/coverage-classify/docs/apply.md +28 -0
  6. package/src/coverage-classify/docs/cache.md +34 -0
  7. package/src/coverage-classify/docs/index.md +40 -0
  8. package/src/coverage-classify/docs/prompt.md +30 -0
  9. package/src/coverage-classify/docs/verdict-schema.md +29 -0
  10. package/src/coverage-fix-extract.mjs +10 -10
  11. package/src/coverage-per-file.mjs +68 -43
  12. package/src/docs/assess-need.md +31 -0
  13. package/src/docs/classify-exports.md +30 -0
  14. package/src/docs/coverage-fix-extract.md +35 -0
  15. package/src/docs/coverage-fix.md +31 -0
  16. package/src/docs/coverage-per-file.md +34 -0
  17. package/src/docs/fix-tests.md +32 -0
  18. package/src/docs/gen-tests.md +38 -0
  19. package/src/docs/index.md +34 -0
  20. package/src/docs/run.md +33 -0
  21. package/src/fix-tests.mjs +233 -87
  22. package/src/gen-tests.mjs +1102 -49
  23. package/src/lib/ast-analyze.mjs +287 -0
  24. package/src/lib/docs/ast-analyze.md +45 -0
  25. package/src/lib/docs/index.md +14 -0
  26. package/src/lib/docs/pi-client.md +29 -0
  27. package/src/lib/docs/runtime-probe.md +36 -0
  28. package/src/lib/docs/vitest-shim.md +31 -0
  29. package/src/lib/pi-client.mjs +18 -3
  30. package/src/lib/runtime-probe.mjs +390 -0
  31. package/src/lib/vitest-shim.mjs +73 -0
  32. package/src/run.mjs +3 -1
  33. package/src/scripts/lib/changed-files.mjs +5 -5
  34. package/src/scripts/lib/docs/changed-files.md +32 -0
  35. package/src/scripts/lib/docs/read-n-cursor-config-lite.md +39 -0
  36. package/src/scripts/utils/docs/index.md +13 -0
  37. package/src/scripts/utils/docs/lock-cache-dir.md +32 -0
  38. package/src/scripts/utils/docs/with-lock.md +38 -0
  39. package/src/scripts/utils/docs/worktree-fingerprint.md +34 -0
  40. package/src/scripts/utils/lock-cache-dir.mjs +1 -1
  41. package/src/scripts/utils/with-lock.mjs +2 -3
package/src/gen-tests.mjs CHANGED
@@ -1,50 +1,1026 @@
1
1
  /**
2
- * Test generation via pi SDK (one file at a time).
2
+ * Test generation via pi SDK with per-export tiered routing.
3
3
  *
4
- * Uses callText() for text-mode generation: gets raw code back,
5
- * extracts the ```js block, writes the file directly.
6
- * No subprocess, no confirmation prompts.
4
+ * Strategy:
5
+ * 1. Classify each export: trivial/simple → local pi model first, complex cloud.
6
+ * 2. Generate a shared header (imports, mocks, setup) via cloud.
7
+ * 3. Generate per-export describe() blocks routed by complexity.
8
+ * 4. Validate local-generated blocks; fall back to cloud on anti-patterns.
9
+ * 5. Merge header + blocks → write test file.
10
+ *
11
+ * Local model is selected via opts.localModel or N_LOCAL_MIN_MODEL env var.
12
+ * All calls (local and cloud) go through the pi SDK — no direct HTTP to omlx.
13
+ * Falls back to single-file cloud generation when no local model is configured
14
+ * or when extractExportsWithComplexity() returns no exports.
7
15
  */
8
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
9
- import { join, relative } from 'node:path'
16
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
17
+ import { spawnSync } from 'node:child_process'
18
+ import { join, relative, dirname } from 'node:path'
10
19
  import { callText } from './lib/pi-client.mjs'
20
+ import { resolveVitestRun } from './lib/vitest-shim.mjs'
21
+ import { extractExportsWithComplexity } from './classify-exports.mjs'
22
+ import { analyzeModule } from './lib/ast-analyze.mjs'
23
+ import { probeModule, probeFetchCalls, probeTimeVariants, probeHelpers } from './lib/runtime-probe.mjs'
24
+ import { checkEnv } from '@nitra/check-env'
11
25
 
12
26
  const MAX_SRC_BYTES = 6000
13
27
 
14
- /** Test file candidates relative to source file. */
28
+ /**
29
+ * Reads a source file and trims it to the prompt budget.
30
+ * @param {string} absPath absolute source path
31
+ * @returns {string} source snippet or empty string when unavailable
32
+ */
33
+ function readSourceSnippet(absPath) {
34
+ if (!existsSync(absPath)) return ''
35
+ const content = readFileSync(absPath, 'utf8')
36
+ return content.length > MAX_SRC_BYTES ? `${content.slice(0, MAX_SRC_BYTES)}\n...(truncated)` : content
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Static regex constants (prefer-static-regex)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const FILE_EXT_RE = /\.[^.]+$/
44
+ const CODE_BLOCK_RE = /```(?:js|javascript|mjs|ts)?\n([\s\S]*?)```/
45
+ const FRONTMATTER_RE = /^---[\s\S]*?---\n/
46
+ const REQUIRE_CALL_RE = /\brequire\s*\(/
47
+ const JEST_ACCESS_RE = /\bjest\./
48
+ const AS_VI_MOCK_RE = /\bas\s+vi\.Mock/
49
+ const AS_JEST_MOCK_RE = /\bas\s+jest\.Mock/
50
+ const MOCK_TYPE_RE = /:\s*\w*Mock\b/
51
+ const FETCH_CALL_RE = /\bfetch\s*\(/
52
+ const TIME_DEPS_RE = /\bnew\s+Date\b|\bgetHours\b|\bgetDay\b|\bgetMinutes\b|\bDate\.now\b/
53
+ const VITEST_FAIL_RE = /Failed Tests|FAIL /
54
+ const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/i
55
+ const EXPECTED_LINE_RE = /Expected:\s+"([^"]+)"/
56
+ const RECEIVED_LINE_RE = /Received:\s+"([^"]+)"/
57
+ const TO_CONTAIN_RE = /to contain '([^='\s]+)=([^']+)'/
58
+ const NOT_CONTAIN_RE = /not to contain '([^']+)'/
59
+ const FLAG_CONTAIN_RE = /to contain '([^'=]+)=true'/
60
+
61
+ /**
62
+ * @callback PiCallFn
63
+ * @param {string} prompt LLM prompt
64
+ * @param {{cwd?: string, model?: string}} [options] call options
65
+ * @returns {Promise<string>} LLM response text
66
+ */
67
+
68
+ /**
69
+ * @callback GenerateOneFn
70
+ * @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
71
+ * @param {string} dir project root
72
+ * @returns {Promise<string|null>} written test path or null
73
+ */
74
+
75
+ /**
76
+ * @typedef {object} GenerateTestsOptions
77
+ * @property {PiCallFn} [callText] - Custom cloud caller.
78
+ * @property {string|null} [localModel] - Local model id; null forces cloud-only mode.
79
+ * @property {GenerateOneFn} [generateOne] - Custom single-file generator.
80
+ */
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Helpers shared across strategies
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Extracts names of exported symbols from JS/TS source.
88
+ * @param {string} content source text
89
+ * @returns {string[]} exported symbol names
90
+ */
91
+ function extractExports(content) {
92
+ return Array.from(content.matchAll(/^export\s+(?:async\s+)?(?:const|function|class|let)\s+(\w+)/gm), m => m[1])
93
+ }
94
+
95
+ /** Detects top-level function calls that run as side-effects on module load. */
96
+ const TOP_LEVEL_CALL_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\(/m
97
+
98
+ /**
99
+ * Test file candidates relative to source file.
100
+ * Primary: tests/ subdirectory (per n-test.mdc convention).
101
+ * @param {string} file relative source path
102
+ * @returns {string[]} candidate test file paths
103
+ */
15
104
  function testCandidates(file) {
16
- const base = file.replace(/\.[^.]+$/, '')
105
+ const base = file.replace(FILE_EXT_RE, '')
17
106
  const lastSlash = base.lastIndexOf('/')
18
107
  const name = lastSlash === -1 ? base : base.slice(lastSlash + 1)
19
108
  const dir = lastSlash === -1 ? '' : base.slice(0, lastSlash)
20
- return [`${base}.test.mjs`, `${base}.test.js`, ...(dir ? [`${dir}/tests/${name}.test.mjs`] : [])]
109
+ const testsDir = dir ? `${dir}/tests` : 'tests'
110
+ return [`${testsDir}/${name}.test.mjs`, `${base}.test.mjs`, `${base}.test.js`]
21
111
  }
22
112
 
23
113
  /**
24
- * Extracts the first ```js...``` block from LLM text output.
25
- * @param {string} text
26
- * @returns {string}
114
+ * Extracts the first fenced JS block from LLM text output.
115
+ * @param {string} text LLM output
116
+ * @returns {string} extracted code or empty string
27
117
  */
28
118
  function extractCode(text) {
29
- const match = text.match(/```(?:js|javascript|mjs|ts)?\n([\s\S]*?)```/)
30
- return match ? match[1].trim() : ''
119
+ const m = CODE_BLOCK_RE.exec(text)
120
+ if (m) return m[1].trim()
121
+ const start = text.indexOf('```')
122
+ if (start === -1) return ''
123
+ const bodyStart = text.indexOf('\n', start)
124
+ if (bodyStart === -1) return ''
125
+ const end = text.indexOf('\n```', bodyStart + 1)
126
+ if (end === -1) return ''
127
+ return text.slice(bodyStart + 1, end).trim()
128
+ }
129
+
130
+ /**
131
+ * Checks whether source text calls or declares a symbol by name.
132
+ * @param {string} text source text to search
133
+ * @param {string} name symbol name to look for
134
+ * @returns {boolean} true when the symbol is invoked
135
+ */
136
+ function hasInvocation(text, name) {
137
+ return text.includes(`${name}(`)
138
+ }
139
+
140
+ /**
141
+ * Finds the project's n-test.mdc rules by walking up from dir (max 4 levels).
142
+ * @param {string} dir project root
143
+ * @returns {string|null} rules text or null
144
+ */
145
+ export function findTestRules(dir) {
146
+ let current = dir
147
+ for (let i = 0; i < 4; i++) {
148
+ const candidate = join(current, '.cursor/rules/n-test.mdc')
149
+ if (existsSync(candidate)) {
150
+ return readFileSync(candidate, 'utf8').replace(FRONTMATTER_RE, '').trim()
151
+ }
152
+ const parent = dirname(current)
153
+ if (parent === current) break
154
+ current = parent
155
+ }
156
+ return null
31
157
  }
32
158
 
159
+ /**
160
+ * Resolves importPath and testFilePath for a source file relative to its test.
161
+ * @param {string} file relative source path
162
+ * @returns {{testFilePath: string, importPath: string}} resolved paths
163
+ */
164
+ function resolveTestPaths(file) {
165
+ const testFilePath = testCandidates(file)[0]
166
+ const testDir = dirname(testFilePath)
167
+ const rel = relative(testDir, file)
168
+ const importPath = rel.startsWith('.') ? rel : `./${rel}`
169
+ return { testFilePath, importPath }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Validation helpers for per-export blocks
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Returns true when a LLM-generated describe block passes basic quality checks.
178
+ * Used to decide whether to accept local output or escalate to cloud.
179
+ * @param {string} block describe block text
180
+ * @returns {boolean} true when the block looks valid
181
+ */
182
+ function isValidBlock(block) {
183
+ if (!block?.trim()) return false
184
+ if (!block.includes('describe(')) return false
185
+ if (REQUIRE_CALL_RE.test(block)) return false
186
+ if (JEST_ACCESS_RE.test(block)) return false
187
+ if (AS_VI_MOCK_RE.test(block)) return false
188
+ if (AS_JEST_MOCK_RE.test(block)) return false
189
+ return !MOCK_TYPE_RE.test(block)
190
+ }
191
+
192
+ /**
193
+ * Combines a shared header with individual describe() blocks.
194
+ * Strips stray import lines that models sometimes add to blocks.
195
+ * @param {string} header shared test header
196
+ * @param {string[]} blocks describe blocks
197
+ * @returns {string | null} merged file content or `null`
198
+ */
199
+ function mergeBlocks(header, blocks) {
200
+ if (!header?.trim()) return null
201
+ const clean = blocks
202
+ .filter(Boolean)
203
+ .map(b =>
204
+ b
205
+ .replaceAll(/^import\s[^;]+;?\n?/gm, '')
206
+ .replaceAll(/^\/\/ .+\n/gm, '')
207
+ .trim()
208
+ )
209
+ .filter(Boolean)
210
+ if (clean.length === 0) return null
211
+ return [header.trim(), '', ...clean].join('\n\n')
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Prompt builders
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * @typedef {object} HeaderPromptOptions
220
+ * @property {string} file source file path
221
+ * @property {string} testFilePath generated test file path
222
+ * @property {string} importPath import path from test to source
223
+ * @property {boolean} hasSideEffects whether source runs top-level side effects
224
+ * @property {string} content source snippet
225
+ * @property {string[]} exports exported symbol names
226
+ * @property {string|null} testRules project test rules
227
+ * @property {object|null} astInfo static AST analysis result
228
+ */
229
+
230
+ /**
231
+ * Builds the header prompt. Accepts pre-computed AST info so mock shapes are
232
+ * derived deterministically — the LLM only fills in vi.stubEnv calls.
233
+ * @param {HeaderPromptOptions} opts header prompt options
234
+ * @returns {string} header prompt text
235
+ */
236
+ function buildHeaderPrompt(opts) {
237
+ const { file, testFilePath, importPath, hasSideEffects, content, exports, testRules, astInfo } = opts
238
+ const mockLines = astInfo?.externalMocks?.map(m => m.mockLine) ?? []
239
+ const envReads = astInfo?.envReads ?? []
240
+ const usesFetch = astInfo?.usesFetch ?? FETCH_CALL_RE.test(content)
241
+
242
+ const importLine = hasSideEffects
243
+ ? `const { ${exports.join(', ')} } = await import("${importPath}")`
244
+ : `import { ${exports.join(', ')} } from "${importPath}"`
245
+
246
+ const envStubHints = envReads.length
247
+ ? envReads.map(k => ` vi.stubEnv("${k}", "test_value")`)
248
+ : [' // vi.stubEnv("KEY", "value") — для env-змінних що читає модуль']
249
+
250
+ const hasTimeDependency = TIME_DEPS_RE.test(content)
251
+ const timerHints = hasTimeDependency
252
+ ? [' vi.useFakeTimers()', ' vi.setSystemTime(new Date("2024-01-01T02:00:00"))']
253
+ : [' // vi.useFakeTimers() + vi.setSystemTime(...) — якщо є new Date()']
254
+
255
+ const template = [
256
+ `import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"`,
257
+ '',
258
+ ...(mockLines.length ? mockLines : ['// vi.mock("pkg", () => ({ fn: vi.fn() }))']),
259
+ '',
260
+ importLine,
261
+ ...(usesFetch ? ['', 'const mockFetch = vi.fn()'] : []),
262
+ '',
263
+ 'beforeEach(() => {',
264
+ ' vi.clearAllMocks()',
265
+ ...(usesFetch
266
+ ? [
267
+ ' vi.stubGlobal("fetch", mockFetch)',
268
+ ' mockFetch.mockResolvedValue({ status: 200, json: async () => ({}) })'
269
+ ]
270
+ : []),
271
+ ...envStubHints,
272
+ ...timerHints,
273
+ '})',
274
+ '',
275
+ 'afterEach(() => {',
276
+ ' vi.restoreAllMocks()',
277
+ ' vi.unstubAllGlobals()',
278
+ ' vi.unstubAllEnvs()',
279
+ ' vi.useRealTimers()',
280
+ '})'
281
+ ].join('\n')
282
+
283
+ const internalNote = astInfo?.internalNames?.length
284
+ ? `Внутрішні (НЕ-exported, НЕ імітувати): ${astInfo.internalNames.join(', ')}`
285
+ : ''
286
+
287
+ return [
288
+ `Заповни template header для unit-тест файлу (без describe/it блоків).`,
289
+ `Тест-файл: \`${testFilePath}\` Source: \`${file}\``,
290
+ '',
291
+ 'TEMPLATE — vi.mock рядки вже точні (з AST). Заповни лише vi.stubEnv і vi.useFakeTimers де потрібно:',
292
+ '```js',
293
+ template,
294
+ '```',
295
+ '',
296
+ 'ПРАВИЛА:',
297
+ `- Імпортуй з \`${importPath}\` — НЕ змінюй розширення, НЕ підміняй цей модуль`,
298
+ `- Exports: ${exports.join(', ')}`,
299
+ ...(internalNote ? [internalNote] : []),
300
+ '- vi.mock() factories вже прописані вище — НЕ додавай нових',
301
+ 'Поверни лише код у ```js … ```',
302
+ ...(testRules ? ['', '## Конвенції проєкту:', testRules] : []),
303
+ '',
304
+ `Source (${file}):`,
305
+ '```js',
306
+ content || '(недоступно)',
307
+ '```'
308
+ ].join('\n')
309
+ }
310
+
311
+ /**
312
+ * Extracts the source of a top-level (non-exported) declaration by name.
313
+ * @param {string} content module source
314
+ * @param {string} name declaration name
315
+ * @returns {string|null} declaration source or null if not found
316
+ */
317
+ function extractInternalSource(content, name) {
318
+ const prefixes = [`const ${name} =`, `let ${name} =`, `var ${name} =`, `function ${name}(`, `async function ${name}(`]
319
+ let start = -1
320
+ for (const prefix of prefixes) {
321
+ const direct = content.indexOf(prefix)
322
+ if (direct !== -1 && (start === -1 || direct < start)) start = direct
323
+ const lineStart = content.indexOf(`\n${prefix}`)
324
+ if (lineStart !== -1) {
325
+ const candidate = lineStart + 1
326
+ if (start === -1 || candidate < start) start = candidate
327
+ }
328
+ }
329
+ if (start === -1) return null
330
+ const after = content.slice(start)
331
+ const markers = ['\nconst ', '\nlet ', '\nvar ', '\nfunction ', '\nasync function ', '\nexport ']
332
+ let end = -1
333
+ for (const marker of markers) {
334
+ const idx = after.indexOf(marker)
335
+ if (idx !== -1 && (end === -1 || idx < end)) end = idx
336
+ }
337
+ return after.slice(0, end === -1 ? Math.min(after.length, 600) : end)
338
+ }
339
+
340
+ const COMPLEXITY_HINTS = {
341
+ trivial: 'константа, 1-2 прості перевірки',
342
+ simple: 'чиста функція',
343
+ complex: 'async/fetch/Date/env'
344
+ }
345
+
346
+ /**
347
+ * Describes the generation budget implied by export complexity.
348
+ * @param {string} complexity export complexity bucket
349
+ * @returns {string} human-readable complexity hint
350
+ */
351
+ function describeExportComplexity(complexity) {
352
+ return COMPLEXITY_HINTS[complexity] ?? COMPLEXITY_HINTS.complex
353
+ }
354
+
355
+ /**
356
+ * Extracts the source fragment relevant to one export.
357
+ * @param {string} content module source
358
+ * @param {string} name exported symbol name
359
+ * @returns {string} narrowed source snippet
360
+ */
361
+ function extractExportSnippet(content, name) {
362
+ const exportPrefixes = [
363
+ `export async function ${name}`,
364
+ `export function ${name}`,
365
+ `export async const ${name}`,
366
+ `export const ${name}`,
367
+ `export let ${name}`,
368
+ `export class ${name}`
369
+ ]
370
+ let startAt = -1
371
+ let startLen = 0
372
+ for (const prefix of exportPrefixes) {
373
+ const direct = content.indexOf(prefix)
374
+ if (direct !== -1 && (startAt === -1 || direct < startAt)) {
375
+ startAt = direct
376
+ startLen = prefix.length
377
+ }
378
+ }
379
+ if (startAt === -1) return content
380
+ const after = content.slice(startAt + startLen)
381
+ const nextExport = after.indexOf('\nexport ')
382
+ const end = nextExport === -1 ? Math.min(after.length, 2000) : nextExport
383
+ return content.slice(startAt, startAt + startLen) + after.slice(0, end)
384
+ }
385
+
386
+ /**
387
+ * Formats runtime-probe results for the block prompt.
388
+ * @param {string} name export name
389
+ * @param {object} probeResults probe results by export name
390
+ * @returns {string[]} prompt lines
391
+ */
392
+ function buildProbeLines(name, probeResults) {
393
+ const probeSection = probeResults?.[name]
394
+ if (Array.isArray(probeSection) && probeSection.length) {
395
+ return [
396
+ '',
397
+ `Реальні виходи \`${name}\` (runtime-probe — використовуй для expected, не вгадуй):`,
398
+ ...probeSection.map(p => `- ${name}(${p.input}) → ${p.output}`)
399
+ ]
400
+ }
401
+ if (probeSection?.constant !== undefined) {
402
+ return ['', `Реальне значення \`${name}\`: ${probeSection.constant}`]
403
+ }
404
+ return []
405
+ }
406
+
407
+ /**
408
+ * Formats fetch capture results for the block prompt.
409
+ * @param {string} name export name
410
+ * @param {object} fetchProbe captured fetch calls by export name
411
+ * @returns {string[]} prompt lines
412
+ */
413
+ function buildFetchLines(name, fetchProbe) {
414
+ const fetchCalls = fetchProbe?.[name]
415
+ return fetchCalls?.length
416
+ ? [
417
+ '',
418
+ `Реальні fetch-виклики \`${name}\` (перехоплено під час probe — використовуй для assert URL):`,
419
+ ...fetchCalls.map(c => `- args ${c.args} → fetch("${c.url}"${c.init ? ', init' : ''})`)
420
+ ]
421
+ : []
422
+ }
423
+
424
+ /**
425
+ * Formats time-variant probe results for the block prompt.
426
+ * @param {string} name export name
427
+ * @param {object} timeProbe time-variant probe results by export name
428
+ * @returns {string[]} prompt lines
429
+ */
430
+ function buildTimeLines(name, timeProbe) {
431
+ const timeVariant = timeProbe?.[name]
432
+ return timeVariant
433
+ ? [
434
+ '',
435
+ `Часова залежність \`${name}\` (виходи змінюються залежно від години):`,
436
+ ...Object.entries(timeVariant).map(([h, v]) => `- ${h.toString().padStart(2, '0')}:00 → ${v}`)
437
+ ]
438
+ : []
439
+ }
440
+
441
+ /**
442
+ * Formats helper introspection results for the block prompt.
443
+ * @param {string} content module source
444
+ * @param {string} snippet narrowed source snippet
445
+ * @param {string[]} internalNames internal top-level names
446
+ * @param {object} helperProbe internal helper probe results by helper name
447
+ * @returns {string[]} prompt lines
448
+ */
449
+ function buildHelperLines(content, snippet, internalNames, helperProbe) {
450
+ const usedHelpers = internalNames.filter(name => hasInvocation(snippet, name))
451
+ const helperSources = usedHelpers
452
+ .map(name => extractInternalSource(content, name))
453
+ .filter(Boolean)
454
+ .slice(0, 3)
455
+ const helperLines = usedHelpers.flatMap(name => {
456
+ const calls = helperProbe?.[name]
457
+ if (!calls?.length) return []
458
+ return [
459
+ '',
460
+ `Реальні виходи internal helper \`${name}\` (НЕ імітувати, лише розуміти):`,
461
+ ...calls.slice(0, 4).map(c => `- ${name}(${JSON.stringify(c.params)}) → ${JSON.stringify(c.result)}`)
462
+ ]
463
+ })
464
+ return helperSources.length
465
+ ? [
466
+ '',
467
+ 'Internal helpers (контекст для розуміння params/API — НЕ імітувати):',
468
+ '```js',
469
+ ...helperSources,
470
+ '```',
471
+ ...helperLines
472
+ ]
473
+ : helperLines
474
+ }
475
+
476
+ /**
477
+ * Builds the prompt for a single describe() block for one export.
478
+ * @param {object} opts block prompt options
479
+ * @param {{name: string, complexity: string}} opts.exp export metadata
480
+ * @param {string} opts.testFilePath generated test file path
481
+ * @param {string} opts.importPath import path from test to source
482
+ * @param {string} opts.content source snippet
483
+ * @param {string} opts.header generated shared test header
484
+ * @param {string|null} opts.testRules project test rules
485
+ * @param {object} opts.probeResults runtime probe results by export name
486
+ * @param {object} opts.fetchProbe captured fetch calls by export name
487
+ * @param {object} opts.timeProbe time variant probe results by export name
488
+ * @param {object} opts.helperProbe internal helper probe results by helper name
489
+ * @param {object|null} opts.astInfo static AST analysis result
490
+ * @returns {string} block prompt text
491
+ */
492
+ function buildBlockPrompt(opts) {
493
+ const {
494
+ exp,
495
+ testFilePath,
496
+ importPath,
497
+ content,
498
+ header,
499
+ testRules,
500
+ probeResults,
501
+ fetchProbe,
502
+ timeProbe,
503
+ helperProbe,
504
+ astInfo
505
+ } = opts
506
+ const snippet = extractExportSnippet(content, exp.name)
507
+ const internalNames = astInfo?.internalNames ?? []
508
+
509
+ return [
510
+ `Тест-файл: \`${testFilePath}\` Source import: \`"${importPath}"\``,
511
+ '',
512
+ 'Header вже написано (НЕ дублюй import/beforeEach/afterEach):',
513
+ '```js',
514
+ header,
515
+ '```',
516
+ '',
517
+ `Напиши ЛИШЕ \`describe("${exp.name}", () => { … })\` для \`${exp.name}\`.`,
518
+ `Складність: ${exp.complexity} — ${describeExportComplexity(exp.complexity)}`,
519
+ ...buildProbeLines(exp.name, probeResults),
520
+ ...buildFetchLines(exp.name, fetchProbe),
521
+ ...buildTimeLines(exp.name, timeProbe),
522
+ ...buildHelperLines(content, snippet, internalNames, helperProbe),
523
+ '',
524
+ 'Правила (СУВОРО):',
525
+ '- Без import, без beforeEach — тільки describe',
526
+ '- ESM only (без require), vi.* (без jest.*)',
527
+ '- vi.mocked(fn) замість type-кастингу',
528
+ '- toBe для примітивів, toEqual для обʼєктів/масивів',
529
+ '- `describe()` callback НЕ може бути async — `await` тільки у top-level, `beforeAll(async()=>{})`, або `it(async()=>{})`',
530
+ "- НЕ використовуй vi.spyOn на ESM-exports — це неможливо (`Cannot spy on export`). Для перевірки виклику fetch використовуй `mockFetch.mock.calls[0][0]` (URL) та `mockFetch.mock.calls[0][1]` (init-об'єкт або undefined)",
531
+ '- fetch завжди викликається як `fetch(url, undefined)` — `toHaveBeenCalledWith` ПРОВАЛИТЬСЯ (2 аргументи). ЗАВЖДИ перевіряй URL через `expect(mockFetch.mock.calls[0][0]).toContain(pattern)` або `.toBe(url)` — НЕ `toHaveBeenCalledWith`',
532
+ '- `expect(str).stringContaining(x)` та `expect(str).not.stringContaining(x)` НЕ існують в Vitest — використовуй `expect(str).toContain(pattern)` та `expect(str).not.toContain(pattern)`',
533
+ '- НЕ створюй окремий mock-спай для тестованої функції — вона вже реальна. Стеж тільки за `mockFetch`',
534
+ '- `formData.get("document")` повертає `File` об\'єкт, НЕ рядок — для перевірки імені файлу: `formData.get("document").name`',
535
+ '- `vi.useFakeTimers()` БЕЗ `vi.setSystemTime(...)` заморожує час на ЗАРАЗ (поточна година) — якщо функція залежить від часу доби, обовʼязково встанови фіксований час: `vi.setSystemTime(new Date("2024-01-01T00:00:00"))` (північ, поза робочими годинами)',
536
+ '- При тестуванні params — перевіряй РЕАЛЬНІ назви полів з internal helpers (наприклад, `disable_notification`, НЕ `silent`)',
537
+ '- Поверни лише describe-блок у ```js … ```',
538
+ ...(testRules ? ['', '## Конвенції:', testRules.slice(0, 1500)] : []),
539
+ '',
540
+ `Source (${exp.name}):`,
541
+ '```js',
542
+ snippet,
543
+ '```'
544
+ ].join('\n')
545
+ }
546
+
547
+ // ---------------------------------------------------------------------------
548
+ // Block validation via real vitest run
549
+ // ---------------------------------------------------------------------------
550
+
551
+ const LOCAL_MAX_ATTEMPTS = 3
552
+ const CLOUD_MAX_ATTEMPTS = 10
553
+
554
+ /**
555
+ * Runs a single describe block (merged with header) in vitest.
556
+ * Writes a temp file inside testDir so relative imports and vitest's include
557
+ * pattern both resolve correctly. Cleans up after the run.
558
+ * @param {string} header shared test file header
559
+ * @param {string} block describe block to validate
560
+ * @param {string} dir project root (cwd for vitest, for config resolution)
561
+ * @param {string} testDir directory where the real test file would live
562
+ * @returns {{ passed: boolean, errors: string }} vitest result
563
+ */
564
+ function runBlock(header, block, dir, testDir) {
565
+ const code = mergeBlocks(header, [block])
566
+ if (!code) return { passed: false, errors: 'mergeBlocks failed' }
567
+
568
+ const { bin, configArgs } = resolveVitestRun(dir)
569
+ mkdirSync(testDir, { recursive: true })
570
+ const tmpFile = join(testDir, '.7n-validate.test.mjs')
571
+ try {
572
+ writeFileSync(tmpFile, code + '\n', 'utf8')
573
+ const result = spawnSync(
574
+ process.execPath,
575
+ [bin, 'run', ...configArgs, '--root', dir, '--reporter=verbose', tmpFile],
576
+ { cwd: dir, encoding: 'utf8', timeout: 30_000, env: process.env }
577
+ )
578
+ if (result.status === 0) return { passed: true, errors: '' }
579
+ const out = (result.stdout ?? '') + (result.stderr ?? '')
580
+ // Extract only the failure section to keep context short
581
+ const lines = out.split('\n')
582
+ const failIdx = lines.findIndex(l => VITEST_FAIL_RE.test(l))
583
+ const relevant = failIdx === -1 ? out : lines.slice(failIdx).join('\n')
584
+ return { passed: false, errors: relevant.slice(0, 3000) }
585
+ } finally {
586
+ try {
587
+ rmSync(tmpFile)
588
+ } catch {
589
+ /* ignore if already gone */
590
+ }
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Detects common env/timer anti-patterns from vitest error output.
596
+ * Pattern-based, project-agnostic — parses Expected/Received from vitest output.
597
+ * Returns root-cause hints to inject into the retry prompt.
598
+ * @param {string} errors vitest error output
599
+ * @returns {string[]} hint messages
600
+ */
601
+ function detectStaleRootCause(errors) {
602
+ const hints = []
603
+
604
+ // Extract structured Expected/Received lines from vitest output
605
+ const expectedMatch = errors.match(EXPECTED_LINE_RE)
606
+ const receivedMatch = errors.match(RECEIVED_LINE_RE)
607
+ const _expected = expectedMatch?.[1] ?? ''
608
+ const received = receivedMatch?.[1] ?? ''
609
+
610
+ // Pattern 1: expected param=X, but received param=test_value (global stub conflict)
611
+ // e.g. expected 'param=12345', received '...param=test_value...'
612
+ const toContainMatch = errors.match(TO_CONTAIN_RE)
613
+ if (toContainMatch && received.includes(`${toContainMatch[1]}=test_value`) && toContainMatch[2] !== 'test_value') {
614
+ const [, param, wantedVal] = toContainMatch
615
+ hints.push(
616
+ `ПРИЧИНА: "${param}=" у результаті має значення "test_value" (зі stubEnv у beforeEach), а не "${wantedVal}".`,
617
+ `Щоб перевірити конкретне значення — додай vi.stubEnv("ENV_KEY", "${wantedVal}") всередині it-блоку перед викликом.`,
618
+ `Або змінить assert на .toContain("${param}=test_value") якщо конкретне значення не важливе.`
619
+ )
620
+ }
621
+
622
+ // Pattern 2: not.toContain(X) fails → X always present (global stub makes it so)
623
+ const notContainMatch = errors.match(NOT_CONTAIN_RE)
624
+ if (notContainMatch && received.includes(notContainMatch[1])) {
625
+ hints.push(
626
+ `ПРИЧИНА: "${notContainMatch[1]}" завжди присутній у результаті — швидше за все через глобальний vi.stubEnv у beforeEach.`,
627
+ `Видали цей тест або перевірте умову при якій "${notContainMatch[1]}" не з'являється.`
628
+ )
629
+ }
630
+
631
+ // Pattern 3: expected to contain 'flag=true' but absent → conditional/time-based logic
632
+ const flagMatch = errors.match(FLAG_CONTAIN_RE)
633
+ if (flagMatch && !received.includes(`${flagMatch[1]}=true`)) {
634
+ hints.push(
635
+ `ПРИЧИНА: "${flagMatch[1]}=true" не з'являється — це умовний параметр (залежить від часу, env або стану).`,
636
+ `Якщо залежить від часу — встанови фіксований час у it-блоці: vi.setSystemTime(new Date("2024-01-01T02:00:00")).`,
637
+ `Якщо залежить від env — stub відповідну змінну перед викликом.`
638
+ )
639
+ }
640
+
641
+ return hints
642
+ }
643
+
644
+ /**
645
+ * Wraps the original block prompt with vitest error feedback for a retry.
646
+ * When rootCauseHints are provided (stale error detected), injects them before the error.
647
+ * @param {string} originalPrompt initial block prompt
648
+ * @param {string} prevBlock previous describe block
649
+ * @param {string} errors vitest error output
650
+ * @param {number} attempt current attempt number
651
+ * @param {string[]} rootCauseHints stale-error diagnostic hints
652
+ * @returns {string} retry prompt text
653
+ */
654
+ function buildRetryPrompt(originalPrompt, prevBlock, errors, attempt, rootCauseHints = []) {
655
+ const hintsSection = rootCauseHints.length
656
+ ? ['', '### Аналіз причини (не ігноруй — помилка повторюється):', ...rootCauseHints.map(h => `- ${h}`)]
657
+ : []
658
+
659
+ return [
660
+ originalPrompt,
661
+ '',
662
+ '---',
663
+ `## Спроба ${attempt}: попередній блок не пройшов vitest`,
664
+ '',
665
+ 'Твій попередній варіант:',
666
+ '```js',
667
+ prevBlock,
668
+ '```',
669
+ '',
670
+ 'Помилки vitest:',
671
+ '```',
672
+ errors,
673
+ '```',
674
+ ...hintsSection,
675
+ '',
676
+ 'Поверни виправлений describe-блок у ```js … ```'
677
+ ].join('\n')
678
+ }
679
+
680
+ const STALE_THRESHOLD = 2
681
+
682
+ /**
683
+ * Builds retry diagnostics for repeated vitest failures.
684
+ * @param {string|null} lastErrors previous error text
685
+ * @param {string|null} prevErrorSig previous stable error signature
686
+ * @param {number} staleCount current repeated-error counter
687
+ * @param {string} label display name for logging
688
+ * @returns {{staleCount: number, prevErrorSig: string|null, rootCauseHints: string[]}} retry diagnostics
689
+ */
690
+ function buildRetryDiagnostics(lastErrors, prevErrorSig, staleCount, label) {
691
+ const errorSig = lastErrors?.slice(0, 120) ?? null
692
+ const nextStaleCount = errorSig && errorSig === prevErrorSig ? staleCount + 1 : 0
693
+ const rootCauseHints = nextStaleCount >= STALE_THRESHOLD ? detectStaleRootCause(lastErrors ?? '') : []
694
+ if (rootCauseHints.length) {
695
+ console.log(` ${label} ⚡ stale error (${nextStaleCount}x) — root cause hints injected`)
696
+ }
697
+ return { staleCount: nextStaleCount, prevErrorSig: errorSig, rootCauseHints }
698
+ }
699
+
700
+ /**
701
+ * Converts an LLM exception into loop-control state.
702
+ * @param {Error} error caught LLM error
703
+ * @param {number} attempt current attempt number
704
+ * @param {number} maxAttempts retry limit
705
+ * @param {string} label display name for logging
706
+ * @returns {{stop: boolean, reset: boolean, lastErrors: string|null}} loop-control state
707
+ */
708
+ function resolveLoopCallFailure(error, attempt, maxAttempts, label) {
709
+ 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}` }
713
+ }
714
+
715
+ /**
716
+ * Generates a describe block with a run → feedback loop.
717
+ * On each vitest failure feeds the error back into the next prompt.
718
+ * @param {string} basePrompt initial block prompt
719
+ * @param {PiCallFn} callFn LLM caller (async)
720
+ * @param {object} callOpts options forwarded to callFn
721
+ * @param {string} header shared test file header
722
+ * @param {string} dir project root (cwd for vitest)
723
+ * @param {string} testDir directory where real test file lives (for relative imports)
724
+ * @param {string} label display name for logging
725
+ * @param {number} maxAttempts cap on retry iterations
726
+ * @param {string|null} seedBlock block from a prior tier (starts loop pre-seeded)
727
+ * @param {string|null} seedErrors errors from a prior tier (shown on attempt 1)
728
+ * @returns {Promise<{ block: string|null, lastBlock: string|null, lastErrors: string|null }>} generation result
729
+ */
730
+ async function generateBlockWithLoop(
731
+ basePrompt,
732
+ callFn,
733
+ callOpts,
734
+ header,
735
+ dir,
736
+ testDir,
737
+ label,
738
+ maxAttempts = CLOUD_MAX_ATTEMPTS,
739
+ seedBlock = null,
740
+ seedErrors = null
741
+ ) {
742
+ let lastBlock = seedBlock
743
+ let lastErrors = seedErrors
744
+ let staleCount = 0
745
+ let prevErrorSig = null // first 120 chars — stable signature for sameness
746
+
747
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
748
+ const diagnostics = buildRetryDiagnostics(lastErrors, prevErrorSig, staleCount, label)
749
+ staleCount = diagnostics.staleCount
750
+ prevErrorSig = diagnostics.prevErrorSig
751
+
752
+ const prompt =
753
+ lastErrors && lastBlock
754
+ ? buildRetryPrompt(basePrompt, lastBlock, lastErrors, attempt, diagnostics.rootCauseHints)
755
+ : basePrompt
756
+
757
+ let resp
758
+ try {
759
+ resp = await callFn(prompt, callOpts)
760
+ } catch (error) {
761
+ const failure = resolveLoopCallFailure(error, attempt, maxAttempts, label)
762
+ 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
+ }
771
+ continue
772
+ }
773
+
774
+ const block = extractCode(resp)
775
+ if (!(await isValidBlock(block))) {
776
+ console.log(` ${label} ✗ invalid block (спроба ${attempt}) → retry`)
777
+ lastBlock = block || lastBlock
778
+ lastErrors =
779
+ 'Блок не містить валідного describe() або має синтаксичну помилку — поверни лише describe-блок у ```js … ```'
780
+ continue
781
+ }
782
+
783
+ const { passed, errors } = runBlock(header, block, dir, testDir)
784
+ if (passed) {
785
+ if (attempt > 1) console.log(` ${label} ✓ passed (спроба ${attempt}/${maxAttempts})`)
786
+ return { block, lastBlock: null, lastErrors: null }
787
+ }
788
+
789
+ console.log(` ${label} ✗ vitest fail (спроба ${attempt}/${maxAttempts})`)
790
+ lastBlock = block
791
+ lastErrors = errors
792
+ }
793
+
794
+ console.log(` ${label} ⚠ ${maxAttempts} спроб вичерпано`)
795
+ return { block: null, lastBlock, lastErrors }
796
+ }
797
+
798
+ // ---------------------------------------------------------------------------
799
+ // Per-export generation
800
+ // ---------------------------------------------------------------------------
801
+
802
+ /**
803
+ * Runs best-effort static analysis without interrupting generation.
804
+ * @param {string} content source snippet
805
+ * @returns {Promise<object|null>} AST analysis result or null
806
+ */
807
+ async function analyzeSourceModule(content) {
808
+ try {
809
+ return await analyzeModule(content)
810
+ } catch {
811
+ return null
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Collects runtime probe data used to ground per-export prompts.
817
+ * @param {string} absPath absolute source path
818
+ * @param {string[]} exports exported symbol names
819
+ * @param {string} content source snippet
820
+ * @param {object|null} astInfo static AST analysis result
821
+ * @returns {object} runtime probe context
822
+ */
823
+ function buildProbeContext(absPath, exports, content, astInfo) {
824
+ const envKeys = astInfo?.envReads ?? []
825
+ return {
826
+ probeResults: exports.length ? probeModule(absPath, exports, envKeys) : {},
827
+ fetchProbe: astInfo?.usesFetch ? probeFetchCalls(absPath, exports, envKeys) : {},
828
+ timeProbe: TIME_DEPS_RE.test(content) ? probeTimeVariants(absPath, exports, envKeys) : {},
829
+ helperProbe: astInfo?.internalNames?.length ? probeHelpers(absPath, astInfo.internalNames, envKeys) : {}
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Prepares source, static analysis and probes for per-export generation.
835
+ * @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
836
+ * @param {string} dir project root
837
+ * @returns {Promise<object>} per-export generation context
838
+ */
839
+ async function buildPerExportContext(fileInfo, dir) {
840
+ const { file } = fileInfo
841
+ const absPath = join(dir, file)
842
+ const content = readSourceSnippet(absPath)
843
+ const { testFilePath, importPath } = resolveTestPaths(file)
844
+ const hasSideEffects = content.length > 0 && TOP_LEVEL_CALL_RE.test(content)
845
+ const exports = extractExports(content)
846
+ const exportsWithComplexity = extractExportsWithComplexity(content)
847
+ const testRules = findTestRules(dir)
848
+ const astInfo = await analyzeSourceModule(content)
849
+
850
+ return {
851
+ file,
852
+ testFilePath,
853
+ importPath,
854
+ content,
855
+ hasSideEffects,
856
+ exports,
857
+ exportsWithComplexity,
858
+ testRules,
859
+ astInfo,
860
+ ...buildProbeContext(absPath, exports, content, astInfo)
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Generates the shared test header through the cloud model.
866
+ * @param {object} ctx per-export generation context
867
+ * @param {string} dir project root
868
+ * @param {PiCallFn} callTextFn cloud LLM caller
869
+ * @returns {Promise<string|null>} generated header or null
870
+ */
871
+ async function generateSharedHeader(ctx, dir, callTextFn) {
872
+ const { file, testFilePath, importPath, hasSideEffects, content, exports, testRules, astInfo } = ctx
873
+ try {
874
+ const headerResp = await callTextFn(
875
+ buildHeaderPrompt({ file, testFilePath, importPath, hasSideEffects, content, exports, testRules, astInfo }),
876
+ { cwd: dir }
877
+ )
878
+ const header = extractCode(headerResp)
879
+ if (header) return header
880
+ console.error(` ✗ cloud не повернув header для ${file}`)
881
+ } catch (error) {
882
+ console.error(` ✗ cloud header error: ${error.message}`)
883
+ }
884
+ return null
885
+ }
886
+
887
+ /**
888
+ * Attempts local generation for simple exports and prepares cloud seed context.
889
+ * @param {object} opts local generation options
890
+ * @returns {Promise<{block: string|null, lastBlock: string|null, lastErrors: string|null}>} local result
891
+ */
892
+ async function generateLocalBlock(opts) {
893
+ const { exp, blockPrompt, header, dir, testDir, callLocalFn, isSimple } = opts
894
+ if (!isSimple || !callLocalFn) return { block: null, lastBlock: null, lastErrors: null }
895
+
896
+ console.log(` ${exp.name} (${exp.complexity}) → local [max ${LOCAL_MAX_ATTEMPTS}]`)
897
+ const result = await generateBlockWithLoop(
898
+ blockPrompt,
899
+ callLocalFn,
900
+ {},
901
+ header,
902
+ dir,
903
+ testDir,
904
+ `${exp.name} [local]:`,
905
+ LOCAL_MAX_ATTEMPTS
906
+ )
907
+ if (result.block) return { block: result.block, lastBlock: null, lastErrors: null }
908
+
909
+ // Only seed cloud with lastBlock if it's a valid block structure.
910
+ // An invalid block as seed causes cascade invalid blocks in cloud.
911
+ const lastBlock = result.lastBlock && isValidBlock(result.lastBlock) ? result.lastBlock : null
912
+ console.log(` ${exp.name} ✗ local exhausted → cloud (з seed-контекстом)`)
913
+ return { block: null, lastBlock, lastErrors: result.lastErrors }
914
+ }
915
+
916
+ /**
917
+ * Generates a block through the cloud tier, optionally seeded by local output.
918
+ * @param {object} opts cloud generation options
919
+ * @returns {Promise<string|null>} generated describe block or last usable block
920
+ */
921
+ async function generateCloudBlock(opts) {
922
+ const { exp, blockPrompt, header, dir, testDir, callTextFn, isSimple, seed } = opts
923
+ const tier = isSimple ? 'cloud fallback' : 'cloud'
924
+ console.log(` ${exp.name} (${exp.complexity}) → ${tier} [max ${CLOUD_MAX_ATTEMPTS}]`)
925
+ const result = await generateBlockWithLoop(
926
+ blockPrompt,
927
+ callTextFn,
928
+ { cwd: dir },
929
+ header,
930
+ dir,
931
+ testDir,
932
+ `${exp.name} [cloud]:`,
933
+ CLOUD_MAX_ATTEMPTS,
934
+ seed.lastBlock,
935
+ seed.lastErrors
936
+ )
937
+ return result.block ?? result.lastBlock
938
+ }
939
+
940
+ /**
941
+ * Generates a single export describe block using local-first tiering when available.
942
+ * @param {object} exp export complexity metadata
943
+ * @param {object} ctx per-export generation context including header
944
+ * @param {string} dir project root
945
+ * @param {string} testDir directory where the real test file lives
946
+ * @param {PiCallFn} callTextFn cloud LLM caller
947
+ * @param {PiCallFn} callLocalFn local LLM caller
948
+ * @returns {Promise<string|null>} generated describe block or null
949
+ */
950
+ async function generateExportBlock(exp, ctx, dir, testDir, callTextFn, callLocalFn) {
951
+ const isSimple = exp.complexity === 'trivial' || exp.complexity === 'simple'
952
+ const blockPrompt = buildBlockPrompt({ exp, ...ctx })
953
+ const seed = await generateLocalBlock({ exp, blockPrompt, header: ctx.header, dir, testDir, callLocalFn, isSimple })
954
+ if (seed.block) return seed.block
955
+ return generateCloudBlock({ exp, blockPrompt, header: ctx.header, dir, testDir, callTextFn, isSimple, seed })
956
+ }
957
+
958
+ /**
959
+ * Writes the merged generated test file.
960
+ * @param {string} dir project root
961
+ * @param {string} testFilePath generated test file path
962
+ * @param {string} code merged test code
963
+ * @param {string[]} blocks generated describe blocks
964
+ * @returns {string} written test path
965
+ */
966
+ function writeGeneratedTest(dir, testFilePath, code, blocks) {
967
+ const testPath = join(dir, testFilePath)
968
+ mkdirSync(dirname(testPath), { recursive: true })
969
+ writeFileSync(testPath, code + '\n', 'utf8')
970
+ console.log(` ✓ Записано: ${relative(dir, testPath)} (${blocks.length} блоків)`)
971
+ return testPath
972
+ }
973
+
974
+ /**
975
+ * Generates a test file using per-export tiered routing:
976
+ * - Cloud for shared header
977
+ * - Local → cloud fallback for trivial/simple exports
978
+ * - Cloud directly for complex exports
979
+ * @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
980
+ * @param {string} dir project root
981
+ * @param {PiCallFn} callTextFn cloud LLM caller
982
+ * @param {PiCallFn} callLocalFn local LLM caller
983
+ * @returns {Promise<string|null>} written test path or null
984
+ */
985
+ async function generatePerExport(fileInfo, dir, callTextFn, callLocalFn) {
986
+ const ctx = await buildPerExportContext(fileInfo, dir)
987
+
988
+ console.log(` header → cloud`)
989
+ const header = await generateSharedHeader(ctx, dir, callTextFn)
990
+ if (!header) return null
991
+
992
+ const blocks = []
993
+ const testDir = dirname(join(dir, ctx.testFilePath))
994
+ const blockCtx = { ...ctx, header }
995
+ for (const exp of ctx.exportsWithComplexity) {
996
+ const block = await generateExportBlock(exp, blockCtx, dir, testDir, callTextFn, callLocalFn)
997
+ if (block) blocks.push(block)
998
+ }
999
+
1000
+ const code = mergeBlocks(header, blocks)
1001
+ if (!code) {
1002
+ console.error(` ✗ merge failed for ${ctx.file}`)
1003
+ return null
1004
+ }
1005
+
1006
+ return writeGeneratedTest(dir, ctx.testFilePath, code, blocks)
1007
+ }
1008
+
1009
+ // ---------------------------------------------------------------------------
1010
+ // Single-file (fallback) generation
1011
+ // ---------------------------------------------------------------------------
1012
+
33
1013
  /**
34
1014
  * Builds a display-only summary prompt (used in tests).
35
- * @param {Array<{file: string, pct: number, reason: string}>} files
36
- * @param {string} dir
37
- * @returns {string}
1015
+ * @param {Array<{file: string, pct: number, reason: string}>} files files to summarize
1016
+ * @param {string} dir project root
1017
+ * @returns {string} summary prompt text
38
1018
  */
39
1019
  export function buildGenTestsPrompt(files, dir) {
40
1020
  return files
41
1021
  .map(({ file, pct, reason }) => {
42
1022
  const absPath = join(dir, file)
43
- let content = ''
44
- if (existsSync(absPath)) {
45
- content = readFileSync(absPath, 'utf8')
46
- if (content.length > MAX_SRC_BYTES) content = content.slice(0, MAX_SRC_BYTES) + '\n...(truncated)'
47
- }
1023
+ const content = readSourceSnippet(absPath)
48
1024
  return (
49
1025
  `### \`${file}\` (покриття: ${pct.toFixed(1)}%)\n` +
50
1026
  (reason ? `Причина: ${reason}\n\n` : '') +
@@ -54,14 +1030,19 @@ export function buildGenTestsPrompt(files, dir) {
54
1030
  .join('\n\n')
55
1031
  }
56
1032
 
1033
+ /**
1034
+ * Builds the single-file prompt (fallback when per-export unavailable).
1035
+ * @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
1036
+ * @param {string} dir project root
1037
+ * @returns {string} single-file generation prompt
1038
+ */
57
1039
  function buildSingleFilePrompt(fileInfo, dir) {
58
- const { file, pct, reason } = fileInfo
1040
+ const { file, pct: _pct, reason } = fileInfo
59
1041
  const absPath = join(dir, file)
60
- let content = ''
61
- if (existsSync(absPath)) {
62
- content = readFileSync(absPath, 'utf8')
63
- if (content.length > MAX_SRC_BYTES) content = content.slice(0, MAX_SRC_BYTES) + '\n...(truncated)'
64
- }
1042
+ const content = readSourceSnippet(absPath)
1043
+
1044
+ const exports = extractExports(content)
1045
+ const hasSideEffects = content.length > 0 && TOP_LEVEL_CALL_RE.test(content)
65
1046
 
66
1047
  const existingTestFile = testCandidates(file).find(c => existsSync(join(dir, c)))
67
1048
  let existingSection = ''
@@ -70,16 +1051,47 @@ function buildSingleFilePrompt(fileInfo, dir) {
70
1051
  existingSection = `\n\nІснуючий тест (доповни):\n\`\`\`js\n${tc.slice(0, 3000)}\n\`\`\``
71
1052
  }
72
1053
 
1054
+ const { testFilePath, importPath } = resolveTestPaths(file)
1055
+
1056
+ const exportsLine =
1057
+ exports.length > 0
1058
+ ? `Тестуй ЛИШЕ публічний API (exports): ${exports.join(', ')}`
1059
+ : 'Тестуй лише публічні (exported) функції — не приватні деталі реалізації'
1060
+
1061
+ const sideEffectsSection = hasSideEffects
1062
+ ? [
1063
+ '',
1064
+ 'УВАГА: модуль має side-effect при завантаженні (виклик функції на рівні модуля).',
1065
+ 'Встанови env/мок ДО import і використовуй dynamic import:',
1066
+ '```js',
1067
+ 'process.env.KEY = "value"',
1068
+ `const { fn } = await import("${importPath}")`,
1069
+ '```'
1070
+ ]
1071
+ : []
1072
+
1073
+ const testRules = findTestRules(dir)
1074
+
73
1075
  return [
74
- `Напиши повний unit-тест для файлу \`${file}\` (покриття зараз ${pct.toFixed(1)}%).`,
1076
+ `Напиши unit-тест у файл \`${testFilePath}\` для джерела \`${file}\`.`,
1077
+ `КРИТИЧНО — імпорт source: \`"${importPath}"\` (тест у \`${testFilePath}\`, source у \`${file}\`)`,
75
1078
  reason ? `Причина: ${reason}` : '',
76
1079
  '',
77
1080
  'Правила (СУВОРО):',
78
- '- Перший рядок: `import { vi, describe, it, expect, beforeEach } from "vitest"`',
79
- '- Мокуй залежності: `vi.mock("module", () => ({ fn: vi.fn() }))` + `vi.mocked(fn)`',
1081
+ '- Перший рядок: `import { vi, describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"` — включай ЛИШЕ те що реально використовуєш',
1082
+ '- Імітуй залежності: `vi.mock("module", () => ({ fn: vi.fn() }))` + `vi.mocked(fn)`',
80
1083
  '- НІКОЛИ `jest.*`, НІКОЛИ `require()`',
81
- '- Лише поведінкові тести — НЕ тестуй приватні деталі реалізації',
1084
+ `- ${exportsLine}`,
1085
+ '- Файл .mjs = чистий JavaScript, НЕ TypeScript. НІКОЛИ: `as Type`, `: TypeName`, generics',
1086
+ '- Замість `fn as vi.Mock` → `vi.mocked(fn)`',
1087
+ '- `vi.spyOn(process, "env")` НЕ ПРАЦЮЄ — для env: `vi.stubEnv("KEY", "val")` + `afterEach(() => vi.unstubAllEnvs())`',
1088
+ '- `vi.spyOn(Date).mockReturnValue(...)` НЕ ПРАЦЮЄ з `new Date()` — для часу: `vi.useFakeTimers()` + `vi.setSystemTime(new Date(...))` + `afterEach(() => vi.useRealTimers())`',
1089
+ `- Шлях до source файлу відносно тест-файлу: \`${importPath}\` (НЕ \`${file}\`)`,
1090
+ '- `describe()` callback НЕ може бути async — `await` тільки у top-level, `beforeAll(async()=>{})`, або `it(async()=>{})`',
1091
+ '- Для regex/escape функцій: НЕ ВГАДУЙ складний expected рядок. Тестуй один символ за раз де результат очевидний: `expect(esc("*")).toBe("\\\\*")`, `expect(esc("!")).toBe("\\\\!")`',
82
1092
  '- Поверни ЛИШЕ код тесту у блоці ```js ... ``` — без пояснень',
1093
+ ...sideEffectsSection,
1094
+ ...(testRules ? ['', '## Конвенції тестів цього проєкту (.cursor/rules/n-test.mdc):', testRules] : []),
83
1095
  '',
84
1096
  `Джерело (\`${file}\`):`,
85
1097
  '```js',
@@ -92,52 +1104,93 @@ function buildSingleFilePrompt(fileInfo, dir) {
92
1104
  }
93
1105
 
94
1106
  /**
95
- * Generates a test for one file via pi text mode.
96
- * @param {{file: string, pct: number, reason: string}} fileInfo
97
- * @param {string} dir
98
- * @param {Function} callTextFn
1107
+ * Generates a test file for a single source file using the cloud LLM.
1108
+ * @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
1109
+ * @param {string} dir project root
1110
+ * @param {PiCallFn} callTextFn cloud LLM caller
99
1111
  * @returns {Promise<string|null>} written test path or null
100
1112
  */
101
1113
  async function generateOneTest(fileInfo, dir, callTextFn) {
102
1114
  const prompt = buildSingleFilePrompt(fileInfo, dir)
103
-
104
1115
  let response
105
1116
  try {
106
1117
  response = await callTextFn(prompt, { cwd: dir })
107
- } catch (err) {
108
- console.error(` ✗ pi помилка для ${fileInfo.file}: ${err.message}`)
1118
+ } catch (error) {
1119
+ console.error(` ✗ pi помилка для ${fileInfo.file}: ${error.message}`)
109
1120
  return null
110
1121
  }
111
-
112
1122
  const code = extractCode(response)
113
1123
  if (!code) {
114
1124
  console.error(` ✗ pi не повернула код для ${fileInfo.file}`)
115
1125
  return null
116
1126
  }
117
-
118
1127
  const testPath = join(dir, testCandidates(fileInfo.file)[0])
1128
+ mkdirSync(dirname(testPath), { recursive: true })
119
1129
  writeFileSync(testPath, code + '\n', 'utf8')
120
1130
  console.log(` ✓ Записано: ${relative(dir, testPath)}`)
121
1131
  return testPath
122
1132
  }
123
1133
 
1134
+ // ---------------------------------------------------------------------------
1135
+ // Public API
1136
+ // ---------------------------------------------------------------------------
1137
+
1138
+ /**
1139
+ * Resolves the effective local model id.
1140
+ * @param {GenerateTestsOptions} opts generation options
1141
+ * @returns {string | null} local model id or null for cloud-only mode
1142
+ */
1143
+ function resolveLocalModel(opts) {
1144
+ if (opts.localModel !== undefined) return opts.localModel
1145
+ return checkEnv(['N_LOCAL_MIN_MODEL']) ?? null
1146
+ }
1147
+
1148
+ /**
1149
+ * Handles one file inside the outer generation loop.
1150
+ * @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
1151
+ * @param {string} dir project root
1152
+ * @param {PiCallFn} callTextFn cloud LLM caller
1153
+ * @param {PiCallFn | null} localFn local LLM caller
1154
+ * @param {GenerateOneFn | undefined} generateOne custom single-file generator
1155
+ * @returns {Promise<void>} resolves after generation for this file completes
1156
+ */
1157
+ async function generateTestsForFile(fileInfo, dir, callTextFn, localFn, generateOne) {
1158
+ console.log(` → ${fileInfo.file} (${fileInfo.pct.toFixed(1)}%)`)
1159
+
1160
+ if (generateOne) {
1161
+ await generateOne(fileInfo, dir)
1162
+ return
1163
+ }
1164
+
1165
+ const exportsInfo = extractExportsWithComplexity(readSourceSnippet(join(dir, fileInfo.file)))
1166
+ if (localFn && exportsInfo.length > 0) {
1167
+ await generatePerExport(fileInfo, dir, callTextFn, localFn)
1168
+ return
1169
+ }
1170
+
1171
+ await generateOneTest(fileInfo, dir, callTextFn)
1172
+ }
1173
+
124
1174
  /**
125
- * Generates tests for all given files (one pi call per file).
126
- * @param {Array<{file: string, pct: number, reason: string}>} files
1175
+ * Generates tests for all given files.
1176
+ * Uses per-export tiered routing when local LLM is available;
1177
+ * falls back to single-file cloud generation otherwise.
1178
+ * @param {Array<{file: string, pct: number, reason: string}>} files files to generate tests for
127
1179
  * @param {string} dir project root
128
- * @param {{ callText?: Function, generateOne?: Function }} [opts]
129
- * @returns {Promise<void>}
1180
+ * @param {GenerateTestsOptions} [opts] generation options
1181
+ * @returns {Promise<void>} resolves after all requested files are processed
130
1182
  */
131
1183
  export async function generateTests(files, dir, opts = {}) {
132
1184
  if (files.length === 0) return
133
1185
 
134
1186
  const callTextFn = opts.callText ?? callText
135
- const generateOneFn = opts.generateOne ?? ((f, d) => generateOneTest(f, d, callTextFn))
1187
+ const localModel = resolveLocalModel(opts)
1188
+ const localFn = localModel ? prompt => callTextFn(prompt, { model: localModel, cwd: dir }) : null
136
1189
 
137
- console.log(`\n🤖 Генерую тести для ${files.length} файлів (pi SDK, по одному)...\n`)
1190
+ const mode = localFn ? `per-export (local:${localModel} + cloud)` : 'single-file (cloud)'
1191
+ console.log(`\n🤖 Генерую тести для ${files.length} файлів [${mode}]...\n`)
138
1192
 
139
1193
  for (const fileInfo of files) {
140
- console.log(` → ${fileInfo.file} (${fileInfo.pct.toFixed(1)}%)`)
141
- await generateOneFn(fileInfo, dir)
1194
+ await generateTestsForFile(fileInfo, dir, callTextFn, localFn, opts.generateOne)
142
1195
  }
143
1196
  }