@7n/test 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +28 -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 +80 -28
  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/fix-tests.mjs CHANGED
@@ -1,157 +1,303 @@
1
1
  /**
2
- * fix-tests: виявляє падаючі unit-тести і викликає pi-агента для їх виправлення.
2
+ * fix-tests: виявляє падаючі unit-тести і виправляє їх через pi SDK (text mode).
3
3
  *
4
4
  * Порядок роботи:
5
5
  * 1. getFailingTests(dir) — запускає vitest run --reporter=json і повертає
6
6
  * список падаючих файлів з повідомленнями про помилки.
7
- * 2. fixFailingTests(dir, opts) — якщо є падіння, будує prompt і викликає pi CLI
8
- * в агентному режимі; перевіряє результат повторним getFailingTests.
7
+ * 2. fixFailingTests(dir, opts) — для кожного падаючого файлу:
8
+ * a. Читає поточний вміст тест-файлу та source-файлу
9
+ * b. callText → отримує виправлений код у ```js блоці
10
+ * c. writeFileSync → записує файл напряму (без Edit інструментів агента)
11
+ * d. Повторює до MAX_FIX_ATTEMPTS якщо ще залишились падіння
12
+ *
13
+ * Перехід з callAgent (Edit-інструменти) на callText+write усуває проблему
14
+ * точного матчу рядків що містять URL, backtick-рядки та спецсимволи.
9
15
  */
10
16
  import { spawnSync } from 'node:child_process'
11
- import { existsSync, readFileSync } from 'node:fs'
17
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
12
18
  import { mkdtemp, rm } from 'node:fs/promises'
13
- import { createRequire } from 'node:module'
14
19
  import { tmpdir } from 'node:os'
15
- import { join, relative, dirname } from 'node:path'
20
+ import { join, relative } from 'node:path'
16
21
  import { env } from 'node:process'
22
+ import { callText } from './lib/pi-client.mjs'
23
+ import { findTestRules } from './gen-tests.mjs'
24
+ import { parseFailingTests } from './coverage-per-file.mjs'
25
+ import { resolveVitestRun } from './lib/vitest-shim.mjs'
17
26
 
18
- const _require = createRequire(import.meta.url)
19
- const VITEST_BIN = join(dirname(_require.resolve('vitest/package.json')), 'vitest.mjs')
27
+ const MODEL = env.N_CURSOR_FIX_TESTS_MODEL ?? env.N_CLOUD_MAX_MODEL ?? undefined
28
+ const MAX_SRC_BYTES = 4000
29
+ const TEST_DIR_MARKERS = ['/tests/', '\\tests\\']
30
+ const TEST_FILE_SUFFIX = '.test.mjs'
31
+ const SOURCE_FILE_SUFFIX = '.js'
32
+ const FILE_MARKER_PREFIX = '<!--'
33
+ const FILE_MARKER_SUFFIX = '-->'
34
+ const FILE_MARKER_LABEL = 'file:'
35
+ const CODE_FENCE_START_RE = /^```(?:js|javascript|mjs|ts)?$/
36
+ const CODE_FENCE_END = '```'
20
37
 
