@nitra/cursor 9.1.1 → 9.3.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [9.3.0] - 2026-06-14
4
+
5
+ ### Added
6
+
7
+ - per-tool omlx-фіксер (point 4): cspell autofix одруків через omlx у fix-режимі lint-text; спільне ядро llm-fix-apply (parse/read/apply, перевикористане llm-worker); generic llm-lint-fix; re-detect лишає валідні терміни для словника. Відновлено канон lint-text.yml --read-only (відкочений паралельним агентом)
8
+
9
+ ## [9.2.0] - 2026-06-14
10
+
11
+ ### Changed
12
+
13
+ - docgen: класифікація omlx-збоїв (transient/systemic/permanent) — ретрай ETIMEDOUT з backoff, circuit-breaker на systemic-каскад (exit 2), permanent→skip; scan поважає .gitignore; прибрано хардкод DEFAULT_OMLX_MODEL (fail-loud, модель через N_LOCAL_MIN_MODEL)
14
+
3
15
  ## [9.1.1] - 2026-06-14
4
16
 
5
17
  ### Changed
package/lib/llm.mjs CHANGED
@@ -121,6 +121,26 @@ export function callLlm(messages, model, opts = {}) {
121
121
  const MEMORY_GUARD_MARKER = 'memory ceiling'
122
122
  /** Тип помилки omlx про відсутній/хибний API-ключ. */
123
123
  const AUTH_ERROR_MARKER = 'authentication_error'
124
+ /** Детерміновані помилки: контекст/модель — ретрай чи чекання не допоможе. */
125
+ const PERMANENT_RE = /too long|exceeds[^.]*context|not found/i
126
+
127
+ /**
128
+ * Класифікує omlx-помилку **після** того, як `callOmlxRaw` вичерпав внутрішні
129
+ * ретраї — для реакції оркестратора (skip vs circuit-breaker vs звичайна помилка):
130
+ * - `permanent` — детерміновано (контекст завеликий, модель відсутня): skip, не ретраїти;
131
+ * - `systemic` — середовище/сервер (memory-guard, auth, down/таймаут): каскадить → circuit-breaker;
132
+ * - `transient` — решта (empty content, bad json): рідкісне, не каскадить.
133
+ * @param {string} message текст `error.message`
134
+ * @returns {'transient'|'systemic'|'permanent'} клас помилки
135
+ */
136
+ export function classifyOmlxError(message) {
137
+ const m = String(message)
138
+ if (PERMANENT_RE.test(m)) return 'permanent'
139
+ if (m.includes(MEMORY_GUARD_MARKER) || m.includes(AUTH_ERROR_MARKER) || m.startsWith('omlx curl')) {
140
+ return 'systemic'
141
+ }
142
+ return 'transient'
143
+ }
124
144
 
125
145
  /**
126
146
  * Preflight-перевірка omlx перед масовим прогоном: мінімальний chat-виклик
package/lib/omlx.mjs CHANGED
@@ -45,11 +45,20 @@ export function resolveOmlxApiKey(apiKey) {
45
45
  }
46
46
  }
47
47
 
48
- /** Дефолтна модель, якщо в id лишився голий `omlx/` (override — `N_CURSOR_OMLX_MODEL`). */
49
- export const DEFAULT_OMLX_MODEL = 'mlx-community--gemma-4-e2b-it-4bit'
50
-
51
48
  const OMLX_PREFIX = 'omlx/'
52
49
 
50
+ /** Backoff між transient-ретраями curl (мс): 2 паузи на 3 спроби. */
51
+ const BACKOFF_MS = [2000, 8000]
52
+
53
+ /**
54
+ * Блокуюча пауза без зайнятого циклу (sync — для retry-loop у `callOmlxRaw`).
55
+ * @param {number} ms тривалість паузи
56
+ * @returns {void}
57
+ */
58
+ function sleepSync(ms) {
59
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
60
+ }
61
+
53
62
  /**
54
63
  * Чи цей model-id адресує локальний omlx-бекенд (префікс `omlx/`).
55
64
  * @param {unknown} model перевірюваний model-id
@@ -92,15 +101,38 @@ export function extractReasoning(message, finishReason) {
92
101
  return { reasoning: null, reasoningSource: null }
93
102
  }
94
103
 
104
+ /**
105
+ * Парсить успішну (curl-exit 0) omlx-відповідь у багатий обʼєкт.
106
+ * @param {string} stdout сире тіло відповіді curl
107
+ * @param {number} attempt номер успішної спроби (для поля `attempts`)
108
+ * @returns {{ content:string, reasoning:string|null, reasoningSource:string|null, finishReason:string|null, usage:object|null, attempts:number }} багатий результат
109
+ * @throws {Error} на поганому JSON, api-помилці чи порожньому контенті
110
+ */
111
+ function parseOmlxResponse(stdout, attempt) {
112
+ let j
113
+ try {
114
+ j = JSON.parse(stdout)
115
+ } catch {
116
+ throw new Error(`omlx bad json: ${stdout?.slice(0, 200) ?? ''}`)
117
+ }
118
+ if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`)
119
+ const message = j.choices?.[0]?.message ?? {}
120
+ const finishReason = j.choices?.[0]?.finish_reason ?? null
121
+ const content = message.content?.trim() ?? ''
122
+ if (!content) throw new Error(`omlx empty content (finish=${finishReason})`)
123
+ const { reasoning, reasoningSource } = extractReasoning(message, finishReason)
124
+ return { content, reasoning, reasoningSource, finishReason, usage: j.usage ?? null, attempts: attempt }
125
+ }
126
+
95
127
  /**
96
128
  * Ядро прямого HTTP-виклику до omlx через `curl` (spawnSync). Повертає **багатий**
97
129
  * обʼєкт: контент + reasoning + usage + finish_reason + кількість спроб. Ретраїть
98
- * лише transient curl-помилки (18 = transfer closed, 52 = empty reply, 56 = recv failure).
130
+ * transient-помилки (curl 18/28/52/56 + spawnSync ETIMEDOUT) із backoff 2s→8s.
99
131
  * @param {Array<{role:string, content:string}>} messages OpenAI-messages (system+user збережено)
100
- * @param {string} model model-id (з/без `omlx/`-префікса); порожній → дефолт
101
- * @param {{ url?: string, timeoutMs?: number, temperature?: number, maxTokens?: number, fallbackModel?: string, apiKey?: string }} [opts] URL, timeout, температура, ліміт виходу, fallback-модель, API-ключ
132
+ * @param {string} model model-id (з/без `omlx/`-префікса); порожній і без `fallbackModel` throw
133
+ * @param {{ url?: string, timeoutMs?: number, temperature?: number, maxTokens?: number, fallbackModel?: string, apiKey?: string, backoffMs?: number[] }} [opts] URL, timeout, температура, ліміт виходу, fallback-модель, API-ключ, backoff між ретраями (мс)
102
134
  * @returns {{ content:string, reasoning:string|null, reasoningSource:string|null, finishReason:string|null, usage:object|null, attempts:number }} багатий результат виклику
103
- * @throws на curl-помилці, не-200 exit, поганому JSON чи порожньому контенті
135
+ * @throws {Error} на curl-помилці, не-200 exit, поганому JSON чи порожньому контенті
104
136
  */
105
137
  export function callOmlxRaw(messages, model, opts = {}) {
106
138
  const {
@@ -108,18 +140,23 @@ export function callOmlxRaw(messages, model, opts = {}) {
108
140
  timeoutMs = 60_000,
109
141
  temperature = 0.2,
110
142
  maxTokens = 4096,
111
- fallbackModel = env.N_CURSOR_OMLX_MODEL ?? DEFAULT_OMLX_MODEL,
112
- apiKey
143
+ fallbackModel = env.N_CURSOR_OMLX_MODEL ?? '',
144
+ apiKey,
145
+ backoffMs = BACKOFF_MS
113
146
  } = opts
114
147
 
115
148
  const m = omlxModelId(model) || fallbackModel
149
+ if (!m) {
150
+ throw new Error('omlx: модель не задано — постав N_LOCAL_MIN_MODEL (або N_CURSOR_OMLX_MODEL)')
151
+ }
116
152
  const body = JSON.stringify({ model: m, messages, max_tokens: maxTokens, temperature })
117
153
  // Ключ локального сервера в argv допустимий: localhost-секрет власної машини,
118
154
  // короткоживучий процес; stdin уже зайнятий body (`--data-binary @-`).
119
155
  const key = resolveOmlxApiKey(apiKey)
120
156
  const authArgs = key ? ['-H', `Authorization: Bearer ${key}`] : []
121
157
 
122
- const TRANSIENT_CURL_CODES = new Set([18, 52, 56])
158
+ // 18=transfer closed, 28=operation timeout, 52=empty reply, 56=recv failure — усі transient.
159
+ const TRANSIENT_CURL_CODES = new Set([18, 28, 52, 56])
123
160
  let lastErr
124
161
  for (let attempt = 1; attempt <= 3; attempt++) {
125
162
  const r = spawnSync(
@@ -143,28 +180,22 @@ export function callOmlxRaw(messages, model, opts = {}) {
143
180
  )
144
181
  if (r.error) {
145
182
  lastErr = new Error(`omlx curl error: ${r.error.message}`)
183
+ // spawnSync-таймаут (ETIMEDOUT) — transient: сервер перевантажений, ретраїмо з backoff.
184
+ if (r.error.code === 'ETIMEDOUT' && attempt < 3) {
185
+ sleepSync(backoffMs[attempt - 1])
186
+ continue
187
+ }
146
188
  break
147
189
  }
148
190
  if (r.status !== 0) {
149
191
  if (TRANSIENT_CURL_CODES.has(r.status) && attempt < 3) {
150
192
  lastErr = new Error(`omlx curl exit ${r.status} (transient, retry ${attempt})`)
193
+ sleepSync(backoffMs[attempt - 1])
151
194
  continue
152
195
  }
153
196
  throw new Error(`omlx curl exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`)
154
197
  }
155
- let j
156
- try {
157
- j = JSON.parse(r.stdout)
158
- } catch {
159
- throw new Error(`omlx bad json: ${r.stdout?.slice(0, 200) ?? ''}`)
160
- }
161
- if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`)
162
- const message = j.choices?.[0]?.message ?? {}
163
- const finishReason = j.choices?.[0]?.finish_reason ?? null
164
- const content = message.content?.trim() ?? ''
165
- if (!content) throw new Error(`omlx empty content (finish=${finishReason})`)
166
- const { reasoning, reasoningSource } = extractReasoning(message, finishReason)
167
- return { content, reasoning, reasoningSource, finishReason, usage: j.usage ?? null, attempts: attempt }
198
+ return parseOmlxResponse(r.stdout, attempt)
168
199
  }
