@nitra/cursor 1.32.0 → 1.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }