@nitra/cursor 12.11.0 → 12.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/n-cursor.js +9 -27
  3. package/package.json +1 -1
  4. package/rules/adr/js/docs/hooks.md +0 -2
  5. package/rules/bun/js/docs/fix-layout.md +25 -0
  6. package/rules/bun/js/fix-layout.mjs +55 -0
  7. package/rules/changelog/js/docs/consistency.md +11 -13
  8. package/rules/changelog/js/docs/fix-consistency.md +27 -0
  9. package/rules/changelog/js/docs/index.md +2 -2
  10. package/rules/changelog/js/fix-consistency.mjs +50 -0
  11. package/rules/ci4/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
  12. package/rules/ci4/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  13. package/rules/ga/policy/vscode_extensions/docs/fix-vscode_extensions.md +22 -0
  14. package/rules/ga/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  15. package/rules/ga/policy/workflow_common/workflow_common.rego +15 -0
  16. package/rules/graphql/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
  17. package/rules/graphql/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  18. package/rules/js/js/docs/dep-policy.md +12 -10
  19. package/rules/js/policy/vscode_extensions/docs/fix-vscode_extensions.md +22 -0
  20. package/rules/js/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  21. package/rules/js-run/js/docs/fix-runtime.md +25 -0
  22. package/rules/js-run/js/fix-runtime.mjs +41 -0
  23. package/rules/k8s/policy/lint_k8s_yml/lint_k8s_yml.rego +57 -0
  24. package/rules/k8s/policy/lint_k8s_yml/target.json +4 -0
  25. package/rules/k8s/policy/lint_k8s_yml/template/lint-k8s.yml.snippet.yml +43 -0
  26. package/rules/nginx-default-tpl/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
  27. package/rules/nginx-default-tpl/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  28. package/rules/rego/policy/vscode_extensions/docs/fix-vscode_extensions.md +22 -0
  29. package/rules/rego/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  30. package/rules/rust/policy/vscode_extensions/docs/fix-vscode_extensions.md +22 -0
  31. package/rules/rust/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  32. package/rules/style/js/docs/fix-tooling.md +29 -0
  33. package/rules/style/js/fix-tooling.mjs +46 -0
  34. package/rules/style/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
  35. package/rules/style/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  36. package/rules/tauri/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
  37. package/rules/tauri/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  38. package/rules/text/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
  39. package/rules/text/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
  40. package/rules/vue/js/docs/packages.md +0 -2
  41. package/scripts/docs/index.md +0 -2
  42. package/scripts/lib/discover-checkable-rules.mjs +1 -0
  43. package/scripts/lib/docs/discover-checkable-rules.md +13 -155
  44. package/scripts/lib/fix/discover-t0-patterns.mjs +83 -0
  45. package/scripts/lib/fix/docs/discover-t0-patterns.md +37 -0
  46. package/scripts/lib/fix/docs/llm-fix-apply.md +12 -10
  47. package/scripts/lib/fix/docs/llm-worker.md +6 -14
  48. package/scripts/lib/fix/docs/orchestrator.md +0 -2
  49. package/scripts/lib/fix/docs/t0.md +11 -10
  50. package/scripts/lib/fix/docs/vscode-ext-add.md +29 -0
  51. package/scripts/lib/fix/llm-fix-apply.mjs +34 -3
  52. package/scripts/lib/fix/llm-worker.mjs +24 -15
  53. package/scripts/lib/fix/t0.mjs +8 -119
  54. package/scripts/lib/fix/vscode-ext-add.mjs +45 -0
  55. package/rules/test/coverage/coverage.mjs +0 -317
  56. package/scripts/coverage-classify/apply.mjs +0 -67
  57. package/scripts/coverage-classify/cache.mjs +0 -77
  58. package/scripts/coverage-classify/docs/apply.md +0 -206
  59. package/scripts/coverage-classify/docs/cache.md +0 -207
  60. package/scripts/coverage-classify/docs/index.md +0 -14
  61. package/scripts/coverage-classify/docs/prompt.md +0 -136
  62. package/scripts/coverage-classify/docs/verdict-schema.md +0 -28
  63. package/scripts/coverage-classify/index.mjs +0 -114
  64. package/scripts/coverage-classify/prompt.mjs +0 -126
  65. package/scripts/coverage-classify/verdict-schema.mjs +0 -35
  66. package/scripts/coverage-fix-extract.mjs +0 -122
  67. package/scripts/coverage-fix.mjs +0 -119
  68. package/scripts/docs/coverage-fix-extract.md +0 -36
  69. package/scripts/docs/coverage-fix.md +0 -181
  70. package/skills/coverage-fix/SKILL.md +0 -131
  71. package/skills/coverage-fix/main.json +0 -1