21
- const MODEL = env.N_CURSOR_FIX_TESTS_MODEL ?? env.N_CLOUD_MAX_MODEL ?? ''
22
- const MAX_ERRORS_PER_FILE = 5
23
- const MAX_ERROR_LINES = 10
38
+ /**
39
+ * Infers a source file path from a conventional `tests/<name>.test.mjs` path.
40
+ * @param {string} absPath absolute test file path
41
+ * @returns {string|null} inferred source path or null when convention does not match
42
+ */
43
+ function inferSourcePath(absPath) {
44
+ const marker = TEST_DIR_MARKERS.find(item => absPath.includes(item))
45
+ if (!marker || !absPath.endsWith(TEST_FILE_SUFFIX)) return null
46
+ const markerAt = absPath.lastIndexOf(marker)
47
+ const dir = absPath.slice(0, markerAt)
48
+ const stem = absPath.slice(markerAt + marker.length, -TEST_FILE_SUFFIX.length)
49
+ if (!stem || stem.includes('/') || stem.includes('\\')) return null
50
+ return `${dir}${marker[0]}${stem}${SOURCE_FILE_SUFFIX}`
51
+ }
52
+
53
+ /**
54
+ * Parses an HTML file marker from a single LLM response line.
55
+ * @param {string} line response line
56
+ * @returns {string|null} file path from marker or null
57
+ */
58
+ function parseFileMarker(line) {
59
+ const trimmed = line.trim()
60
+ if (!trimmed.startsWith(FILE_MARKER_PREFIX) || !trimmed.endsWith(FILE_MARKER_SUFFIX)) return null
61
+ const inner = trimmed.slice(FILE_MARKER_PREFIX.length, -FILE_MARKER_SUFFIX.length).trim()
62
+ if (!inner.startsWith(FILE_MARKER_LABEL)) return null
63
+ const file = inner.slice(FILE_MARKER_LABEL.length).trim()
64
+ return file || null
65
+ }
24
66
 
25
67
  /**
26
68
  * Runs vitest in JSON mode and returns failing test files with error messages.
27
69
  * @param {string} dir project root
28
- * @returns {Promise<Array<{file: string, errors: string[]}>>}
70
+ * @returns {Promise<Array<{file: string, errors: string[]}>>} failing test files with error messages
29
71
  */
30
72
  export async function getFailingTests(dir) {
31
73
  const tmpDir = await mkdtemp(join(tmpdir(), '7n-fix-'))
32
74
  const outputFile = join(tmpDir, 'results.json')
33
75
 
76
+ const { bin, configArgs } = resolveVitestRun(dir)
34
77
  try {
35
78
  spawnSync(
36
79
  process.execPath,
37
- [VITEST_BIN, 'run', '--reporter=json', `--outputFile=${outputFile}`, '--passWithNoTests'],
38
- {
39
- cwd: dir,
40
- stdio: 'inherit',
41
- env
42
- }
80
+ [bin, 'run', ...configArgs, '--root', dir, '--reporter=json', `--outputFile=${outputFile}`, '--passWithNoTests'],
81
+ { cwd: dir, stdio: 'inherit', env }
43
82
  )
44
83
 
45
84
  if (!existsSync(outputFile)) return []
46
85
 
47
- let data
86
+ return parseFailingTests(outputFile, dir)
87
+ } finally {
48
88
  try {
49
- data = JSON.parse(readFileSync(outputFile, 'utf8'))
89
+ await rm(tmpDir, { recursive: true, force: true })
50
90
  } catch {
51
- return []
91
+ /* ignore cleanup errors */
52
92
  }
53
-
54
- return (data.testResults ?? [])
55
- .filter(r => r.status === 'failed')
56
- .map(r => {
57
- const assertionErrors = (r.assertionResults ?? [])
58
- .filter(a => a.status === 'failed')
59
- .slice(0, MAX_ERRORS_PER_FILE)
60
- .map(a => {
61
- const name = [...(a.ancestorTitles ?? []), a.title].join(' > ')
62
- const msg = (a.failureMessages?.[0] ?? '').split('\n').slice(0, MAX_ERROR_LINES).join('\n')
63
- return `${name}:\n${msg}`
64
- })
65
- const errors = assertionErrors.length > 0
66
- ? assertionErrors
67
- : [`Suite error: ${(r.message ?? r.failureMessage ?? 'module-level failure').split('\n').slice(0, MAX_ERROR_LINES).join('\n')}`]
68
- return { file: relative(dir, r.testFilePath ?? r.name), errors }
69
- })
70
- .filter(f => !f.file.startsWith('..'))
71
- } finally {
72
- await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
73
93
  }
74
94
  }
