@nitra/cursor 1.34.1 → 1.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.35.2] - 2026-05-30
4
+
5
+ ### Fixed
6
+
7
+ - test: meta-тести no-process-chdir і test-helpers перенесено на конкатенацію/Identifier-аргументи, щоб не тригерити власні сканери (false positives)
8
+
9
+ ## [1.35.1] - 2026-05-30
10
+
11
+ ### Added
12
+
13
+ - Нові тести для підвищення coverage: run-dotenv-linter (spawnSync error paths), lint.mjs preflight-OK paths, ua_http_route.mjs (default cwd + readFile chmod-fail), auto-rules.mjs (readdir/JSON catch-блоки), run-shellcheck-errors, run-v8r, sync-claude-config, test-helpers absolute-path guard, rename-yaml-extensions sort, graphql langFromPath, capacitor segmentMinMajor, coverage-classify cache/retry/fallback, npm-module package_structure, changelog consistency, bun-sql-scan BinaryExpression. Lines coverage: ~83.59% → 88.32%.
14
+
15
+ ## [1.35.0] - 2026-05-30
16
+
17
+ ### Added
18
+
19
+ - LLM-класифікатор survived мутантів у n-cursor coverage: для кожного survived Claude Sonnet 4.6 виносить verdict (worth-testing/equivalent/defensive/glue/wrapper) з reasoning + confidence. Allowed gaps виключаються з знаменника mutation score. Cache по git-blob-hash. Graceful skip без API key. Threshold у .n-cursor.json#coverage.classifyConfidenceThreshold (default 1.1 — rollout mode).
20
+
3
21
  ## [1.34.1] - 2026-05-30
4
22
 
5
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.34.1",
3
+ "version": "1.35.2",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -52,11 +52,12 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@anthropic-ai/claude-agent-sdk": "^0.3.0",
55
- "@anthropic-ai/sdk": "^0.54.0",
55
+ "@anthropic-ai/sdk": "^0.100.1",
56
56
  "oxc-parser": "^0.128.0",
57
57
  "picomatch": "^4.0.4",
58
58
  "smol-toml": "^1.6.1",
59
- "yaml": "^2.8.3"
59
+ "yaml": "^2.8.3",
60
+ "zod": "^4.4.3"
60
61
  },