@@ -1,317 +0,0 @@
1
- /**
2
- * Канонічна команда `n-cursor coverage`: збирає метрики покриття + мутаційного
3
- * тестування з усіх провайдерів, чиє правило активне в `.n-cursor.json#rules`,
4
- * агрегує та записує COVERAGE.md у корінь проєкту.
5
- *
6
- * Discovery провайдерів — за `.n-cursor.json#rules`: для кожного `ruleId` зі
7
- * списку шукаємо `npm/rules/<ruleId>/coverage/coverage.mjs` і динамічно
8
- * імпортуємо. Якщо файлу немає — провайдер для цього правила відсутній (skip
9
- * silently, не помилка).
10
- *
11
- * Лок — прямий виклик `withLock('coverage', steps)`. Один CLI-консумер, один
12
- * callsite — спільна точка входу не виноситься (YAGNI, див. C4 у
13
- * specs/2026-05-24-coverage-rule-design.md).
14
- */
15
- import { existsSync } from 'node:fs'
16
- import { readFile, writeFile } from 'node:fs/promises'
17
- import { dirname, join } from 'node:path'
18
- import { fileURLToPath, pathToFileURL } from 'node:url'
19
-
20
- import { applyVerdicts } from '../../../scripts/coverage-classify/apply.mjs'
21
- import { classify } from '../../../scripts/coverage-classify/index.mjs'
22
- import { collectChangedFilesSince, resolveChangedBase } from '../../../scripts/lib/changed-files.mjs'
23
- import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
24
- import { withLock } from '../../../scripts/utils/with-lock.mjs'
25
-
26
- /** Корінь `npm/rules/` — `<rules>/test/coverage` → `<rules>` */
27
- const RULES_DIR = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
28
-
29
- /**
30
- * Сума двох coverage-totals.
31
- * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший subtotal
32
- * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий subtotal
33
- * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сумарні lines/functions
34
- */
35
- export function addCoverage(a, b) {
36
- return {
37
- lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
38
- functions: {
39
- covered: a.functions.covered + b.functions.covered,
40
- total: a.functions.total + b.functions.total
41
- }
42
- }
43
- }
44
-
45
- /**
46
- * Сума двох mutation-counts.
47
- * @param {{caught:number,total:number}} a перший subtotal
48
- * @param {{caught:number,total:number}} b другий subtotal
49
- * @returns {{caught:number,total:number}} сумарні caught/total
50
- */
51
- export function addMutation(a, b) {
52
- return { caught: a.caught + b.caught, total: a.total + b.total }
53
- }
54
-
55
- /**
56
- * Форматує covered/total як `XX.XX% (covered/total)`.
57
- * @param {{covered:number,total:number}} metric метрика lines або functions
58
- * @returns {string} відформатований рядок для таблиці COVERAGE.md
59
- */
60
- export function formatCoverage({ covered, total }) {
61
- const percent = total === 0 ? '—' : `${((covered / total) * 100).toFixed(2)}%`
62
- return `${percent} (${covered}/${total})`
63
- }
64
-
65
- /**
66
- * Форматує мутаційний score як `XX.XX%`.
67
- * @param {{caught:number,total:number}} metric агрегований mutation score
68
- * @returns {string} відформатований score або прочерк
69
- */
70
- export function formatScore({ caught, total }) {
71
- return total === 0 ? '—' : `${((caught / total) * 100).toFixed(2)}%`
72
- }
73
-
74
- /**
75
- * Рендерить таблицю покриття + мутаційного тестування як Markdown.
76
- * Якщо будь-який рядок містить непустий `survived`, додає секцію
77
- * `## Вцілілі мутанти` з JSON-блоком для `/n-coverage-fix`.
78
- * Якщо `allowedGaps` непустий, додає секцію `## Allowed gaps` з таблицею
79
- * verdict/confidence/reason для кожного LLM-класифікованого мутанта.
80
- * Без timestamp, щоб git diff рухався лише при зміні метрик.
81
- * @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 рядки провайдерів
82
- * @param {Array<{file:string, mutant:{line:number,col:number,mutantType:string,original:string,replacement:string}, verdict:{verdict:string,confidence:number,reason:string}}>} [allowedGaps] мутанти виключені класифікатором
83
- * @returns {string} Markdown з заголовком `# Coverage`
84
- */
85
- export function renderMarkdown(rows, allowedGaps = []) {
86
- const lines = [
87
- '# Coverage',
88
- '',
89
- '| Область | Рядки | Функції | Вбито мутацій | Score |',
90
- '| --- | --- | --- | --- | --- |'
91
- ]
92
- for (const row of rows) {
93
- lines.push(
94
- `| ${row.area} | ${formatCoverage(row.coverage.lines)} | ${formatCoverage(row.coverage.functions)} | ` +
95
- `${row.mutation.caught}/${row.mutation.total} | ${formatScore(row.mutation)} |`
96
- )
97
- }
98
-
99
- const allSurvived = rows.flatMap(r => r.survived ?? [])
100
- if (allSurvived.length > 0) {
101
- lines.push('', '## Вцілілі мутанти', '', '```json', JSON.stringify(allSurvived, null, 2), '```')
102
- // Зрозуміла для людини таблиця
103
- for (const group of allSurvived) {
104
- lines.push('', `### ${group.file}`, '', '| Рядок | Оригінал | Заміна | Тип |', '| --- | --- | --- | --- |')
105
- for (const m of group.mutants) {
106
- lines.push(`| ${m.line} | \`${m.original}\` | \`${m.replacement}\` | ${m.mutantType} |`)
107
- }
108
- if (group.exampleTest) {
109
- lines.push(
110
- '',
111
- `**Приклад тесту** (\`${group.exampleTest.testFile}\`):`,
112
- '',
113
- '```js',
114
- group.exampleTest.code ?? '',
115
- '```'
116
- )
117
- }
118
- if (group.recommendationText) {
119
- lines.push('', '**Що треба протестувати:**', '', group.recommendationText)
120
- }
121
- }
122
- }
123
-
124
- if (allowedGaps.length > 0) {
125
- // Group allowed gaps by file
126
- const gapsByFile = new Map()
127
- for (const gap of allowedGaps) {
128
- if (!gapsByFile.has(gap.file)) gapsByFile.set(gap.file, [])
129
- gapsByFile.get(gap.file).push(gap)
130
- }
131
-
132
- lines.push(
133
- '',
134
- '## Allowed gaps',
135
- '',
136
- `> LLM-класифікатор виключив ${allowedGaps.length} survived мутант(ів) зі знаменника mutation score.`,
137
- '> Категорії: equivalent (поведінково еквівалентний), defensive (impossible state), glue/wrapper (integration test покриває).'
138
- )
139
-
140
- for (const [file, gaps] of gapsByFile) {
141
- lines.push(
142
- '',
143
- `### ${file}`,
144
- '',
145
- '| Line | Mutant | Verdict | Confidence | Reason |',
146
- '| --- | --- | --- | --- | --- |'
147
- )
148
- for (const { mutant, verdict } of gaps) {
149
- const sanitizedReason = verdict.reason.replaceAll('|', String.raw`\|`).replaceAll('\n', ' ')
150
- lines.push(
151
- `| ${mutant.line} | \`${mutant.original}\` → \`${mutant.replacement}\` | ${verdict.verdict} | ${verdict.confidence.toFixed(2)} | ${sanitizedReason} |`
152
- )
153
- }
154
- }
155
- }
156
-
157
- return `${lines.join('\n')}\n`
158
- }
159
-
160
- /**
161
- * Завантажує provider-модуль з `<rulesDir>/<ruleId>/coverage/coverage.mjs`.
162
- * Повертає null коли:
163
- * - файлу немає (rule без coverage-провайдера),
164
- * - файл існує, але не експортує `detect` + `collect` як функції (наприклад,
165
- * `rules/test/coverage/coverage.mjs` — сам оркестратор, не провайдер).
166
- * @param {string} rulesDir корінь `npm/rules/`
167
- * @param {string} ruleId id правила з `.n-cursor.json#rules`
168
- * @returns {Promise<{detect:(cwd:string)=>Promise<boolean>, collect:(cwd:string)=>Promise<Array<object>>}|null>} provider-модуль або null
169
- */
170
- async function loadProvider(rulesDir, ruleId) {
171
- const providerPath = join(rulesDir, ruleId, 'coverage', 'coverage.mjs')
172
- if (!existsSync(providerPath)) return null
173
- const mod = await import(pathToFileURL(providerPath).href)
174
- if (typeof mod.detect !== 'function' || typeof mod.collect !== 'function') return null
175
- return mod
176
- }
177
-
178
- /**
179
- * Будує підсумковий рядок «Разом» через сумування всіх coverage/mutation.
180
- * @param {Array<{area:string, coverage:object, mutation:object}>} rows рядки провайдерів без totals
181
- * @returns {{area:string, coverage:object, mutation:{caught:number,total:number}}} агрегований рядок «Разом»
182
- */
183
- function buildTotalsRow(rows) {
184
- let totalCoverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
185
- let totalMutation = { caught: 0, total: 0 }
186
- for (const row of rows) {
187
- totalCoverage = addCoverage(totalCoverage, row.coverage)
188
- totalMutation = addMutation(totalMutation, row.mutation)
189
- }
190
- return { area: '**Разом**', coverage: totalCoverage, mutation: totalMutation }
191
- }
192
-
193
- /**
194
- * Читає `.n-cursor.json#coverage.classifyConfidenceThreshold` (default 1.1 — rollout mode).
195
- * @param {string} cwd корінь проєкту
196
- * @returns {Promise<number>} threshold у [0, 1.1]
197
- */
198
- async function readClassifyThreshold(cwd) {
199
- try {
200
- const raw = await readFile(join(cwd, '.n-cursor.json'), 'utf8')
201
- const parsed = JSON.parse(raw)
202
- const t = parsed?.coverage?.classifyConfidenceThreshold
203
- return typeof t === 'number' && Number.isFinite(t) ? t : 1.1
204
- } catch {
205
- return 1.1
206
- }
207
- }
208
-
209
- /**
210
- * Резолвить scope змінених файлів для `--changed`-режиму.
211
- *
212
- * Base — git merge-base поточної гілки з `main` або `origin/main`.
213
- * `git diff <base>` проти робочого дерева ловить committed і uncommitted однаково,
214
- * тож scope не залежить від того, чи крок уже закомічено.
215
- * @param {string} cwd корінь проєкту
216
- * @returns {{base: string|null, files: string[]}} base-ref і relative-posix список змінених файлів
217
- */
218
- export function resolveChangedScope(cwd) {
219
- const base = resolveChangedBase(cwd)
220
- return { base, files: collectChangedFilesSince(base, cwd) }
221
- }
222
-
223
- /**
224
- * Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
225
- * detect+collect для кожного, агрегація, запис COVERAGE.md.
226
- * При `opts.fix === true` після запису COVERAGE.md запускає агента (coverage-fix.mjs)
227
- * для написання тестів по вцілілих мутантах.
228
- * При `opts.changed === true` провайдери звужують scope до змінених від base файлів
229
- * для швидкого gate. Порожній scope (нема релевантних змін) — це pass (exit 0)
230
- * без перезапису наявного COVERAGE.md, а НЕ помилка «жодного провайдера».
231
- * @param {{cwd?:string, rulesDir?:string, fix?:boolean, changed?:boolean}} [opts] ін'єкція cwd/rulesDir для тестів; fix — --fix режим; changed — scope лише змінених
232
- * @returns {Promise<number>} exit code (0 OK, 1 коли жоден провайдер не дав даних у full-режимі)
233
- */
234
- export async function runCoverageSteps(opts = {}) {
235
- const cwd = opts.cwd ?? process.cwd()
236
- const rulesDir = opts.rulesDir ?? RULES_DIR
237
- const config = await readNCursorConfigLite(cwd)
238
- const scope = opts.changed ? resolveChangedScope(cwd) : null
239
- const collectOpts = scope ? { changedFiles: scope.files, base: scope.base } : {}
240
- const rows = []
241
-
242
- for (const ruleId of config.rules) {
243
- if (config.disableRules.includes(ruleId)) continue
244
- const provider = await loadProvider(rulesDir, ruleId)
245
- if (!provider) continue
246
- if (!(await provider.detect(cwd))) continue
247
- console.log(`→ ${ruleId} coverage…`)
248
- rows.push(...(await provider.collect(cwd, collectOpts)))
249
- }
250
-
251
- if (rows.length === 0) {
252
- // --changed: порожній scope = «нема релевантних змін» → pass, не чіпаємо COVERAGE.md.
253
- if (opts.changed) {
254
- console.log('✓ coverage --changed: немає змінених файлів у scope провайдерів — пропускаю')
255
- return 0
256
- }
257
- console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
258
- return 1
259
- }
260
-
261
- // --changed (турнікет): рішення гейту визначає лише exit-код — vitest/Stryker кинули б помилку
262
- // під час collect, якби тести/прогін упали. Тут НЕ перезаписуємо повний COVERAGE.md частковим
263
- // scoped-звітом і не ганяємо LLM-класифікацію (зайвий кошт у per-step циклі).
264
- if (opts.changed) {
265
- console.log('✓ coverage --changed: змінені файли перевірено')
266
- return 0
267
- }
268
-
269
- // LLM-класифікація survived мутантів (graceful skip без API key)
270
- const allSurvived = rows.flatMap(r => r.survived ?? [])
271
- let augmentedRows = rows
272
- let allowedGaps = []
273
- if (allSurvived.length > 0) {
274
- const verdicts = await classify(allSurvived, cwd)
275
- if (verdicts.length > 0) {
276
- const threshold = await readClassifyThreshold(cwd)
277
- const applied = applyVerdicts(rows, verdicts, threshold)
278
- augmentedRows = applied.rows
279
- allowedGaps = applied.allowedGaps
280
- }
281
- }
282
-
283
- // Підсумок «Разом» має сенс лише коли провайдерів ≥2; для єдиного рядка він
284
- // дублює його значення, тож не додаємо.
285
- if (augmentedRows.filter(r => r.area !== '**Разом**').length > 1) {
286
- augmentedRows.push(buildTotalsRow(augmentedRows.filter(r => r.area !== '**Разом**')))
287
- }
288
- const md = renderMarkdown(augmentedRows, allowedGaps)
289
- // Stryker disable next-line StringLiteral: equivalent – writeFile(path, str, '') behaves identically to 'utf8' in Node/Bun
290
- await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
291
- console.log('✓ COVERAGE.md')
292
-
293
- if (opts.fix) {
294
- const { fixSurvivedMutants } = await import(new URL('../../../scripts/coverage-fix.mjs', import.meta.url).href)
295
- await fixSurvivedMutants(allSurvived, cwd)
296
- }
297
-
298
- return 0
299
- }
300
-
301
- /**
302
- * CLI entrypoint для `n-cursor coverage [--fix] [--changed]`.
303
- * Із `--fix`: збирає метрики → запускає агента → повторно збирає метрики.
304
- * Без `--fix`: лише збирає метрики.
305
- * Із `--changed`: звужує scope до змінених від git merge-base файлів.
306
- * Лок охоплює кожен coverage-прогін окремо.
307
- * @param {{fix?:boolean, changed?:boolean}} [opts] прапори --fix / --changed
308
- * @returns {Promise<number>} exit code
309
- */
310
- export async function runCoverageCli(opts = {}) {
311
- const code = await withLock('coverage', () => runCoverageSteps(opts))
312
- if (code === 0 && opts.fix) {
313
- console.log('\n♻️ Повторний coverage після агента…\n')
314
- return withLock('coverage', () => runCoverageSteps({ fix: false, changed: opts.changed }))
315
- }
316
- return code
317
- }
@@ -1,67 +0,0 @@
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
- }
@@ -1,77 +0,0 @@
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('sha256').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
- }
@@ -1,206 +0,0 @@
1
- ---
2
- type: JS Module
3
- title: apply.mjs
4
- resource: npm/scripts/coverage-classify/apply.mjs
5
- docgen:
6
- crc: 0f54e6a0
7
- ---
8
-
9
- Модуль `apply.mjs` із пакета `coverage-classify` відповідає за **застосування вердиктів класифікатора мутаційних розривів** до табличних coverage-рядків. Він фільтрує список «вижилих» мутантів (`survived`) у кожному рядку, ділячи їх на дві категорії:
10
-
11
- 1. **Allowed gaps** — мутанти, які класифікатор позначив як `equivalent`, `defensive`, `glue` або `wrapper` з рівнем впевненості (`confidence`) не нижче встановленого порогу. Такі мутанти виключаються з підрахунку «killable» (зменшують `mutation.total`) та виносяться в окремий список для подальшого рендеру в `COVERAGE.md`.
12
- 2. **Залишок (remaining survived)** — все, що варто тестувати (`worth-testing`), а також низько-впевнені `skip`-вердикти. Ці мутанти залишаються у вихідному `row.survived` і впливають на mutation score.
13
-
14
- Модуль **не мутує вхідні дані** (`rows`, `verdicts`) — повертає нові об'єкти. Це дозволяє безпечно використовувати його у пайплайнах, де ті ж самі рядки можуть оброблятись паралельно або кешуватись.
15
-
16
- Логіка віднімання `skippedCount` з `mutation.total` зумовлена тим, що allowed-gap-мутанти не є реальною прогалиною в покритті: вони або еквівалентні оригіналу (нічого не ламають), або захисні (стосуються неможливих гілок), або клейові/обгорткові — тобто, тестувати їх економічно невиправдано.
17
-
18
- ## Експорти / API
19
-
20
- | Експорт | Тип | Призначення |
21
- | ------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------- |
22
- | `isAllowedGap(verdict, threshold)` | named function | Перевіряє, чи окремий verdict-об'єкт кваліфікує мутанта як allowed-gap. |
23
- | `applyVerdicts(rows, verdicts, threshold)` | named function | Застосовує мапу вердиктів до набору coverage-рядків і повертає augmented rows + список allowed-gaps. |
24
-
25
- Default-експорту немає. Внутрішня константа `SKIP_VERDICTS` не експортується.
26
-
27
- ## Функції
28
-
29
- ### `isAllowedGap(verdict, threshold)`
30
-
31
- **Сигнатура:**
32
-
33
- ```js
34
- isAllowedGap(verdict: { verdict: string, confidence: number }, threshold: number): boolean
35
- ```
36
-
37
- **Параметри:**
38
-
39
- - `verdict` — об'єкт із полями:
40
- - `verdict` (`string`) — категорія вердикту класифікатора. Очікувані значення: `'equivalent' | 'defensive' | 'glue' | 'wrapper' | 'worth-testing'` (та потенційно інші).
41
- - `confidence` (`number`) — рівень впевненості класифікатора в діапазоні `[0, 1]`.
42
- - `threshold` (`number`) — мінімальна впевненість, починаючи з якої skip-вердикт визнається allowed-gap (наприклад, `0.7`).
43
-
44
- **Повертає:** `boolean`. `true` — якщо `verdict.verdict` належить до `SKIP_VERDICTS` (`equivalent`, `defensive`, `glue`, `wrapper`) **і** `verdict.confidence >= threshold`. Інакше — `false`.
45
-
46
- **Side effects:** немає. Чиста функція.
47
-
48
- **Гранична поведінка:**
49
-
50
- - Низько-впевнений skip-verdict (`confidence < threshold`) → `false`, мутант залишиться як survived.
51
- - `worth-testing` із будь-якою впевненістю → `false`.
52
- - Невідома категорія, відсутня в `SKIP_VERDICTS` → `false`.
53
-
54
- ---
55
-
56
- ### `applyVerdicts(rows, verdicts, threshold)`
57
-
58
- **Сигнатура:**
59
-
60
- ```js
61
- applyVerdicts(
62
- rows: Array<Row>,
63
- verdicts: Array<{ key: string, verdict: VerdictObj }>,
64
- threshold: number
65
- ): { rows: Array<Row>, allowedGaps: Array<AllowedGap> }
66
- ```
67
-
68
- Де:
69
-
70
- ```ts
71
- Row = {
72
- area: string,
73
- coverage: object,
74
- mutation: { caught: number, total: number },
75
- survived?: Array<{
76
- file: string,
77
- mutants: Array<Mutant>,
78
- exampleTest?: object | null,
79
- recommendationText?: string | null
80
- }>
81
- }
82
-
83
- Mutant = { line: number, col: number, replacement: string, ...rest }
84
- VerdictObj = { verdict: string, confidence: number, reason: string }
85
- AllowedGap = { file: string, mutant: Mutant, verdict: VerdictObj }
86
- ```
87
-
88
- **Параметри:**
89
-
90
- - `rows` — масив coverage-рядків (один рядок на «area», тобто workspace/директорію). Кожен рядок містить агреговану статистику мутацій та опційний список `survived`-груп (згрупованих по файлу).
91
- - `verdicts` — масив об'єктів `{ key, verdict }`, де `key` має формат `${file}:${line}:${col}:${replacement}` і однозначно ідентифікує мутанта.
92
- - `threshold` — поріг впевненості (передається в `isAllowedGap`).
93
-
94
- **Повертає:** об'єкт з двома полями:
95
-
96
- - `rows` (`Array<Row>`) — нові рядки, де:
97
- - `survived` містить лише ті групи й тих мутантів, які **не** є allowed-gap; порожні групи (де всі мутанти стали allowed-gap) виключаються.
98
- - `mutation.total` зменшено на сумарну кількість allowed-gap-мутантів у цьому рядку (`skippedCount`).
99
- - `mutation.caught`, `coverage`, `area` залишаються без змін.
100
- - Усі інші поля рядка зберігаються через spread (`...row`).
101
- - `allowedGaps` (`Array<AllowedGap>`) — плоский список (без групування по area/file) усіх мутантів, які класифіковано як allowed-gap, разом із посиланням на файл та оригінальним verdict-об'єктом. Призначений для рендеру окремої секції в `COVERAGE.md`.
102
-
103
- **Side effects:** немає. Не мутує `rows`, `verdicts`, `verdict`-об'єкти, групи `survived`, окремих мутантів. Кожен новий об'єкт створюється через `{...row}` / `{...group, mutants: remainingMutants}`.
104
-
105
- **Алгоритм:**
106
-
107
- 1. Побудувати `Map<key, verdict>` із масиву `verdicts` для O(1)-пошуку.
108
- 2. Ініціалізувати порожній акумулятор `allowedGaps`.
109
- 3. Для кожного `row` (через `rows.map(...)`):
110
- - Прочитати `survived ?? []` (підтримка рядків без поля `survived`).
111
- - Завести лічильник `skippedCount = 0` і масив `remainingSurvived`.
112
- - Для кожної `group` із `survived`:
113
- - Завести `remainingMutants`.
114
- - Для кожного `mutant` зібрати ключ `${group.file}:${mutant.line}:${mutant.col}:${mutant.replacement}`.
115
- - Знайти verdict у мапі. Якщо знайдено й `isAllowedGap(verdict, threshold)` — додати `{ file: group.file, mutant, verdict }` у `allowedGaps` та інкрементувати `skippedCount`. Інакше — додати мутанта в `remainingMutants`.
116
- - Якщо `remainingMutants` непорожній — додати в `remainingSurvived` об'єкт-копію групи з оновленим списком мутантів. Порожні групи відсіюються.
117
- - Повернути новий рядок: `{ ...row, survived: remainingSurvived, mutation: { ...row.mutation, total: row.mutation.total - skippedCount } }`.
118
- 4. Повернути `{ rows: augmentedRows, allowedGaps }`.
119
-
120
- **Гранична поведінка:**
121
-
122
- - Мутант без відповідного запису у `verdicts` (verdict не знайдено в мапі) → залишається в `remainingMutants` (вважаємо «не класифіковано» → не allowed-gap).
123
- - `row.survived` відсутній / `undefined` → `survived` у вихідному рядку буде `[]` (порожній масив), `mutation.total` без змін.
124
- - Усі мутанти однієї групи стали allowed-gap → група не з'являється в `remainingSurvived`.
125
- - Жоден мутант не визнано allowed-gap → `allowedGaps` буде порожнім, `mutation.total` без змін.
126
- - `mutation.total - skippedCount` може теоретично стати від'ємним, якщо `total` був неконсистентним із кількістю survived (модуль не валідує цей інваріант, надія на коректність вхідних даних).
127
-
128
- ## Залежності
129
-
130
- **Зовнішні (npm):** немає. Файл — чистий ES-модуль без імпортів.
131
-
132
- **Внутрішні:** немає. Модуль є самодостатнім listener-free helper'ом без побічних залежностей.
133
-
134
- **Runtime:** Node.js / Bun (ESM, синтаксис `export function`). Використовує стандартні структури даних `Map` та `Set` без полі­філів.
135
-
136
- **Тип-формат:** усі типи описані JSDoc-блоками (без TypeScript-файлу типів). Структури `Row`, `Mutant`, `VerdictObj` визначені неявно через JSDoc у сигнатурах.
137
-
138
- ## Потік виконання / Використання
139
-
140
- Модуль є проміжною ланкою в пайплайні класифікації мутаційних прогалин у coverage-звіті:
141
-
142
- 1. **Збір coverage-рядків.** Інший етап пайплайну агрегує статистику покриття й мутаційного тестування по кожній area (workspace) та формує `rows` із полями `mutation.{caught,total}` та `survived` (групи `{file, mutants[]}`).
143
- 2. **Класифікація через LLM (або іншого класифікатора).** Для кожного survived-мутанта будується ключ `${file}:${line}:${col}:${replacement}` і отримується `verdict = { verdict, confidence, reason }`. Результат — масив `{key, verdict}`.
144
- 3. **Виклик `applyVerdicts(rows, verdicts, threshold)`.** На цьому етапі мутанти діляться на allowed-gaps та залишок, а `mutation.total` коригується.
145
- 4. **Рендер у `COVERAGE.md`.** Поверне́ні `rows` рендеряться у таблицю покриття; `allowedGaps` — в окрему секцію «Allowed gaps» (з причинами вердиктів).
146
-
147
- **Типовий приклад використання:**
148
-
149
- ```js
150
- import { applyVerdicts, isAllowedGap } from './apply.mjs'
151
-
152
- const threshold = 0.7
153
-
154
- const rows = [
155
- {
156
- area: 'npm/foo',
157
- coverage: { lines: 95.2 },
158
- mutation: { caught: 18, total: 20 },
159
- survived: [
160
- {
161
- file: 'npm/foo/src/index.mjs',
162
- mutants: [
163
- { line: 10, col: 5, replacement: '!=' },
164
- { line: 22, col: 9, replacement: '+' }
165
- ]
166
- }
167
- ]
168
- }
169
- ]
170
-
171
- const verdicts = [
172
- {
173
- key: 'npm/foo/src/index.mjs:10:5:!=',
174
- verdict: { verdict: 'equivalent', confidence: 0.9, reason: 'no behavioral diff' }
175
- },
176
- {
177
- key: 'npm/foo/src/index.mjs:22:9:+',
178
- verdict: { verdict: 'worth-testing', confidence: 0.85, reason: 'real gap' }
179
- }
180
- ]
181
-
182
- const { rows: augmented, allowedGaps } = applyVerdicts(rows, verdicts, threshold)
183
-
184
- // augmented[0].mutation.total === 19 (20 - 1 allowed-gap)
185
- // augmented[0].survived[0].mutants має 1 елемент (другий мутант)
186
- // allowedGaps має 1 елемент із file: 'npm/foo/src/index.mjs'
187
- ```
188
-
189
- **Інваріанти, на які слід зважати при змінах:**
190
-
191
- - Ключ мутанта **має точно збігатися** з ключем у `verdicts` (формат `${file}:${line}:${col}:${replacement}`). Зміна формату в одному місці ламає матчинг.
192
- - `mutation.caught` ніколи не змінюється — allowed-gaps вилучаються тільки з `total` (бо вони і так не були caught).
193
- - Не покладайтесь на стабільний порядок `allowedGaps`: він залежить від порядку `rows` і всередині — порядку груп та мутантів. Якщо потрібен детермінований ордер, сортуйте у викликачі.
194
-
195
- ## Rebuild Test
196
-
197
- За цією документацією має бути можливо повністю відтворити поведінку `apply.mjs`:
198
-
199
- - Скласти `Set` SKIP_VERDICTS = `{equivalent, defensive, glue, wrapper}`.
200
- - Реалізувати `isAllowedGap(verdict, threshold)` як `SKIP_VERDICTS.has(verdict.verdict) && verdict.confidence >= threshold`.
201
- - Реалізувати `applyVerdicts(rows, verdicts, threshold)`:
202
- - побудувати `Map` з `verdicts`,
203
- - пройти `rows.map` з immutable-оновленням,
204
- - для кожного survived-мутанта зібрати ключ за фіксованим форматом, перевірити через `isAllowedGap`, зібрати окремий список allowedGaps та зменшити `mutation.total`,
205
- - відсіяти порожні групи, повернути `{rows, allowedGaps}`.
206
- - Не імпортувати нічого; не мутувати входи.