@7n/rules 1.43.1 → 1.44.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/docs/vitest.config.md +14 -19
  3. package/package.json +1 -1
  4. package/rules/abie/lib/docs/index.md +0 -2
  5. package/rules/abie/lib/http-route.mjs +1 -0
  6. package/rules/abie/lib/yaml.mjs +2 -0
  7. package/rules/ci4/marksman_config/docs/index.md +10 -0
  8. package/rules/ci4/marksman_config/main.mjs +2 -0
  9. package/rules/doc-files/docgen-prompts/docs/index.md +9 -0
  10. package/rules/doc-files/docgen-prompts/main.mjs +5 -0
  11. package/rules/hasura/internal_urls/docs/index.md +0 -2
  12. package/rules/hasura/internal_urls/docs/main.md +27 -15
  13. package/rules/hasura/internal_urls/main.mjs +1 -0
  14. package/rules/image-avif/avif_generation/docs/index.md +10 -0
  15. package/rules/image-avif/avif_generation/main.mjs +2 -0
  16. package/rules/rego/vscode_settings/docs/fix-vscode_settings.md +12 -10
  17. package/rules/rego/vscode_settings/fix-vscode_settings.mjs +5 -0
  18. package/rules/tauri/cargo_mutants_config/docs/index.md +0 -2
  19. package/rules/tauri/cargo_mutants_config/docs/main.md +38 -12
  20. package/rules/tauri/cargo_mutants_config/main.mjs +5 -1
  21. package/rules/tauri/core_test_isolation/docs/main.md +19 -16
  22. package/rules/tauri/core_test_isolation/main.mjs +3 -1
  23. package/rules/tauri/linux_deps/docs/main.md +25 -18
  24. package/rules/tauri/linux_deps/main.mjs +2 -1
  25. package/rules/tauri/release/docs/main.md +20 -12
  26. package/rules/tauri/release/main.mjs +2 -0
  27. package/rules/tauri/updater/docs/main.md +31 -14
  28. package/rules/tauri/updater/main.mjs +4 -0
  29. package/rules/test/coverage/concern.json +9 -0
  30. package/rules/test/coverage/fix-worker.mjs +111 -0
  31. package/rules/test/coverage/lib/classify/apply.mjs +67 -0
  32. package/rules/test/coverage/lib/classify/cache.mjs +77 -0
  33. package/rules/test/coverage/lib/classify/docs/apply.md +28 -0
  34. package/rules/test/coverage/lib/classify/docs/cache.md +34 -0
  35. package/rules/test/coverage/lib/classify/docs/index.md +37 -0
  36. package/rules/test/coverage/lib/classify/docs/prompt.md +30 -0
  37. package/rules/test/coverage/lib/classify/docs/verdict-schema.md +30 -0
  38. package/rules/test/coverage/lib/classify/index.mjs +140 -0
  39. package/rules/test/coverage/lib/classify/prompt.mjs +136 -0
  40. package/rules/test/coverage/lib/classify/verdict-schema.mjs +163 -0
  41. package/rules/test/coverage/lib/llm.mjs +100 -0
  42. package/rules/test/coverage/main.mjs +161 -0
  43. package/rules/test/main.json +1 -0
  44. package/rules/test/main.mdc +140 -0
  45. package/rules/test/package_json/concern.json +11 -0
  46. package/rules/test/package_json/package_json.mdc +18 -0
  47. package/rules/test/package_json/package_json.rego +25 -0
  48. package/rules/test/package_json/template/package.json.contains.json +6 -0
  49. package/rules/text/oxfmtrc/fix-oxfmtrc.mjs +5 -0
  50. package/rules/text/vscode_settings/fix-vscode_settings.mjs +5 -0
  51. package/rules/worktree/vscode_settings/docs/fix-vscode_settings.md +10 -7
  52. package/rules/worktree/vscode_settings/fix-vscode_settings.mjs +5 -0
  53. package/rules/worktree/zed_settings/fix-zed_settings.mjs +5 -0
  54. package/schemas/n-rules.json +18 -1
  55. package/scripts/lib/adr/docs/index.md +0 -2
  56. package/scripts/lib/adr/docs/normalize-pipeline.md +44 -28
  57. package/scripts/lib/adr/normalize-pipeline.mjs +11 -0
  58. package/scripts/lib/auto-worktree.mjs +9 -3
  59. package/scripts/lib/docs/auto-worktree.md +50 -12
  60. package/scripts/lib/docs/inline-template-links.md +17 -8
  61. package/scripts/lib/docs/plugin-api.md +1 -1
  62. package/scripts/lib/docs/worktree-notice.md +65 -10
  63. package/scripts/lib/inline-template-links.mjs +5 -0
  64. package/scripts/lib/lint-surface/docs/run-detectors.md +25 -22
  65. package/scripts/lib/lint-surface/docs/tier-sampling-experiment.md +30 -14
  66. package/scripts/lib/lint-surface/run-detectors.mjs +1 -1
  67. package/scripts/lib/lint-surface/tier-sampling-experiment.mjs +1 -0
  68. package/scripts/lib/plugin-api.mjs +46 -0
  69. package/scripts/lib/worktree-notice.mjs +5 -3
  70. package/scripts/utils/docs/walkDir.md +26 -15
  71. package/scripts/utils/docs/worktree-fingerprint.md +16 -15
  72. package/scripts/utils/walkDir.mjs +9 -5
  73. package/scripts/utils/worktree-fingerprint.mjs +5 -0
  74. package/skills/storybook/SKILL.md +4 -4
  75. 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'