@nitra/cursor 12.11.1 → 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.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/rules/adr/js/docs/hooks.md +0 -2
- package/rules/bun/js/docs/fix-layout.md +25 -0
- package/rules/bun/js/fix-layout.mjs +55 -0
- package/rules/changelog/js/docs/fix-consistency.md +27 -0
- package/rules/changelog/js/docs/index.md +2 -2
- package/rules/changelog/js/fix-consistency.mjs +50 -0
- package/rules/ci4/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
- package/rules/ci4/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/ga/policy/vscode_extensions/docs/fix-vscode_extensions.md +22 -0
- package/rules/ga/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/graphql/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
- package/rules/graphql/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/js/js/docs/dep-policy.md +12 -10
- package/rules/js/policy/vscode_extensions/docs/fix-vscode_extensions.md +22 -0
- package/rules/js/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/js-run/js/docs/fix-runtime.md +25 -0
- package/rules/js-run/js/fix-runtime.mjs +41 -0
- package/rules/nginx-default-tpl/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
- package/rules/nginx-default-tpl/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/rego/policy/vscode_extensions/docs/fix-vscode_extensions.md +22 -0
- package/rules/rego/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/rust/policy/vscode_extensions/docs/fix-vscode_extensions.md +22 -0
- package/rules/rust/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/style/js/docs/fix-tooling.md +29 -0
- package/rules/style/js/fix-tooling.mjs +46 -0
- package/rules/style/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
- package/rules/style/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/tauri/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
- package/rules/tauri/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/text/policy/vscode_extensions/docs/fix-vscode_extensions.md +21 -0
- package/rules/text/policy/vscode_extensions/fix-vscode_extensions.mjs +1 -0
- package/rules/vue/js/docs/packages.md +0 -2
- package/scripts/docs/index.md +0 -2
- package/scripts/lib/discover-checkable-rules.mjs +1 -0
- package/scripts/lib/docs/discover-checkable-rules.md +13 -155
- package/scripts/lib/fix/discover-t0-patterns.mjs +83 -0
- package/scripts/lib/fix/docs/discover-t0-patterns.md +37 -0
- package/scripts/lib/fix/docs/llm-fix-apply.md +12 -10
- package/scripts/lib/fix/docs/llm-worker.md +6 -14
- package/scripts/lib/fix/docs/orchestrator.md +0 -2
- package/scripts/lib/fix/docs/t0.md +11 -10
- package/scripts/lib/fix/docs/vscode-ext-add.md +29 -0
- package/scripts/lib/fix/t0.mjs +8 -234
- package/scripts/lib/fix/vscode-ext-add.mjs +45 -0
- package/rules/test/coverage/coverage.mjs +0 -317
- package/scripts/coverage-classify/apply.mjs +0 -67
- package/scripts/coverage-classify/cache.mjs +0 -77
- package/scripts/coverage-classify/docs/apply.md +0 -206
- package/scripts/coverage-classify/docs/cache.md +0 -207
- package/scripts/coverage-classify/docs/index.md +0 -14
- package/scripts/coverage-classify/docs/prompt.md +0 -136
- package/scripts/coverage-classify/docs/verdict-schema.md +0 -28
- package/scripts/coverage-classify/index.mjs +0 -114
- package/scripts/coverage-classify/prompt.mjs +0 -126
- package/scripts/coverage-classify/verdict-schema.mjs +0 -35
- package/scripts/coverage-fix-extract.mjs +0 -122
- package/scripts/coverage-fix.mjs +0 -119
- package/scripts/docs/coverage-fix-extract.md +0 -36
- package/scripts/docs/coverage-fix.md +0 -181
- package/skills/coverage-fix/SKILL.md +0 -131
- package/skills/coverage-fix/main.json +0 -1
package/scripts/lib/fix/t0.mjs
CHANGED
|
@@ -1,238 +1,14 @@
|
|
|
1
1
|
/** @see ./docs/t0.md */
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { join } from 'node:path'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
5
4
|
|
|
5
|
+
import { discoverT0Patterns } from './discover-t0-patterns.mjs'
|
|
6
6
|
import { runConformanceCheck } from './run-conformance-check.mjs'
|
|
7
|
-
import { writeChange } from '../../../rules/release/change.mjs'
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
// Конформність changelog: «<ws>: є релевантні зміни, але немає change-файлу».
|
|
14
|
-
const MISSING_CHANGE_RE = /є релевантні зміни, але немає change-файлу/
|
|
15
|
-
const MISSING_CHANGE_MATCH_ALL_RE = /(?:^|\s)([\w./@-]+): є релевантні зміни, але немає change-файлу/gm
|
|
16
|
-
/** Дефолти autofix-створеного change-файлу (узгоджено з n-changelog.mdc / consistency.mjs). */
|
|
17
|
-
const CHANGE_BUMP = 'patch'
|
|
18
|
-
const CHANGE_SECTION = 'Changed'
|
|
19
|
-
const CHANGE_FALLBACK_MESSAGE = 'оновлення'
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Опис для авто-створеного change-файлу: subject останнього коміту, інакше fallback.
|
|
23
|
-
* @param {string} cwd корінь репозиторію
|
|
24
|
-
* @returns {string} непорожній опис
|
|
25
|
-
*/
|
|
26
|
-
function autoChangeMessage(cwd) {
|
|
27
|
-
const r = spawnSync('git', ['log', '-1', '--format=%s'], { cwd, encoding: 'utf8' })
|
|
28
|
-
const subject = r.status === 0 ? (r.stdout ?? '').trim() : ''
|
|
29
|
-
return subject || CHANGE_FALLBACK_MESSAGE
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Патерни T0-auto.
|
|
34
|
-
* Кожен паттерн: {
|
|
35
|
-
* id: string — унікальна назва паттерну (для логу)
|
|
36
|
-
* test: (output)=>bool — чи підходить цей output до паттерну
|
|
37
|
-
* apply: (match, cwd)=>{ ok: bool, action: string } — застосувати фікс
|
|
38
|
-
* }
|
|
39
|
-
*/
|
|
40
|
-
const PATTERNS = [
|
|
41
|
-
// ── vscode-ext-add ──────────────────────────────────────────────────────────
|
|
42
|
-
// Violation: «recommendations має містити "tsandall.opa"»
|
|
43
|
-
// Fix: додати рядок у .vscode/extensions.json#recommendations
|
|
44
|
-
{
|
|
45
|
-
id: 'vscode-ext-add',
|
|
46
|
-
test: out => REC_REQUIRE_RE.test(out),
|
|
47
|
-
apply: (out, cwd) => {
|
|
48
|
-
const matches = [...out.matchAll(REC_MATCH_ALL_RE)]
|
|
49
|
-
if (matches.length === 0) return { ok: false, action: 'no match' }
|
|
50
|
-
|
|
51
|
-
const extPath = join(cwd, '.vscode/extensions.json')
|
|
52
|
-
if (!existsSync(extPath)) {
|
|
53
|
-
return { ok: false, action: '.vscode/extensions.json не знайдено' }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
let parsed
|
|
57
|
-
try {
|
|
58
|
-
parsed = JSON.parse(readFileSync(extPath, 'utf8'))
|
|
59
|
-
} catch {
|
|
60
|
-
return { ok: false, action: '.vscode/extensions.json: невалідний JSON' }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const recs = Array.isArray(parsed.recommendations) ? parsed.recommendations : []
|
|
64
|
-
const toAdd = matches.map(m => m[1]).filter(e => !recs.includes(e))
|
|
65
|
-
if (toAdd.length === 0) return { ok: false, action: 'вже є' }
|
|
66
|
-
|
|
67
|
-
parsed.recommendations = [...recs, ...toAdd]
|
|
68
|
-
writeFileSync(extPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8')
|
|
69
|
-
return { ok: true, action: `додано до extensions.json: ${toAdd.join(', ')}` }
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
// ── rm-forbidden-file ────────────────────────────────────────────────────────
|
|
74
|
-
// Violation: «Знайдено заборонений файл: package-lock.json»
|
|
75
|
-
// Fix: видалити файл
|
|
76
|
-
{
|
|
77
|
-
id: 'rm-forbidden-file',
|
|
78
|
-
test: out => FORBIDDEN_FILE_RE.test(out),
|
|
79
|
-
apply: (out, cwd) => {
|
|
80
|
-
const matches = [...out.matchAll(FORBIDDEN_FILE_MATCH_ALL_RE)]
|
|
81
|
-
if (matches.length === 0) return { ok: false, action: 'no match' }
|
|
82
|
-
|
|
83
|
-
const removed = []
|
|
84
|
-
for (const m of matches) {
|
|
85
|
-
const filePath = join(cwd, m[1])
|
|
86
|
-
if (existsSync(filePath)) {
|
|
87
|
-
rmSync(filePath, { force: true })
|
|
88
|
-
removed.push(m[1])
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if (removed.length === 0) return { ok: false, action: 'файлів не знайдено' }
|
|
92
|
-
return { ok: true, action: `видалено: ${removed.join(', ')}` }
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
|
|
96
|
-
// ── changelog-create-change-file ─────────────────────────────────────────────
|
|
97
|
-
// Violation: «<ws>: є релевантні зміни, але немає change-файлу»
|
|
98
|
-
// Fix: створити change-файл через канонічну `writeChange` (без LLM) — той самий
|
|
99
|
-
// механізм, що autofix changelog-конформності. Прибирає ескалацію в хмару на цьому кейсі.
|
|
100
|
-
{
|
|
101
|
-
id: 'changelog-create-change-file',
|
|
102
|
-
test: out => MISSING_CHANGE_RE.test(out),
|
|
103
|
-
apply: async (out, cwd) => {
|
|
104
|
-
const workspaces = Array.from(out.matchAll(MISSING_CHANGE_MATCH_ALL_RE), m => m[1])
|
|
105
|
-
if (workspaces.length === 0) return { ok: false, action: 'no match' }
|
|
106
|
-
|
|
107
|
-
const message = autoChangeMessage(cwd)
|
|
108
|
-
const created = []
|
|
109
|
-
for (const ws of workspaces) {
|
|
110
|
-
try {
|
|
111
|
-
const rel = await writeChange({ bump: CHANGE_BUMP, section: CHANGE_SECTION, message, ws, cwd })
|
|
112
|
-
created.push(ws === '.' ? rel : join(ws, rel))
|
|
113
|
-
} catch (error) {
|
|
114
|
-
return { ok: false, action: `writeChange ${ws}: ${error.message}` }
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return { ok: true, action: `створено change-файл (${CHANGE_BUMP}/${CHANGE_SECTION}): ${created.join(', ')}` }
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
// ── bun-bunfig-create ────────────────────────────────────────────────────────
|
|
122
|
-
// Violation: «Відсутній bunfig.toml — створи з [install] linker = "hoisted"»
|
|
123
|
-
// Fix: створити bunfig.toml з канонічним вмістом (bun.mdc)
|
|
124
|
-
{
|
|
125
|
-
id: 'bun-bunfig-create',
|
|
126
|
-
test: out => /Відсутній bunfig\.toml/.test(out),
|
|
127
|
-
apply: (_out, cwd) => {
|
|
128
|
-
const target = join(cwd, 'bunfig.toml')
|
|
129
|
-
if (existsSync(target)) return { ok: false, action: 'bunfig.toml вже існує' }
|
|
130
|
-
writeFileSync(target, '[install]\nlinker = "hoisted"\n', 'utf8')
|
|
131
|
-
return { ok: true, action: 'створено bunfig.toml' }
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
|
-
|
|
135
|
-
// ── bun-yarn-dir-remove ──────────────────────────────────────────────────────
|
|
136
|
-
// Violation: «Знайдено директорію .yarn — видали її»
|
|
137
|
-
// Fix: рекурсивно видалити .yarn/
|
|
138
|
-
{
|
|
139
|
-
id: 'bun-yarn-dir-remove',
|
|
140
|
-
test: out => /Знайдено директорію \.yarn/.test(out),
|
|
141
|
-
apply: (_out, cwd) => {
|
|
142
|
-
const target = join(cwd, '.yarn')
|
|
143
|
-
if (!existsSync(target)) return { ok: false, action: '.yarn не знайдено' }
|
|
144
|
-
rmSync(target, { recursive: true, force: true })
|
|
145
|
-
return { ok: true, action: 'видалено .yarn/' }
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
|
|
149
|
-
// ── style-stylelintignore-create ─────────────────────────────────────────────
|
|
150
|
-
// Violation: «.stylelintignore не існує — створи з вмістом: dist/»
|
|
151
|
-
// Fix: створити .stylelintignore з рядком dist/
|
|
152
|
-
{
|
|
153
|
-
id: 'style-stylelintignore-create',
|
|
154
|
-
test: out => /\.stylelintignore не існує/.test(out),
|
|
155
|
-
apply: (_out, cwd) => {
|
|
156
|
-
writeFileSync(join(cwd, '.stylelintignore'), 'dist/\n', 'utf8')
|
|
157
|
-
return { ok: true, action: 'створено .stylelintignore' }
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
|
|
161
|
-
// ── style-stylelintignore-dist-add ───────────────────────────────────────────
|
|
162
|
-
// Violation: «.stylelintignore не містить рядка dist/»
|
|
163
|
-
// Fix: дописати dist/ до існуючого .stylelintignore
|
|
164
|
-
{
|
|
165
|
-
id: 'style-stylelintignore-dist-add',
|
|
166
|
-
test: out => /\.stylelintignore не містить рядка dist\//.test(out),
|
|
167
|
-
apply: (_out, cwd) => {
|
|
168
|
-
const target = join(cwd, '.stylelintignore')
|
|
169
|
-
appendFileSync(target, '\ndist/\n', 'utf8')
|
|
170
|
-
return { ok: true, action: 'додано dist/ до .stylelintignore' }
|
|
171
|
-
}
|
|
172
|
-
},
|
|
173
|
-
|
|
174
|
-
// ── style-pkg-stylelint-add ──────────────────────────────────────────────────
|
|
175
|
-
// Violation: «Немає конфігу stylelint — додай "stylelint": {...} до package.json»
|
|
176
|
-
// Fix: додати поле stylelint до кореневого package.json
|
|
177
|
-
{
|
|
178
|
-
id: 'style-pkg-stylelint-add',
|
|
179
|
-
test: out => /Немає конфігу stylelint/.test(out),
|
|
180
|
-
apply: (_out, cwd) => {
|
|
181
|
-
const pkgPath = join(cwd, 'package.json')
|
|
182
|
-
if (!existsSync(pkgPath)) return { ok: false, action: 'package.json не знайдено' }
|
|
183
|
-
let pkg
|
|
184
|
-
try {
|
|
185
|
-
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
186
|
-
} catch {
|
|
187
|
-
return { ok: false, action: 'package.json: невалідний JSON' }
|
|
188
|
-
}
|
|
189
|
-
if (pkg.stylelint) return { ok: false, action: 'stylelint вже є в package.json' }
|
|
190
|
-
pkg.stylelint = { extends: '@nitra/stylelint-config' }
|
|
191
|
-
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
|
|
192
|
-
return { ok: true, action: 'додано stylelint до package.json' }
|
|
193
|
-
}
|
|
194
|
-
},
|
|
195
|
-
|
|
196
|
-
// ── js-run-jsconfig-create ───────────────────────────────────────────────────
|
|
197
|
-
// Violation: «[packages/api] є каталог src/, але немає jsconfig.json»
|
|
198
|
-
// Fix: для кожного воркспейсу з violation створити канонічний jsconfig.json
|
|
199
|
-
// (NodeNext + include: src/**/*; шаблон: js-run/policy/jsconfig/template/)
|
|
200
|
-
{
|
|
201
|
-
id: 'js-run-jsconfig-create',
|
|
202
|
-
test: out => /є каталог src\/, але немає jsconfig\.json/.test(out),
|
|
203
|
-
apply: (out, cwd) => {
|
|
204
|
-
const RE = /\[([^\]]+)\] є каталог src\/, але немає jsconfig\.json/gu
|
|
205
|
-
const matches = [...out.matchAll(RE)]
|
|
206
|
-
if (matches.length === 0) return { ok: false, action: 'no match' }
|
|
207
|
-
const canonical =
|
|
208
|
-
JSON.stringify(
|
|
209
|
-
{
|
|
210
|
-
compilerOptions: {
|
|
211
|
-
lib: ['esnext'],
|
|
212
|
-
module: 'NodeNext',
|
|
213
|
-
moduleResolution: 'NodeNext',
|
|
214
|
-
target: 'esnext',
|
|
215
|
-
checkJs: false
|
|
216
|
-
},
|
|
217
|
-
include: ['src/**/*']
|
|
218
|
-
},
|
|
219
|
-
null,
|
|
220
|
-
2
|
|
221
|
-
) + '\n'
|
|
222
|
-
const created = []
|
|
223
|
-
for (const m of matches) {
|
|
224
|
-
const ws = m[1]
|
|
225
|
-
const target = join(cwd, ws, 'jsconfig.json')
|
|
226
|
-
if (!existsSync(target)) {
|
|
227
|
-
writeFileSync(target, canonical, 'utf8')
|
|
228
|
-
created.push(ws)
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
if (created.length === 0) return { ok: false, action: 'jsconfig.json вже існує в усіх воркспейсах' }
|
|
232
|
-
return { ok: true, action: `створено jsconfig.json: ${created.join(', ')}` }
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
]
|
|
8
|
+
// Паттерни живуть у rule-level fix-*.mjs файлах; агрегуємо тут через discovery.
|
|
9
|
+
// Top-level await: ініціалізація один раз при завантаженні модуля.
|
|
10
|
+
const RULES_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../../rules')
|
|
11
|
+
const PATTERNS = await discoverT0Patterns(RULES_DIR)
|
|
236
12
|
|
|
237
13
|
/**
|
|
238
14
|
* Застосовує всі T0-auto паттерни до одного violation-output.
|
|
@@ -250,9 +26,7 @@ export async function applyT0Auto(ruleId, violationOutput, cwd) {
|
|
|
250
26
|
// Патерн може бути sync ({ok,action}) або async (Promise) — await нормалізує обидва.
|
|
251
27
|
const result = await p.apply(violationOutput, cwd)
|
|
252
28
|
actions.push(`[${p.id}] ${result.action}`)
|
|
253
|
-
if (result.ok)
|
|
254
|
-
applied = true
|
|
255
|
-
}
|
|
29
|
+
if (result.ok) applied = true
|
|
256
30
|
}
|
|
257
31
|
|
|
258
32
|
return { applied, actions }
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared T0-autofix паттерн для правил із `vscode_extensions.rego`.
|
|
3
|
+
* Читає назву розширення з violation-message і додає його до
|
|
4
|
+
* `.vscode/extensions.json#recommendations`.
|
|
5
|
+
*
|
|
6
|
+
* Не прив'язаний до конкретного правила — один механізм для всіх правил,
|
|
7
|
+
* що емітують «recommendations має містити "…"».
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const REC_REQUIRE_RE = /recommendations має містити "[^"]+"/
|
|
13
|
+
const REC_MATCH_ALL_RE = /recommendations має містити "([^"]+)"/g
|
|
14
|
+
|
|
15
|
+
/** @type {import('./discover-t0-patterns.mjs').T0Pattern[]} */
|
|
16
|
+
export const patterns = [
|
|
17
|
+
{
|
|
18
|
+
id: 'vscode-ext-add',
|
|
19
|
+
test: out => REC_REQUIRE_RE.test(out),
|
|
20
|
+
apply: (out, cwd) => {
|
|
21
|
+
const matches = [...out.matchAll(REC_MATCH_ALL_RE)]
|
|
22
|
+
if (matches.length === 0) return { ok: false, action: 'no match' }
|
|
23
|
+
|
|
24
|
+
const extPath = join(cwd, '.vscode/extensions.json')
|
|
25
|
+
if (!existsSync(extPath)) {
|
|
26
|
+
return { ok: false, action: '.vscode/extensions.json не знайдено' }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let parsed
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(readFileSync(extPath, 'utf8'))
|
|
32
|
+
} catch {
|
|
33
|
+
return { ok: false, action: '.vscode/extensions.json: невалідний JSON' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const recs = Array.isArray(parsed.recommendations) ? parsed.recommendations : []
|
|
37
|
+
const toAdd = matches.map(m => m[1]).filter(e => !recs.includes(e))
|
|
38
|
+
if (toAdd.length === 0) return { ok: false, action: 'вже є' }
|
|
39
|
+
|
|
40
|
+
parsed.recommendations = [...recs, ...toAdd]
|
|
41
|
+
writeFileSync(extPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8')
|
|
42
|
+
return { ok: true, action: `додано до extensions.json: ${toAdd.join(', ')}` }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
]
|
|
@@ -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
|
-
}
|