@nitra/cursor 1.32.0 → 1.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/bin/n-cursor.js +13 -1
- package/github-actions/release/action.yml +9 -0
- package/package.json +4 -3
- package/rules/changelog/changelog.mdc +11 -12
- package/rules/changelog/js/consistency.mjs +70 -98
- package/rules/nginx-default-tpl/js/template.mjs +1 -0
- package/rules/release/change.mjs +57 -0
- package/rules/release/fix.mjs +17 -0
- package/rules/release/lib/aggregate.mjs +82 -0
- package/rules/release/lib/change-file.mjs +99 -0
- package/rules/release/lib/fallback.mjs +48 -0
- package/rules/release/release.mjs +135 -0
- package/rules/test/coverage/coverage.mjs +66 -5
- package/rules/test/js/no-relative-fs-path.mjs +3 -3
- package/scripts/coverage-classify/apply.mjs +67 -0
- package/scripts/coverage-classify/cache.mjs +77 -0
- package/scripts/coverage-classify/index.mjs +129 -0
- package/scripts/coverage-classify/prompt.mjs +126 -0
- package/scripts/coverage-classify/verdict-schema.mjs +35 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API класифікатора: classify(survived, cwd, opts) → verdicts[]
|
|
3
|
+
*
|
|
4
|
+
* Orchestration:
|
|
5
|
+
* 1. Перевірка ANTHROPIC_API_KEY + dynamic import SDK (graceful skip).
|
|
6
|
+
* 2. Для кожного мутанта: cache lookup → класифікація → cache write.
|
|
7
|
+
* 3. На неуспішну класифікацію після retries — conservative fallback worth-testing/confidence=0.
|
|
8
|
+
*
|
|
9
|
+
* Prompt caching: system-prompt передається з cache_control: ephemeral —
|
|
10
|
+
* усі мутанти одного прогону reuse кешований префікс на стороні API.
|
|
11
|
+
*/
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { env } from 'node:process'
|
|
14
|
+
import { setTimeout } from 'node:timers/promises'
|
|
15
|
+
|
|
16
|
+
import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
|
|
17
|
+
import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
|
|
18
|
+
import { parseVerdict } from './verdict-schema.mjs'
|
|
19
|
+
|
|
20
|
+
const MODEL = 'claude-sonnet-4-6'
|
|
21
|
+
const MAX_RETRIES = 2
|
|
22
|
+
const DEFAULT_RETRY_DELAY_MS = 1000
|
|
23
|
+
|
|
24
|
+
const FALLBACK_VERDICT = {
|
|
25
|
+
verdict: 'worth-testing',
|
|
26
|
+
confidence: 0,
|
|
27
|
+
reason: 'LLM-classification unavailable, conservative fallback (treat as worth-testing)'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Класифікує survived мутантів через Claude API.
|
|
32
|
+
* Без API key / без SDK / при критичних помилках — повертає [] (graceful skip).
|
|
33
|
+
* @param {Array<{file: string, mutants: Array<object>, exampleTest?: object|null, recommendationText?: string|null}>} survived список survived груп (як у COVERAGE.md)
|
|
34
|
+
* @param {string} cwd корінь проєкту
|
|
35
|
+
* @param {{cachePath?: string, client?: object, retryDelayMs?: number}} [opts] ін'єкції для тестів
|
|
36
|
+
* @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
|
|
37
|
+
*/
|
|
38
|
+
export async function classify(survived, cwd, opts = {}) {
|
|
39
|
+
const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
|
|
40
|
+
const retryDelayMs = opts.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
|
|
41
|
+
|
|
42
|
+
if (!env.ANTHROPIC_API_KEY) {
|
|
43
|
+
console.warn('⚠ coverage classify: ANTHROPIC_API_KEY not set, classification skipped')
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let SDK
|
|
48
|
+
try {
|
|
49
|
+
SDK = await import('@anthropic-ai/sdk')
|
|
50
|
+
} catch {
|
|
51
|
+
console.warn('⚠ coverage classify: @anthropic-ai/sdk not installed, classification skipped')
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
const Anthropic = SDK.default
|
|
55
|
+
const client = opts.client ?? new Anthropic()
|
|
56
|
+
|
|
57
|
+
const cache = readCache(cachePath)
|
|
58
|
+
if (cache.model !== MODEL) {
|
|
59
|
+
cache.entries = {}
|
|
60
|
+
cache.model = MODEL
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const verdicts = []
|
|
64
|
+
for (const group of survived) {
|
|
65
|
+
for (const mutant of group.mutants) {
|
|
66
|
+
const lookupKey = `${group.file}:${mutant.line}:${mutant.col}:${mutant.replacement}`
|
|
67
|
+
const cacheKey = deriveCacheKey(join(cwd, group.file), mutant)
|
|
68
|
+
|
|
69
|
+
let verdict = null
|
|
70
|
+
if (cacheKey && cache.entries[cacheKey]) {
|
|
71
|
+
const cached = cache.entries[cacheKey]
|
|
72
|
+
verdict = {
|
|
73
|
+
verdict: cached.verdict,
|
|
74
|
+
confidence: cached.confidence,
|
|
75
|
+
reason: cached.reason,
|
|
76
|
+
...(cached.suggestedTest ? { suggestedTest: cached.suggestedTest } : {})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!verdict) {
|
|
80
|
+
verdict = await classifyOne(client, group, mutant, cwd, retryDelayMs)
|
|
81
|
+
if (cacheKey) {
|
|
82
|
+
cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
verdicts.push({ key: lookupKey, verdict })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
writeCache(cachePath, cache)
|
|
91
|
+
return verdicts
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Один виклик API з retry. На фейл після MAX_RETRIES — повертає FALLBACK_VERDICT.
|
|
96
|
+
* @param {{messages: {create: Function}}} client SDK client
|
|
97
|
+
* @param {{file: string}} group group для контексту
|
|
98
|
+
* @param {object} mutant mutant data
|
|
99
|
+
* @param {string} cwd корінь
|
|
100
|
+
* @param {number} retryDelayMs base delay для exp-backoff (0 у тестах)
|
|
101
|
+
* @returns {Promise<object>} verdict (parsed або fallback)
|
|
102
|
+
*/
|
|
103
|
+
async function classifyOne(client, group, mutant, cwd, retryDelayMs) {
|
|
104
|
+
const userPrompt = buildUserPrompt({ ...mutant, file: group.file }, cwd)
|
|
105
|
+
let lastError = null
|
|
106
|
+
|
|
107
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
108
|
+
try {
|
|
109
|
+
const response = await client.messages.create({
|
|
110
|
+
model: MODEL,
|
|
111
|
+
max_tokens: 1024,
|
|
112
|
+
system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
|
|
113
|
+
messages: [{ role: 'user', content: userPrompt }]
|
|
114
|
+
})
|
|
115
|
+
const text = response?.content?.[0]?.text ?? ''
|
|
116
|
+
return parseVerdict(text)
|
|
117
|
+
} catch (err) {
|
|
118
|
+
lastError = err
|
|
119
|
+
if (attempt < MAX_RETRIES && retryDelayMs > 0) {
|
|
120
|
+
await setTimeout(retryDelayMs * Math.pow(2, attempt))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.warn(
|
|
126
|
+
`⚠ coverage classify: ${group.file}:${mutant.line}:${mutant.col} failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message ?? 'unknown'}`
|
|
127
|
+
)
|
|
128
|
+
return { ...FALLBACK_VERDICT }
|
|
129
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Промпт-builder для coverage-classify.
|
|
3
|
+
* SYSTEM_PROMPT — статичний, кешується через cache_control: ephemeral у API call.
|
|
4
|
+
* buildUserPrompt — асемблює per-mutant контекст (location, source ±10, tests, git).
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from 'node:child_process'
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { basename, dirname, join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const CONTEXT_LINES = 10
|
|
11
|
+
const TEST_FILE_MAX_LINES = 2000
|
|
12
|
+
|
|
13
|
+
export const SYSTEM_PROMPT = `You are a mutation testing classifier.
|
|
14
|
+
|
|
15
|
+
For each survived Stryker mutant, classify it into exactly one verdict:
|
|
16
|
+
|
|
17
|
+
- **worth-testing**: pure logic with real branches that should be tested. The mutant
|
|
18
|
+
exposes a missing assertion in a unit test. Recommend a test approach.
|
|
19
|
+
- **equivalent**: the mutated code is behaviorally indistinguishable from the original
|
|
20
|
+
(e.g., both branches produce the same observable output, or the mutant lies on dead
|
|
21
|
+
code). You MUST cite a concrete reason referencing input flow or output equivalence.
|
|
22
|
+
- **defensive**: the branch guards against an impossible state given input contracts
|
|
23
|
+
or type system. You MUST identify the invariant that makes the state unreachable.
|
|
24
|
+
- **glue**: thin CLI entrypoint, factory, or boilerplate (e.g., runStandardRule
|
|
25
|
+
wrapper, fix.mjs stubs). Integration tests via subprocess cover the behavior.
|
|
26
|
+
Name the integration test or pattern.
|
|
27
|
+
- **wrapper**: thin shell around an external tool (spawnSync, fetch, dynamic import).
|
|
28
|
+
The wrapper has no logic worth unit-testing in isolation; behavior comes from the
|
|
29
|
+
wrapped tool. Name the integration test or pattern.
|
|
30
|
+
|
|
31
|
+
Output ONLY a single JSON object matching this schema:
|
|
32
|
+
|
|
33
|
+
\`\`\`
|
|
34
|
+
{
|
|
35
|
+
"verdict": "worth-testing" | "equivalent" | "defensive" | "glue" | "wrapper",
|
|
36
|
+
"confidence": number 0-1,
|
|
37
|
+
"reason": string (20-500 chars; concrete code-level reference, not "seems like"),
|
|
38
|
+
"suggestedTest": string (max 300 chars; required only when verdict is worth-testing)
|
|
39
|
+
}
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
Confidence guidance:
|
|
43
|
+
- 0.9+: cite specific code fragment, identifier, or input contract proving the verdict.
|
|
44
|
+
- 0.7-0.9: strong inference from visible code structure.
|
|
45
|
+
- <0.7: ambiguity, lacking context, or unfamiliar pattern. Be honest.
|
|
46
|
+
|
|
47
|
+
Never invent integration test names. If you cannot identify a covering test, use
|
|
48
|
+
worth-testing with low confidence instead of glue/wrapper.
|
|
49
|
+
`
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Витягує describe/test/it title з рядка тексту.
|
|
53
|
+
* @param {string} content повний текст test-файла
|
|
54
|
+
* @returns {string} список "describe: <title>" / "test: <title>" або порожній
|
|
55
|
+
*/
|
|
56
|
+
function extractTestTitles(content) {
|
|
57
|
+
const titles = []
|
|
58
|
+
for (const match of content.matchAll(/^\s*(describe|test|it)\(['"`](.+?)['"`]/gmu)) {
|
|
59
|
+
titles.push(`${match[1]}: ${match[2]}`)
|
|
60
|
+
}
|
|
61
|
+
return titles.join('\n') || '(no describe/test blocks found)'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Будує користувацький промпт для класифікації одного мутанта.
|
|
66
|
+
* @param {{file: string, line: number, col: number, mutantType: string, original: string, replacement: string}} mutant параметри мутанта (file — відносний до cwd)
|
|
67
|
+
* @param {string} cwd корінь проєкту
|
|
68
|
+
* @returns {string} user prompt
|
|
69
|
+
*/
|
|
70
|
+
export function buildUserPrompt(mutant, cwd) {
|
|
71
|
+
const absPath = join(cwd, mutant.file)
|
|
72
|
+
|
|
73
|
+
// Source context
|
|
74
|
+
let srcContext = '(source file unavailable)'
|
|
75
|
+
if (existsSync(absPath)) {
|
|
76
|
+
const lines = readFileSync(absPath, 'utf8').split('\n')
|
|
77
|
+
const start = Math.max(0, mutant.line - 1 - CONTEXT_LINES)
|
|
78
|
+
const end = Math.min(lines.length, mutant.line + CONTEXT_LINES)
|
|
79
|
+
srcContext = lines
|
|
80
|
+
.slice(start, end)
|
|
81
|
+
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
82
|
+
.join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Existing tests
|
|
86
|
+
const testPath = join(dirname(absPath), 'tests', `${basename(absPath, '.mjs')}.test.mjs`)
|
|
87
|
+
let existingTests = '(no test file)'
|
|
88
|
+
if (existsSync(testPath)) {
|
|
89
|
+
const content = readFileSync(testPath, 'utf8')
|
|
90
|
+
if (content.split('\n').length > TEST_FILE_MAX_LINES) {
|
|
91
|
+
existingTests = extractTestTitles(content)
|
|
92
|
+
} else {
|
|
93
|
+
existingTests = content
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Recent git activity (graceful если нет git або untracked)
|
|
98
|
+
let recentActivity = '(no git history)'
|
|
99
|
+
try {
|
|
100
|
+
const out = execFileSync('git', ['log', '-1', '--format=%ar', '--', absPath], {
|
|
101
|
+
cwd,
|
|
102
|
+
encoding: 'utf8',
|
|
103
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
104
|
+
}).trim()
|
|
105
|
+
if (out) recentActivity = out
|
|
106
|
+
} catch {
|
|
107
|
+
// git unavailable or file untracked — keep placeholder
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return `# Mutant
|
|
111
|
+
File: ${mutant.file}
|
|
112
|
+
Line: ${mutant.line}:${mutant.col}
|
|
113
|
+
Type: ${mutant.mutantType}
|
|
114
|
+
Original code: \`${mutant.original}\`
|
|
115
|
+
Mutated to: \`${mutant.replacement}\`
|
|
116
|
+
|
|
117
|
+
# Source context (±${CONTEXT_LINES} lines)
|
|
118
|
+
${srcContext}
|
|
119
|
+
|
|
120
|
+
# Existing tests
|
|
121
|
+
${existingTests}
|
|
122
|
+
|
|
123
|
+
# Recent activity
|
|
124
|
+
File last modified: ${recentActivity}
|
|
125
|
+
`
|
|
126
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod-схема для verdict-відповіді LLM-класифікатора (coverage-classify).
|
|
3
|
+
* parseVerdict — витяг JSON з raw-text LLM-відповіді + validate.
|
|
4
|
+
*
|
|
5
|
+
* Категорії:
|
|
6
|
+
* - worth-testing: pure logic, real branches — пиши тест
|
|
7
|
+
* - equivalent: мутант поведінково еквівалентний (не killable)
|
|
8
|
+
* - defensive: гілка для impossible state (не killable)
|
|
9
|
+
* - glue: CLI entry / runStandardRule wrapper (integration covers)
|
|
10
|
+
* - wrapper: тонкий spawn/fetch wrapper (integration covers)
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
|
|
14
|
+
export const VerdictSchema = z.object({
|
|
15
|
+
verdict: z.enum(['worth-testing', 'equivalent', 'defensive', 'glue', 'wrapper']),
|
|
16
|
+
confidence: z.number().min(0).max(1),
|
|
17
|
+
reason: z.string().min(20).max(500),
|
|
18
|
+
suggestedTest: z.string().max(300).optional()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Витягує JSON-об'єкт з raw-text LLM-відповіді і валідує через VerdictSchema.
|
|
23
|
+
* @param {string} rawText raw-text відповідь LLM
|
|
24
|
+
* @returns {{verdict: string, confidence: number, reason: string, suggestedTest?: string}} verdict
|
|
25
|
+
* @throws якщо JSON не знайдено, не парситься, або не відповідає схемі
|
|
26
|
+
*/
|
|
27
|
+
export function parseVerdict(rawText) {
|
|
28
|
+
const jsonStart = rawText.indexOf('{')
|
|
29
|
+
const jsonEnd = rawText.lastIndexOf('}')
|
|
30
|
+
if (jsonStart < 0 || jsonEnd < 0) {
|
|
31
|
+
throw new Error('No JSON object found in LLM response')
|
|
32
|
+
}
|
|
33
|
+
const json = JSON.parse(rawText.slice(jsonStart, jsonEnd + 1))
|
|
34
|
+
return VerdictSchema.parse(json)
|
|
35
|
+
}
|