@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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.1] - 2026-07-02
4
+
5
+ ### Fixed
6
+
7
+ - callText і callAgent (pi-client) тепер ретраять transient-помилки з'єднання (напр. до локального omlx-сервера під навантаженням) з експоненційним backoff + jitter замість негайного провалу файлу; кількість спроб і базова затримка налаштовуються через N_PI_RETRY_ATTEMPTS / N_PI_RETRY_DELAY_MS.
8
+
9
+ ## [0.10.0] - 2026-07-02
10
+
11
+ ### Changed
12
+
13
+ - Оновлено залежність @nitra/check-env до ^4.0.0
14
+
15
+ ### Fixed
16
+
17
+ - Coverage/gen-tests/fix-tests тепер віддають перевагу локально встановленому vitest цільового проєкту замість підміненого шим-конфіга: власний vitest.config.js (setupFiles, environment, plugins) і провайдери оточення (happy-dom тощо) з node_modules цільового проєкту більше не ігноруються.
18
+
19
+ ## [0.9.1] - 2026-07-02
20
+
21
+ ### Changed
22
+
23
+ - docs
24
+
25
+ ### Fixed
26
+
27
+ - Виніс спільний парсинг vitest failure у `coverage-per-file.mjs` і перевів `fix-tests.mjs` на його повторне використання, щоб прибрати jscpd-дубль.
28
+ - - Зменшено cognitive complexity генерації тестів через винесення підготовки контексту та tiered block generation у менші helper-и.
29
+ - - Виправлено JSDoc-описи та спрощено цикл генерації тестових блоків, щоб пройти JS lint для `gen-tests.mjs`.
30
+
3
31
  ## [0.9.0] - 2026-06-28
4
32
 
5
33
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -41,8 +41,10 @@
41
41
  "dependencies": {
42
42
  "@earendil-works/pi-coding-agent": "^0.80.2",
43
43
  "@vitest/coverage-v8": "^4.1.9",
44
+ "rollup": "^4.62.2",
44
45
  "vitest": "^4.1.9",
45
- "zod": "^3.23.0"
46
+ "zod": "^3.23.0",
47
+ "@nitra/check-env": "^4.0.0"
46
48
  },
