@nitra/cursor 1.32.0 → 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 +41 -0
- package/bin/n-cursor.js +13 -1
- package/github-actions/release/action.yml +9 -0
- package/package.json +4 -3
- package/rules/changelog/changelog.mdc +11 -12
- package/rules/changelog/js/consistency.mjs +70 -98
- package/rules/nginx-default-tpl/js/template.mjs +1 -0
- package/rules/release/change.mjs +57 -0
- package/rules/release/fix.mjs +17 -0
- package/rules/release/lib/aggregate.mjs +82 -0
- package/rules/release/lib/change-file.mjs +99 -0
- package/rules/release/lib/fallback.mjs +48 -0
- package/rules/release/release.mjs +135 -0
- package/rules/test/coverage/coverage.mjs +66 -5
- package/rules/test/js/no-relative-fs-path.mjs +3 -3
- 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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Один change-файл `<ws>/.changes/<timestamp>-<rand>.md`: YAML-подібний frontmatter
|
|
3
|
+
* із двома ключами (`bump`, `section`) + текст опису. Парсер мінімальний — лише ці два
|
|
4
|
+
* ключі, без зовнішніх залежностей.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomBytes } from 'node:crypto'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** Дозволені semver-бампи, від найбільшого до найменшого (порядок використовується для max). */
|
|
13
|
+
export const VALID_BUMPS = Object.freeze(['major', 'minor', 'patch'])
|
|
14
|
+
|
|
15
|
+
/** Дозволені Keep a Changelog секції (заголовок `### {section}`). */
|
|
16
|
+
export const VALID_SECTIONS = Object.freeze(['Added', 'Changed', 'Fixed', 'Removed'])
|
|
17
|
+
|
|
18
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} block тіло frontmatter (між `---`)
|
|
22
|
+
* @returns {Record<string, string>} пари ключ→значення
|
|
23
|
+
*/
|
|
24
|
+
function parseFrontmatterBlock(block) {
|
|
25
|
+
/** @type {Record<string, string>} */
|
|
26
|
+
const out = {}
|
|
27
|
+
for (const line of block.split('\n')) {
|
|
28
|
+
const idx = line.indexOf(':')
|
|
29
|
+
if (idx === -1) continue
|
|
30
|
+
out[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} text вміст change-файлу
|
|
37
|
+
* @returns {{ bump: string, section: string, description: string }} розпарсений запис
|
|
38
|
+
*/
|
|
39
|
+
export function parseChangeFile(text) {
|
|
40
|
+
const m = FRONTMATTER_RE.exec(text)
|
|
41
|
+
if (!m) throw new Error('change-файл: відсутній frontmatter `---`')
|
|
42
|
+
const fm = parseFrontmatterBlock(m[1])
|
|
43
|
+
const description = m[2].trim()
|
|
44
|
+
if (!VALID_BUMPS.includes(fm.bump)) {
|
|
45
|
+
throw new Error(`change-файл: bump має бути одним із ${VALID_BUMPS.join('|')} (отримано «${fm.bump ?? ''}»)`)
|
|
46
|
+
}
|
|
47
|
+
if (!VALID_SECTIONS.includes(fm.section)) {
|
|
48
|
+
throw new Error(`change-файл: section має бути одним із ${VALID_SECTIONS.join('|')} (отримано «${fm.section ?? ''}»)`)
|
|
49
|
+
}
|
|
50
|
+
if (!description) throw new Error('change-файл: порожній опис')
|
|
51
|
+
return { bump: fm.bump, section: fm.section, description }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {{ bump: string, section: string, description: string }} entry запис
|
|
56
|
+
* @returns {string} вміст change-файлу
|
|
57
|
+
*/
|
|
58
|
+
export function serializeChangeFile(entry) {
|
|
59
|
+
return `---\nbump: ${entry.bump}\nsection: ${entry.section}\n---\n${entry.description}\n`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Підкаталог зі change-файлами всередині workspace. */
|
|
63
|
+
export const CHANGES_DIR = '.changes'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {number} timestamp `Date.now()`
|
|
67
|
+
* @param {string} suffix короткий випадковий суфікс (hex)
|
|
68
|
+
* @returns {string} `<timestamp>-<suffix>.md`
|
|
69
|
+
*/
|
|
70
|
+
export function changeFileName(timestamp, suffix) {
|
|
71
|
+
return `${timestamp}-${suffix}.md`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Унікальне ім'я для нового change-файлу: timestamp (порядок) + rand (анти-колізія
|
|
76
|
+
* для паралельних агентів у різних worktree, що пишуть у ту саму мілісекунду).
|
|
77
|
+
* @returns {string} результат
|
|
78
|
+
*/
|
|
79
|
+
export function newChangeFileName() {
|
|
80
|
+
return changeFileName(Date.now(), randomBytes(3).toString('hex'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} ws шлях workspace (відносно `cwd`)
|
|
85
|
+
* @param {string} [cwd] корінь репозиторію
|
|
86
|
+
* @returns {Promise<Array<{ file: string, entry: { bump: string, section: string, description: string } }>>} розпарсені change-файли
|
|
87
|
+
*/
|
|
88
|
+
export async function readChangeFiles(ws, cwd = process.cwd()) {
|
|
89
|
+
const dir = join(cwd, ws, CHANGES_DIR)
|
|
90
|
+
if (!existsSync(dir)) return []
|
|
91
|
+
const entries = await readdir(dir)
|
|
92
|
+
const names = entries.filter(n => n.endsWith('.md')).toSorted()
|
|
93
|
+
const result = []
|
|
94
|
+
for (const file of names) {
|
|
95
|
+
const text = await readFile(join(dir, file), 'utf8')
|
|
96
|
+
result.push({ file, entry: parseChangeFile(text) })
|
|
97
|
+
}
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback (n-cursor-release-design рішення 3): коли в workspace є релевантні зміни,
|
|
3
|
+
* але жодного change-файлу — синтезуємо один запис із commit-subjects від останнього
|
|
4
|
+
* релізного тегу `<name>@*`. Усі git-виклики через `runGit` (ін'єкція для тестів).
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} cwd робочий каталог
|
|
13
|
+
* @returns {(args: string[]) => Promise<string | null>} тихий git-раннер (null при помилці)
|
|
14
|
+
*/
|
|
15
|
+
export function defaultRunGit(cwd) {
|
|
16
|
+
return async args => {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execFileAsync('git', args, { cwd })
|
|
19
|
+
return stdout
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} name ім'я пакета (для тегу `<name>@*`)
|
|
28
|
+
* @param {string} ws workspace (pathspec для `git log`; `.` → без обмеження шляху)
|
|
29
|
+
* @param {object} [opts] опції
|
|
30
|
+
* @param {(args: string[]) => Promise<string | null>} [opts.runGit] git-раннер
|
|
31
|
+
* @returns {Promise<{ bump: string, section: string, description: string } | null>} синтезований запис або null
|
|
32
|
+
*/
|
|
33
|
+
export async function synthesizeChangeFromCommits(name, ws, opts = {}) {
|
|
34
|
+
const runGit = opts.runGit ?? defaultRunGit(process.cwd())
|
|
35
|
+
const lastTagRaw = await runGit(['describe', '--tags', '--abbrev=0', '--match', `${name}@*`, 'HEAD'])
|
|
36
|
+
const lastTag = lastTagRaw?.trim()
|
|
37
|
+
// Bootstrap: якщо жодного попереднього тегу немає — перший реліз зроблено вручну;
|
|
38
|
+
// fallback-синтез не запускаємо, щоб не подвоїти bump.
|
|
39
|
+
if (!lastTag) return null
|
|
40
|
+
const pathspec = ws === '.' ? [] : ['--', `${ws}/`]
|
|
41
|
+
const logRaw = await runGit(['log', '--no-merges', '--format=%s', `${lastTag}..HEAD`, ...pathspec])
|
|
42
|
+
const subjects = (logRaw ?? '')
|
|
43
|
+
.split('\n')
|
|
44
|
+
.map(s => s.trim())
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
if (subjects.length === 0) return null
|
|
47
|
+
return { bump: 'patch', section: 'Changed', description: subjects.join('; ') }
|
|
48
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `n-cursor release` — агрегує per-workspace change-файли у version-bump + CHANGELOG,
|
|
3
|
+
* комітить, ставить тег `<name>@<version>`, видаляє use-up change-файли. Запускається
|
|
4
|
+
* у CI на `main` (n-cursor-release-design, варіант A). Сам нічого не публікує.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { readFile, rm, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
import { getMonorepoProjectRootDirs, readPackageManifest } from '../changelog/lib/package-manifest.mjs'
|
|
11
|
+
import { aggregateWorkspace, prependChangelogSection } from './lib/aggregate.mjs'
|
|
12
|
+
import { CHANGES_DIR, readChangeFiles } from './lib/change-file.mjs'
|
|
13
|
+
import { defaultRunGit, synthesizeChangeFromCommits } from './lib/fallback.mjs'
|
|
14
|
+
|
|
15
|
+
const SEMVER_LINE_RE = /("version"\s*:\s*")[^"]*(")/
|
|
16
|
+
const PY_VERSION_LINE_RE = /^(version\s*=\s*")[^"]*(")/m
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Записує нову version у маніфест, зберігаючи форматування файлу.
|
|
20
|
+
* @param {string} cwd корінь
|
|
21
|
+
* @param {import('../changelog/lib/package-manifest.mjs').PackageManifest} manifest маніфест
|
|
22
|
+
* @param {string} newVersion нова версія
|
|
23
|
+
* @returns {Promise<void>} результат
|
|
24
|
+
*/
|
|
25
|
+
async function writeManifestVersion(cwd, manifest, newVersion) {
|
|
26
|
+
const path = join(cwd, manifest.ws === '.' ? manifest.manifestRel : `${manifest.ws}/${manifest.manifestRel}`)
|
|
27
|
+
const text = await readFile(path, 'utf8')
|
|
28
|
+
const re = manifest.kind === 'npm' ? SEMVER_LINE_RE : PY_VERSION_LINE_RE
|
|
29
|
+
const replaced = text.replace(re, `$1${newVersion}$2`)
|
|
30
|
+
if (replaced === text) {
|
|
31
|
+
throw new Error(`release: не вдалося оновити version у ${manifest.ws}/${manifest.manifestRel} — патерн version не знайдено`)
|
|
32
|
+
}
|
|
33
|
+
await writeFile(path, replaced)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} cwd корінь
|
|
38
|
+
* @param {string} ws workspace
|
|
39
|
+
* @param {string} sectionBlock новий блок CHANGELOG
|
|
40
|
+
* @returns {Promise<void>} результат
|
|
41
|
+
*/
|
|
42
|
+
async function prependWorkspaceChangelog(cwd, ws, sectionBlock) {
|
|
43
|
+
const path = join(cwd, ws, 'CHANGELOG.md')
|
|
44
|
+
const existing = existsSync(path) ? await readFile(path, 'utf8') : ''
|
|
45
|
+
await writeFile(path, prependChangelogSection(existing, sectionBlock))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Зібрати change-файли workspace (явні + fallback-синтез, якщо явних нема, але є коміти).
|
|
50
|
+
* @param {string} cwd корінь
|
|
51
|
+
* @param {import('../changelog/lib/package-manifest.mjs').PackageManifest} manifest маніфест
|
|
52
|
+
* @param {(args: string[]) => Promise<string | null>} runGit git-раннер
|
|
53
|
+
* @returns {Promise<Array<{ file: string | null, entry: { bump: string, section: string, description: string } }>>} change-файли
|
|
54
|
+
*/
|
|
55
|
+
async function collectChangeFiles(cwd, manifest, runGit) {
|
|
56
|
+
const explicit = await readChangeFiles(manifest.ws, cwd)
|
|
57
|
+
if (explicit.length > 0) return explicit
|
|
58
|
+
if (!manifest.name) return []
|
|
59
|
+
const synthesized = await synthesizeChangeFromCommits(manifest.name, manifest.ws, { runGit })
|
|
60
|
+
if (!synthesized) return []
|
|
61
|
+
console.warn(`⚠️ ${manifest.ws}: немає change-файлів — синтезовано запис із комітів (fallback)`)
|
|
62
|
+
return [{ file: null, entry: synthesized }]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} [opts] опції
|
|
67
|
+
* @param {string} [opts.cwd] корінь
|
|
68
|
+
* @param {string} [opts.date] `YYYY-MM-DD` (за замовчуванням сьогодні)
|
|
69
|
+
* @param {(args: string[]) => Promise<string | null>} [opts.runGit] git-раннер
|
|
70
|
+
* @returns {Promise<Array<{ ws: string, name: string | null, newVersion: string }>>} зрелізовані пакети
|
|
71
|
+
*/
|
|
72
|
+
export async function release(opts = {}) {
|
|
73
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
74
|
+
const date = opts.date ?? new Date().toISOString().slice(0, 10)
|
|
75
|
+
const runGit = opts.runGit ?? defaultRunGit(cwd)
|
|
76
|
+
|
|
77
|
+
const workspaces = await getMonorepoProjectRootDirs(cwd)
|
|
78
|
+
const subWorkspaces = workspaces.filter(w => w !== '.')
|
|
79
|
+
const isMonorepoRoot = subWorkspaces.length > 0
|
|
80
|
+
|
|
81
|
+
/** @type {Array<{ ws: string, name: string | null, newVersion: string }>} */
|
|
82
|
+
const released = []
|
|
83
|
+
const tags = []
|
|
84
|
+
|
|
85
|
+
for (const ws of workspaces) {
|
|
86
|
+
if (ws === '.' && isMonorepoRoot) continue
|
|
87
|
+
const manifest = await readPackageManifest(ws, cwd)
|
|
88
|
+
if (!manifest || !manifest.version) continue
|
|
89
|
+
|
|
90
|
+
const changeFiles = await collectChangeFiles(cwd, manifest, runGit)
|
|
91
|
+
const agg = aggregateWorkspace({ currentVersion: manifest.version, changeFiles, date })
|
|
92
|
+
if (!agg) continue
|
|
93
|
+
|
|
94
|
+
await writeManifestVersion(cwd, manifest, agg.newVersion)
|
|
95
|
+
await prependWorkspaceChangelog(cwd, ws, agg.sectionBlock)
|
|
96
|
+
for (const file of agg.consumedFiles.filter(Boolean)) {
|
|
97
|
+
await rm(join(cwd, ws, CHANGES_DIR, file))
|
|
98
|
+
}
|
|
99
|
+
released.push({ ws, name: manifest.name, newVersion: agg.newVersion })
|
|
100
|
+
if (manifest.name) tags.push(`${manifest.name}@${agg.newVersion}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (released.length > 0) {
|
|
104
|
+
const subject = tags.length > 0 ? tags.join(', ') : released.map(r => `${r.ws}@${r.newVersion}`).join(', ')
|
|
105
|
+
await runGit(['add', '-A'])
|
|
106
|
+
const committed = await runGit(['commit', '-m', `release: ${subject}`])
|
|
107
|
+
if (committed === null) {
|
|
108
|
+
throw new Error('release: git commit не вдався — теги та push скасовано')
|
|
109
|
+
}
|
|
110
|
+
for (const tag of tags) {
|
|
111
|
+
await runGit(['tag', tag])
|
|
112
|
+
}
|
|
113
|
+
await runGit(['push', '--follow-tags'])
|
|
114
|
+
}
|
|
115
|
+
return released
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string[]} _args аргументи CLI (наразі без опцій)
|
|
120
|
+
* @returns {Promise<number>} exit-код
|
|
121
|
+
*/
|
|
122
|
+
export async function runReleaseCli(_args) {
|
|
123
|
+
try {
|
|
124
|
+
const released = await release()
|
|
125
|
+
if (released.length === 0) {
|
|
126
|
+
console.log('release: немає змін для релізу')
|
|
127
|
+
} else {
|
|
128
|
+
for (const r of released) console.log(`✅ ${r.name ?? r.ws}@${r.newVersion}`)
|
|
129
|
+
}
|
|
130
|
+
return 0
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error(`❌ ${error instanceof Error ? error.message : String(error)}`)
|
|
133
|
+
return 1
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -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,14 +224,31 @@ export async function runCoverageSteps(opts = {}) {
|
|
|
180
224
|
return 1
|
|
181
225
|
}
|
|
182
226
|
|
|
183
|
-
|
|
184
|
-
const
|
|
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
|
+
|
|
241
|
+
// Підсумок «Разом» має сенс лише коли провайдерів ≥2; для єдиного рядка він
|
|
242
|
+
// дублює його значення, тож не додаємо.
|
|
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)
|
|
185
247
|
// Stryker disable next-line StringLiteral: equivalent – writeFile(path, str, '') behaves identically to 'utf8' in Node/Bun
|
|
186
248
|
await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
|
|
187
249
|
console.log('✓ COVERAGE.md')
|
|
188
250
|
|
|
189
251
|
if (opts.fix) {
|
|
190
|
-
const allSurvived = rows.flatMap(r => r.survived ?? [])
|
|
191
252
|
// eslint-disable-next-line no-unsanitized/method -- шлях відносний до пакету, не user-input
|
|
192
253
|
const { fixSurvivedMutants } = await import(new URL('../../../scripts/coverage-fix.mjs', import.meta.url).href)
|
|
193
254
|
await fixSurvivedMutants(allSurvived, cwd)
|
|
@@ -145,7 +145,7 @@ function extractFsFunctionName(callee) {
|
|
|
145
145
|
/**
|
|
146
146
|
* Чи файл — JS-тест (`*.test.mjs` / `*.test.js`).
|
|
147
147
|
* @param {string} absPath абсолютний шлях
|
|
148
|
-
* @returns {boolean}
|
|
148
|
+
* @returns {boolean} true якщо файл є тестом
|
|
149
149
|
*/
|
|
150
150
|
function isTestFile(absPath) {
|
|
151
151
|
const name = basename(absPath)
|
|
@@ -187,7 +187,7 @@ function findOffendersInBody(body) {
|
|
|
187
187
|
*/
|
|
188
188
|
function computeLineOffsets(body) {
|
|
189
189
|
const offsets = [0]
|
|
190
|
-
for (let i = 0; i < body.length; i
|
|
190
|
+
for (let i = 0; i < body.length; i++) {
|
|
191
191
|
if (body[i] === '\n') offsets.push(i + 1)
|
|
192
192
|
}
|
|
193
193
|
return offsets
|
|
@@ -202,7 +202,7 @@ function offsetToLineFromCache(offsets, offset) {
|
|
|
202
202
|
let lo = 0
|
|
203
203
|
let hi = offsets.length - 1
|
|
204
204
|
while (lo < hi) {
|
|
205
|
-
const mid = (lo + hi + 1)
|
|
205
|
+
const mid = Math.floor((lo + hi + 1) / 2)
|
|
206
206
|
if (offsets[mid] <= offset) lo = mid
|
|
207
207
|
else hi = mid - 1
|
|
208
208
|
}
|
|
@@ -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
|
+
}
|