75
95
 
76
96
  /**
77
- * Builds a prompt for pi agent to fix failing tests.
78
- * @param {Array<{file: string, errors: string[]}>} failures
79
- * @returns {string}
97
+ * Extracts the first fenced JavaScript block from LLM text output.
98
+ * @param {string} text LLM response text.
99
+ * @returns {string} Extracted code or an empty string.
80
100
  */
81
- export function buildFixTestsPrompt(failures) {
82
- const fileList = failures
83
- .map(({ file, errors }) => {
84
- const errBlock = errors.join('\n\n')
85
- return `### \`${file}\`\n\`\`\`\n${errBlock}\n\`\`\``
86
- })
87
- .join('\n\n')
101
+ function extractCode(text) {
102
+ const start = text.indexOf('```')
103
+ if (start === -1) return ''
104
+ const bodyStart = text.indexOf('\n', start)
105
+ if (bodyStart === -1) return ''
106
+ const end = text.indexOf('\n```', bodyStart + 1)
107
+ if (end === -1) return ''
108
+ return text.slice(bodyStart + 1, end).trim()
109
+ }
110
+
111
+ /**
112
+ * Builds a prompt to fix one failing test file.
113
+ * Includes current file content so the LLM doesn't need file-read tools.
114
+ * @param {Array<{file: string, errors: string[]}>} failures failing test files
115
+ * @param {string} [dir] project root
116
+ * @returns {string} prompt text for the LLM
117
+ */
118
+ export function buildFixTestsPrompt(failures, dir) {
119
+ const testRules = dir ? findTestRules(dir) : null
120
+
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
+ })
88
147
 
89
148
  return [
90
- 'Виправ падаючі unit-тести. Для кожного файлу:',
91
- '1. Прочитай тест-файл і source-файл що він тестує',
92
- '2. Запусти: `bunx vitest run <testFile>` — переконайся в точній помилці',
93
- '3. Виправ ЛИШЕ тест-файл — source-файли не чіпай',
94
- '4. Запусти тест ще раз — переконайся що зелений',
149
+ 'Виправ падаючі unit-тести. Поверни ПОВНИЙ вміст КОЖНОГО виправленого тест-файлу.',
95
150
  '',
96
151
  '## Правила:',
97
- '- Міняй виключно тест-файли',
98
- '- Зберігай структуру тестів (describe/it/expect)',
152
+ '- Міняй виключно тест-файли — source-файли не чіпай',
153
+ '- Зберігай структуру тестів (describe/it/expect) — НЕ видаляй тести',
99
154
  '- Якщо тест перевіряє неіснуючий API — адаптуй до реального',
100
- '- НЕ видаляй тести лише виправляй',
101
- '- Якщо тест тестує видалений функціоналзакоментуй з поясненням чому',
155
+ '- Файл .mjs = чистий JavaScript, НЕ TypeScript. НІКОЛИ: `as Type`, generics',
156
+ '- `import { vi, describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"` імпортуй ВСЕ що використовуєш',
157
+ '- Замість `fn as vi.Mock` → `vi.mocked(fn)`',
158
+ '- `vi.spyOn(process, "env")` НЕ ПРАЦЮЄ — use `vi.stubEnv("KEY", "val")` + `afterEach(() => vi.unstubAllEnvs())`',
159
+ '- `vi.spyOn(Date).mockReturnValue(...)` НЕ ПРАЦЮЄ з `new Date()` — use `vi.useFakeTimers()` + `vi.setSystemTime(new Date(...))` + `afterEach(() => vi.useRealTimers())`',
160
+ "- `sendMessage` викликає fetch з одним аргументом (URL-рядком) — перевіряй через `expect.stringContaining(...)`, НЕ через об'єктний matcher",
161
+ '- `describe()` callback НЕ може бути async. `await` тільки у: top-level async IIFE, `beforeAll(async () => {})`, або `it(async () => {})`',
162
+ "- `vi.mock` hoisting: фабрика виконується до будь-якого `const`/`let` у модулі. Якщо потрібен спільний mock-об'єкт — оголошуй його через `vi.hoisted()`: `const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }))`; потім використовуй в factory та тестах",
163
+ '- Якщо AssertionError показує `Expected: "A" Received: "B"` — функція реально повертає B. ВИПРАВ expected на точне значення з рядка "Received:", не переосмислюй логіку функції',
164
+ '- Для regex/escape функцій: тестуй по одному символу (`expect(esc("*")).toBe("\\\\*")`), НЕ комбінований рядок — легко помилитись в подвійному екрануванні',
165
+ ...(testRules ? ['', '## Конвенції тестів цього проєкту (.cursor/rules/n-test.mdc):', testRules] : []),
166
+ '',
167
+ '## Падаючі файли:',
102
168
  '',
103
- '## Падаючі файли та помилки:',
169
+ ...sections,
104
170
  '',
105
- fileList
171
+ 'Для кожного файлу поверни його ПОВНИЙ вміст у блоці:',
172
+ '<!-- file: <шлях до файлу> -->',
173
+ '```js',
174
+ '... повний вміст ...',
175
+ '```'
106
176
  ].join('\n')