61
62
  "engines": {
62
63
  "bun": ">=1.3",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння (npm і Python)
3
- version: '3.0'
3
+ version: '3.1'
4
4
  alwaysApply: true
5
5
  ---
6
6
 
@@ -9,14 +9,12 @@ alwaysApply: true
9
9
  > **Якщо в цій сесії ти змінив(ла) файли в пакетному workspace** (код, rego, правила, скіли, скрипти, конфіги, тести — **не** лише `docs/` / `doc/`) — **не завершуй задачу**, поки не виконаєш **усі три** кроки нижче в **тому ж** наборі змін. Це не «опційно після синку» — це частина PR.
10
10
 
11
11
  1. **Поклади change-файл** `<ws>/.changes/<timestamp>-<rand>.md` з frontmatter `bump:` (`major|minor|patch`) + `section:` (`Added|Changed|Fixed|Removed`) і текстом опису. Команда: `npx @nitra/cursor change --bump <…> --section <…> --message "<…>" [--ws <шлях>]`.
12
- 2. **Не** редагуй `version` і `CHANGELOG.md` вручну — їх формує `n-cursor release` у CI на `main` (агрегує change-файли, ставить git-тег `<name>@<version>`).
13
- 3. **`npx @nitra/cursor fix changelog`** → exit **`0`** (м'яка перевірка: достатньо наявності change-файлу **або** вже піднятого `version`).
14
-
15
- **Legacy / hotfix:** ручний bump `version` + новий запис у `CHANGELOG.md` усе ще приймається перевіркою як альтернатива change-файлу.
12
+ 2. **Ніколи** не редагуй `version` і `CHANGELOG.md` вручну — навіть для hotfix. Єдиний артефакт зміни — change-файл; `version`/CHANGELOG формує `n-cursor release` у CI на `main` (агрегує change-файли, ставить git-тег `<name>@<version>`). Будь-яка зміна `version` поза CI (drift від бази чи опублікованої) завалює `check changelog` — навіть якщо поряд є change-файл.
13
+ 3. **`npx @nitra/cursor fix changelog`** → exit **`0`** (достатньо наявності change-файлу; `version` лишається незмінним).
16
14
 
17
15
  **Тригер шляхів (приклади):** `npm/**`, `packages/foo/**`, будь-який каталог з власним `package.json` / `pyproject.toml`, куди потрапили правки.
18
16
 
19
- **Інверсія (bump не потрібен):** лише `docs/` / `doc/`; синхронізований із `@nitra/cursor` інструментарій (`.cursor/`, `.claude/`); лише `.gitignore`; лише сам релізний крок (`CHANGELOG.md` + `version`). **Корінь монорепо** (воркспейс `.` за наявності підпакетів) не перевіряється взагалі — отже й кореневі `AGENTS.md` / `CLAUDE.md` та bump `@nitra/cursor` у `devDependencies`.
17
+ **Інверсія (change-файл не потрібен):** лише `docs/` / `doc/`; синхронізований із `@nitra/cursor` інструментарій (`.cursor/`, `.claude/`); лише `.gitignore`. **Корінь монорепо** (воркспейс `.` за наявності підпакетів) не перевіряється взагалі — отже й кореневі `AGENTS.md` / `CLAUDE.md` та bump `@nitra/cursor` у `devDependencies`. Окремого «релізного кроку» у feature-флоу немає — `version`/`CHANGELOG.md` змінює лише CI.
20
18
 
21
19
  **Pre-commit (людина):** `hk` у цьому репо також запускає `check changelog` при змінах під `npm/**` — агент не покладайся лише на commit hook; виконай кроки 1–3 **до** фінальної відповіді.
22
20
 
@@ -35,15 +33,14 @@ alwaysApply: true
35
33
 
36
34
  Повний алгоритм — у блоці **STOP** вище; тут лише уточнення.
37
35
 
38
- **Інверсія (за замовчуванням не вимагають bump/CHANGELOG):**
36
+ **Інверсія (за замовчуванням не вимагають change-файлу):**
39
37
 
40
38
  - зміни **лише** під `docs/` або `doc/`;
41
39
  - синхронізований із `@nitra/cursor` інструментарій під `.cursor/` (канонічні правила та скіли) і `.claude/` (ADR-хуки) — це дзеркало tooling-пакета, а не логіка воркспейсу;
42
40
  - будь-які зміни в **корені монорепо** (воркспейс `.` за наявності підпакетів) — корінь веде glue/конфіг/tooling, власного CHANGELOG не має; помітні зміни документують підпакети. Сюди потрапляють і кореневі `AGENTS.md` / `CLAUDE.md`, і bump `@nitra/cursor` у `devDependencies`;
43
- - файли під **`.gitignore`**;
44
- - правки **лише** `CHANGELOG.md` або поля `version` у маніфесті як сам релізний крок.
41
+ - файли під **`.gitignore`**.
45
42
 
46
- **Вимагають bump + нову секцію CHANGELOG** — усі інші зміни в каталозі workspace (код, rego, правила, скіли, конфіги, тести тощо). Виняток `.cursor/` / `.claude/` **не** поширюється на джерело правил у репо `@nitra/cursor` — воно лежить під `npm/`, тож зміни в ньому далі вимагають bump.
43
+ **Вимагають change-файл** — усі інші зміни в каталозі workspace (код, rego, правила, скіли, конфіги, тести тощо). Виняток `.cursor/` / `.claude/` **не** поширюється на джерело правил у репо `@nitra/cursor` — воно лежить під `npm/`, тож зміни в ньому далі вимагають change-файлу.
47
44
 
48
45
  Перевірка програмна (`changelog/js/consistency.mjs`).
49
46
 
@@ -57,8 +54,8 @@ alwaysApply: true
57
54
 
58
55
  **Python:** статичні `project.name` і `project.version` у `pyproject.toml` (або Poetry-секція).
59
56
 
60
- 1. **Локальна `version` ≠ опублікованій** (npm / PyPI): запис у `<ws>/CHANGELOG.md`; для npm також `"CHANGELOG.md"` у `files`.
61
- 2. **Версії збігаються**, але в git є **релевантні** зміни без bump → fail.
57
+ 1. **Локальна `version` ≠ опублікованій** (npm / PyPI): drift поза CI **fail** (ручний bump заборонено; навіть із change-файлом). Відкоти `version`.
58
+ 2. **Версії збігаються**, але в git є **релевантні** зміни без change-файлу → fail. Для npm `"CHANGELOG.md"` має бути в `files` (публікується разом із пакетом).
62
59
  3. **Реєстр недосяжний** — fail-safe pass.
63
60
  4. **Немає релевантних змін** — pass.
64
61
 
@@ -69,7 +66,7 @@ alwaysApply: true
69
66
  1. На **`dev`** local-only не активний (крім незакомічених registry-published).
70
67
  2. На **`main`** — diff від **`origin/main`** (попередній опублікований `main`); без remote — від `HEAD~1`. **`dev` не використовується** як база на `main`.
71
68
  3. На **feature-гілці** — `merge-base` з **`dev`**, якщо є; інакше з **`main`** (репо без `dev`).
72
- 4. Bump + CHANGELOG **раз на PR** / direct-commit на `main`.
69
+ 4. Drift `version` від бази **fail** (ручний bump заборонено). Зміни фіксуй change-файлом; bump зробить CI.
73
70
 
74
71
  Якщо немає git або немає `dev`/`main`/`origin/main` — local-only пропускається.
75
72
 
@@ -1,25 +1,24 @@
1
1
  /**
2
- * Перевіряє, що в кожному workspace із релізно-релевантними змінами підвищена `version`
3
- * у маніфесті (`package.json` або `pyproject.toml`) і в `<ws>/CHANGELOG.md` є запис
4
- * `## [version] - YYYY-MM-DD` (формат Keep a Changelog).
2
+ * Перевіряє, що кожен workspace із релізно-релевантними змінами зафіксував їх через
3
+ * change-файл `<ws>/.changes/*.md` єдиний дозволений артефакт зміни. Bump `version`
4
+ * і генерацію `CHANGELOG.md` робить виключно `n-cursor release` у CI на `main`.
5
+ *
6
+ * Інваріант (на будь-якій гілці): `version` не має відхилятися від бази. Будь-який drift
7
+ * `version` (vs опублікована в реєстрі або vs git-база) — ручний bump поза CI — завалює
8
+ * перевірку, навіть якщо присутній change-файл. Pass лише коли є change-файл, а version
9
+ * не зрушено; зміни без change-файлу — fail.
5
10
  *
6
11
  * Дві моделі бази — на рівні воркспейсу (див. n-changelog.mdc):
7
12
  *
8
13
  * 1) **registry-published** (npm: `name` + `files`, не `private`; Python: `project.name` +
9
- * статична `project.version` у `pyproject.toml`): база = опублікована версія в npm / PyPI.
10
- * Якщо локальна версія відрізняється потрібен CHANGELOG; для npm також `"CHANGELOG.md"`
11
- * у `files`. Якщо версії збігаються, але в git є релевантні зміни без bump — fail.
12
- *
13
- * 2) **local-only** (приватні npm, без `files`, Python без імені/версії для реєстру):
14
+ * статична `project.version`): база = опублікована версія в npm / PyPI.
15
+ * 2) **local-only** (приватні npm без `files`, Python без імені/версії для реєстру):
14
16
  * feature-гілка — `merge-base` з `dev`, інакше з `main`; на `main` — diff від
15
- * `origin/main` (попередній опублікований main) або `HEAD~1` без remote.
17
+ * `origin/main` (або `HEAD~1` без remote).
16
18
  *
17
19
  * Усі `git` і зовнішні виклики — через `execFile` / `fetch`, без shell-інтерполяції.
18
20
  */
19
21
  import { execFile } from 'node:child_process'
20
- import { existsSync } from 'node:fs'
21
- import { readFile } from 'node:fs/promises'
22
- import { join } from 'node:path'
23
22
  import { promisify } from 'node:util'
24
23
 
25
24
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
@@ -277,16 +276,6 @@ async function readBaseVersion(baseRef, manifest, cwd) {
277
276
  return parsePyprojectFields(out).version
278
277
  }
279
278
 
280
- /**
281
- * @param {string} text параметр
282
- * @param {string} version параметр
283
- * @returns {boolean} результат
284
- */
285
- function changelogHasVersionEntry(text, version) {
286
- const needle = `## [${version}]`
287
- return text.startsWith(needle) || text.includes(`\n${needle}`)
288
- }
289
-
290
279
  /**
291
280
  * @param {string} name параметр
292
281
  * @returns {Promise<string | null>} результат
@@ -363,31 +352,6 @@ function checkNpmFilesArrayContainsChangelog(manifest, pass, fail) {
363
352
  }
364
353
  }
365
354
 
366
- /**
367
- * @param {string} ws параметр
368
- * @param {string} version параметр
369
- * @param {(msg: string) => void} pass параметр
370
- * @param {(msg: string) => void} fail параметр
371
- * @param {string} cwd робочий каталог
372
- * @returns {Promise<boolean>} результат
373
- */
374
- async function verifyChangelogEntry(ws, version, pass, fail, cwd) {
375
- const label = ws === '.' ? '<root>' : ws
376
- const changelogRel = join(ws, 'CHANGELOG.md')
377
- const changelogAbs = join(cwd, changelogRel)
378
- if (!existsSync(changelogAbs)) {
379
- fail(`${label}: відсутній ${changelogRel} (Keep a Changelog, див. n-changelog.mdc)`)
380
- return false
381
- }
382
- const text = await readFile(changelogAbs, 'utf8')
383
- if (changelogHasVersionEntry(text, version)) {
384
- pass(`${changelogRel}: знайдено запис для версії ${version}`)
385
- return true
386
- }
387
- fail(`${changelogRel}: відсутній запис для ${version} (формат "## [${version}] - YYYY-MM-DD")`)
388
- return false
389
- }
390
-
391
355
  /**
392
356
  * @param {import('../lib/package-manifest.mjs').PackageManifest} manifest параметр
393
357
  * @returns {string} результат
@@ -396,6 +360,20 @@ function workspaceLabel(manifest) {
396
360
  return manifest.ws === '.' ? '<root>' : manifest.ws
397
361
  }
398
362
 
363
+ /**
364
+ * Повідомлення «поклади change-файл» для workspace з релевантними змінами без change-файлу.
365
+ * @param {string} label мітка воркспейсу
366
+ * @param {string} mf шлях до маніфесту
367
+ * @returns {string} текст fail
368
+ */
369
+ function missingChangeFileMessage(label, mf) {
370
+ return (
371
+ `${label}: є релевантні зміни, але немає change-файлу (version у ${mf} не чіпай вручну). ` +
372
+ `Поклади change-файл: npx @nitra/cursor change --bump <major|minor|patch> --section <Added|Changed|Fixed|Removed> --message "<…>"; ` +
373
+ `bump зробить CI на main (n-changelog.mdc)`
374
+ )
375
+ }
376
+
399
377
  /**
400
378
  * Чи має workspace незрелізні change-файли (намір зафіксовано — bump зробить CI).
401
379
  * @param {string} ws workspace
@@ -409,18 +387,20 @@ async function hasPendingChangeFiles(ws, cwd) {
409
387
 
410
388
  /**
411
389
  * @param {import('../lib/package-manifest.mjs').PackageManifest} manifest параметр
412
- * @param {string} Vcurrent параметр
390
+ * @param {string} _Vcurrent параметр (для сумісності сигнатури; bump робить CI)
413
391
  * @param {string[]} subWorkspaces параметр
414
392
  * @param {(msg: string) => void} pass параметр
415
393
  * @param {(msg: string) => void} fail параметр
416
394
  * @param {string} cwd робочий каталог
417
395
  * @returns {Promise<void>} результат
418
396
  */
419
- async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd) {
397
+ async function checkPublishedWorkspacePendingGitChanges(manifest, _Vcurrent, subWorkspaces, pass, fail, cwd) {
420
398
  const label = workspaceLabel(manifest)
421
399
  const mf = manifestFilePath(manifest.ws, manifest)
422
400
  if (await hasPendingChangeFiles(manifest.ws, cwd)) {
423
401
  pass(`${label}: є change-файл(и) у .changes/ — bump зробить CI (n-changelog.mdc)`)
402
+ // Реліз наближається → CHANGELOG має публікуватися разом із пакетом.
403
+ checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
424
404
  return
425
405
  }
426
406
  if (!(await isInsideGitRepo(cwd))) {
@@ -431,42 +411,19 @@ async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subW
431
411
 
432
412
  if (branch === LOCAL_ONLY_SKIP_BRANCH) {
433
413
  if (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd)) {
434
- fail(
435
- `${label}: у registry-published пакеті є незакомічені зміни при version ${Vcurrent}, що вже в реєстрі. ` +
436
- `Підвищ version у ${mf} і додай запис у CHANGELOG.md (n-changelog.mdc)`
437
- )
414
+ fail(missingChangeFileMessage(label, mf))
438
415
  }
439
416
  return
440
417
  }
441
418
 
442
419
  const comparison = await resolveChangelogComparisonPoint(branch, cwd)
443
- if (
444
- comparison &&
445
- (await workspaceHasRelevantChangesAgainstBase(comparison.ref, manifest.ws, subWorkspaces, cwd))
446
- ) {
447
- const Vbase = await readBaseVersion(comparison.ref, manifest, cwd)
448
- const baseLabel = comparison.label
449
- if (Vbase === null) {
450
- pass(
451
- `${label}: новий registry-published воркспейс (на ${baseLabel} відсутній ${mf}) — перевіряємо CHANGELOG для ${Vcurrent}`
452
- )
453
- await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail, cwd)
454
- checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
455
- } else if (Vbase === Vcurrent) {
456
- fail(
457
- `${label}: у цій гілці є зміни в registry-published пакеті, але version у ${mf} ` +
458
- `не підвищено (на ${baseLabel} — ${Vbase}). Bump + запис у CHANGELOG.md обов'язкові (n-changelog.mdc)`
459
- )
460
- } else {
461
- pass(`${label}: version змінено (${Vbase} → ${Vcurrent}) — очікується запис CHANGELOG після bump`)
462
- }
420
+ if (comparison && (await workspaceHasRelevantChangesAgainstBase(comparison.ref, manifest.ws, subWorkspaces, cwd))) {
421
+ fail(missingChangeFileMessage(label, mf))
422
+ return
463
423
  }
464
424
 
465
425
  if (branch === 'main' && (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd))) {
466
- fail(
467
- `${label}: у registry-published пакеті є незакомічені зміни при version ${Vcurrent}, що вже в реєстрі. ` +
468
- `Підвищ version у ${mf} і додай запис у CHANGELOG.md (n-changelog.mdc)`
469
- )
426
+ fail(missingChangeFileMessage(label, mf))
470
427
  }
471
428
  }
472
429
 
@@ -497,14 +454,18 @@ async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVers
497
454
  pass(`${label}: ${name} — опублікована версія недоступна (мережа/реєстр), перевірку пропущено`)
498
455
  return
499
456
  }
