@nitra/cursor 1.34.1 → 1.35.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.
- package/CHANGELOG.md +12 -0
- package/package.json +4 -3
- package/rules/changelog/changelog.mdc +10 -13
- package/rules/changelog/js/consistency.mjs +54 -102
- package/rules/test/coverage/coverage.mjs +64 -5
- package/scripts/coverage-classify/apply.mjs +67 -0
- package/scripts/coverage-classify/cache.mjs +77 -0
- package/scripts/coverage-classify/index.mjs +129 -0
- package/scripts/coverage-classify/prompt.mjs +126 -0
- package/scripts/coverage-classify/verdict-schema.mjs +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.35.1] - 2026-05-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Нові тести для підвищення 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%.
|
|
8
|
+
|
|
9
|
+
## [1.35.0] - 2026-05-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 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).
|
|
14
|
+
|
|
3
15
|
## [1.34.1] - 2026-05-30
|
|
4
16
|
|
|
5
17
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.1",
|
|
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.
|
|
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.
|
|
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.
|
|
13
|
-
3. **`npx @nitra/cursor fix changelog`** → exit **`0`** (
|
|
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
|
-
**Інверсія (
|
|
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
|
-
**Інверсія (за замовчуванням не вимагають
|
|
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
|
-
**Вимагають
|
|
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):
|
|
61
|
-
2. **Версії збігаються**, але в git є **релевантні** зміни без
|
|
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.
|
|
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
|
-
* Перевіряє, що
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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`
|
|
10
|
-
*
|
|
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` (
|
|
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}
|
|
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,
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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} —
|
|
506
|
-
await
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
return
|
|
529
|
-
}
|
|
483
|
+
// Drift version від бази має пріоритет над change-файлом: ручний bump заборонено
|
|
484
|
+
// навіть якщо change-файл присутній (симетрично з published-шляхом).
|
|
530
485
|
const Vbase = await readBaseVersion(comparisonRef, manifest, cwd)
|
|
531
|
-
if (Vbase
|
|
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}: у
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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 (
|
|
186
|
-
|
|
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
|
+
}
|