@7n/rules 1.43.1 → 1.44.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 +15 -0
- package/docs/vitest.config.md +14 -19
- package/package.json +1 -1
- package/rules/abie/lib/docs/index.md +0 -2
- package/rules/abie/lib/http-route.mjs +1 -0
- package/rules/abie/lib/yaml.mjs +2 -0
- package/rules/ci4/marksman_config/docs/index.md +10 -0
- package/rules/ci4/marksman_config/main.mjs +2 -0
- package/rules/doc-files/docgen-prompts/docs/index.md +9 -0
- package/rules/doc-files/docgen-prompts/main.mjs +5 -0
- package/rules/hasura/internal_urls/docs/index.md +0 -2
- package/rules/hasura/internal_urls/docs/main.md +27 -15
- package/rules/hasura/internal_urls/main.mjs +1 -0
- package/rules/image-avif/avif_generation/docs/index.md +10 -0
- package/rules/image-avif/avif_generation/main.mjs +2 -0
- package/rules/rego/vscode_settings/docs/fix-vscode_settings.md +12 -10
- package/rules/rego/vscode_settings/fix-vscode_settings.mjs +5 -0
- package/rules/tauri/cargo_mutants_config/docs/index.md +0 -2
- package/rules/tauri/cargo_mutants_config/docs/main.md +38 -12
- package/rules/tauri/cargo_mutants_config/main.mjs +5 -1
- package/rules/tauri/core_test_isolation/docs/main.md +19 -16
- package/rules/tauri/core_test_isolation/main.mjs +3 -1
- package/rules/tauri/linux_deps/docs/main.md +25 -18
- package/rules/tauri/linux_deps/main.mjs +2 -1
- package/rules/tauri/release/docs/main.md +20 -12
- package/rules/tauri/release/main.mjs +2 -0
- package/rules/tauri/updater/docs/main.md +31 -14
- package/rules/tauri/updater/main.mjs +4 -0
- package/rules/test/coverage/concern.json +9 -0
- package/rules/test/coverage/fix-worker.mjs +111 -0
- package/rules/test/coverage/lib/classify/apply.mjs +67 -0
- package/rules/test/coverage/lib/classify/cache.mjs +77 -0
- package/rules/test/coverage/lib/classify/docs/apply.md +28 -0
- package/rules/test/coverage/lib/classify/docs/cache.md +34 -0
- package/rules/test/coverage/lib/classify/docs/index.md +37 -0
- package/rules/test/coverage/lib/classify/docs/prompt.md +30 -0
- package/rules/test/coverage/lib/classify/docs/verdict-schema.md +30 -0
- package/rules/test/coverage/lib/classify/index.mjs +140 -0
- package/rules/test/coverage/lib/classify/prompt.mjs +136 -0
- package/rules/test/coverage/lib/classify/verdict-schema.mjs +163 -0
- package/rules/test/coverage/lib/llm.mjs +100 -0
- package/rules/test/coverage/main.mjs +161 -0
- package/rules/test/main.json +1 -0
- package/rules/test/main.mdc +140 -0
- package/rules/test/package_json/concern.json +11 -0
- package/rules/test/package_json/package_json.mdc +18 -0
- package/rules/test/package_json/package_json.rego +25 -0
- package/rules/test/package_json/template/package.json.contains.json +6 -0
- package/rules/text/oxfmtrc/fix-oxfmtrc.mjs +5 -0
- package/rules/text/vscode_settings/fix-vscode_settings.mjs +5 -0
- package/rules/worktree/vscode_settings/docs/fix-vscode_settings.md +10 -7
- package/rules/worktree/vscode_settings/fix-vscode_settings.mjs +5 -0
- package/rules/worktree/zed_settings/fix-zed_settings.mjs +5 -0
- package/schemas/n-rules.json +18 -1
- package/scripts/lib/adr/docs/index.md +0 -2
- package/scripts/lib/adr/docs/normalize-pipeline.md +44 -28
- package/scripts/lib/adr/normalize-pipeline.mjs +11 -0
- package/scripts/lib/docs/inline-template-links.md +17 -8
- package/scripts/lib/docs/plugin-api.md +1 -1
- package/scripts/lib/inline-template-links.mjs +5 -0
- package/scripts/lib/lint-surface/docs/run-detectors.md +25 -22
- package/scripts/lib/lint-surface/docs/tier-sampling-experiment.md +30 -14
- package/scripts/lib/lint-surface/run-detectors.mjs +1 -1
- package/scripts/lib/lint-surface/tier-sampling-experiment.mjs +1 -0
- package/scripts/lib/plugin-api.mjs +46 -0
- package/scripts/utils/docs/walkDir.md +26 -15
- package/scripts/utils/docs/worktree-fingerprint.md +16 -15
- package/scripts/utils/walkDir.mjs +9 -5
- package/scripts/utils/worktree-fingerprint.mjs +5 -0
- package/skills/storybook/SKILL.md +4 -4
- package/skills/taze/js/migration-cache.mjs +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: prompt.mjs
|
|
4
|
+
resource: npm/src/coverage-classify/prompt.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: b4dd4c52
|
|
7
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
+
score: 100
|
|
9
|
+
issues: judge:inaccurate:0.98
|
|
10
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Огляд
|
|
14
|
+
|
|
15
|
+
Промпт-builder для `coverage-classify` генерує контекстні інструкції. Він визначає статичну роль класифікатора (`SYSTEM_PROMPT`), яка кешується через `cache_control: ephemeral` при виклику API. Динамічно він збирає деталі кожного мутанта (location, source $\pm$10, tests, git) у промпт, що здійснюється функцією `buildUserPrompt`. Процес працює з механізмом fail-safe, запобігаючи виникненню винятків, а кешування відбувається в межах одного прогону. `SYSTEM_PROMPT` явно вимагає строго валідний JSON без markdown-fence і prose навколо нього, з інструкцією екранувати лапки/backslash усередині `reason`/`suggestedTest` і триматись у межах довжини — це знижує частку відповідей, які не парсяться як JSON.
|
|
16
|
+
|
|
17
|
+
## Поведінка
|
|
18
|
+
|
|
19
|
+
SYSTEM_PROMPT визначає роль класифікатора мутацій і встановлює чіткий JSON-схему для відповіді, де мутації класифікуються як 'worth-testing', 'equivalent', 'defensive', 'glue' або 'wrapper'.
|
|
20
|
+
buildUserPrompt збирає повний контекст для класифікації конкретного мутанта, включаючи фрагмент вихідного коду навколо місця мутації, вміст супутніх тестів та останні зміни у Git для створення деталізованого промпта.
|
|
21
|
+
|
|
22
|
+
## Публічний API
|
|
23
|
+
|
|
24
|
+
SYSTEM_PROMPT — Визначає основні інструкції та обмеження для AI-агента.
|
|
25
|
+
buildUserPrompt — Формує запит від користувача для розпізнавання конкретного мутагенту.
|
|
26
|
+
|
|
27
|
+
## Гарантії поведінки
|
|
28
|
+
|
|
29
|
+
- Перехоплює помилки і не пропускає винятків назовні (fail-safe).
|
|
30
|
+
- Кешує результати в межах одного прогону.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: verdict-schema.mjs
|
|
4
|
+
resource: npm/src/coverage-classify/verdict-schema.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 51190b81
|
|
7
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
+
score: 100
|
|
9
|
+
issues: judge:inaccurate:0.98
|
|
10
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Огляд
|
|
14
|
+
|
|
15
|
+
Цей файл визначає `VerdictSchema` для структурованого відображення результатів класифікації, які генерує LLM-класифікатор (coverage-classify). Функція `parseVerdict` відповідає за вилучення та валідацію JSON-структури з необробленого тексту відповіді LLM, що дозволяє класифікувати вихід у категорії: `worth-testing`, `equivalent`, `defensive`, `glue` або `wrapper`. Витяг JSON толерантний до типових LLM-огріхів (markdown fences, prose навколо JSON, неекрановані лапки/backslash/control-символи всередині string-значень), які на практиці регулярно ламали наївний `JSON.parse`.
|
|
16
|
+
|
|
17
|
+
## Поведінка
|
|
18
|
+
|
|
19
|
+
VerdictSchema: Визначає структуру даних для результатів класифікації, отриманих від LLM.
|
|
20
|
+
parseVerdict: Знаходить перший JSON-об'єкт у сирому тексті відповіді (розрізаючи markdown-fence, якщо він є), ремонтує типові огріхи всередині string-значень (неекрановані лапки, невалідні backslash-escape як `\d`, буквальні control-символи, trailing comma), обрізає candidate по balanced-brace межі першого `{…}` (ігноруючи prose після нього), а надто довгі `reason`/`suggestedTest` — обрізає до ліміту схеми замість falling через `too_big`-помилку валідації. Лише після цього валідує через `VerdictSchema`.
|
|
21
|
+
|
|
22
|
+
## Публічний API
|
|
23
|
+
|
|
24
|
+
VerdictSchema — схема, що описує очікувану структуру відповіді від LLM.
|
|
25
|
+
parseVerdict — видобуває та перевіряє відповідь від LLM щодо відповідності схемі VerdictSchema; кидає помилку, якщо JSON не знайдено, JSON не парситься навіть після repair, або результат не проходить схему.
|
|
26
|
+
|
|
27
|
+
## Гарантії поведінки
|
|
28
|
+
|
|
29
|
+
- Read-only: не виконує операцій запису (ФС/БД).
|
|
30
|
+
- Repair-крок ніколи не звертається до мережі чи файлової системи — чиста трансформація рядка.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API класифікатора: classify(survived, cwd, opts) → verdicts[]
|
|
3
|
+
*
|
|
4
|
+
* Routing через pi SDK (callText):
|
|
5
|
+
* 1. Cache lookup → hit → використати збережений verdict.
|
|
6
|
+
* 2. Cache miss → Tier 1 (N_LOCAL_MIN_MODEL через pi) → parseVerdict.
|
|
7
|
+
* 3. Tier 1 fail → Tier 2 (N_CLOUD_MIN_MODEL через pi) → parseVerdict.
|
|
8
|
+
* 4. Tier 2 fail → conservative fallback worth-testing/confidence=0.
|
|
9
|
+
*/
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { callText } from '../llm.mjs'
|
|
13
|
+
import { CLOUD_MIN, LOCAL_MIN } from '@7n/llm-lib/model-tiers'
|
|
14
|
+
import { startChain } from '@7n/llm-lib/chain'
|
|
15
|
+
import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
|
|
16
|
+
import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
|
|
17
|
+
import { parseVerdict } from './verdict-schema.mjs'
|
|
18
|
+
|
|
19
|
+
const FALLBACK_VERDICT = {
|
|
20
|
+
verdict: 'worth-testing',
|
|
21
|
+
confidence: 0,
|
|
22
|
+
reason: 'LLM-classification unavailable, conservative fallback (treat as worth-testing)'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Викликає pi через callText з опційним model-id.
|
|
27
|
+
* @param {string} prompt готовий промпт класифікації
|
|
28
|
+
* @param {string} model provider/model-id або '' для pi-дефолту
|
|
29
|
+
* @param {string} cwd корінь проєкту
|
|
30
|
+
* @param {{chain?: object}} [callOpts] chain handle поточного мутанта
|
|
31
|
+
* @returns {Promise<string>} сирий текст відповіді моделі
|
|
32
|
+
*/
|
|
33
|
+
function callModel(prompt, model, cwd, { chain } = {}) {
|
|
34
|
+
return callText(prompt, { cwd, chain, ...(model && { model }) })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Два тири: tier1 (local-min) → tier2 (cloud-min) → FALLBACK_VERDICT.
|
|
39
|
+
* Кожен мутант — окремий ланцюжок (kind: mutant-classify): tier1 = крок 1,
|
|
40
|
+
* tier2 = крок 2; fallback-вердикт = outcome:'fail' (LLM не впорався).
|
|
41
|
+
* @param {{file: string, mutants: object[]}} group група survived-мутантів одного файлу
|
|
42
|
+
* @param {object} mutant один survived-мутант групи
|
|
43
|
+
* @param {string} cwd корінь проєкту
|
|
44
|
+
* @param {(prompt: string, model: string, cwd: string, callOpts?: {chain?: object}) => Promise<string>} callModelFn виклик моделі (інжект у тестах)
|
|
45
|
+
* @param {string} tier1 model-spec першого тиру ('' = pi-дефолт)
|
|
46
|
+
* @param {string} tier2 model-spec другого тиру ('' = pi-дефолт)
|
|
47
|
+
* @param {typeof startChain} makeChain фабрика ланцюжка
|
|
48
|
+
* @returns {Promise<object>} verdict
|
|
49
|
+
*/
|
|
50
|
+
async function classifyOne(group, mutant, cwd, callModelFn, tier1, tier2, makeChain) {
|
|
51
|
+
const prompt = `${SYSTEM_PROMPT}\n\n${buildUserPrompt({ ...mutant, file: group.file }, cwd)}`
|
|
52
|
+
const loc = `${group.file}:${mutant.line}:${mutant.col}`
|
|
53
|
+
const chain = makeChain({ kind: 'mutant-classify', unit: loc, cwd })
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const text = await callModelFn(prompt, tier1, cwd, { chain })
|
|
57
|
+
const verdict = parseVerdict(text)
|
|
58
|
+
chain.end({ outcome: 'success', extra: verdictExtra(verdict, mutant) })
|
|
59
|
+
return verdict
|
|
60
|
+
} catch {
|
|
61
|
+
try {
|
|
62
|
+
const text = await callModelFn(prompt, tier2, cwd, { chain })
|
|
63
|
+
const verdict = parseVerdict(text)
|
|
64
|
+
chain.end({ outcome: 'success', extra: verdictExtra(verdict, mutant) })
|
|
65
|
+
return verdict
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.warn(`⚠ coverage classify: ${loc} both tiers failed: ${error.message}`)
|
|
68
|
+
chain.end({ outcome: 'fail', extra: { error: String(error.message ?? error).slice(0, 200) } })
|
|
69
|
+
return { ...FALLBACK_VERDICT }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extra-поля фінального chain-запису мутанта.
|
|
76
|
+
* @param {{verdict: string, confidence: number}} verdict розпарсений вердикт
|
|
77
|
+
* @param {{replacement?: string}} mutant мутант
|
|
78
|
+
* @returns {object} extra
|
|
79
|
+
*/
|
|
80
|
+
function verdictExtra(verdict, mutant) {
|
|
81
|
+
return {
|
|
82
|
+
verdict: verdict.verdict,
|
|
83
|
+
confidence: verdict.confidence,
|
|
84
|
+
replacement: String(mutant.replacement ?? '').slice(0, 120)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Класифікує survived мутантів через pi (N_LOCAL_MIN_MODEL → N_CLOUD_MIN_MODEL → fallback).
|
|
90
|
+
* @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived survived-мутанти з виміру, згруповані по файлах
|
|
91
|
+
* @param {string} cwd корінь проєкту
|
|
92
|
+
* @param {{cachePath?: string, callModel?: (prompt: string, model: string, cwd: string, callOpts?: {chain?: object}) => Promise<string>,
|
|
93
|
+
* tier1?: string, tier2?: string, startChain?: typeof startChain}} [opts] `tier1`/`tier2` — явні model-specs (дефолт: LOCAL_MIN/CLOUD_MIN пакета;
|
|
94
|
+
* інжектовні, бо тир-константи фіксуються при імпорті й у тестах не стабляться через env); `startChain` — фабрика ланцюжка (інжект для тестів)
|
|
95
|
+
* @returns {Promise<Array<{key: string, verdict: object}>>} вердикти по кожному мутанту
|
|
96
|
+
*/
|
|
97
|
+
export async function classify(survived, cwd, opts = {}) {
|
|
98
|
+
const cachePath = opts.cachePath ?? join(cwd, 'reports', 'coverage-classify.cache.json')
|
|
99
|
+
const callModelFn = opts.callModel ?? callModel
|
|
100
|
+
const makeChain = opts.startChain ?? startChain
|
|
101
|
+
const tier1 = opts.tier1 ?? LOCAL_MIN
|
|
102
|
+
const tier2 = opts.tier2 ?? CLOUD_MIN
|
|
103
|
+
const cacheModel = `${tier1 || 'default'}+${tier2 || 'cloud'}`
|
|
104
|
+
|
|
105
|
+
const cache = readCache(cachePath)
|
|
106
|
+
if (cache.model !== cacheModel) {
|
|
107
|
+
cache.entries = {}
|
|
108
|
+
cache.model = cacheModel
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const verdicts = []
|
|
112
|
+
for (const group of survived) {
|
|
113
|
+
for (const mutant of group.mutants) {
|
|
114
|
+
const lookupKey = `${group.file}:${mutant.line}:${mutant.col}:${mutant.replacement}`
|
|
115
|
+
const cacheKey = deriveCacheKey(join(cwd, group.file), mutant)
|
|
116
|
+
|
|
117
|
+
let verdict = null
|
|
118
|
+
if (cacheKey && cache.entries[cacheKey]) {
|
|
119
|
+
const cached = cache.entries[cacheKey]
|
|
120
|
+
verdict = {
|
|
121
|
+
verdict: cached.verdict,
|
|
122
|
+
confidence: cached.confidence,
|
|
123
|
+
reason: cached.reason,
|
|
124
|
+
...(cached.suggestedTest && { suggestedTest: cached.suggestedTest })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!verdict) {
|
|
128
|
+
verdict = await classifyOne(group, mutant, cwd, callModelFn, tier1, tier2, makeChain)
|
|
129
|
+
if (cacheKey) {
|
|
130
|
+
cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
verdicts.push({ key: lookupKey, verdict })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
writeCache(cachePath, cache)
|
|
139
|
+
return verdicts
|
|
140
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
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 — no markdown code fences, no
|
|
32
|
+
prose before or after, nothing but the JSON object itself:
|
|
33
|
+
|
|
34
|
+
\`\`\`
|
|
35
|
+
{
|
|
36
|
+
"verdict": "worth-testing" | "equivalent" | "defensive" | "glue" | "wrapper",
|
|
37
|
+
"confidence": number 0-1,
|
|
38
|
+
"reason": string (20-500 chars; concrete code-level reference, not "seems like"),
|
|
39
|
+
"suggestedTest": string (max 300 chars; required only when verdict is worth-testing)
|
|
40
|
+
}
|
|
41
|
+
\`\`\`
|
|
42
|
+
|
|
43
|
+
The response must be valid JSON, parseable by a strict JSON.parse. In particular:
|
|
44
|
+
- "reason" and "suggestedTest" are single-line strings: no literal newlines — if you
|
|
45
|
+
need to separate points, use "; " instead of a line break.
|
|
46
|
+
- Escape every double quote and backslash that occurs inside a string value (e.g. a
|
|
47
|
+
quoted identifier like \`user.role\`, or a regex like /\\d+/, must become
|
|
48
|
+
\\"user.role\\" and /\\\\d+/ inside the JSON string).
|
|
49
|
+
- Stay within the 500/300 char limits above — summarize instead of running long;
|
|
50
|
+
a response that gets cut off for length is worse than a terse one.
|
|
51
|
+
|
|
52
|
+
Confidence guidance:
|
|
53
|
+
- 0.9+: cite specific code fragment, identifier, or input contract proving the verdict.
|
|
54
|
+
- 0.7-0.9: strong inference from visible code structure.
|
|
55
|
+
- <0.7: ambiguity, lacking context, or unfamiliar pattern. Be honest.
|
|
56
|
+
|
|
57
|
+
Never invent integration test names. If you cannot identify a covering test, use
|
|
58
|
+
worth-testing with low confidence instead of glue/wrapper.
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Витягує describe/test/it title з рядка тексту.
|
|
63
|
+
* @param {string} content повний текст test-файла
|
|
64
|
+
* @returns {string} список "describe: <title>" / "test: <title>" або порожній
|
|
65
|
+
*/
|
|
66
|
+
function extractTestTitles(content) {
|
|
67
|
+
const titles = []
|
|
68
|
+
for (const match of content.matchAll(/^[ \t]{0,16}(describe|test|it)\(['"`](.{1,300}?)['"`]/gmu)) {
|
|
69
|
+
titles.push(`${match[1]}: ${match[2]}`)
|
|
70
|
+
}
|
|
71
|
+
return titles.join('\n') || '(no describe/test blocks found)'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Будує користувацький промпт для класифікації одного мутанта.
|
|
76
|
+
* @param {{file: string, line: number, col: number, mutantType: string, original: string, replacement: string}} mutant параметри мутанта (file — відносний до cwd)
|
|
77
|
+
* @param {string} cwd корінь проєкту
|
|
78
|
+
* @returns {string} user prompt
|
|
79
|
+
*/
|
|
80
|
+
export function buildUserPrompt(mutant, cwd) {
|
|
81
|
+
const absPath = join(cwd, mutant.file)
|
|
82
|
+
|
|
83
|
+
// Source context
|
|
84
|
+
let srcContext = '(source file unavailable)'
|
|
85
|
+
if (existsSync(absPath)) {
|
|
86
|
+
const lines = readFileSync(absPath, 'utf8').split('\n')
|
|
87
|
+
const start = Math.max(0, mutant.line - 1 - CONTEXT_LINES)
|
|
88
|
+
const end = Math.min(lines.length, mutant.line + CONTEXT_LINES)
|
|
89
|
+
srcContext = lines
|
|
90
|
+
.slice(start, end)
|
|
91
|
+
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
92
|
+
.join('\n')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Existing tests
|
|
96
|
+
const testPath = join(dirname(absPath), 'tests', `${basename(absPath, '.mjs')}.test.mjs`)
|
|
97
|
+
let existingTests = '(no test file)'
|
|
98
|
+
if (existsSync(testPath)) {
|
|
99
|
+
const content = readFileSync(testPath, 'utf8')
|
|
100
|
+
if (content.split('\n').length > TEST_FILE_MAX_LINES) {
|
|
101
|
+
existingTests = extractTestTitles(content)
|
|
102
|
+
} else {
|
|
103
|
+
existingTests = content
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Recent git activity (graceful если нет git або untracked)
|
|
108
|
+
let recentActivity = '(no git history)'
|
|
109
|
+
try {
|
|
110
|
+
const out = execFileSync('git', ['log', '-1', '--format=%ar', '--', absPath], {
|
|
111
|
+
cwd,
|
|
112
|
+
encoding: 'utf8',
|
|
113
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
114
|
+
}).trim()
|
|
115
|
+
if (out) recentActivity = out
|
|
116
|
+
} catch {
|
|
117
|
+
// git unavailable or file untracked — keep placeholder
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return `# Mutant
|
|
121
|
+
File: ${mutant.file}
|
|
122
|
+
Line: ${mutant.line}:${mutant.col}
|
|
123
|
+
Type: ${mutant.mutantType}
|
|
124
|
+
Original code: \`${mutant.original}\`
|
|
125
|
+
Mutated to: \`${mutant.replacement}\`
|
|
126
|
+
|
|
127
|
+
# Source context (±${CONTEXT_LINES} lines)
|
|
128
|
+
${srcContext}
|
|
129
|
+
|
|
130
|
+
# Existing tests
|
|
131
|
+
${existingTests}
|
|
132
|
+
|
|
133
|
+
# Recent activity
|
|
134
|
+
File last modified: ${recentActivity}
|
|
135
|
+
`
|
|
136
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
// Трохи ширше за prompt-ліміт (500) — запас на моделі, які трохи перевищують
|
|
15
|
+
// інструкцію; понад це вже truncate-имо самі перед валідацією (REASON_SOFT_MAX нижче).
|
|
16
|
+
const REASON_SOFT_MAX = 500
|
|
17
|
+
const SUGGESTED_TEST_SOFT_MAX = 300
|
|
18
|
+
|
|
19
|
+
export const VerdictSchema = z.object({
|
|
20
|
+
verdict: z.enum(['worth-testing', 'equivalent', 'defensive', 'glue', 'wrapper']),
|
|
21
|
+
confidence: z.number().min(0).max(1),
|
|
22
|
+
reason: z.string().min(20).max(REASON_SOFT_MAX),
|
|
23
|
+
suggestedTest: z.string().max(SUGGESTED_TEST_SOFT_MAX).optional()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const VALID_JSON_ESCAPES = new Set(['"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u'])
|
|
27
|
+
|
|
28
|
+
/** Пробільний символ (для peekNextStructural). */
|
|
29
|
+
const WHITESPACE_RE = /\s/u
|
|
30
|
+
/** Fenced json-блок (потрійні бектіки) у raw-відповіді LLM. */
|
|
31
|
+
const FENCED_JSON_RE = /```(?:json)?\s*([\s\S]*?)```/iu
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Перший non-whitespace символ від `from` — рішення, чи подвійна лапка
|
|
35
|
+
* закриває рядок, чи це буквальна лапка всередині значення.
|
|
36
|
+
* @param {string} text текст
|
|
37
|
+
* @param {number} from індекс, з якого шукати
|
|
38
|
+
* @returns {string | undefined} символ або undefined якщо кінець тексту
|
|
39
|
+
*/
|
|
40
|
+
function peekNextStructural(text, from) {
|
|
41
|
+
let i = from
|
|
42
|
+
while (i < text.length && WHITESPACE_RE.test(text[i])) i++
|
|
43
|
+
return text[i]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Одночасно (1) ремонтує типові LLM-огріхи всередині JSON string-літералів
|
|
48
|
+
* (неекрановані `"`/backslash/control-символи — джерело "Bad escaped character"
|
|
49
|
+
* і "Expected ',' or '}'" помилок JSON.parse) і (2) обрізає candidate на
|
|
50
|
+
* balanced-brace межі першого `{…}`, ігноруючи prose після нього.
|
|
51
|
+
* @param {string} text candidate-текст, що починається з `{`
|
|
52
|
+
* @returns {string} repaired JSON-текст (balanced або best-effort до кінця тексту)
|
|
53
|
+
*/
|
|
54
|
+
function repairAndBalance(text) {
|
|
55
|
+
let out = ''
|
|
56
|
+
let inString = false
|
|
57
|
+
let depth = 0
|
|
58
|
+
for (let i = 0; i < text.length; i++) {
|
|
59
|
+
const ch = text[i]
|
|
60
|
+
|
|
61
|
+
if (inString) {
|
|
62
|
+
if (ch === '\\') {
|
|
63
|
+
const next = text[i + 1]
|
|
64
|
+
if (next !== undefined && VALID_JSON_ESCAPES.has(next)) {
|
|
65
|
+
out += ch + next
|
|
66
|
+
i++
|
|
67
|
+
} else {
|
|
68
|
+
out += String.raw`\\` // невалідний escape (напр. \d, \s) → буквальний backslash
|
|
69
|
+
}
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (ch === '"') {
|
|
73
|
+
const nextStructural = peekNextStructural(text, i + 1)
|
|
74
|
+
const closesString = nextStructural === undefined || ',}]:'.includes(nextStructural)
|
|
75
|
+
if (closesString) {
|
|
76
|
+
inString = false
|
|
77
|
+
out += ch
|
|
78
|
+
} else {
|
|
79
|
+
out += String.raw`\"` // буквальна лапка всередині значення (напр. цитата коду)
|
|
80
|
+
}
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (ch === '\n') {
|
|
84
|
+
out += String.raw`\n`
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (ch === '\r') {
|
|
88
|
+
out += String.raw`\r`
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (ch === '\t') {
|
|
92
|
+
out += String.raw`\t`
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
out += ch
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (ch === '"') {
|
|
100
|
+
inString = true
|
|
101
|
+
out += ch
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (ch === '{') {
|
|
105
|
+
depth++
|
|
106
|
+
out += ch
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (ch === '}') {
|
|
110
|
+
depth--
|
|
111
|
+
out += ch
|
|
112
|
+
if (depth === 0) return out
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (ch === ',' && '}]'.includes(peekNextStructural(text, i + 1) ?? '')) {
|
|
116
|
+
continue // trailing comma перед закриттям — прибираємо
|
|
117
|
+
}
|
|
118
|
+
out += ch
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Витягує JSON-об'єкт з raw-text LLM-відповіді. Толерантний до markdown
|
|
125
|
+
* code fences і prose до/після JSON-блоку.
|
|
126
|
+
* @param {string} rawText raw-text відповідь LLM
|
|
127
|
+
* @returns {string | null} candidate-текст, що починається з першого `{`, або null
|
|
128
|
+
*/
|
|
129
|
+
function extractJsonCandidate(rawText) {
|
|
130
|
+
const fenced = rawText.match(FENCED_JSON_RE)
|
|
131
|
+
const text = fenced ? fenced[1] : rawText
|
|
132
|
+
const start = text.indexOf('{')
|
|
133
|
+
return start === -1 ? null : text.slice(start)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Витягує JSON-об'єкт з raw-text LLM-відповіді і валідує через VerdictSchema.
|
|
138
|
+
* Толерантний до markdown fences, prose навколо JSON, неекранованих лапок/
|
|
139
|
+
* backslash/control-символів усередині string-значень (типові LLM-огріхи, що
|
|
140
|
+
* ламають наївний `JSON.parse`).
|
|
141
|
+
* @param {string} rawText raw-text відповідь LLM
|
|
142
|
+
* @returns {{verdict: string, confidence: number, reason: string, suggestedTest?: string}} verdict
|
|
143
|
+
* @throws {Error} якщо JSON не знайдено, не парситься навіть після repair, або не відповідає схемі
|
|
144
|
+
*/
|
|
145
|
+
export function parseVerdict(rawText) {
|
|
146
|
+
const candidate = extractJsonCandidate(rawText)
|
|
147
|
+
if (!candidate) {
|
|
148
|
+
throw new Error('No JSON object found in LLM response')
|
|
149
|
+
}
|
|
150
|
+
const repaired = repairAndBalance(candidate)
|
|
151
|
+
const json = JSON.parse(repaired)
|
|
152
|
+
|
|
153
|
+
if (json && typeof json === 'object') {
|
|
154
|
+
if (typeof json.reason === 'string' && json.reason.length > REASON_SOFT_MAX) {
|
|
155
|
+
json.reason = json.reason.slice(0, REASON_SOFT_MAX)
|
|
156
|
+
}
|
|
157
|
+
if (typeof json.suggestedTest === 'string' && json.suggestedTest.length > SUGGESTED_TEST_SOFT_MAX) {
|
|
158
|
+
json.suggestedTest = json.suggestedTest.slice(0, SUGGESTED_TEST_SOFT_MAX)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return VerdictSchema.parse(json)
|
|
163
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Тонкий адаптер `@7n/test` над `@7n/llm-lib` (Ф3 спеки llm-lib-extraction):
|
|
3
|
+
* зберігає звичний контракт `callText`/`callAgent` для внутрішніх колерів,
|
|
4
|
+
* але транспорт/registry/trace повністю живуть у пакеті.
|
|
5
|
+
*
|
|
6
|
+
* Політика fail-fast успадковується від пакета: жодних retry/backoff на
|
|
7
|
+
* connection- чи memory-помилках (колишні knobs N_PI_RETRY_ATTEMPTS,
|
|
8
|
+
* N_PI_MEMORY_RETRY_ATTEMPTS тощо видалено разом із withRetry).
|
|
9
|
+
* Єдина локальна політика — одноразове
|
|
10
|
+
* подвоєння `maxTokens` на обрізаній відповіді (`stopReason: 'length'`):
|
|
11
|
+
* це семантичний повтор без пауз, а не очікування зайнятого сервера.
|
|
12
|
+
*/
|
|
13
|
+
import { env } from 'node:process'
|
|
14
|
+
import { runOneShot } from '@7n/llm-lib/one-shot'
|
|
15
|
+
import { runAgentSkill } from '@7n/llm-lib/agent-skill'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Стеля відповіді моделі для подвоєння на `stopReason: 'length'` — межа
|
|
19
|
+
* `maxTokens` реєстру для локальної моделі (див. `~/.pi/agent/models.json`).
|
|
20
|
+
*/
|
|
21
|
+
const MAX_TOKENS_CEILING = 32_768
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Таймаут агентного виклику (паритет зі старим spawnSync pi CLI). Override:
|
|
25
|
+
* `N_CURSOR_AGENT_TIMEOUT_MS` — для великих проєктів, де навіть один batch
|
|
26
|
+
* (див. `coverage-fix.mjs#fixSurvivedMutants`) потребує більше часу.
|
|
27
|
+
*/
|
|
28
|
+
const AGENT_TIMEOUT_MS = Number(env.N_CURSOR_AGENT_TIMEOUT_MS) || 900_000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Одноразовий text-виклик (без tools). Кидає Error на будь-якій помилці
|
|
32
|
+
* виклику (колери класифікують memory-guard через `MEMORY_ERROR_RE`).
|
|
33
|
+
* @param {string} prompt текст запиту для моделі
|
|
34
|
+
* @param {object} [opts] додаткові параметри виклику
|
|
35
|
+
* @param {string} [opts.cwd] робоча директорія для session
|
|
36
|
+
* @param {string} [opts.model] provider/model-id (напр. "openai/gpt-5.5"); без значення — default pi
|
|
37
|
+
* @param {number} [opts.maxTokens] стеля відповіді для цього виклику; на
|
|
38
|
+
* `stopReason: 'length'` виклик повторюється один раз із подвоєною стелею
|
|
39
|
+
* @param {object} [opts.chain] chain handle (`@7n/llm-lib/chain`) — виклик стає кроком ланцюжка
|
|
40
|
+
* @param {object} [opts.deps] інжекти для тестів (прокидаються у runOneShot)
|
|
41
|
+
* @returns {Promise<string>} текстова відповідь моделі
|
|
42
|
+
*/
|
|
43
|
+
export async function callText(prompt, opts = {}) {
|
|
44
|
+
let maxTokens = opts.maxTokens
|
|
45
|
+
let lengthRetried = Boolean(opts._lengthRetried)
|
|
46
|
+
|
|
47
|
+
while (true) {
|
|
48
|
+
const r = await runOneShot({
|
|
49
|
+
messages: [{ role: 'user', content: prompt }],
|
|
50
|
+
modelSpec: opts.model ?? '',
|
|
51
|
+
maxTokens,
|
|
52
|
+
timeoutMs: 0,
|
|
53
|
+
cwd: opts.cwd,
|
|
54
|
+
caller: '7n-test:text',
|
|
55
|
+
chain: opts.chain ?? null,
|
|
56
|
+
deps: opts.deps
|
|
57
|
+
})
|
|
58
|
+
if (r.error) throw new Error(r.error)
|
|
59
|
+
|
|
60
|
+
// Обрізана генерація зі зниженою стелею — не палимо retry-цикли колера
|
|
61
|
+
// на «invalid block», а один раз повторюємо з подвоєною стелею.
|
|
62
|
+
if (r.stopReason === 'length' && maxTokens && maxTokens < MAX_TOKENS_CEILING && !lengthRetried) {
|
|
63
|
+
const doubled = Math.min(maxTokens * 2, MAX_TOKENS_CEILING)
|
|
64
|
+
console.log(` ⚠ відповідь обрізана (stopReason: length) — повтор із maxTokens ${maxTokens} → ${doubled}`)
|
|
65
|
+
maxTokens = doubled
|
|
66
|
+
lengthRetried = true
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return r.content
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Агентний виклик із повним tool-set (read/write/edit/bash/grep/find/ls):
|
|
76
|
+
* агент пише файли напряму, текст стрімиться у stdout. Кидає Error на
|
|
77
|
+
* помилці виклику. Заміна колишнього `spawnSync('pi', ['-p', ...])`.
|
|
78
|
+
* @param {string} prompt текст завдання для агента
|
|
79
|
+
* @param {string} cwd робоча директорія, куди агент може писати файли
|
|
80
|
+
* @param {object} [opts] додаткові параметри
|
|
81
|
+
* @param {string} [opts.model] provider/model-id або '' для pi-дефолту
|
|
82
|
+
* @param {object} [opts.chain] chain handle (`@7n/llm-lib/chain`) — виклик стає кроком ланцюжка
|
|
83
|
+
* @param {object} [opts.deps] інжекти для тестів (прокидаються у runAgentSkill)
|
|
84
|
+
* @returns {Promise<void>} проміс завершується після виконання агента
|
|
85
|
+
*/
|
|
86
|
+
export async function callAgent(prompt, cwd, opts = {}) {
|
|
87
|
+
const r = await runAgentSkill(prompt, {
|
|
88
|
+
skillId: '7n-test',
|
|
89
|
+
modelSpec: opts.model ?? '',
|
|
90
|
+
cwd,
|
|
91
|
+
timeoutMs: AGENT_TIMEOUT_MS,
|
|
92
|
+
maxTokens: 0, // без стелі: агент пише цілі тест-файли (паритет зі старим CLI-шляхом)
|
|
93
|
+
caller: 'agent:7n-test',
|
|
94
|
+
chain: opts.chain ?? null,
|
|
95
|
+
deps: opts.deps
|
|
96
|
+
})
|
|
97
|
+
if (r.error) throw new Error(r.error)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { MEMORY_ERROR_RE } from '@7n/llm-lib/one-shot'
|