500
- if (Vpublished === Vcurrent) {
501
- pass(`${label}: ${name}@${Vcurrent} збігається з реєстром перевіряємо git на незрелізні зміни`)
502
- await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd)
457
+ // Drift від опублікованої версії має пріоритет над change-файлом: ручний bump
458
+ // заборонено навіть із change-файлом (симетрично з local-only-шляхом).
459
+ if (Vpublished !== Vcurrent) {
460
+ fail(
461
+ `${label}: version у ${mf} (${Vcurrent}) розходиться з опублікованою (${Vpublished}) — ` +
462
+ `ручний bump заборонено. Відкоти version і поклади change-файл ` +
463
+ `(npx @nitra/cursor change …); bump зробить CI на main (n-changelog.mdc)`
464
+ )
503
465
  return
504
466
  }
505
- pass(`${label}: ${name} — нова локальна версія (${Vpublished} → ${Vcurrent})`)
506
- await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail, cwd)
507
- checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
467
+ pass(`${label}: ${name}@${Vcurrent} збігається з реєстром перевіряємо git на незрелізні зміни`)
468
+ await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd)
508
469
  }
509
470
 
510
471
  /**
@@ -518,31 +479,22 @@ async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVers
518
479
  async function checkLocalOnlyChangedWorkspace(comparisonRef, manifest, baseLabel, pass, fail, cwd) {
519
480
  const label = workspaceLabel(manifest)
520
481
  const mf = manifestFilePath(manifest.ws, manifest)
521
- if (await hasPendingChangeFiles(manifest.ws, cwd)) {
522
- pass(`${label}: є change-файл(и) у .changes/ — bump зробить CI (n-changelog.mdc)`)
523
- return
524
- }
525
482
  const Vcurrent = manifest.version
526
- if (!Vcurrent) {
527
- fail(`${label}: у ${mf} відсутнє поле version (потрібне для запису в CHANGELOG)`)
528
- return
529
- }
483
+ // Drift version від бази має пріоритет над change-файлом: ручний bump заборонено
484
+ // навіть якщо change-файл присутній (симетрично з published-шляхом).
530
485
  const Vbase = await readBaseVersion(comparisonRef, manifest, cwd)
531
- if (Vbase === null) {
532
- pass(`${label}: новий воркспейс (на ${baseLabel} відсутній ${mf}) — перевіряємо CHANGELOG для ${Vcurrent}`)
533
- if (!(await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail, cwd))) return
534
- checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
535
- return
536
- }
537
- if (Vbase === Vcurrent) {
486
+ if (Vbase !== null && Vcurrent !== null && Vbase !== Vcurrent) {
538
487
  fail(
539
- `${label}: у цій гілці є зміни, але version у ${mf} не підвищено (на ${baseLabel} — ${Vbase}). Bump + запис у CHANGELOG.md обов'язкові на PR`
488
+ `${label}: version у ${mf} змінено поза CI (${Vbase} ${Vcurrent}) ручний bump заборонено (на ${baseLabel} — ${Vbase}). ` +
489
+ `Відкоти version і поклади change-файл (npx @nitra/cursor change …); bump зробить CI (n-changelog.mdc)`
540
490
  )
541
491
  return
542
492
  }
543
- pass(`${label}: version підвищено (${Vbase} ${Vcurrent})`)
544
- if (!(await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail, cwd))) return
545
- checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
493
+ if (await hasPendingChangeFiles(manifest.ws, cwd)) {
494
+ pass(`${label}: є change-файл(и) у .changes/ bump зробить CI (n-changelog.mdc)`)
495
+ return
496
+ }
497
+ fail(missingChangeFileMessage(label, mf))
546
498
  }
547
499
 
548
500
  /**
@@ -13,10 +13,12 @@
13
13
  * specs/2026-05-24-coverage-rule-design.md).
14
14
  */