47
49
  "engines": {
48
50
  "bun": ">=1.3",
@@ -48,7 +48,10 @@ function stripComments(src) {
48
48
  */
49
49
  export function quickClassify(content) {
50
50
  const stripped = stripComments(content)
51
- const lines = stripped.split('\n').map(l => l.trim()).filter(Boolean)
51
+ const lines = stripped
52
+ .split('\n')
53
+ .map(l => l.trim())
54
+ .filter(Boolean)
52
55
 
53
56
  // All lines are imports/re-exports → no testable logic
54
57
  if (lines.length > 0 && lines.every(l => WIRING_RE.test(l))) {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Classifies named exports of a JS/MJS source file by test-generation complexity.
3
+ * Used to route: trivial/simple → local LLM, complex → cloud LLM.
4
+ */
5
+
6
+ const EXPORT_RE = /^export\s+(?:async\s+)?(?:const|function|class|let)\s+(\w+)/gm
7
+ const PRIMITIVE_LITERAL_RE = /^(?:\d[\d_]*(?:\.\d+)?|'[^']*'|"[^"]*"|true|false|null)\s*$/
8
+ const NEXT_EXPORT_RE = /\nexport\s/m
9
+
10
+ /**
11
+ * Patterns that flag an export as too complex for local model.
12
+ * Matched against the export's extracted body (up to 3000 chars).
13
+ */
14
+ const COMPLEX_SIGNALS = [
15
+ /\bfetch\b/,
16
+ /\bnew\s+Date\b/,
17
+ /\bprocess\.env\b/,
18
+ /\benv\.[A-Z_]{2,}/,
19
+ /\bFormData\b/,
20
+ /\bcheckEnv\b/,
21
+ /\bXMLHttpRequest\b/,
22
+ /\bWebSocket\b/,
23
+ /\bsetTimeout\b|\bsetInterval\b/
24
+ ]
25
+
26
+ /**
27
+ * @typedef {'trivial'|'simple'|'complex'} ExportComplexity
28
+ * @typedef {{ name: string, complexity: ExportComplexity }} ExportInfo
29
+ */
30
+
31
+ /**
32
+ * Extracts all named exports and classifies each by test complexity.
33
+ * @param {string} content source file text
34
+ * @returns {ExportInfo[]} named exports with complexity labels
35
+ */
36
+ export function extractExportsWithComplexity(content) {
37
+ const names = Array.from(content.matchAll(EXPORT_RE), m => m[1])
38
+ return names.map(name => ({ name, complexity: classifyExport(name, content) }))
39
+ }
40
+
41
+ /**
42
+ * Classifies one export by inspecting the code region that defines it.
43
+ * @param {string} name export name
44
+ * @param {string} content source file text
45
+ * @returns {ExportComplexity} complexity label
46
+ */
47
+ function classifyExport(name, content) {
48
+ if (isPrimitiveConstExport(name, content)) return 'trivial'
49
+
50
+ const body = extractBody(name, content)
51
+ if (!body) return 'simple'
52
+ if (COMPLEX_SIGNALS.some(re => re.test(body))) return 'complex'
53
+ return 'simple'
54
+ }
55
+
56
+ /**
57
+ * Checks whether the export is a primitive `export const NAME = <literal>`.
58
+ * @param {string} name export name
59
+ * @param {string} content source file text
60
+ * @returns {boolean} true when the export is a primitive constant
61
+ */
62
+ function isPrimitiveConstExport(name, content) {
63
+ const prefix = `export const ${name} =`
64
+ for (const line of content.split('\n')) {
65
+ if (!line.startsWith(prefix)) continue
66
+ return PRIMITIVE_LITERAL_RE.test(line.slice(prefix.length).trim())
67
+ }
68
+ return false
69
+ }
70
+
71
+ /**
72
+ * Finds the declaration start for a named export.
73
+ * @param {string} name export name
74
+ * @param {string} content source file text
75
+ * @returns {number} start index or `-1`
76
+ */
77
+ function findExportStart(name, content) {
78
+ const prefixes = [
79
+ `export async function ${name}`,
80
+ `export function ${name}`,
81
+ `export const ${name}`,
82
+ `export class ${name}`,
83
+ `export let ${name}`
84
+ ]
85
+ let start = -1
86
+ for (const prefix of prefixes) {
87
+ const idx = content.indexOf(prefix)
88
+ if (idx !== -1 && (start === -1 || idx < start)) start = idx
89
+ }
90
+ return start
91
+ }
92
+
93
+ /**
94
+ * Extracts the code region from the export declaration to the next export.
95
+ * Used for complexity signal matching only — not exact AST.
96
+ * @param {string} name export name
97
+ * @param {string} content source file text
98
+ * @returns {string|null} declaration snippet or `null`
99
+ */
100
+ function extractBody(name, content) {
101
+ const start = findExportStart(name, content)
102
+ if (start === -1) return null
103
+
104
+ const after = content.slice(start)
105
+ const nextExport = after.search(NEXT_EXPORT_RE)
106
+ const end = nextExport === -1 ? Math.min(after.length, 3000) : nextExport
107
+ return after.slice(0, end)
108
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ type: JS Module
3
+ title: apply.mjs
4
+ resource: npm/src/coverage-classify/apply.mjs
5
+ docgen:
6
+ crc: 0f54e6a0
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ score: 100
9
+ judgeModel: openai-codex/gpt-5.4-mini
10
+ ---
11
+
12
+ ## Огляд
13
+
14
+ Файл відповідає за застосування вердиктів до рядків покриття, фільтруючи виживших мутантів. Застосовується Skip rule, якщо `verdict` належить до `{equivalent, defensive, glue, wrapper}` і `confidence` досягає заданого порогу. Для інших мутантів, включаючи ті, що мають `worth-testing` або низьку впевненість, мутанти залишаються в наборі виживших. Крім того, загальна кількість мутантів (`mutation.total`) декрементується на кількість `allowed-gaps`, а окремий список `allowedGaps` повертається для візуалізації в `COVERAGE.md`.
15
+
16
+ ## Поведінка
17
+
18
+ isAllowedGap визначає, чи мутант відповідає критеріям для віднесення до категорії allowed-gap, що дозволяє його ігнорувати при оцінці покриття.
19
+ applyVerdicts фільтрує виживших мутантів на основі наданих verdict-ів, зменшує загальну кількість мутантів та повертає новий набір рядків покриття разом зі списком allowedGaps.
20
+
21
+ ## Публічний API
22
+
23
+ isAllowedGap — Визначає, чи слід вважати мутанта прийнятним пропуском (виключає його з категорії потенційно "вбиваних" мутантів).
24
+ applyVerdicts — Призначає рішення (verdicts) до рядків покриття. Відфільтровує мутанти, які вижили, якщо вони кваліфікуються як прийнятний пропуск, і зменшує загальну кількість мутантів у системі відповідно до кількості таких прийнятних пропусків.
25
+
26
+ ## Гарантії поведінки
27
+
28
+ - (специфічних машинно-виведених гарантій немає)
@@ -0,0 +1,34 @@
1
+ ---
2
+ type: JS Module
3
+ title: cache.mjs
4
+ resource: npm/src/coverage-classify/cache.mjs
5
+ docgen:
6
+ crc: 53b251b1
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ score: 100
9
+ issues: judge:inaccurate:0.98
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Механізм кешування фіксує вердикти класифікації мутантів для прискорення аналізу. Він використовує кеш, описаний у `cache.json`, де ключами є ідентифікатори контенту у форматі `<blob-hash>:<line>:<col>:<base64url>`, що динамічно формується на основі хешу файлу (`git hash-object` або `sha1`). Збережені дані включають вердикт, рівень впевненості та причину, а інвалідація кешу спрацьовує при зміні джерела коду, що призводить до нового хешу.
16
+
17
+ ## Поведінка
18
+
19
+ deriveBlobHash обчислює унікальний хеш контенту файлу, використовуючи `git hash-object` або SHA256 контенту у випадку відсутності Git.
20
+ deriveCacheKey створює унікальний ключ кешу для мутанта на основі хешу файлу, номера рядка, колонки та заміни.
21
+ readCache зчитує дані кешу з файлу, повертаючи порожній кеш у разі відсутності або некоректності.
22
+ writeCache зберігає об'єкт кешу на диск, автоматично створюючи необхідні батьківські директорії.
23
+
24
+ ## Публічний API
25
+
26
+ deriveBlobHash — Створює унікальний ідентифікатор вмісту файлу (SHA1 хеш).
27
+ deriveCacheKey — Генерує ключ для кешування, що описує зміну файлу у певному стані.
28
+ readCache — Завантажує кешовані дані з диска; повертає порожній кеш при будь-якій помилці зчитування.
29
+ writeCache — Зберігає кешовані дані на диск, створюючи необхідну структуру каталогів.
30
+
31
+ ## Гарантії поведінки
32
+
33
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
34
+ - Кешує результати в межах одного прогону.
@@ -0,0 +1,40 @@
1
+ ---
2
+ type: JS Module
3
+ title: index.mjs
4
+ resource: npm/src/coverage-classify/index.mjs
5
+ docgen:
6
+ crc: b7eff232
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ score: 100
9
+ issues: judge:inaccurate:0.99
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Класифікатор визначає, чи слід вважати мутант, переданий у межах `classify`, мутантом, згенерованим тестом. Механізм працює через маршрутизацію через pi SDK (callText). Процес включає перевірку в кеші, пошук у локальній (Tier 1) моделі, пошук у хмарній (Tier 2) моделі, а у разі повної відмови систем — застосування консервативного результату. Усі результати класифікації зберігаються у кеші, який контролюється конфігурацією у `coverage-classify.cache.json`.
16
+
17
+ Ключовий механізм роботи:
18
+ Перевірка кешу з файлу `coverage-classify.cache.json`. Якщо відповідний запис знайдено, повертається збережений verdicts[]. Якщо кеш не містить результату, ініціюється послідовна класифікація: спочатку за допомогою локальної моделі (`N_LOCAL_MIN_MODEL`), а після невдачі — за допомогою хмарної моделі (`N_CLOUD_MIN_MODEL`). У випадку невдачі обох рівнів, застосовується консервативний fallback (worth-testing/confidence=0). Після успішної класифікації результат додається до кешу.
19
+
20
+ ## Поведінка
21
+
22
+ 1. Отримується кеш з файлу `coverage-classify.cache.json`. Якщо конфігурація моделей у кеші не відповідає поточній, кеш ініціалізується.
23
+ 2. Для кожного скомпільованого мутанта у наданому наборі виконується перевірка в кеші за унікальним ключем, що включає шлях до файлу, рядок, стовпець та заміну.
24
+ 3. Якщо знайдено відповідний запис у кеші, він повертається як результат класифікації.
25
+ 4. Якщо в кеші запису немає, ініціюється класифікація мутанта.
26
+ 5. Класифікація здійснюється послідовно через джерела ШІ: спочатку через модель, визначену у змінній `N_LOCAL_MIN_MODEL`, а якщо це не вдається, то через модель, визначену у змінній `N_CLOUD_MIN_MODEL`.
27
+ 6. У разі невдачі обох моделей, встановлюється консервативний результат, що вважається мутантом, вартим тестування з низькою впевненістю.
28
+ 7. Успішно класифікований результат додається до кешу з часовою відміткою.
29
+ 8. Після обробки всіх мутантів, оновлений кеш записується у файл `coverage-classify.cache.json`.
30
+ 9. Повертається список результатів класифікації для всіх мутантів.
31
+
32
+ ## Публічний API
33
+
34
+ - classify — Визначає, які мутанти вижили, використовуючи модель для локальних даних, потім для хмарних, а якщо це не вдається, застосовує запасний варіант.
35
+
36
+ ## Гарантії поведінки
37
+
38
+ - Read-only: не виконує операцій запису (ФС/БД).
39
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
40
+ - Кешує результати в межах одного прогону.
@@ -0,0 +1,30 @@
1
+ ---
2
+ type: JS Module
3
+ title: prompt.mjs
4
+ resource: npm/src/coverage-classify/prompt.mjs
5
+ docgen:
6
+ crc: 12bfb99a
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ score: 100
9
+ issues: judge:inaccurate:0.98
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Промпт-builder для `coverage-classify` генерує контекстні інструкції. Він визначає статичну роль класифікатора (`SYSTEM_PROMPT`), яка кешується через `cache_control: ephemeral` при виклику API. Динамічно він збирає деталі кожного мутанта (location, source $\pm$10, tests, git) у промпт, що здійснюється функцією `buildUserPrompt`. Процес працює з механізмом fail-safe, запобігаючи виникненню винятків, а кешування відбувається в межах одного прогону.
16
+
17
+ ## Поведінка
18
+
19
+ SYSTEM_PROMPT визначає роль класифікатора мутацій і встановлює чіткий JSON-схему для відповіді, де мутації класифікуються як 'worth-testing', 'equivalent', 'defensive', 'glue' або 'wrapper'.
20
+ buildUserPrompt збирає повний контекст для класифікації конкретного мутанта, включаючи фрагмент вихідного коду навколо місця мутації, вміст супутніх тестів та останні зміни у Git для створення деталізованого промпта.
21
+
22
+ ## Публічний API
23
+
24
+ SYSTEM_PROMPT — Визначає основні інструкції та обмеження для AI-агента.
25
+ buildUserPrompt — Формує запит від користувача для розпізнавання конкретного мутагенту.
26
+
27
+ ## Гарантії поведінки
28
+
29
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
30
+ - Кешує результати в межах одного прогону.
@@ -0,0 +1,29 @@
1
+ ---
2
+ type: JS Module
3
+ title: verdict-schema.mjs
4
+ resource: npm/src/coverage-classify/verdict-schema.mjs
5
+ docgen:
6
+ crc: ecf5dfe1
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ score: 100
9
+ issues: judge:inaccurate:0.98
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Цей файл визначає `VerdictSchema` для структурованого відображення результатів класифікації, які генерує LLM-класифікатор (coverage-classify). Функція `parseVerdict` відповідає за вилучення та валідацію JSON-структури з необробленого тексту відповіді LLM, що дозволяє класифікувати вихід у категорії: `worth-testing`, `equivalent`, `defensive`, `glue` або `wrapper`.
16
+
17
+ ## Поведінка
18
+
19
+ VerdictSchema: Визначає структуру даних для результатів класифікації, отриманих від LLM.
20
+ parseVerdict: Витягує та перевіряє відповідність JSON-об'єкта з текстової відповіді LLM визначеній схемі.
21
+
22
+ ## Публічний API
23
+
24
+ VerdictSchema — схема, що описує очікувану структуру відповіді від LLM.
25
+ parseVerdict — видобуває та перевіряє відповідь від LLM щодо відповідності схемі VerdictSchema.
26
+
27
+ ## Гарантії поведінки
28
+
29
+ - Read-only: не виконує операцій запису (ФС/БД).
@@ -7,7 +7,7 @@
7
7
  * спалює сотні тисяч токенів лише на парсинг. Натомість важкий парсинг несе цей
8
8
  * скрипт (для JS — мілісекунди, 0 токенів), а агенту віддається рівно потрібна
9
9
  * порція:
10
- * - `index` — крихітний `[{file, mutants}]` для рішення про фан-аут;
10
+ * - `index` — крихітний масив записів `file`/`mutants` для рішення про фан-аут;
11
11
  * - `slice --file <path>` — промпт лише для одного файлу (контекст ±3 рядки),
12
12
  * рівно під когнітивне навантаження одного субагента.
13
13
  *
@@ -23,7 +23,7 @@ import { buildFixPrompt } from './coverage-fix.mjs'
23
23
  const SURVIVED_SECTION = '## Вцілілі мутанти'
24
24
 
25
25
  /**
26
- * Огорожа json-блоку: ≥3 бектики, далі `json` і решта рядка до `\n`. Довжина
26
+ * Огорожа json-блоку: ≥3 зворотних апострофи, далі `json` і решта рядка до `\n`. Довжина
27
27
  * захоплюється в групу 1 — renderMarkdown пише 3, але oxfmt підвищує до 4+, коли
28
28
  * сам JSON-вміст містить ``` (типово для original/replacement мутантів).
29
29
  */
@@ -31,9 +31,9 @@ const FENCE_OPEN_RE = /(`{3,8})json[^\n]{0,200}\n/
31
31
 
32
32
  /**
33
33
  * Витягує JSON-масив вцілілих мутантів із тексту COVERAGE.md: знаходить секцію
34
- * `## Вцілілі мутанти`, перший огороджений ` ```json ` блок під нею і парсить.
34
+ * `## Вцілілі мутанти`, перший огороджений JSON-блок під нею і парсить.
35
35
  * @param {string} md повний текст COVERAGE.md
36
- * @returns {import('./coverage-fix.mjs').SurvivedFileGroup[]} групи вцілілих по файлах (порожньо, якщо секції/блоку немає або JSON невалідний)
36
+ * @returns {object[]} групи вцілілих по файлах (порожньо, якщо секції/блоку немає або JSON невалідний)
37
37
  */
38
38
  export function parseSurvivedBlock(md) {
39
39
  const sectionAt = md.indexOf(SURVIVED_SECTION)
@@ -44,9 +44,9 @@ export function parseSurvivedBlock(md) {
44
44
  const fence = open[1]
45
45
  const bodyStart = open.index + open[0].length
46
46
  const rest = after.slice(bodyStart)
47
- // Закриття — рядок із тих самих бектиків. Усередині JSON реальних переводів
47
+ // Закриття — рядок із тих самих зворотних апострофів. Усередині JSON реальних переводів
48
48
  // рядка немає (JSON.stringify екранує їх як `\n`), тож `\n<fence>` унікально
49
- // позначає кінець блоку навіть якщо значення містять бектики.
49
+ // позначає кінець блоку навіть якщо значення містять зворотні апострофи.
50
50
  const closeAt = rest.indexOf(`\n${fence}`)
51
51
  const json = closeAt === -1 ? rest : rest.slice(0, closeAt)
52
52
  try {
@@ -60,7 +60,7 @@ export function parseSurvivedBlock(md) {
60
60
  /**
61
61
  * Читає `COVERAGE.md` із кореня проєкту і повертає структуровані групи вцілілих.
62
62
  * @param {string} cwd корінь проєкту
63
- * @returns {Promise<import('./coverage-fix.mjs').SurvivedFileGroup[]>} групи вцілілих по файлах
63
+ * @returns {Promise<object[]>} групи вцілілих по файлах
64
64
  */
65
65
  export async function readSurvived(cwd) {
66
66
  let md
@@ -73,9 +73,9 @@ export async function readSurvived(cwd) {
73
73
  }
74
74
 
75
75
  /**
76
- * Згортає групи вцілілих у компактний index `[{file, mutants}]`.
77
- * @param {import('./coverage-fix.mjs').SurvivedFileGroup[]} survived групи вцілілих
78
- * @returns {Array<{file:string, mutants:number}>} файл → кількість вцілілих мутантів
76
+ * Згортає групи вцілілих у компактний index із полями `file` і `mutants`.
77
+ * @param {object[]} survived групи вцілілих
78
+ * @returns {object[]} файл → кількість вцілілих мутантів
79
79
  */
80
80
  export function buildIndex(survived) {
81
81
  return survived
@@ -1,27 +1,25 @@
1
1
  /**
2
2
  * Per-file coverage via vitest + lcov.
3
- * Runs vitest (bundled with @7n/test) in a single pass and returns
3
+ * Runs vitest (bundled with \@7n/test) in a single pass and returns
4
4
  * both per-file coverage data and failing tests.
5
- * Target projects do NOT need vitest or @vitest/coverage-v8 installed.
5
+ * Target projects do NOT need vitest or \@vitest/coverage-v8 installed.
6
6
  */
7
7
  import { spawnSync } from 'node:child_process'
8
8
  import { existsSync, readFileSync } from 'node:fs'
9
9
  import { mkdtemp, readdir, rm } from 'node:fs/promises'
10
10
  import { tmpdir } from 'node:os'
11
- import { createRequire } from 'node:module'
12
- import { join, relative, dirname } from 'node:path'
11
+ import { join, relative } from 'node:path'
13
12
  import { env } from 'node:process'
13
+ import { resolveVitestRun } from './lib/vitest-shim.mjs'
14
14
 
15
- const _require = createRequire(import.meta.url)
16
- const VITEST_BIN = join(dirname(_require.resolve('vitest/package.json')), 'vitest.mjs')
17
-
18
- const TEST_FILE_RE = /\.(test|spec)\.[^.]+$|[/\\]tests?[/\\]/
15
+ const TEST_FILE_RE = /\.(test|spec)\.[^.]+$|(?:^|[/\\])tests?[/\\]/
16
+ const VITEST_UNSUPPORTED_TEST_RE = /bun:test|Cannot find package 'bun/i
19
17
  const MAX_ERRORS_PER_FILE = 5
20
18
  const MAX_ERROR_LINES = 10
21
19
 
22
20
  /**
23
21
  * @param {string} text lcov.info content
24
- * @returns {Array<{file: string, pct: number, linesFound: number, linesCovered: number}>}
22
+ * @returns {Array<{file: string, pct: number, linesFound: number, linesCovered: number}>} per-file coverage rows parsed from lcov
25
23
  */
26
24
  function parseLcovPerFile(text) {
27
25
  const files = []
@@ -53,32 +51,37 @@ function parseLcovPerFile(text) {
53
51
  /**
54
52
  * @param {string} jsonPath path to vitest JSON results file
55
53
  * @param {string} dir project root for relative paths
56
- * @returns {Array<{file: string, errors: string[]}>}
54
+ * @returns {Array<{file: string, errors: string[]}>} список failing test-файлів з короткими помилками
57
55
  */
58
- function parseFailingTests(jsonPath, dir) {
56
+ export function parseFailingTests(jsonPath, dir) {
59
57
  try {
60
58
  const data = JSON.parse(readFileSync(jsonPath, 'utf8'))
61
- return (data.testResults ?? [])
62
- .filter(r => r.status === 'failed')
63
- .map(r => {
64
- const assertionErrors = (r.assertionResults ?? [])
65
- .filter(a => a.status === 'failed')
66
- .slice(0, MAX_ERRORS_PER_FILE)
67
- .map(a => {
68
- const name = [...(a.ancestorTitles ?? []), a.title].join(' > ')
69
- const msg = (a.failureMessages?.[0] ?? '').split('\n').slice(0, MAX_ERROR_LINES).join('\n')
70
- return `${name}:\n${msg}`
71
- })
72
- // Module-level errors (import/syntax) produce no assertionResults
73
- const errors =
74
- assertionErrors.length > 0
75
- ? assertionErrors
76
- : [
77
- `Suite error: ${(r.message ?? r.failureMessage ?? 'module-level failure').split('\n').slice(0, MAX_ERROR_LINES).join('\n')}`
78
- ]
79
- return { file: relative(dir, r.testFilePath ?? r.name), errors }
80
- })
81
- .filter(f => !f.file.startsWith('..'))
59
+ return (
60
+ (data.testResults ?? [])
61
+ .filter(r => r.status === 'failed')
62
+ .map(r => {
63
+ const assertionErrors = (r.assertionResults ?? [])
64
+ .filter(a => a.status === 'failed')
65
+ .slice(0, MAX_ERRORS_PER_FILE)
66
+ .map(a => {
67
+ const name = [...(a.ancestorTitles ?? []), a.title].join(' > ')
68
+ const msg = (a.failureMessages?.[0] ?? '').split('\n').slice(0, MAX_ERROR_LINES).join('\n')
69
+ return `${name}:\n${msg}`
70
+ })
71
+ // Module-level errors (import/syntax) produce no assertionResults
72
+ const errors =
73
+ assertionErrors.length > 0
74
+ ? assertionErrors
75
+ : [
76
+ `Suite error: ${(r.message ?? r.failureMessage ?? 'module-level failure').split('\n').slice(0, MAX_ERROR_LINES).join('\n')}`
77
+ ]
78
+ return { file: relative(dir, r.testFilePath ?? r.name), errors }
79
+ })
80
+ .filter(f => !f.file.startsWith('..'))
81
+ // Skip test files that use a non-vitest runner (bun:test, jest, etc.)
82
+ // They are expected to fail and cannot be fixed by this tool.
83
+ .filter(f => f.errors.every(e => !VITEST_UNSUPPORTED_TEST_RE.test(e)))
84
+ )
82
85
  } catch {
83
86
  return []
84
87
  }
@@ -87,20 +90,23 @@ function parseFailingTests(jsonPath, dir) {
87
90
  /**
88
91
  * Runs vitest coverage + JSON reporter in a single pass.
89
92
  * Returns per-file coverage and any failing tests detected in the same run.
90
- *
91
93
  * @param {string} dir project root
92
- * @returns {Promise<{files: Array<{file: string, pct: number, linesFound: number, linesCovered: number}>, failingTests: Array<{file: string, errors: string[]}>}>}
94
+ * @returns {Promise<{files: Array<{file: string, pct: number, linesFound: number, linesCovered: number}>, failingTests: Array<{file: string, errors: string[]}>}>} coverage rows and failing tests from one vitest run
93
95
  */
94
96
  export async function measureCoveragePerFile(dir) {
95
97
  const lcovDir = await mkdtemp(join(tmpdir(), '7n-cov-'))
96
98
  const jsonResultsFile = join(lcovDir, 'test-results.json')
97
99
 
100
+ const { bin, configArgs } = resolveVitestRun(dir)
98
101
  try {
99
102
  spawnSync(
100
103
  process.execPath,
101
104
  [
102
- VITEST_BIN,
105
+ bin,
103
106
  'run',
107
+ ...configArgs,
108
+ '--root',
109
+ dir,
104
110
  '--passWithNoTests',
105
111
  '--coverage',
106
112
  '--coverage.reporter=lcov',
@@ -124,15 +130,19 @@ export async function measureCoveragePerFile(dir) {
124
130
 
125
131
  return { files, failingTests }
126
132
  } finally {
127
- await rm(lcovDir, { recursive: true, force: true }).catch(() => {})
133
+ try {
134
+ await rm(lcovDir, { recursive: true, force: true })
135
+ } catch {
136
+ /* ignore cleanup failures */
137
+ }
128
138
  }
129
139
  }
130
140
 
131
141
  /**
132
142
  * Files below the coverage threshold.
133
- * @param {Array<{file: string, pct: number}>} files
134
- * @param {number} [threshold=80]
135
- * @returns {Array<{file: string, pct: number}>}
143
+ * @param {Array<{file: string, pct: number}>} files список файлів із coverage-метриками
144
+ * @param {number} [threshold] coverage floor; за замовчуванням `80`
145
+ * @returns {Array<{file: string, pct: number}>} файли, чий coverage нижчий за threshold
136
146
  */
137
147
  export function getUncoveredFiles(files, threshold = 80) {
138
148
  return files.filter(f => f.pct < threshold)
@@ -152,19 +162,27 @@ const IGNORE_DIRS = new Set([
152
162
  '.pi',
153
163
  'docs',
154
164
  'bin',
155
- 'reports'
165
+ 'reports',
166
+ 'types'
156
167
  ])
157
168
 
169
+ // Config/tooling files that are not unit-testable source files.
170
+ const CONFIG_FILE_RE =
171
+ /^(?:vitest|jest|eslint|prettier|stryker|babel|webpack|vite|rollup|tsconfig|jsconfig|knip)\.config\./
172
+
158
173
  /**
159
174
  * Recursively finds source code files in a directory, excluding tests and
160
175
  * ignored directories. Used for bootstrap when no coverage data exists.
161
- *
162
176
  * @param {string} dir project root
163
- * @returns {Promise<string[]>} relative paths to source files
177
+ * @returns {Promise<string[]>} relative paths to source files that look unit-testable
164
178
  */
165
179
  export async function findSourceFiles(dir) {
166
180
  const results = []
167
181
 
182
+ /**
183
+ * @param {string} current absolute directory path
184
+ * @param {string} relBase relative path prefix
185
+ */
168
186
  async function walk(current, relBase) {
169
187
  let entries
170
188
  try {
@@ -177,7 +195,14 @@ export async function findSourceFiles(dir) {
177
195
  const rel = relBase ? `${relBase}/${entry.name}` : entry.name
178
196
  if (entry.isDirectory()) {
179
197
  if (!IGNORE_DIRS.has(entry.name)) await walk(join(current, entry.name), rel)
180
- } else if (entry.isFile() && SOURCE_EXT_RE.test(entry.name) && !TEST_FILE_RE.test(rel)) {
198
+ } else if (
199
+ entry.isFile() &&
200
+ SOURCE_EXT_RE.test(entry.name) &&
201
+ !entry.name.endsWith('.d.ts') &&
202
+ !entry.name.endsWith('.d.mts') &&
203
+ !CONFIG_FILE_RE.test(entry.name) &&
204
+ !TEST_FILE_RE.test(rel)
205
+ ) {
181
206
  results.push(rel)
182
207
  }
183
208
  }
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: JS Module
3
+ title: assess-need.mjs
4
+ resource: npm/src/assess-need.mjs
5
+ docgen:
6
+ crc: 02dd5f01
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ score: 90
9
+ issues: internal-name:callText,judge:inaccurate:0.98
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Модуль оцінює необхідність написання юніт-тестів для файлів, які не покриті тестами, застосовуючи спочатку швидкі локальні евристичні перевірки. У випадку невизначеності, для отримання структурованої JSON-відповіді, викликається `callText` (без інструментів). Функціонал керується публічними методами `quickClassify` та `assessNeed`. Модуль працює у read-only режимі, не взаємодіючи з ФС чи БД, та використовує механізм fail-safe для обробки помилок.
16
+
17
+ ## Поведінка
18
+
19
+ Поведінка
20
+ quickClassify визначає, чи виправдано написання юніт-тестів для коду на основі швидких локальних евристик, повертаючи рішення або `null`, якщо випадок неоднозначний.
21
+ assessNeed оцінює перелік файлів з низьким покриттям, вирішуючи очевидні випадки локально та передаючи неоднозначні файли для оцінки за допомогою моделі.
22
+
23
+ ## Публічний API
24
+
25
+ quickClassify — Швидко визначає клас об'єкта локально, не використовуючи зовнішніх ресурсів (I/O або LLM), повертаючи результат для очевидних випадків або null у випадку невизначеності.
26
+ assessNeed — Визначає, чи потребують тестування перераховані файли, локально розв'язуючи очевидні випадки (наприклад, ре-експорти чи функції з умовами) та ініціюючи виклик LLM лише для неоднозначних файлів.
27
+
28
+ ## Гарантії поведінки
29
+
30
+ - Read-only: не виконує операцій запису (ФС/БД).
31
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).