107
177
  }
108
178
 
109
179
  /**
110
- * Calls pi CLI as agent (live output) to fix failing tests.
111
- * @param {string} prompt
112
- * @param {string} model provider/model-id або '' для pi-дефолту
113
- * @param {string} cwd
180
+ * Parses fenced JavaScript blocks with file markers from LLM response.
181
+ * @param {string} text LLM response text.
182
+ * @returns {Array<{file: string|null, code: string}>} Parsed file/code pairs.
114
183
  */
115
- function callPi(prompt, model, cwd) {
116
- const modelArgs = model ? ['--model', model] : []
117
- spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session'], {
118
- cwd,
119
- stdio: 'inherit',
120
- timeout: 900_000
121
- })
184
+ function parseFixedFiles(text) {
185
+ const results = []
186
+ const lines = text.split('\n')
187
+
188
+ let lineIndex = 0
189
+ while (lineIndex < lines.length) {
190
+ const file = parseFileMarker(lines[lineIndex])
191
+ if (!file) {
192
+ lineIndex++
193
+ continue
194
+ }
195
+
196
+ const fenceStart = lines.findIndex((line, index) => index > lineIndex && CODE_FENCE_START_RE.test(line.trim()))
197
+ if (fenceStart === -1) break
198
+
199
+ const fenceEnd = lines.findIndex((line, index) => index > fenceStart && line.trim() === CODE_FENCE_END)
200
+ if (fenceEnd === -1) break
201
+
202
+ const code = lines
203
+ .slice(fenceStart + 1, fenceEnd)
204
+ .join('\n')
205
+ .trim()
206
+ if (code) results.push({ file, code })
207
+ lineIndex = fenceEnd + 1
208
+ }
209
+
210
+ // Fallback: single unnamed block when only one file is being fixed
211
+ if (results.length === 0) {
212
+ const code = extractCode(text)
213
+ if (code) results.push({ file: null, code })
214
+ }
215
+ return results
122
216
  }
123
217
 