169
200
  throw lastErr ?? new Error('omlx unknown failure')
170
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "9.1.1",
3
+ "version": "9.3.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -20,9 +20,14 @@
20
20
  * docgen:
21
21
  * source: src/lib/foo.js
22
22
  * crc: a3f1c9e0
23
+ * model: omlx/gemma-4-e4b-it-OptiQ-4bit
23
24
  * score: 55
24
25
  * issues: short-behavior,internal-name:bar
25
26
  * ---
27
+ *
28
+ * `model` — повний id моделі-генератора (як повертає resolveModel, із префіксом
29
+ * провайдера). Пасивна метадата: маркер «віку» доки за моделлю на додачу до CRC
30
+ * джерела. На staleness НЕ впливає — звіряється лише `crc`.
26
31
  */
27
32
  import { existsSync, readFileSync } from 'node:fs'
28
33
  import { crc32 as zlibCrc32 } from 'node:zlib'
@@ -46,6 +51,7 @@ export function crc32(input) {
46
51
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/u
47
52
  const SOURCE_RE = /^[ \t]{0,8}source:[ \t]{0,8}(.+)$/mu
48
53
  const CRC_RE = /^[ \t]{0,8}crc:[ \t]{0,8}(.+)$/mu
54
+ const MODEL_RE = /^[ \t]{0,8}model:[ \t]{0,8}(.+)$/mu
49
55
  const SCORE_RE = /^[ \t]{0,8}score:[ \t]{0,8}(\d+)$/mu
50
56
  const ISSUES_RE = /^[ \t]{0,8}issues:[ \t]{0,8}(.+)$/mu
51
57
  const LEADING_NEWLINES_RE = /^\n+/u
@@ -53,10 +59,10 @@ const ISSUE_CODE_TAIL_RE = /[,:]$/u
53
59
 
54
60
  /**
55
61
  * Парсить frontmatter файлової доки. Без блоку — `data:null` і `body` дорівнює входу.
56
- * Поля `score`/`issues` опційні (back-compat зі старими доками): без них —
57
- * `score:null`, `issues:[]`.
62
+ * Поля `model`/`score`/`issues` опційні (back-compat зі старими доками): без них —
63
+ * `model:null`, `score:null`, `issues:[]`.
58
64
  * @param {string} md вміст md-файлу
59
- * @returns {{ data: { source: string|null, crc: string|null, score: number|null, issues: string[] }|null, body: string }} метадані + тіло без frontmatter
65
+ * @returns {{ data: { source: string|null, crc: string|null, model: string|null, score: number|null, issues: string[] }|null, body: string }} метадані + тіло без frontmatter
60
66
  */
61
67
  export function parseDocFrontmatter(md) {
62
68
  const match = md.match(FRONTMATTER_RE)
@@ -68,6 +74,7 @@ export function parseDocFrontmatter(md) {
68
74
  data: {
69
75
  source: block.match(SOURCE_RE)?.[1].trim() ?? null,
70
76
  crc: block.match(CRC_RE)?.[1].trim() ?? null,
77
+ model: block.match(MODEL_RE)?.[1].trim() ?? null,
71
78
  score: scoreRaw === undefined ? null : Number(scoreRaw),
72
79
  issues: issuesRaw
73
80
  ? issuesRaw
@@ -97,14 +104,16 @@ function issueCodes(issues) {
97
104
  }
98
105
 
99
106
  /**
100
- * Будує frontmatter-блок із шляхом джерела, CRC і (опційно) якістю.
107
+ * Будує frontmatter-блок із шляхом джерела, CRC, (опційно) моделлю-генератором і якістю.
101
108
  * @param {string} source відносний шлях джерела
102
109
  * @param {string} crc CRC32 джерела у hex
103
110
  * @param {{ score: number, issues?: string[] }|null} [quality] det-оцінка доки; null — без полів якості
104
- * @returns {string} рядок `---\ndocgen:\n source: …\n crc: …[\n score: …][\n issues: …]\n---\n`
111
+ * @param {string|null} [model] повний id моделі-генератора; null — без поля `model`
112
+ * @returns {string} рядок `---\ndocgen:\n source: …\n crc: …[\n model: …][\n score: …][\n issues: …]\n---\n`
105
113
  */
106
- export function buildDocFrontmatter(source, crc, quality = null) {
114
+ export function buildDocFrontmatter(source, crc, quality = null, model = null) {
107
115
  const lines = [`source: ${source}`, `crc: ${crc}`]
116
+ if (model) lines.push(`model: ${model}`)
108
117
  if (quality && typeof quality.score === 'number') {
109
118
  lines.push(`score: ${quality.score}`)
110
119
  const codes = issueCodes(quality.issues ?? [])
@@ -120,11 +129,12 @@ export function buildDocFrontmatter(source, crc, quality = null) {
120
129
  * @param {string} source відносний шлях джерела
121
130
  * @param {string} crc CRC32 джерела у hex
122
131
  * @param {{ score: number, issues?: string[] }|null} [quality] det-оцінка доки
132
+ * @param {string|null} [model] повний id моделі-генератора; null — без поля `model`
123
133
  * @returns {string} md зі свіжим frontmatter
124
134
  */
125
- export function stampDoc(md, source, crc, quality = null) {
135
+ export function stampDoc(md, source, crc, quality = null, model = null) {
126
136
  const { body } = parseDocFrontmatter(md)
127
- return `${buildDocFrontmatter(source, crc, quality)}\n${body.replace(LEADING_NEWLINES_RE, '')}`
137
+ return `${buildDocFrontmatter(source, crc, quality, model)}\n${body.replace(LEADING_NEWLINES_RE, '')}`
128
138
  }
129
139
 
130
140
  /**
@@ -148,6 +158,17 @@ export function readDocQuality(docAbsPath) {
148
158
  return { score: data?.score ?? null, issues: data?.issues ?? [] }
149
159
  }
150
160
 
161
+ /**
162
+ * Модель-генератор, збережена у frontmatter доки; `null` — доки немає або поле відсутнє
163
+ * (старі доки до введення `model`).
164
+ * @param {string} docAbsPath абсолютний шлях md-доки
165
+ * @returns {string|null} повний id моделі або null
166
+ */
167
+ export function readDocModel(docAbsPath) {
168
+ if (!existsSync(docAbsPath)) return null
169
+ return parseDocFrontmatter(readFileSync(docAbsPath, 'utf8')).data?.model ?? null
170
+ }
171
+
151
172
  /**
152
173
  * Стан застарілості доки відносно її джерела.
153
174
  * `missing` — доки немає; `crc-mismatch` — CRC джерела ≠ CRC у доці; інакше свіжа.
@@ -10,13 +10,13 @@
10
10
  * Перед масовим прогоном — health-check omlx: memory-guard зайнятої 8GB машини
11
11
  * означає «відклади прогін», а не сотні хибних «✗» у звіті.
12
12
  */
13
- import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'
13
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs'
14
14
  import { dirname, join } from 'node:path'
15
15
 
16
16
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
17
- import { omlxHealthCheck, pickBackend } from '../../../lib/llm.mjs'
17
+ import { omlxHealthCheck, pickBackend, classifyOmlxError } from '../../../lib/llm.mjs'
18
18
  import { generateDoc, DEFAULT_LOCAL_MODEL } from './docgen-gen.mjs'
19
- import { crc32, stampDoc, readDocQuality, QUALITY_THRESHOLD } from './docgen-crc.mjs'
19
+ import { crc32, stampDoc, readDocQuality, readDocModel, QUALITY_THRESHOLD } from './docgen-crc.mjs'
20
20
  import { resolveRoot, scanForDocFiles } from './docgen-scan.mjs'
21
21
 
22
22
  /**
@@ -64,6 +64,9 @@ function selectTargets(root, all, { overwrite, retryDegraded }) {
64
64
  * @returns {string|null} текст фатальної проблеми або null якщо можна генерувати
65
65
  */
66
66
  function preflightProblem() {
67
+ if (!DEFAULT_LOCAL_MODEL) {
68
+ return 'модель не задано. Вистав N_LOCAL_MIN_MODEL (напр. omlx/mlx-community--gemma-4-e4b-it-OptiQ-4bit) і повтори.'
69
+ }
67
70
  if (pickBackend(DEFAULT_LOCAL_MODEL) !== 'omlx') return null
68
71
  const hc = omlxHealthCheck({ model: DEFAULT_LOCAL_MODEL })
69
72
  if (hc.ok) return null
@@ -103,17 +106,39 @@ function fmtTiming(r) {
103
106
  return `${s(r.ms)} (llm ${s(llmMs)}/${r.llmCalls ?? 0} calls, orch ${s(r.ms - llmMs)})`
104
107
  }
105
108
 
109
+ /** Скільки systemic-збоїв підряд → негайний abort батчу (fail-fast, без cooldown). */
110
+ const SYSTEMIC_ABORT_STREAK = 3
111
+
112
+ /**
113
+ * Діагностика розміру джерела (для дослідження, що роздуває контекст):
114
+ * байти + груба оцінка токенів (~bytes/4). Без size-guard-гейта — лише вивід.
115
+ * @param {number} bytes розмір файлу в байтах
116
+ * @returns {string} напр. `12.3KB ~3.1k tok`
117
+ */
118
+ function fmtSize(bytes) {
119
+ return `${(bytes / 1024).toFixed(1)}KB ~${(bytes / 4 / 1000).toFixed(1)}k tok`
120
+ }
121
+
106
122
  /**
107
123
  * Генерує й штампує доку для одного файлу, оновлюючи лічильники й прогрес.
124
+ * Помилку класифікує (`classifyOmlxError`): `permanent` → skip (не «помилка для
125
+ * перегону»), `systemic`/`transient` → у `errors`. Повертає клас для циклу
126
+ * (circuit-breaker рахує systemic-підряд).
108
127
  * @param {object} file елемент scanForDocFiles
109
128
  * @param {string} root абсолютний корінь
110
129
  * @param {{ done: number, total: number }} progress позиція у прогресі
111
- * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats акумулятор статистики
112
- * @returns {Promise<void>}
130
+ * @param {{ ok: number, degraded: number, err: number, errors: string[], skipped: string[] }} stats акумулятор
131
+ * @returns {Promise<'ok'|'permanent'|'systemic'|'transient'>} результат для керування циклом
113
132
  */
114
133
  async function generateOne(file, root, progress, stats) {
115
134
  const sourceAbs = join(root, file.sourcePath)
116
- process.stdout.write(` [${progress.done}/${progress.total}] ${file.sourcePath} `)
135
+ let size = 0
136
+ try {
137
+ size = statSync(sourceAbs).size
138
+ } catch {
139
+ // файл зник між скануванням і генерацією — лишаємо розмір 0
140
+ }
141
+ process.stdout.write(` [${progress.done}/${progress.total}] ${file.sourcePath} [${fmtSize(size)}] … `)
117
142
  try {
118
143
  const docAbs = join(root, file.docPath)
119
144
  // Варіант B: передаємо наявну доку, щоб зберегти захищену секцію «Призначення»
@@ -123,7 +148,7 @@ async function generateOne(file, root, progress, stats) {
123
148
  mkdirSync(dirname(docAbs), { recursive: true })
124
149
  const quality =
125
150
  result.score === null ? null : { score: result.score, issues: result.degraded ? result.issues : [] }
126
- writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality))
151
+ writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality, result.model))
127
152
  stats.ok++
128
153
  if (result.degraded) {
129
154
  stats.degraded++
@@ -131,24 +156,38 @@ async function generateOne(file, root, progress, stats) {
131
156
  } else {
132
157
  process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc} ${fmtTiming(result)}\n`)
133
158
  }
159
+ return 'ok'
134
160
  } catch (error) {
135
- stats.err++
136
- stats.errors.push(file.sourcePath)
137
- process.stdout.write(`✗ ${error.message}\n`)
161
+ const cls = classifyOmlxError(error.message)
162
+ if (cls === 'permanent') {
163
+ stats.skipped.push(file.sourcePath)
164
+ process.stdout.write(`⊘ skip (permanent): ${error.message}\n`)
165
+ } else {
166
+ stats.err++
167
+ stats.errors.push(file.sourcePath)
168
+ process.stdout.write(`✗ ${cls}: ${error.message}\n`)
169
+ }
170
+ return cls
138
171
  }
139
172
  }
140
173
 
141
174
  /**
142
175
  * Підсумковий звіт прогону у stdout.
143
- * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats статистика
176
+ * @param {{ ok: number, degraded: number, err: number, errors: string[], skipped: string[] }} stats статистика
144
177
  * @returns {void}
145
178
  */
146
179
  function reportStats(stats) {
147
- console.log(`\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err}`)
180
+ console.log(
181
+ `\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err} ⊘ Skip: ${stats.skipped.length}`
182
+ )
148
183
  if (stats.errors.length > 0) {
149
184
  console.log('Помилки:')
150
185
  for (const e of stats.errors) console.log(` - ${e}`)
151
186
  }
187
+ if (stats.skipped.length > 0) {
188
+ console.log('Пропущено (permanent — завеликий контекст / модель відсутня):')
189
+ for (const e of stats.skipped) console.log(` - ${e}`)
190
+ }
152
191
  if (stats.degraded > 0) {
153
192
  console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor fix-doc-files --retry-degraded`)
154
193
  }
@@ -157,7 +196,7 @@ function reportStats(stats) {
157
196
  /**
158
197
  * `doc-files gen` — згенерувати документацію для застарілих/відсутніх док.
159
198
  * @param {string[]} argv аргументи після назви субкоманди
160
- * @returns {Promise<number>} exit-код: 0 — без помилок, 1 — хоча б одна помилка або фейл preflight
199
+ * @returns {Promise<number>} exit-код: 0 — без помилок; 1 — помилки/фейл preflight; 2 systemic-abort
161
200
  */
162
201
  export async function runDocFilesGenCli(argv) {
163
202
  const root = resolveRoot(argv)
@@ -182,22 +221,38 @@ export async function runDocFilesGenCli(argv) {
182
221
  }
183
222
 
184
223
  console.log(`📋 doc-files: до генерації ${targets.length} файл(ів)${modeSuffix({ overwrite, retryDegraded })}`)
185
- const stats = { ok: 0, degraded: 0, err: 0, errors: [] }
224
+ const stats = { ok: 0, degraded: 0, err: 0, errors: [], skipped: [] }
186
225
 
187
226
  let done = 0
227
+ let systemicStreak = 0
228
+ let aborted = false
188
229
  for (const file of targets) {
189
230
  done++
190
- await generateOne(file, root, { done, total: targets.length }, stats)
231
+ const status = await generateOne(file, root, { done, total: targets.length }, stats)
232
+ // Circuit-breaker: K systemic-збоїв підряд → негайний abort (середовище впало,
233
+ // решта файлів так само згорить). Будь-який не-systemic результат скидає лічильник.
234
+ if (status === 'systemic') {
235
+ if (++systemicStreak >= SYSTEMIC_ABORT_STREAK) {
236
+ aborted = true
237
+ console.error(
238
+ `\n✗ doc-files: ${SYSTEMIC_ABORT_STREAK} systemic-збої підряд (omlx memory-guard / сервер) — abort на ${done}/${targets.length}.\n Звільни RAM або перезапусти omlx і повтори — зроблене лишилось, решта підбереться за CRC.`
239
+ )
240
+ break
241
+ }
242
+ } else {
243
+ systemicStreak = 0
244
+ }
191
245
  }
192
246
 
193
247
  reportStats(stats)
248
+ if (aborted) return 2
194
249
  return stats.err > 0 ? 1 : 0
195
250
  }
196
251
 
197
252
  /**
198
253
  * `doc-files stamp` — детерміновано (пере)штампувати frontmatter `source`+`crc`
199
254
  * у НАЯВНИХ доках без виклику LLM. Для міграції док, які ще не мають CRC.
200
- * Поля якості (`score`/`issues`) при цьому зберігаються з наявного frontmatter.
255
+ * Поля `model` та якості (`score`/`issues`) при цьому зберігаються з наявного frontmatter.
201
256
  * @param {string[]} argv аргументи після назви субкоманди
202
257
  * @returns {number} exit-код: 0 — успіх
203
258
  */
@@ -211,7 +266,8 @@ export function runDocFilesStampCli(argv) {
211
266
  const crc = crc32(readFileSync(sourceAbs))
212
267
  const md = readFileSync(docAbs, 'utf8')
213
268
  const { score, issues } = readDocQuality(docAbs)
214
- writeFileSync(docAbs, stampDoc(md, file.sourcePath, crc, score === null ? null : { score, issues }))
269
+ const model = readDocModel(docAbs)
270
+ writeFileSync(docAbs, stampDoc(md, file.sourcePath, crc, score === null ? null : { score, issues }, model))
215
271
  stamped++
216
272
  }
217
273
  console.log(`✓ fix-doc-files --stamp: оновлено frontmatter у ${stamped} доці(ах).`)
@@ -3,7 +3,6 @@ import { readFileSync, existsSync } from 'node:fs'
3
3
  import { basename } from 'node:path'
4
4
  import { env } from 'node:process'
5
5
  import { resolveModel } from '../../../lib/models.mjs'
6
- import { DEFAULT_OMLX_MODEL } from '../../../lib/omlx.mjs'
7
6
  import { callLlm as callLlmRaw } from '../../../lib/llm.mjs'
8
7
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
9
8
  import { docPathForSource } from './docgen-scan.mjs'
@@ -365,11 +364,12 @@ function orchestratedDoc(facts, src, model, timeoutMs, { anchors = null, tempera
365
364
  /** Максимальний час генерації одного LLM-виклику. */
366
365
  const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
367
366
  /**
368
- * Дефолтна модель: N_CURSOR_DOCGEN_MODEL → resolveModel('min') → omlx напряму.
369
- * Останній fallback гарантує local-only шлях без жодних env (через pi CLI той
370
- * самий локальний виклик виміряно повільніший на ~46%).
367
+ * Дефолтна модель: N_CURSOR_DOCGEN_MODEL → resolveModel('min') (N_LOCAL_MIN_MODEL).
368
+ * Без хардкод-fallback: модель налаштовує кожен локально (`N_LOCAL_MIN_MODEL`); якщо
369
+ * нічого не задано порожньо, і preflight оркестратора фейлить гучно (а не шле
370
+ * запит до неіснуючої моделі).
371
371
  */
372
- export const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? (resolveModel('min') || `omlx/${DEFAULT_OMLX_MODEL}`)
372
+ export const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? resolveModel('min')
373
373
 
374
374
  /**
375
375
  * Головний API: файл → md-дока з det-оцінкою.
@@ -78,9 +78,35 @@ export function describeFile(root, sourcePath) {
78
78
  return { sourcePath, docPath, stale, reason }
79
79
  }
80
80
 
81
+ /**
82
+ * Підмножина шляхів, які git вважає ігнорованими (`.gitignore` + global excludes).
83
+ * Один батч-виклик `git check-ignore --stdin`. Tracked-файли git не репортить як
84
+ * ігноровані (тож `euscp.js` лишається кандидатом). Поза git-репо / коли жоден не
85
+ * ігнорується — порожній набір (graceful: фільтр просто не застосовується).
86
+ * @param {string} root абсолютний корінь (cwd для git)
87
+ * @param {string[]} relPaths posix-шляхи від кореня
88
+ * @returns {Set<string>} підмножина ігнорованих relPaths
89
+ */
90
+ function gitIgnoredPaths(root, relPaths) {
91
+ if (relPaths.length === 0) return new Set()
92
+ try {
93
+ const out = execFileSync('git', ['check-ignore', '--stdin'], {
94
+ cwd: root,
95
+ input: relPaths.join('\n'),
96
+ encoding: 'utf8',
97
+ stdio: ['pipe', 'pipe', 'ignore'] // git пише «not a git repository» у stderr — глушимо
98
+ })
99
+ return new Set(out.split('\n').map(s => s.trim()).filter(Boolean))
100
+ } catch {
101
+ // exit 1 (жоден не ігнорується) і 128 (не git-репо) → execFileSync кидає; обидва = «не фільтруємо».
102
+ return new Set()
103
+ }
104
+ }
105
+
81
106
  /**
82
107
  * Рекурсивно обходить дерево від `root`, повертає кодові файли зі станом застарілості.
83
108
  * Синхронний `readdirSync` — детермінований порядок без гонок; обсяг дерева це дозволяє.
109
+ * Поверх `DOCGEN_IGNORE_GLOBS` відсіює ще й те, що в `.gitignore` (через git check-ignore).
84
110
  * @param {string} root абсолютний корінь обходу
85
111
  * @returns {Array<{sourcePath:string, docPath:string, stale:boolean, reason:'missing'|'crc-mismatch'|null}>} кандидати з відносними шляхами
86
112
  */
@@ -111,7 +137,8 @@ export function scanForDocFiles(root) {
111
137
  }
112
138
 
113
139
  walk(root)
114
- return results
140
+ const ignored = gitIgnoredPaths(root, results.map(r => r.sourcePath))
141
+ return ignored.size ? results.filter(r => !ignored.has(r.sourcePath)) : results
115
142
  }
116
143
 
117
144
  /**
@@ -0,0 +1,104 @@
1
+ /**
2
+ * cspell у ланцюжку lint-text із omlx-автофіксом (point 4 спеки).
3
+ *
4
+ * cspell не має нативного `--fix`. У fix-режимі: детект (захоплення виводу) → групування
5
+ * знахідок по файлах → per-file omlx-фікс справжніх одруків (`llmLintFix`) → re-detect.
6
+ * У read-only: лише детект (нуль мутацій). Валідні терміни omlx лишає — їх ловить повторний
7
+ * cspell (далі — у словник `@nitra/cspell-dict`).
8
+ */
9
+ import { spawnSync } from 'node:child_process'
10
+
11
+ import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
12
+ import { llmLintFix } from '../../../scripts/lib/fix/llm-lint-fix.mjs'
13
+
14
+ /** Рядок cspell: `<file>:<line>:<col> - Unknown word (xxx)`. */
15
+ const CSPELL_LINE_RE = /^(.+?):\d+:\d+\s+-\s+Unknown word/u
16
+ /** Максимум файлів під omlx-фікс за прогін (без тихого обрізання — логуємо надлишок). */
17
+ const MAX_FIX_FILES = 25
18
+
19
+ /**
20
+ * Запускає `cspell .` із захопленням виводу.
21
+ * @param {string} cwd корінь
22
+ * @param {string} bin шлях до cspell (npx/локальний)
23
+ * @returns {{ code:number, out:string }} код + обʼєднаний stdout/stderr
24
+ */
25
+ function detectCspell(cwd, bin) {
26
+ const r = spawnSync(bin, ['cspell', '.'], { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, env: process.env })
27
+ return { code: typeof r.status === 'number' ? r.status : 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }
28
+ }
29
+
30
+ /**
31
+ * Групує cspell-знахідки за файлом.
32
+ * @param {string} out вивід cspell
33
+ * @returns {Map<string, string[]>} файл → рядки знахідок
34
+ */
35
+ export function groupFindingsByFile(out) {
36
+ /** @type {Map<string, string[]>} */
37
+ const byFile = new Map()
38
+ for (const line of out.split('\n')) {
39
+ const m = CSPELL_LINE_RE.exec(line.trim())
40
+ if (!m) continue
41
+ const file = m[1]
42
+ if (!byFile.has(file)) byFile.set(file, [])
43
+ byFile.get(file).push(line.trim())
44
+ }
45
+ return byFile
46
+ }
47
+
48
+ const CSPELL_INSTRUCTION = [
49
+ 'Correct genuine spelling typos in the file(s).',
50
+ 'Each flagged "Unknown word" is listed below.',
51
+ 'ONLY fix obvious misspellings of real words.',
52
+ 'If a flagged token is a valid identifier, technical term, abbreviation, proper noun, URL,',
53
+ 'or an intentional non-English word, leave it UNCHANGED (it will be added to the dictionary).',
54
+ 'Preserve all code, formatting, and unrelated text exactly.'
55
+ ].join(' ')
56
+
57
+ /**
58
+ * cspell-крок lint-text з omlx-автофіксом.
59
+ * @param {string} [cwd] корінь
60
+ * @param {boolean} [readOnly] true → лише детект (нуль мутацій)
61
+ * @returns {number} 0 — чисто; 1 — лишились знахідки / помилка середовища
62
+ */
63
+ export function runCspellText(cwd = process.cwd(), readOnly = false) {
64
+ const bin = resolveCmd('npx')
65
+ if (!bin) {
66
+ process.stderr.write('❌ npx не знайдено в PATH (cspell).\n')
67
+ return 1
68
+ }
69
+
70
+ const first = detectCspell(cwd, bin)
71
+ if (first.code === 0) return 0
72
+ if (readOnly) {
73
+ process.stdout.write(first.out)
74
+ return first.code
75
+ }
76
+
77
+ // Fix-режим: omlx по файлах зі справжніми одруками.
78
+ const byFile = groupFindingsByFile(first.out)
79
+ const files = [...byFile.keys()]
80
+ if (files.length === 0) {
81
+ process.stdout.write(first.out)
82
+ return first.code
83
+ }
84
+ const targets = files.slice(0, MAX_FIX_FILES)
85
+ if (files.length > MAX_FIX_FILES) {
86
+ process.stdout.write(`ℹ️ cspell: omlx-фікс перших ${MAX_FIX_FILES}/${files.length} файлів (решта — наступний прогін)\n`)
87
+ }
88
+
89
+ for (const file of targets) {
90
+ const res = llmLintFix({
91
+ tool: 'cspell',
92
+ instruction: CSPELL_INSTRUCTION,
93
+ findings: byFile.get(file).join('\n'),
94
+ filePaths: [file],
95
+ projectRoot: cwd
96
+ })
97
+ process.stdout.write(res.ok ? ` ⚡ cspell omlx-фікс: ${file}\n` : ` ⚠️ cspell omlx-фікс пропущено (${file}): ${res.error}\n`)
98
+ }
99
+
100
+ // Re-detect: що лишилось (валідні терміни → у словник).
101
+ const second = detectCspell(cwd, bin)
102
+ if (second.code !== 0) process.stdout.write(second.out)
103
+ return second.code
104
+ }
@@ -24,6 +24,7 @@ import { runLintStep } from '../../../scripts/lib/run-lint-step.mjs'
24
24
  import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
25
25
  import { runStandardLint } from '../../../scripts/lib/run-standard-lint.mjs'
26
26
  import { ensureTool } from '../../../scripts/lib/ensure-tool.mjs'
27
+ import { runCspellText } from './cspell-fix.mjs'
27
28
  import { runDotenvLinter } from './run-dotenv-linter.mjs'
28
29
  import { runShellcheckText } from './run-shellcheck.mjs'
29
30
  import { runV8rWithGlobs } from './run-v8r.mjs'
@@ -106,7 +107,8 @@ function runLintTextSteps(readOnly = false) {
106
107
  // patch потрібен лише для авто-фіксу shellcheck; у read-only пропускаємо preflight.
107
108
  if (!readOnly && !preflight(PATCH_PREFLIGHT)) return 1
108
109
 
109
- const cspellCode = runLintStep('cspell', 'npx', ['cspell', '.'])
110
+ console.log(`\n▶ cspell (${readOnly ? 'перевірка' : 'omlx-автофікс одруків + перевірка'})`)
111
+ const cspellCode = runCspellText(process.cwd(), readOnly)
110
112
  if (cspellCode !== 0) return cspellCode
111
113
 
112
114
  console.log(`\n▶ shellcheck (${readOnly ? 'перевірка' : 'авто-фікс + фінальна перевірка'} *.sh)`)
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Спільне ядро LLM-фіксу: парс відповіді `{changes:[{path,content}]}`, читання файлів
3
+ * під фікс і застосування змін. Використовують і `llm-worker.mjs` (конформність), і
4
+ * `llm-lint-fix.mjs` (per-tool лінтер-фіксери) — щоб не дублювати парс/apply (knip/jscpd).
5
+ */
6
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/
10
+
11
+ /**
12
+ * Парсить JSON-відповідь моделі: прямий JSON → ```json-блок``` → перший `{…}`-блок.
13
+ * @param {string} text сирий текст відповіді
14
+ * @returns {{ changes?: Array<{path:string,content:string}>, error?: string } | null} патч або null
15
+ */
16
+ export function parseChangesResponse(text) {
17
+ try {
18
+ return JSON.parse(text)
19
+ } catch {
20
+ /* fallthrough */
21
+ }
22
+ const block = text.match(JSON_CODE_BLOCK_RE)
23
+ if (block) {
24
+ try {
25
+ return JSON.parse(block[1].trim())
26
+ } catch {
27
+ /* fallthrough */
28
+ }
29
+ }
30
+ const start = text.indexOf('{')
31
+ const end = text.lastIndexOf('}')
32
+ if (start !== -1 && end > start) {
33
+ try {
34
+ return JSON.parse(text.slice(start, end + 1))
35
+ } catch {
36
+ /* fallthrough */
37
+ }
38
+ }
39
+ return null
40
+ }
41
+
42
+ /**
43
+ * Читає існуючі файли за відносними шляхами у форму `{path, content}` (для prompt).
44
+ * @param {string[]} filePaths відносні шляхи від кореня
45
+ * @param {string} projectRoot абсолютний корінь
46
+ * @returns {Array<{path:string, content:string}>} наявні файли з вмістом
47
+ */
48
+ export function readFilesForFix(filePaths, projectRoot) {
49
+ return filePaths
50
+ .map(p => {
51
+ const abs = join(projectRoot, p)
52
+ if (!existsSync(abs)) return null
53
+ try {
54
+ return { path: p, content: readFileSync(abs, 'utf8') }
55
+ } catch {
56
+ return null
57
+ }
58
+ })
59
+ .filter(Boolean)
60
+ }
61
+
62
+ /**
63
+ * Застосовує `changes` до ФС (повний вміст файлу, не diff).
64
+ * @param {Array<{path:string, content:string}>} changes зміни
65
+ * @param {string} projectRoot абсолютний корінь
66
+ * @returns {{ ok: boolean, error?: string }} статус
67
+ */
68
+ export function applyChanges(changes, projectRoot) {
69
+ for (const change of changes) {
70
+ if (!change.path || typeof change.content !== 'string') continue
71
+ try {
72
+ writeFileSync(join(projectRoot, change.path), change.content, 'utf8')
73
+ } catch (error) {
74
+ return { ok: false, error: `write ${change.path}: ${error.message}` }
75
+ }
76
+ }
77
+ return { ok: true }
78
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Per-tool omlx-фікс лінтер-знахідок (point 4 спеки lint-orchestrator-fix-readonly).
3
+ *
4
+ * Для detect-only тулів без нативного `--fix` (cspell, knip, actionlint, v8r тощо): читає
5
+ * уражені файли, просить omlx виправити за tool-специфічною інструкцією, застосовує `{changes}`.
6
+ * Re-detect (перевірка, що знахідка закрита) — на стороні caller (convergence-патерн).
7
+ *
8
+ * Маршрут моделі — через `callLlm` за префіксом: `omlx/<model>` → локальний HTTP (дефолт
9
+ * `resolveModel('min')`); cloud — фолбек каскаду. Парс/застосування — спільне ядро `llm-fix-apply`.
10
+ */
11
+ import { env } from 'node:process'
12
+
13
+ import { resolveModel } from '../../../lib/models.mjs'
14
+ import { callLlm } from '../../../lib/llm.mjs'
15
+ import { applyChanges, parseChangesResponse, readFilesForFix } from './llm-fix-apply.mjs'
16
+
17
+ /** Дефолтний локальний тир (omlx); env `N_CURSOR_FIX_MODEL` перекриває. */
18
+ const DEFAULT_MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
19
+
20
+ /**
21
+ * Будує prompt для omlx: tool-інструкція + знахідки + повний вміст файлів.
22
+ * @param {string} tool назва тула (cspell/knip/…)
23
+ * @param {string} instruction що саме виправити (tool-специфічно)
24
+ * @param {string} findings сирий вивід тула (знахідки)
25
+ * @param {Array<{path:string, content:string}>} files файли під фікс
26
+ * @returns {string} prompt
27
+ */
28
+ function buildLintFixPrompt(tool, instruction, findings, files) {
29
+ const filesBlock = files.map(f => `<file path="${f.path}">\n${f.content}\n</file>`).join('\n\n')
30
+ return [
31
+ `You fix ${tool} lint findings. Return ONLY valid JSON — no explanation, no markdown.`,
32
+ ``,
33
+ `Task: ${instruction}`,
34
+ ``,
35
+ `${tool} findings:`,
36
+ findings,
37
+ ``,
38
+ `Current file contents:`,
39
+ filesBlock,
40
+ ``,
41
+ `Return JSON with this exact shape:`,
42
+ `{"changes":[{"path":"relative/path","content":"full corrected file content"}]}`,
43
+ ``,
44
+ `Rules:`,
45
+ `- "path" is relative to the project root (use the path from the <file> tag)`,
46
+ `- "content" is the COMPLETE new file content (not a diff)`,
47
+ `- Only include files that actually need to change; preserve everything unrelated verbatim`,
48
+ `- If nothing should be auto-fixed, return {"changes":[],"error":"reason"}`
49
+ ].join('\n')
50
+ }
51
+
52
+ /**
53
+ * Виправляє лінтер-знахідки через omlx і застосовує зміни.
54
+ * @param {{ tool:string, instruction:string, findings:string, filePaths:string[], projectRoot:string, model?:string }} opts параметри
55
+ * @returns {{ ok:boolean, error?:string, fixed:string[] }} статус + список змінених шляхів
56
+ */
57
+ export function llmLintFix({ tool, instruction, findings, filePaths, projectRoot, model }) {
58
+ const m = model ?? DEFAULT_MODEL
59
+ const files = readFilesForFix(filePaths, projectRoot)
60
+ if (files.length === 0) return { ok: false, error: 'no readable files to fix', fixed: [] }
61
+
62
+ let text
63
+ try {
64
+ text = callLlm([{ role: 'user', content: buildLintFixPrompt(tool, instruction, findings, files) }], m, {
65
+ timeoutMs: 120_000,
66
+ caller: `lint:${tool}`
67
+ })
68
+ } catch (error) {
69
+ return { ok: false, error: String(error.message), fixed: [] }
70
+ }
71
+
72
+ const parsed = parseChangesResponse(text)
73
+ if (!parsed) return { ok: false, error: `cannot parse omlx response: ${String(text).slice(0, 200)}`, fixed: [] }
74
+ if (parsed.error) return { ok: false, error: parsed.error, fixed: [] }
75
+
76
+ const changes = (parsed.changes ?? []).filter(c => c.path && typeof c.content === 'string')
77
+ if (changes.length === 0) return { ok: false, error: 'omlx returned no changes', fixed: [] }
78
+
79
+ const applied = applyChanges(changes, projectRoot)
80
+ if (!applied.ok) return { ok: false, error: applied.error, fixed: [] }
81
+ return { ok: true, fixed: changes.map(c => c.path) }
82
+ }
@@ -1,17 +1,17 @@
1
1
  /** @see ./docs/llm-worker.md */
2
2
 
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { env } from 'node:process'
6
6
  import { resolveModel } from '../../../lib/models.mjs'
7
7
  import { callLlm } from '../../../lib/llm.mjs'
8
+ import { applyChanges, parseChangesResponse, readFilesForFix } from './llm-fix-apply.mjs'
8
9
 
9
10
  // Тир за замовчуванням: min → avg при ескалації (каскад local→cloud).
10
11
  // Перевизначення через N_CURSOR_FIX_MODEL / N_CURSOR_FIX_MODEL_HEAVY.
11
12
  export const MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
12
13
  export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ?? resolveModel('avg')
13
14
 
14
- const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/
15
15
  const API_KEY_RE = /api key/i
16
16
 
17
17
  /**
@@ -113,44 +113,6 @@ function callModel(prompt, model) {
113
113
  }
114
114
  }
115
115
 
116
- /**
117
- * Парсить JSON-відповідь від моделі.
118
- * Модель може обгорнути JSON у ```json ... ```, тому пробуємо витягти.
119
- * @param {string} text сирий текст відповіді
120
- * @returns {{ changes: Array<{path:string,content:string}>, error?: string } | null} розпарсений патч або null
121
- */
122
- function parseResponse(text) {
123
- // Спроба 1: прямий JSON
124
- try {
125
- return JSON.parse(text)
126
- } catch {
127
- /* fallthrough */
128
- }
129
-
130
- // Спроба 2: витягти з ```json ... ```
131
- const m = text.match(JSON_CODE_BLOCK_RE)
132
- if (m) {
133
- try {
134
- return JSON.parse(m[1].trim())
135
- } catch {
136
- /* fallthrough */
137
- }
138
- }
139
-
140
- // Спроба 3: перший { ... } блок
141
- const start = text.indexOf('{')
142
- const end = text.lastIndexOf('}')
143
- if (start !== -1 && end > start) {
144
- try {
145
- return JSON.parse(text.slice(start, end + 1))
146
- } catch {
147
- /* fallthrough */
148
- }
149
- }
150
-
151
- return null
152
- }
153
-
154
116
  /**
155
117
  * LLM-worker: виправляє одне rule-порушення через pi (C1 pattern).
156
118
  * @param {string} ruleId ID правила
@@ -167,18 +129,7 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
167
129
  const ruleMdc = existsSync(mdcPath) ? readFileSync(mdcPath, 'utf8') : '(rule file not found)'
168
130
 
169
131
  // 2. Витягуємо файли з violation output і читаємо їх
170
- const filePaths = extractFilePaths(violationOutput)
171
- const files = filePaths
172
- .map(p => {
173
- const abs = join(projectRoot, p)
174
- if (!existsSync(abs)) return null
175
- try {
176
- return { path: p, content: readFileSync(abs, 'utf8') }
177
- } catch {
178
- return null
179
- }
180
- })
181
- .filter(Boolean)
132
+ const files = readFilesForFix(extractFilePaths(violationOutput), projectRoot)
182
133
 
183
134
  // 3. Будуємо prompt і викликаємо модель
184
135
  const prompt = buildPrompt(ruleId, ruleMdc, violationOutput, files)
@@ -188,7 +139,7 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
188
139
  if (!text) return { ok: false, error: 'model returned empty response' }
189
140
 
190
141
  // 4. Парсимо відповідь
191
- const parsed = parseResponse(text)
142
+ const parsed = parseChangesResponse(text)
192
143
  if (!parsed) return { ok: false, error: `cannot parse pi response: ${text.slice(0, 200)}` }
193
144
  if (parsed.error) return { ok: false, error: parsed.error }
194
145
 
@@ -196,15 +147,5 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
196
147
  if (changes.length === 0) return { ok: false, error: 'pi returned no changes' }
197
148
 
198
149
  // 5. Застосовуємо зміни
199
- for (const change of changes) {
200
- if (!change.path || typeof change.content !== 'string') continue
201
- const abs = join(projectRoot, change.path)
202
- try {
203
- writeFileSync(abs, change.content, 'utf8')
204
- } catch (error) {
205
- return { ok: false, error: `write ${change.path}: ${error.message}` }
206
- }
207
- }
208
-
209
- return { ok: true }
150
+ return applyChanges(changes, projectRoot)
210
151
  }