15
15
  import { existsSync } from 'node:fs'
16
- import { writeFile } from 'node:fs/promises'
16
+ import { readFile, writeFile } from 'node:fs/promises'
17
17
  import { dirname, join } from 'node:path'
18
18
  import { fileURLToPath, pathToFileURL } from 'node:url'
19
19
 
20
+ import { applyVerdicts } from '../../../scripts/coverage-classify/apply.mjs'
21
+ import { classify } from '../../../scripts/coverage-classify/index.mjs'
20
22
  import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
21
23
  import { withLock } from '../../../scripts/utils/with-lock.mjs'
22
24
 
@@ -72,11 +74,14 @@ export function formatScore({ caught, total }) {
72
74
  * Рендерить таблицю покриття + мутаційного тестування як Markdown.
73
75
  * Якщо будь-який рядок містить непустий `survived`, додає секцію
74
76
  * `## Вцілілі мутанти` з JSON-блоком для `/n-fix-tests`.
77
+ * Якщо `allowedGaps` непустий, додає секцію `## Allowed gaps` з таблицею
78
+ * verdict/confidence/reason для кожного LLM-класифікованого мутанта.
75
79
  * Без timestamp, щоб git diff рухався лише при зміні метрик.
76
80
  * @param {Array<{area:string, coverage:{lines:{covered:number,total:number},functions:{covered:number,total:number}}, mutation:{caught:number,total:number}, survived?: Array<{file:string,line:number,col:number,mutantType:string,original:string,replacement:string}>}>} rows рядки провайдерів
81
+ * @param {Array<{file:string, mutant:{line:number,col:number,mutantType:string,original:string,replacement:string}, verdict:{verdict:string,confidence:number,reason:string}}>} [allowedGaps] мутанти виключені класифікатором
77
82
  * @returns {string} Markdown з заголовком `# Coverage`
78
83
  */
79
- export function renderMarkdown(rows) {
84
+ export function renderMarkdown(rows, allowedGaps = []) {
80
85
  const lines = [
81
86
  '# Coverage',
82
87
  '',
@@ -115,6 +120,29 @@ export function renderMarkdown(rows) {
115
120
  }
116
121
  }
117
122
 
123
+ if (allowedGaps.length > 0) {
124
+ // Group allowed gaps by file
125
+ const gapsByFile = new Map()
126
+ for (const gap of allowedGaps) {
127
+ if (!gapsByFile.has(gap.file)) gapsByFile.set(gap.file, [])
128
+ gapsByFile.get(gap.file).push(gap)
129
+ }
130
+
131
+ lines.push('', '## Allowed gaps', '')
132
+ lines.push(`> LLM-класифікатор виключив ${allowedGaps.length} survived мутант(ів) зі знаменника mutation score.`)
133
+ lines.push('> Категорії: equivalent (поведінково еквівалентний), defensive (impossible state), glue/wrapper (integration test покриває).')
134
+
135
+ for (const [file, gaps] of gapsByFile) {
136
+ lines.push('', `### ${file}`, '', '| Line | Mutant | Verdict | Confidence | Reason |', '| --- | --- | --- | --- | --- |')
137
+ for (const { mutant, verdict } of gaps) {
138
+ const sanitizedReason = verdict.reason.replaceAll('|', '\\|').replaceAll('\n', ' ')
139
+ lines.push(
140
+ `| ${mutant.line} | \`${mutant.original}\` → \`${mutant.replacement}\` | ${verdict.verdict} | ${verdict.confidence.toFixed(2)} | ${sanitizedReason} |`
141
+ )
142
+ }
143
+ }
144
+ }
145
+
118
146
  return `${lines.join('\n')}\n`
119
147
  }
120
148
 
@@ -152,6 +180,22 @@ function buildTotalsRow(rows) {
152
180
  return { area: '**Разом**', coverage: totalCoverage, mutation: totalMutation }
153
181
  }
154
182
 
183
+ /**
184
+ * Читає `.n-cursor.json#coverage.classifyConfidenceThreshold` (default 1.1 — rollout mode).
185
+ * @param {string} cwd корінь проєкту
186
+ * @returns {Promise<number>} threshold у [0, 1.1]
187
+ */
188
+ async function readClassifyThreshold(cwd) {
189
+ try {
190
+ const raw = await readFile(join(cwd, '.n-cursor.json'), 'utf8')
191
+ const parsed = JSON.parse(raw)
192
+ const t = parsed?.coverage?.classifyConfidenceThreshold
193
+ return typeof t === 'number' && Number.isFinite(t) ? t : 1.1
194
+ } catch {
195
+ return 1.1
196
+ }
197
+ }
198
+
155
199
  /**
156
200
  * Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
157
201
  * detect+collect для кожного, агрегація, запис COVERAGE.md.
@@ -180,16 +224,31 @@ export async function runCoverageSteps(opts = {}) {
180
224
  return 1
181
225
  }
182
226
 
227
+ // LLM-класифікація survived мутантів (graceful skip без API key)
228
+ const allSurvived = rows.flatMap(r => r.survived ?? [])
229
+ let augmentedRows = rows
230
+ let allowedGaps = []
231
+ if (allSurvived.length > 0) {
232
+ const verdicts = await classify(allSurvived, cwd)
233
+ if (verdicts.length > 0) {
234
+ const threshold = await readClassifyThreshold(cwd)
235
+ const applied = applyVerdicts(rows, verdicts, threshold)
236
+ augmentedRows = applied.rows
237
+ allowedGaps = applied.allowedGaps
238
+ }
239
+ }
240
+
183
241
  // Підсумок «Разом» має сенс лише коли провайдерів ≥2; для єдиного рядка він
184
242
  // дублює його значення, тож не додаємо.
185
- if (rows.length > 1) rows.push(buildTotalsRow(rows))
186
- const md = renderMarkdown(rows)
243
+ if (augmentedRows.filter(r => r.area !== '**Разом**').length > 1) {
244
+ augmentedRows.push(buildTotalsRow(augmentedRows.filter(r => r.area !== '**Разом**')))
245
+ }
246
+ const md = renderMarkdown(augmentedRows, allowedGaps)
187
247
  // Stryker disable next-line StringLiteral: equivalent – writeFile(path, str, '') behaves identically to 'utf8' in Node/Bun
188
248
  await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
189
249
  console.log('✓ COVERAGE.md')
190
250
 
191
251
  if (opts.fix) {
192
- const allSurvived = rows.flatMap(r => r.survived ?? [])
193
252
  // eslint-disable-next-line no-unsanitized/method -- шлях відносний до пакету, не user-input
194
253
  const { fixSurvivedMutants } = await import(new URL('../../../scripts/coverage-fix.mjs', import.meta.url).href)
195
254
  await fixSurvivedMutants(allSurvived, cwd)
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Застосовує verdicts до coverage rows: фільтрує survived мутантів,
3
+ * декрементує mutation.total на кількість allowed-gaps, повертає окремий
4
+ * список allowedGaps для рендеру в COVERAGE.md.
5
+ *
6
+ * Skip rule: verdict ∈ {equivalent,defensive,glue,wrapper} AND confidence ≥ threshold.
7
+ * Решта (включно з worth-testing і low-confidence skip-verdicts) залишаються в survived.
8
+ */
9
+
10
+ const SKIP_VERDICTS = new Set(['equivalent', 'defensive', 'glue', 'wrapper'])
11
+
12
+ /**
13
+ * Чи verdict кваліфікує мутанта як allowed-gap (виключити з Killable).
14
+ * @param {{verdict: string, confidence: number}} verdict verdict-об'єкт
15
+ * @param {number} threshold confidence threshold (наприклад 0.7)
16
+ * @returns {boolean} true якщо мутант — allowed gap
17
+ */
18
+ export function isAllowedGap(verdict, threshold) {
19
+ return SKIP_VERDICTS.has(verdict.verdict) && verdict.confidence >= threshold
20
+ }
21
+
22
+ /**
23
+ * Застосовує verdicts до coverage rows. Фільтрує `survived` за isAllowedGap,
24
+ * зменшує `mutation.total` на скільки мутантів стало allowed-gap.
25
+ * Не мутує вхідні дані.
26
+ * @param {Array<{area: string, coverage: object, mutation: {caught: number, total: number}, survived?: Array<{file: string, mutants: Array<object>, exampleTest?: object|null, recommendationText?: string|null}>}>} rows вхідні рядки
27
+ * @param {Array<{key: string, verdict: {verdict: string, confidence: number, reason: string}}>} verdicts класифіковані verdict-и
28
+ * @param {number} threshold confidence threshold для allowed-gap
29
+ * @returns {{rows: Array<object>, allowedGaps: Array<{file: string, mutant: object, verdict: object}>}} augmented rows + список allowed-gaps
30
+ */
31
+ export function applyVerdicts(rows, verdicts, threshold) {
32
+ const verdictByKey = new Map()
33
+ for (const { key, verdict } of verdicts) verdictByKey.set(key, verdict)
34
+
35
+ const allowedGaps = []
36
+
37
+ const augmentedRows = rows.map(row => {
38
+ const survived = row.survived ?? []
39
+ let skippedCount = 0
40
+ const remainingSurvived = []
41
+
42
+ for (const group of survived) {
43
+ const remainingMutants = []
44
+ for (const mutant of group.mutants) {
45
+ const key = `${group.file}:${mutant.line}:${mutant.col}:${mutant.replacement}`
46
+ const verdict = verdictByKey.get(key)
47
+ if (verdict && isAllowedGap(verdict, threshold)) {
48
+ allowedGaps.push({ file: group.file, mutant, verdict })
49
+ skippedCount += 1
50
+ } else {
51
+ remainingMutants.push(mutant)
52
+ }
53
+ }
54
+ if (remainingMutants.length > 0) {
55
+ remainingSurvived.push({ ...group, mutants: remainingMutants })
56
+ }
57
+ }
58
+
59
+ return {
60
+ ...row,
61
+ survived: remainingSurvived,
62
+ mutation: { ...row.mutation, total: row.mutation.total - skippedCount }
63
+ }
64
+ })
65
+
66
+ return { rows: augmentedRows, allowedGaps }
67
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * File-hash-keyed cache для coverage-classify verdicts.
3
+ *
4
+ * Cache key = `<blob-hash>:<line>:<col>:<base64url(replacement)>`.
5
+ * Blob hash рахуємо через `git hash-object <file>` (детерміновано на working tree)
6
+ * з fallback на sha1(readFile) якщо git недоступний.
7
+ *
8
+ * Cache schema:
9
+ * { version: 1, model: string|null, entries: Record<key, { verdict, confidence, reason, suggestedTest?, classifiedAt }> }
10
+ *
11
+ * Інвалідація: будь-яка зміна source → новий blob-hash → cache miss → re-classify.
12
+ */
13
+ import { execFileSync } from 'node:child_process'
14
+ import { createHash } from 'node:crypto'
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
16
+ import { dirname } from 'node:path'
17
+
18
+ const CACHE_VERSION = 1
19
+
20
+ /**
21
+ * Хеш контенту файла (sha1, 40 hex chars). Спочатку `git hash-object`,
22
+ * інакше sha1 контенту.
23
+ * @param {string} filePath абсолютний шлях до файла
24
+ * @returns {string | null} 40-char hex hash або null якщо файл недоступний
25
+ */
26
+ export function deriveBlobHash(filePath) {
27
+ if (!existsSync(filePath)) return null
28
+ try {
29
+ return execFileSync('git', ['hash-object', filePath], { encoding: 'utf8' }).trim()
30
+ } catch {
31
+ const content = readFileSync(filePath)
32
+ return createHash('sha1').update(content).digest('hex')
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Cache-ключ для конкретного мутанта в конкретному стані файла.
38
+ * @param {string} filePath абсолютний шлях до source файла
39
+ * @param {{line: number, col: number, replacement: string}} mutant параметри мутанта
40
+ * @returns {string | null} ключ або null якщо файл недоступний
41
+ */
42
+ export function deriveCacheKey(filePath, mutant) {
43
+ const blobHash = deriveBlobHash(filePath)
44
+ if (!blobHash) return null
45
+ const replacement = Buffer.from(mutant.replacement, 'utf8').toString('base64url')
46
+ return `${blobHash}:${mutant.line}:${mutant.col}:${replacement}`
47
+ }
48
+
49
+ /**
50
+ * Читає cache з диска. При будь-якій проблемі (file absent, corrupt JSON,
51
+ * schema/version mismatch, entries не object) — повертає empty cache.
52
+ * @param {string} cachePath абсолютний шлях до cache.json
53
+ * @returns {{version: number, model: string|null, entries: Record<string, object>}} cache
54
+ */
55
+ export function readCache(cachePath) {
56
+ const empty = { version: CACHE_VERSION, model: null, entries: {} }
57
+ if (!existsSync(cachePath)) return empty
58
+ try {
59
+ const data = JSON.parse(readFileSync(cachePath, 'utf8'))
60
+ if (data?.version !== CACHE_VERSION) return empty
61
+ if (!data.entries || typeof data.entries !== 'object' || Array.isArray(data.entries)) return empty
62
+ return data
63
+ } catch {
64
+ return empty
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Записує cache на диск. Створює батьківські директорії.
70
+ * @param {string} cachePath абсолютний шлях
71
+ * @param {{version: number, model: string|null, entries: Record<string, object>}} cache cache-об'єкт
72
+ * @returns {void}
73
+ */
74
+ export function writeCache(cachePath, cache) {
75
+ mkdirSync(dirname(cachePath), { recursive: true })
76
+ writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf8')
77
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Public API класифікатора: classify(survived, cwd, opts) → verdicts[]
3
+ *
4
+ * Orchestration:
5
+ * 1. Перевірка ANTHROPIC_API_KEY + dynamic import SDK (graceful skip).
6
+ * 2. Для кожного мутанта: cache lookup → класифікація → cache write.
7
+ * 3. На неуспішну класифікацію після retries — conservative fallback worth-testing/confidence=0.
8
+ *
9
+ * Prompt caching: system-prompt передається з cache_control: ephemeral —
10
+ * усі мутанти одного прогону reuse кешований префікс на стороні API.
11
+ */
12
+ import { join } from 'node:path'
13
+ import { env } from 'node:process'
14
+ import { setTimeout } from 'node:timers/promises'
15
+
16
+ import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
17
+ import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
18
+ import { parseVerdict } from './verdict-schema.mjs'
19
+
20
+ const MODEL = 'claude-sonnet-4-6'
21
+ const MAX_RETRIES = 2
22
+ const DEFAULT_RETRY_DELAY_MS = 1000
23
+
24
+ const FALLBACK_VERDICT = {
25
+ verdict: 'worth-testing',
26
+ confidence: 0,
27
+ reason: 'LLM-classification unavailable, conservative fallback (treat as worth-testing)'
28
+ }
29
+
30
+ /**
31
+ * Класифікує survived мутантів через Claude API.
32
+ * Без API key / без SDK / при критичних помилках — повертає [] (graceful skip).
33
+ * @param {Array<{file: string, mutants: Array<object>, exampleTest?: object|null, recommendationText?: string|null}>} survived список survived груп (як у COVERAGE.md)
34
+ * @param {string} cwd корінь проєкту
35
+ * @param {{cachePath?: string, client?: object, retryDelayMs?: number}} [opts] ін'єкції для тестів
36
+ * @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
37
+ */
38
+ export async function classify(survived, cwd, opts = {}) {
39
+ const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
40
+ const retryDelayMs = opts.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
41
+
42
+ if (!env.ANTHROPIC_API_KEY) {
43
+ console.warn('⚠ coverage classify: ANTHROPIC_API_KEY not set, classification skipped')
44
+ return []
45
+ }
46
+
47
+ let SDK
48
+ try {
49
+ SDK = await import('@anthropic-ai/sdk')
50
+ } catch {
51
+ console.warn('⚠ coverage classify: @anthropic-ai/sdk not installed, classification skipped')
52
+ return []
53
+ }
54
+ const Anthropic = SDK.default
55
+ const client = opts.client ?? new Anthropic()
56
+
57
+ const cache = readCache(cachePath)
58
+ if (cache.model !== MODEL) {
59
+ cache.entries = {}
60
+ cache.model = MODEL
61
+ }
62
+
63
+ const verdicts = []
64
+ for (const group of survived) {
65
+ for (const mutant of group.mutants) {
66
+ const lookupKey = `${group.file}:${mutant.line}:${mutant.col}:${mutant.replacement}`
67
+ const cacheKey = deriveCacheKey(join(cwd, group.file), mutant)
68
+
69
+ let verdict = null
70
+ if (cacheKey && cache.entries[cacheKey]) {
71
+ const cached = cache.entries[cacheKey]
72
+ verdict = {
73
+ verdict: cached.verdict,
74
+ confidence: cached.confidence,
75
+ reason: cached.reason,
76
+ ...(cached.suggestedTest ? { suggestedTest: cached.suggestedTest } : {})
77
+ }
78
+ }
79
+ if (!verdict) {
80
+ verdict = await classifyOne(client, group, mutant, cwd, retryDelayMs)
81
+ if (cacheKey) {
82
+ cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
83
+ }
84
+ }
85
+
86
+ verdicts.push({ key: lookupKey, verdict })
87
+ }
88
+ }
89
+
90
+ writeCache(cachePath, cache)
91
+ return verdicts
92
+ }
93
+
94
+ /**
95
+ * Один виклик API з retry. На фейл після MAX_RETRIES — повертає FALLBACK_VERDICT.
96
+ * @param {{messages: {create: Function}}} client SDK client
97
+ * @param {{file: string}} group group для контексту
98
+ * @param {object} mutant mutant data
99
+ * @param {string} cwd корінь
100
+ * @param {number} retryDelayMs base delay для exp-backoff (0 у тестах)
101
+ * @returns {Promise<object>} verdict (parsed або fallback)
102
+ */
103
+ async function classifyOne(client, group, mutant, cwd, retryDelayMs) {
104
+ const userPrompt = buildUserPrompt({ ...mutant, file: group.file }, cwd)
105
+ let lastError = null
106
+
107
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
108
+ try {
109
+ const response = await client.messages.create({
110
+ model: MODEL,
111
+ max_tokens: 1024,
112
+ system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
113
+ messages: [{ role: 'user', content: userPrompt }]
114
+ })
115
+ const text = response?.content?.[0]?.text ?? ''
116
+ return parseVerdict(text)
117
+ } catch (err) {
118
+ lastError = err
119
+ if (attempt < MAX_RETRIES && retryDelayMs > 0) {
120
+ await setTimeout(retryDelayMs * Math.pow(2, attempt))
121
+ }
122
+ }
123
+ }
124
+
125
+ console.warn(
126
+ `⚠ coverage classify: ${group.file}:${mutant.line}:${mutant.col} failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message ?? 'unknown'}`
127
+ )
128
+ return { ...FALLBACK_VERDICT }
129
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Промпт-builder для coverage-classify.
3
+ * SYSTEM_PROMPT — статичний, кешується через cache_control: ephemeral у API call.
4
+ * buildUserPrompt — асемблює per-mutant контекст (location, source ±10, tests, git).
5
+ */
6
+ import { execFileSync } from 'node:child_process'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { basename, dirname, join } from 'node:path'
9
+
10
+ const CONTEXT_LINES = 10
11
+ const TEST_FILE_MAX_LINES = 2000
12
+
13
+ export const SYSTEM_PROMPT = `You are a mutation testing classifier.
14
+
15
+ For each survived Stryker mutant, classify it into exactly one verdict:
16
+
17
+ - **worth-testing**: pure logic with real branches that should be tested. The mutant
18
+ exposes a missing assertion in a unit test. Recommend a test approach.
19
+ - **equivalent**: the mutated code is behaviorally indistinguishable from the original
20
+ (e.g., both branches produce the same observable output, or the mutant lies on dead
21
+ code). You MUST cite a concrete reason referencing input flow or output equivalence.
22
+ - **defensive**: the branch guards against an impossible state given input contracts
23
+ or type system. You MUST identify the invariant that makes the state unreachable.
24
+ - **glue**: thin CLI entrypoint, factory, or boilerplate (e.g., runStandardRule
25
+ wrapper, fix.mjs stubs). Integration tests via subprocess cover the behavior.
26
+ Name the integration test or pattern.
27
+ - **wrapper**: thin shell around an external tool (spawnSync, fetch, dynamic import).
28
+ The wrapper has no logic worth unit-testing in isolation; behavior comes from the
29
+ wrapped tool. Name the integration test or pattern.
30
+
31
+ Output ONLY a single JSON object matching this schema:
32
+
33
+ \`\`\`
34
+ {
35
+ "verdict": "worth-testing" | "equivalent" | "defensive" | "glue" | "wrapper",
36
+ "confidence": number 0-1,
37
+ "reason": string (20-500 chars; concrete code-level reference, not "seems like"),
38
+ "suggestedTest": string (max 300 chars; required only when verdict is worth-testing)
39
+ }
40
+ \`\`\`
41
+
42
+ Confidence guidance:
43
+ - 0.9+: cite specific code fragment, identifier, or input contract proving the verdict.
44
+ - 0.7-0.9: strong inference from visible code structure.
45
+ - <0.7: ambiguity, lacking context, or unfamiliar pattern. Be honest.
46
+
47
+ Never invent integration test names. If you cannot identify a covering test, use
48
+ worth-testing with low confidence instead of glue/wrapper.
49
+ `
50
+
51
+ /**
52
+ * Витягує describe/test/it title з рядка тексту.
53
+ * @param {string} content повний текст test-файла
54
+ * @returns {string} список "describe: <title>" / "test: <title>" або порожній
55
+ */
56
+ function extractTestTitles(content) {
57
+ const titles = []
58
+ for (const match of content.matchAll(/^\s*(describe|test|it)\(['"`](.+?)['"`]/gmu)) {
59
+ titles.push(`${match[1]}: ${match[2]}`)
60
+ }
61
+ return titles.join('\n') || '(no describe/test blocks found)'
62
+ }
63
+
64
+ /**
65
+ * Будує користувацький промпт для класифікації одного мутанта.
66
+ * @param {{file: string, line: number, col: number, mutantType: string, original: string, replacement: string}} mutant параметри мутанта (file — відносний до cwd)
67
+ * @param {string} cwd корінь проєкту
68
+ * @returns {string} user prompt
69
+ */
70
+ export function buildUserPrompt(mutant, cwd) {
71
+ const absPath = join(cwd, mutant.file)
72
+
73
+ // Source context
74
+ let srcContext = '(source file unavailable)'
75
+ if (existsSync(absPath)) {
76
+ const lines = readFileSync(absPath, 'utf8').split('\n')
77
+ const start = Math.max(0, mutant.line - 1 - CONTEXT_LINES)
78
+ const end = Math.min(lines.length, mutant.line + CONTEXT_LINES)
79
+ srcContext = lines
80
+ .slice(start, end)
81
+ .map((l, i) => `${start + i + 1}: ${l}`)
82
+ .join('\n')
83
+ }
84
+
85
+ // Existing tests
86
+ const testPath = join(dirname(absPath), 'tests', `${basename(absPath, '.mjs')}.test.mjs`)
87
+ let existingTests = '(no test file)'
88
+ if (existsSync(testPath)) {
89
+ const content = readFileSync(testPath, 'utf8')
90
+ if (content.split('\n').length > TEST_FILE_MAX_LINES) {
91
+ existingTests = extractTestTitles(content)
92
+ } else {
93
+ existingTests = content
94
+ }
95
+ }
96
+
97
+ // Recent git activity (graceful если нет git або untracked)
98
+ let recentActivity = '(no git history)'
99
+ try {
100
+ const out = execFileSync('git', ['log', '-1', '--format=%ar', '--', absPath], {
101
+ cwd,
102
+ encoding: 'utf8',
103
+ stdio: ['ignore', 'pipe', 'ignore']
104
+ }).trim()
105
+ if (out) recentActivity = out
106
+ } catch {
107
+ // git unavailable or file untracked — keep placeholder
108
+ }
109
+
110
+ return `# Mutant
111
+ File: ${mutant.file}
112
+ Line: ${mutant.line}:${mutant.col}
113
+ Type: ${mutant.mutantType}
114
+ Original code: \`${mutant.original}\`
115
+ Mutated to: \`${mutant.replacement}\`
116
+
117
+ # Source context (±${CONTEXT_LINES} lines)
118
+ ${srcContext}
119
+
120
+ # Existing tests
121
+ ${existingTests}
122
+
123
+ # Recent activity
124
+ File last modified: ${recentActivity}
125
+ `
126
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Zod-схема для verdict-відповіді LLM-класифікатора (coverage-classify).
3
+ * parseVerdict — витяг JSON з raw-text LLM-відповіді + validate.
4
+ *
5
+ * Категорії:
6
+ * - worth-testing: pure logic, real branches — пиши тест
7
+ * - equivalent: мутант поведінково еквівалентний (не killable)
8
+ * - defensive: гілка для impossible state (не killable)
9
+ * - glue: CLI entry / runStandardRule wrapper (integration covers)
10
+ * - wrapper: тонкий spawn/fetch wrapper (integration covers)
11
+ */
12
+ import { z } from 'zod'
13
+
14
+ export const VerdictSchema = z.object({
15
+ verdict: z.enum(['worth-testing', 'equivalent', 'defensive', 'glue', 'wrapper']),
16
+ confidence: z.number().min(0).max(1),
17
+ reason: z.string().min(20).max(500),
18
+ suggestedTest: z.string().max(300).optional()
19
+ })
20
+
21
+ /**
22
+ * Витягує JSON-об'єкт з raw-text LLM-відповіді і валідує через VerdictSchema.
23
+ * @param {string} rawText raw-text відповідь LLM
24
+ * @returns {{verdict: string, confidence: number, reason: string, suggestedTest?: string}} verdict
25
+ * @throws якщо JSON не знайдено, не парситься, або не відповідає схемі
26
+ */
27
+ export function parseVerdict(rawText) {
28
+ const jsonStart = rawText.indexOf('{')
29
+ const jsonEnd = rawText.lastIndexOf('}')
30
+ if (jsonStart < 0 || jsonEnd < 0) {
31
+ throw new Error('No JSON object found in LLM response')
32
+ }
33
+ const json = JSON.parse(rawText.slice(jsonStart, jsonEnd + 1))
34
+ return VerdictSchema.parse(json)
35
+ }