218
+ const MAX_FIX_ATTEMPTS = 3
219
+
124
220
  /**
125
- * Detects and fixes failing tests using a pi agent.
221
+ * Writes generated fixes to existing test files.
222
+ * @param {Array<{file: string|null, code: string}>} fixed Parsed fixes.
223
+ * @param {Array<{file: string, errors: string[]}>} remaining Current failing files.
224
+ * @param {string} dir Project root.
225
+ * @returns {void}
226
+ */
227
+ function writeFixedFiles(fixed, remaining, dir) {
228
+ for (const { file, code } of fixed) {
229
+ let absPath = null
230
+ if (file) {
231
+ absPath = join(dir, file)
232
+ } else if (remaining.length === 1) {
233
+ absPath = join(dir, remaining[0].file)
234
+ }
235
+ if (!absPath) {
236
+ console.error(' ✗ не вдалось визначити файл для запису (немає маркера <!-- file: ... -->)')
237
+ continue
238
+ }
239
+ if (!existsSync(absPath)) {
240
+ console.error(` ✗ файл не існує: ${relative(dir, absPath)}`)
241
+ continue
242
+ }
243
+ writeFileSync(absPath, code + '\n', 'utf8')
244
+ console.log(` ✓ Записано: ${relative(dir, absPath)}`)
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Detects and fixes failing tests using pi SDK text mode + direct writeFileSync.
126
250
  * Returns immediately with count=0 if all tests are already passing.
127
- *
128
251
  * @param {string} dir project root
129
252
  * @param {{
130
253
  * failures?: Array<{file: string, errors: string[]}>,
131
- * callPi?: (prompt: string, model: string, cwd: string) => void
132
- * }} [opts]
133
- * @returns {Promise<{count: number, fixed: number, remaining: number}>}
254
+ * callTextFn?: (prompt: string, opts?: object) => Promise<string>
255
+ * }} [opts] overrides for callText and pre-fetched failures
256
+ * @returns {Promise<{count: number, fixed: number, remaining: number}>} fix result summary
134
257
  */
135
258
  export async function fixFailingTests(dir, opts = {}) {
136
- const callPiFn = opts.callPi ?? callPi
259
+ const callTextFn = opts.callTextFn ?? (prompt => callText(prompt, { model: MODEL, cwd: dir }))
137
260
  const failures = opts.failures ?? (await getFailingTests(dir))
138
261
 
139
262
  if (failures.length === 0) return { count: 0, fixed: 0, remaining: 0 }
140
263
 
141
- console.log(`\n🔧 Виправляю ${failures.length} падаючих test-файлів (pi agent)...\n`)
264
+ console.log(`\n🔧 Виправляю ${failures.length} падаючих test-файлів (pi text mode)...\n`)
142
265
  for (const f of failures) {
143
266
  console.log(` • ${f.file} (${f.errors.length} помилок)`)
144
267
  }
145
268
  console.log()
146
269
 
147
- const prompt = buildFixTestsPrompt(failures)
148
- callPiFn(prompt, MODEL, dir)
270
+ 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`)
274
+ }
275
+
276
+ const prompt = buildFixTestsPrompt(remaining, dir)
277
+ let response
278
+ try {
279
+ response = await callTextFn(prompt)
280
+ } catch (error) {
281
+ console.error(` ✗ pi помилка: ${error.message}`)
282
+ break
283
+ }
284
+
285
+ const fixed = parseFixedFiles(response)
286
+
287
+ if (fixed.length === 0) {
288
+ console.error(' ✗ pi не повернула виправлений код')
289
+ break
290
+ }
291
+
292
+ writeFixedFiles(fixed, remaining, dir)
293
+
294
+ remaining = await getFailingTests(dir)
295
+ }
149
296
 
150
- const after = await getFailingTests(dir)
151
- const fixed = failures.length - after.length
297
+ const fixedCount = failures.length - remaining.length
152
298
 
153
- if (fixed > 0) console.log(`✓ Виправлено: ${fixed}/${failures.length} файлів`)
154
- if (after.length > 0) console.log(`⚠ Залишились падати: ${after.length} файлів`)
299
+ if (fixedCount > 0) console.log(`✓ Виправлено: ${fixedCount}/${failures.length} файлів`)
300
+ if (remaining.length > 0) console.log(`⚠ Залишились падати: ${remaining.length} файлів`)
155
301
 
156
- return { count: failures.length, fixed, remaining: after.length }
302
+ return { count: failures.length, fixed: fixedCount, remaining: remaining.length }
157
303
  }