@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.
@@ -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
- rows.push(buildTotalsRow(rows))
184
- const md = renderMarkdown(rows)
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 += 1) {
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) >>> 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
+ }