@7n/test 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-06-26
4
+
5
+ ### Changed
6
+
7
+ - Виправлено пакування: тест-файли (`*.test.mjs`) виключені з npm-артефакту через negation-патерн у `files`. Додано `@earendil-works/pi-coding-agent` до `dependencies`. CI: додано `workflow_dispatch` тригер і Trusted Publisher (OIDC provenance) для беззастосного публікування без `NPM_TOKEN`.
8
+
3
9
  ## [0.2.0] - 2026-06-26
4
10
 
5
11
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -1,315 +0,0 @@
1
- /**
2
- * Канонічна команда `n coverage`: збирає метрики покриття + мутаційного
3
- * тестування з усіх провайдерів, чиє правило активне в `.n-cursor.json#rules`,
4
- * агрегує та записує COVERAGE.md у корінь проєкту.
5
- *
6
- * Discovery провайдерів — за `.n-cursor.json#rules`: для кожного `ruleId` зі
7
- * списку шукаємо `<rulesDir>/<ruleId>/coverage/coverage.mjs` і динамічно
8
- * імпортуємо. Якщо файлу немає — провайдер для цього правила відсутній (skip
9
- * silently, не помилка).
10
- *
11
- * `rulesDir` резолвиться з `node_modules/@nitra/cursor/rules` у cwd проєкту.
12
- * Override — через `opts.rulesDir` у `runCoverageSteps`.
13
- *
14
- * Лок — прямий виклик `withLock('coverage', steps)`.
15
- */
16
- import { existsSync } from 'node:fs'
17
- import { readFile, writeFile } from 'node:fs/promises'
18
- import { join } from 'node:path'
19
- import { pathToFileURL } from 'node:url'
20
-
21
- import { applyVerdicts } from '../coverage-classify/apply.mjs'
22
- import { classify } from '../coverage-classify/index.mjs'
23
- import { collectChangedFilesSince, resolveChangedBase } from '../scripts/lib/changed-files.mjs'
24
- import { readNCursorConfigLite } from '../scripts/lib/read-n-cursor-config-lite.mjs'
25
- import { withLock } from '../scripts/utils/with-lock.mjs'
26
-
27
- /**
28
- * Знаходить rulesDir @nitra/cursor у node_modules проєкту.
29
- * @param {string} cwd корінь проєкту
30
- * @returns {string|null} абсолютний шлях до rules/ або null
31
- */
32
- function resolveDefaultRulesDir(cwd) {
33
- const localPath = join(cwd, 'node_modules', '@nitra', 'cursor', 'rules')
34
- if (existsSync(localPath)) return localPath
35
- return null
36
- }
37
-
38
- /**
39
- * Сума двох coverage-totals.
40
- * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший subtotal
41
- * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий subtotal
42
- * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сумарні lines/functions
43
- */
44
- export function addCoverage(a, b) {
45
- return {
46
- lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
47
- functions: {
48
- covered: a.functions.covered + b.functions.covered,
49
- total: a.functions.total + b.functions.total
50
- }
51
- }
52
- }
53
-
54
- /**
55
- * Сума двох mutation-counts.
56
- * @param {{caught:number,total:number}} a перший subtotal
57
- * @param {{caught:number,total:number}} b другий subtotal
58
- * @returns {{caught:number,total:number}} сумарні caught/total
59
- */
60
- export function addMutation(a, b) {
61
- return { caught: a.caught + b.caught, total: a.total + b.total }
62
- }
63
-
64
- /**
65
- * Форматує covered/total як `XX.XX% (covered/total)`.
66
- * @param {{covered:number,total:number}} metric метрика lines або functions
67
- * @returns {string} відформатований рядок для таблиці COVERAGE.md
68
- */
69
- export function formatCoverage({ covered, total }) {
70
- const percent = total === 0 ? '—' : `${((covered / total) * 100).toFixed(2)}%`
71
- return `${percent} (${covered}/${total})`
72
- }
73
-
74
- /**
75
- * Форматує мутаційний score як `XX.XX%`.
76
- * @param {{caught:number,total:number}} metric агрегований mutation score
77
- * @returns {string} відформатований score або прочерк
78
- */
79
- export function formatScore({ caught, total }) {
80
- return total === 0 ? '—' : `${((caught / total) * 100).toFixed(2)}%`
81
- }
82
-
83
- /**
84
- * Рендерить таблицю покриття + мутаційного тестування як Markdown.
85
- * Якщо будь-який рядок містить непустий `survived`, додає секцію
86
- * `## Вцілілі мутанти` з JSON-блоком для coverage-fix.
87
- * Якщо `allowedGaps` непустий, додає секцію `## Allowed gaps` з таблицею
88
- * verdict/confidence/reason для кожного LLM-класифікованого мутанта.
89
- * Без timestamp, щоб git diff рухався лише при зміні метрик.
90
- * @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 рядки провайдерів
91
- * @param {Array<{file:string, mutant:{line:number,col:number,mutantType:string,original:string,replacement:string}, verdict:{verdict:string,confidence:number,reason:string}}>} [allowedGaps] мутанти виключені класифікатором
92
- * @returns {string} Markdown з заголовком `# Coverage`
93
- */
94
- export function renderMarkdown(rows, allowedGaps = []) {
95
- const lines = [
96
- '# Coverage',
97
- '',
98
- '| Область | Рядки | Функції | Вбито мутацій | Score |',
99
- '| --- | --- | --- | --- | --- |'
100
- ]
101
- for (const row of rows) {
102
- lines.push(
103
- `| ${row.area} | ${formatCoverage(row.coverage.lines)} | ${formatCoverage(row.coverage.functions)} | ` +
104
- `${row.mutation.caught}/${row.mutation.total} | ${formatScore(row.mutation)} |`
105
- )
106
- }
107
-
108
- const allSurvived = rows.flatMap(r => r.survived ?? [])
109
- if (allSurvived.length > 0) {
110
- lines.push('', '## Вцілілі мутанти', '', '```json', JSON.stringify(allSurvived, null, 2), '```')
111
- // Зрозуміла для людини таблиця
112
- for (const group of allSurvived) {
113
- lines.push('', `### ${group.file}`, '', '| Рядок | Оригінал | Заміна | Тип |', '| --- | --- | --- | --- |')
114
- for (const m of group.mutants) {
115
- lines.push(`| ${m.line} | \`${m.original}\` | \`${m.replacement}\` | ${m.mutantType} |`)
116
- }
117
- if (group.exampleTest) {
118
- lines.push(
119
- '',
120
- `**Приклад тесту** (\`${group.exampleTest.testFile}\`):`,
121
- '',
122
- '```js',
123
- group.exampleTest.code ?? '',
124
- '```'
125
- )
126
- }
127
- if (group.recommendationText) {
128
- lines.push('', '**Що треба протестувати:**', '', group.recommendationText)
129
- }
130
- }
131
- }
132
-
133
- if (allowedGaps.length > 0) {
134
- // Group allowed gaps by file
135
- const gapsByFile = new Map()
136
- for (const gap of allowedGaps) {
137
- if (!gapsByFile.has(gap.file)) gapsByFile.set(gap.file, [])
138
- gapsByFile.get(gap.file).push(gap)
139
- }
140
-
141
- lines.push(
142
- '',
143
- '## Allowed gaps',
144
- '',
145
- `> LLM-класифікатор виключив ${allowedGaps.length} survived мутант(ів) зі знаменника mutation score.`,
146
- '> Категорії: equivalent (поведінково еквівалентний), defensive (impossible state), glue/wrapper (integration test покриває).'
147
- )
148
-
149
- for (const [file, gaps] of gapsByFile) {
150
- lines.push(
151
- '',
152
- `### ${file}`,
153
- '',
154
- '| Line | Mutant | Verdict | Confidence | Reason |',
155
- '| --- | --- | --- | --- | --- |'
156
- )
157
- for (const { mutant, verdict } of gaps) {
158
- const sanitizedReason = verdict.reason.replaceAll('|', String.raw`\|`).replaceAll('\n', ' ')
159
- lines.push(
160
- `| ${mutant.line} | \`${mutant.original}\` → \`${mutant.replacement}\` | ${verdict.verdict} | ${verdict.confidence.toFixed(2)} | ${sanitizedReason} |`
161
- )
162
- }
163
- }
164
- }
165
-
166
- return `${lines.join('\n')}\n`
167
- }
168
-
169
- /**
170
- * Завантажує provider-модуль з `<rulesDir>/<ruleId>/coverage/coverage.mjs`.
171
- * Повертає null коли:
172
- * - файлу немає (rule без coverage-провайдера),
173
- * - файл існує, але не експортує `detect` + `collect` як функції.
174
- * @param {string} rulesDir корінь rules/ (з @nitra/cursor або override)
175
- * @param {string} ruleId id правила з `.n-cursor.json#rules`
176
- * @returns {Promise<{detect:(cwd:string)=>Promise<boolean>, collect:(cwd:string)=>Promise<Array<object>>}|null>} provider-модуль або null
177
- */
178
- async function loadProvider(rulesDir, ruleId) {
179
- const providerPath = join(rulesDir, ruleId, 'coverage', 'coverage.mjs')
180
- if (!existsSync(providerPath)) return null
181
- const mod = await import(pathToFileURL(providerPath).href)
182
- if (typeof mod.detect !== 'function' || typeof mod.collect !== 'function') return null
183
- return mod
184
- }
185
-
186
- /**
187
- * Будує підсумковий рядок «Разом» через сумування всіх coverage/mutation.
188
- * @param {Array<{area:string, coverage:object, mutation:object}>} rows рядки провайдерів без totals
189
- * @returns {{area:string, coverage:object, mutation:{caught:number,total:number}}} агрегований рядок «Разом»
190
- */
191
- function buildTotalsRow(rows) {
192
- let totalCoverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
193
- let totalMutation = { caught: 0, total: 0 }
194
- for (const row of rows) {
195
- totalCoverage = addCoverage(totalCoverage, row.coverage)
196
- totalMutation = addMutation(totalMutation, row.mutation)
197
- }
198
- return { area: '**Разом**', coverage: totalCoverage, mutation: totalMutation }
199
- }
200
-
201
- /**
202
- * Читає `.n-cursor.json#coverage.classifyConfidenceThreshold` (default 1.1 — rollout mode).
203
- * @param {string} cwd корінь проєкту
204
- * @returns {Promise<number>} threshold у [0, 1.1]
205
- */
206
- async function readClassifyThreshold(cwd) {
207
- try {
208
- const raw = await readFile(join(cwd, '.n-cursor.json'), 'utf8')
209
- const parsed = JSON.parse(raw)
210
- const t = parsed?.coverage?.classifyConfidenceThreshold
211
- return typeof t === 'number' && Number.isFinite(t) ? t : 1.1
212
- } catch {
213
- return 1.1
214
- }
215
- }
216
-
217
- /**
218
- * Резолвить scope змінених файлів для `--changed`-режиму.
219
- * @param {string} cwd корінь проєкту
220
- * @returns {{base: string|null, files: string[]}} base-ref і relative-posix список змінених файлів
221
- */
222
- export function resolveChangedScope(cwd) {
223
- const base = resolveChangedBase(cwd)
224
- return { base, files: collectChangedFilesSince(base, cwd) }
225
- }
226
-
227
- /**
228
- * Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
229
- * detect+collect для кожного, агрегація, запис COVERAGE.md.
230
- * @param {{cwd?:string, rulesDir?:string, fix?:boolean, changed?:boolean}} [opts]
231
- * @returns {Promise<number>} exit code (0 OK, 1 помилка)
232
- */
233
- export async function runCoverageSteps(opts = {}) {
234
- const cwd = opts.cwd ?? process.cwd()
235
- const rulesDir = opts.rulesDir ?? resolveDefaultRulesDir(cwd)
236
-
237
- if (!rulesDir) {
238
- console.error(
239
- '✗ Не знайдено @nitra/cursor у node_modules. ' +
240
- 'Встанови @nitra/cursor як devDependency або передай rulesDir через opts.'
241
- )
242
- return 1
243
- }
244
-
245
- const config = await readNCursorConfigLite(cwd)
246
- const scope = opts.changed ? resolveChangedScope(cwd) : null
247
- const collectOpts = scope ? { changedFiles: scope.files, base: scope.base } : {}
248
- const rows = []
249
-
250
- for (const ruleId of config.rules) {
251
- if (config.disableRules.includes(ruleId)) continue
252
- const provider = await loadProvider(rulesDir, ruleId)
253
- if (!provider) continue
254
- if (!(await provider.detect(cwd))) continue
255
- console.log(`→ ${ruleId} coverage…`)
256
- rows.push(...(await provider.collect(cwd, collectOpts)))
257
- }
258
-
259
- if (rows.length === 0) {
260
- if (opts.changed) {
261
- console.log('✓ coverage --changed: немає змінених файлів у scope провайдерів — пропускаю')
262
- return 0
263
- }
264
- console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
265
- return 1
266
- }
267
-
268
- if (opts.changed) {
269
- console.log('✓ coverage --changed: змінені файли перевірено')
270
- return 0
271
- }
272
-
273
- // LLM-класифікація survived мутантів (graceful skip без API key)
274
- const allSurvived = rows.flatMap(r => r.survived ?? [])
275
- let augmentedRows = rows
276
- let allowedGaps = []
277
- if (allSurvived.length > 0) {
278
- const verdicts = await classify(allSurvived, cwd)
279
- if (verdicts.length > 0) {
280
- const threshold = await readClassifyThreshold(cwd)
281
- const applied = applyVerdicts(rows, verdicts, threshold)
282
- augmentedRows = applied.rows
283
- allowedGaps = applied.allowedGaps
284
- }
285
- }
286
-
287
- if (augmentedRows.filter(r => r.area !== '**Разом**').length > 1) {
288
- augmentedRows.push(buildTotalsRow(augmentedRows.filter(r => r.area !== '**Разом**')))
289
- }
290
- const md = renderMarkdown(augmentedRows, allowedGaps)
291
- // Stryker disable next-line StringLiteral: equivalent – writeFile(path, str, '') behaves identically to 'utf8' in Node/Bun
292
- await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
293
- console.log('✓ COVERAGE.md')
294
-
295
- if (opts.fix) {
296
- const { fixSurvivedMutants } = await import(new URL('../coverage-fix.mjs', import.meta.url).href)
297
- await fixSurvivedMutants(allSurvived, cwd)
298
- }
299
-
300
- return 0
301
- }
302
-
303
- /**
304
- * CLI entrypoint для `n coverage [--fix] [--changed]`.
305
- * @param {{fix?:boolean, changed?:boolean, rulesDir?:string}} [opts]
306
- * @returns {Promise<number>} exit code
307
- */
308
- export async function runCoverageCli(opts = {}) {
309
- const code = await withLock('coverage', () => runCoverageSteps(opts))
310
- if (code === 0 && opts.fix) {
311
- console.log('\n♻️ Повторний coverage після агента…\n')
312
- return withLock('coverage', () => runCoverageSteps({ fix: false, changed: opts.changed, rulesDir: opts.rulesDir }))
313
- }
314
- return code
315
- }