@nitra/cursor 11.1.0 → 11.2.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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [11.2.0] - 2026-06-15
4
+
5
+ ### Changed
6
+
7
+ - cspell-fix: заміна зламаного whole-file rewrite на classify→.cspell.json. Стара схема (llmLintFix whole-file) давала timeout 120с та parse-fail на реальних файлах; нова класифікує знахідки через bounded omlx-запит (≤80 слів → компактний JSON) і авто-дописує валідні слова у .cspell.json словник (sorted+dedup), а ймовірні одруки емітує на рев'ю без авто-застосування. Підтверджено на репо: 73 слова у словник за ≈5с (vs 120с timeout раніше).
8
+ - cspell-fix: whole-file omlx-апплай → класифікація+словник (нова схема, спека docs/specs/2026-06-15-opportunistic-llm-fix-tier.md). Старий підхід (модель повертає весь файл як JSON) на укр+тех-репо операційно ламався — 120с-таймаути, memory-guard reject, parse-fail (вимір: 1406 знахідок / 292 файли, ~90% — валідні терміни, не одруки). Новий fix-режим робить **один bounded omlx-виклик**: класифікує distinct «Unknown word» → валідні слова авто-дописує у `.cspell.json#words` (sorted/dedup, видно в diff) → ймовірні одруки лишає списком на рев'ю (НЕ авто-виправляє — апплай небезпечний) → re-detect. read-only незмінний (нуль мутацій). Єдиний knob моделі — `N_LOCAL_MIN_MODEL` (прибрано `N_CURSOR_FIX_MODEL` із цього шляху). Preflight omlx (fast-skip замість приречених викликів), cap класифікації з логом надлишку. `groupFindingsByFile` → `unknownWords`/`appendWordsToDict`.
9
+ - llm: спільний `preflightLocalModel(model)` у `npm/lib/llm.mjs` — omlx health-check (memory-guard / down / auth) як одна цеглина opportunistic LLM-fix tier (спека docs/specs/2026-06-15-opportunistic-llm-fix-tier.md). Прибрано дубльовані локальні `preflightProblem` із `doc-files/js/docgen-files-batch.mjs` (генерація) і `text/lint/cspell-fix.mjs` (класифікація) — обидва тепер кличуть спільний хелпер із `N_LOCAL_MIN_MODEL`. Per-target loop/circuit-breaker лишається у doc-files (cspell — single-call). Поведінка незмінна.
10
+
3
11
  ## [11.1.0] - 2026-06-15
4
12
 
5
13
  ### Changed
package/lib/llm.mjs CHANGED
@@ -168,3 +168,30 @@ export function omlxHealthCheck(opts = {}) {
168
168
  return { ok: false, reason: 'error', detail }
169
169
  }
170
170
  }
171
+
172
+ /**
173
+ * Спільний preflight локальної fix/gen-моделі (opportunistic LLM-fix tier, спека
174
+ * docs/specs/2026-06-15-opportunistic-llm-fix-tier.md): чи можна звати модель зараз.
175
+ * Health-check лише для omlx-бекенду (pi/cloud — завжди дозволено). Використовують
176
+ * doc-files (генерація) і text/cspell (класифікація) — fast-skip замість приречених викликів.
177
+ * @param {string} model model-id (зазвичай `N_LOCAL_MIN_MODEL`)
178
+ * @returns {string|null} людинозрозумілий текст проблеми, або null якщо можна викликати
179
+ */
180
+ export function preflightLocalModel(model) {
181
+ if (!model) {
182
+ return 'модель не задано. Вистав N_LOCAL_MIN_MODEL (напр. omlx/mlx-community--gemma-4-e4b-it-OptiQ-4bit) і повтори.'
183
+ }
184
+ if (pickBackend(model) !== 'omlx') return null
185
+ const hc = omlxHealthCheck({ model })
186
+ if (hc.ok) return null
187
+ if (hc.reason === 'memory-guard') {
188
+ return `omlx memory-guard: модель не влазить у динамічну стелю пам'яті (машина зайнята).\n Звільни пам'ять або повтори прогін пізніше.\n ${hc.detail}`
189
+ }
190
+ if (hc.reason === 'down') {
191
+ return `omlx-сервер не відповідає. Запусти \`omlx serve\` і повтори.\n ${hc.detail}`
192
+ }
193
+ if (hc.reason === 'auth') {
194
+ return `omlx вимагає API-ключ. Вистав N_CURSOR_OMLX_KEY (auth.api_key з ~/.omlx/settings.json).\n ${hc.detail}`
195
+ }
196
+ return `omlx помилка: ${hc.detail}`
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "11.1.0",
3
+ "version": "11.2.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -10,8 +10,8 @@
10
10
  * Degraded-маркер (ADR 260610-2228): якщо локальний конвеєр не дотягнув до порогу
11
11
  * якості, дока все одно пишеться, а frontmatter додатково несе `score` (det-оцінка)
12
12
  * та `issues` (коди проблем). CRC при цьому свіжий — Stop-гейт не блокує задачі через
13
- * слабкість моделі; борг видимий через `check --degraded` і адресно перегенеровується
14
- * через `gen --retry-degraded`.
13
+ * слабкість моделі; борг видимий через `check --degraded` і автоматично доретраюється
14
+ * наступним `gen` (рівно один раз на версію джерела — далі `retried: true` у frontmatter).
15
15
  *
16
16
  * Frontmatter — єдиний дозволений виняток із правила «чистий Markdown без HTML»:
17
17
  * це машинні метадані, не контент. Формат:
@@ -54,6 +54,8 @@ const CRC_RE = /^[ \t]{0,8}crc:[ \t]{0,8}(.+)$/mu
54
54
  const MODEL_RE = /^[ \t]{0,8}model:[ \t]{0,8}(.+)$/mu
55
55
  const SCORE_RE = /^[ \t]{0,8}score:[ \t]{0,8}(\d+)$/mu
56
56
  const ISSUES_RE = /^[ \t]{0,8}issues:[ \t]{0,8}(.+)$/mu
57
+ const RETRIED_RE = /^[ \t]{0,8}retried:[ \t]{0,8}true[ \t]*$/mu
58
+ const JUDGE_MODEL_RE = /^[ \t]{0,8}judgeModel:[ \t]{0,8}(.+)$/mu
57
59
  const LEADING_NEWLINES_RE = /^\n+/u
58
60
  const ISSUE_CODE_TAIL_RE = /[,:]$/u
59
61
 
@@ -62,7 +64,7 @@ const ISSUE_CODE_TAIL_RE = /[,:]$/u
62
64
  * Поля `model`/`score`/`issues` опційні (back-compat зі старими доками): без них —
63
65
  * `model:null`, `score:null`, `issues:[]`.
64
66
  * @param {string} md вміст md-файлу
65
- * @returns {{ data: { source: string|null, crc: string|null, model: string|null, score: number|null, issues: string[] }|null, body: string }} метадані + тіло без frontmatter
67
+ * @returns {{ data: { source: string|null, crc: string|null, model: string|null, score: number|null, issues: string[], retried: boolean, judgeModel: string|null }|null, body: string }} метадані + тіло без frontmatter
66
68
  */
67
69
  export function parseDocFrontmatter(md) {
68
70
  const match = md.match(FRONTMATTER_RE)
@@ -81,7 +83,9 @@ export function parseDocFrontmatter(md) {
81
83
  .split(',')
82
84
  .map(s => s.trim())
83
85
  .filter(Boolean)
84
- : []
86
+ : [],
87
+ retried: RETRIED_RE.test(block),
88
+ judgeModel: block.match(JUDGE_MODEL_RE)?.[1].trim() ?? null
85
89
  },
86
90
  body: md.slice(match[0].length)
87
91
  }
@@ -107,7 +111,7 @@ function issueCodes(issues) {
107
111
  * Будує frontmatter-блок із шляхом джерела, CRC, (опційно) моделлю-генератором і якістю.
108
112
  * @param {string} source відносний шлях джерела
109
113
  * @param {string} crc CRC32 джерела у hex
110
- * @param {{ score: number, issues?: string[] }|null} [quality] det-оцінка доки; null — без полів якості
114
+ * @param {{ score: number, issues?: string[], retried?: boolean, judge?: {model?: string} }|null} [quality] det-оцінка доки (+ опц. `retried`-маркер і `judge.model` хмарного судді); null — без полів якості
111
115
  * @param {string|null} [model] повний id моделі-генератора; null — без поля `model`
112
116
  * @returns {string} рядок `---\ndocgen:\n source: …\n crc: …[\n model: …][\n score: …][\n issues: …]\n---\n`
113
117
  */
@@ -118,6 +122,8 @@ export function buildDocFrontmatter(source, crc, quality = null, model = null) {
118
122
  lines.push(`score: ${quality.score}`)
119
123
  const codes = issueCodes(quality.issues ?? [])
120
124
  if (codes.length > 0) lines.push(`issues: ${codes.join(',')}`)
125
+ if (quality.retried) lines.push('retried: true')
126
+ if (quality.judge && quality.judge.model) lines.push(`judgeModel: ${quality.judge.model}`)
121
127
  }
122
128
  const indented = lines.map(l => ' ' + l).join('\n')
123
129
  return `---\ndocgen:\n${indented}\n---\n`
@@ -128,7 +134,7 @@ export function buildDocFrontmatter(source, crc, quality = null, model = null) {
128
134
  * @param {string} md тіло доки (з frontmatter або без)
129
135
  * @param {string} source відносний шлях джерела
130
136
  * @param {string} crc CRC32 джерела у hex
131
- * @param {{ score: number, issues?: string[] }|null} [quality] det-оцінка доки
137
+ * @param {{ score: number, issues?: string[], retried?: boolean, judge?: {model?: string} }|null} [quality] det-оцінка доки (+ опц. `retried`-маркер і `judge.model` хмарного судді)
132
138
  * @param {string|null} [model] повний id моделі-генератора; null — без поля `model`
133
139
  * @returns {string} md зі свіжим frontmatter
134
140
  */
@@ -150,12 +156,17 @@ export function readDocCrc(docAbsPath) {
150
156
  /**
151
157
  * Якість, збережена у frontmatter доки.
152
158
  * @param {string} docAbsPath абсолютний шлях md-доки
153
- * @returns {{ score: number|null, issues: string[] }} `score:null` — доки немає або поле відсутнє
159
+ * @returns {{ score: number|null, issues: string[], retried: boolean, judgeModel: string|null }} `score:null` — доки немає або поле відсутнє; `retried` — чи док уже доретраювали при цьому CRC; `judgeModel` — хмарна модель-суддя, що позначила док (або null)
154
160
  */
155
161
  export function readDocQuality(docAbsPath) {
156
- if (!existsSync(docAbsPath)) return { score: null, issues: [] }
162
+ if (!existsSync(docAbsPath)) return { score: null, issues: [], retried: false, judgeModel: null }
157
163
  const data = parseDocFrontmatter(readFileSync(docAbsPath, 'utf8')).data
158
- return { score: data?.score ?? null, issues: data?.issues ?? [] }
164
+ return {
165
+ score: data?.score ?? null,
166
+ issues: data?.issues ?? [],
167
+ retried: data?.retried ?? false,
168
+ judgeModel: data?.judgeModel ?? null
169
+ }
159
170
  }
160
171
 
161
172
  /**
@@ -4,8 +4,8 @@
4
4
  * Уся черга/батчинг/CRC-штамп живуть тут, а не в контексті моделі — тому
5
5
  * масовий перший прогін на сотні файлів не «заморює» агента. Конвеєр суто
6
6
  * локальний: жодних cloud-ескалацій; якщо det-score нижче порогу — дока все
7
- * одно пишеться з degraded-маркером (`score`/`issues` у frontmatter), а
8
- * `gen --retry-degraded` адресно переганяє лише такі доки пізніше.
7
+ * одно пишеться з degraded-маркером (`score`/`issues` у frontmatter), а наступний
8
+ * `gen` автоматично доретраює такі доки (один раз на версію джерела — далі `retried:true`).
9
9
  *
10
10
  * Перед масовим прогоном — health-check omlx: memory-guard зайнятої 8GB машини
11
11
  * означає «відклади прогін», а не сотні хибних «✗» у звіті.
@@ -14,7 +14,7 @@ import { readFileSync, mkdirSync, writeFileSync, existsSync, statSync } from 'no
14
14
  import { dirname, join } from 'node:path'
15
15
 
16
16
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
17
- import { omlxHealthCheck, pickBackend, classifyOmlxError } from '../../../lib/llm.mjs'
17
+ import { classifyOmlxError, preflightLocalModel } from '../../../lib/llm.mjs'
18
18
  import { generateDoc, DEFAULT_LOCAL_MODEL } from './docgen-gen.mjs'
19
19
  import { crc32, stampDoc, readDocQuality, readDocModel, QUALITY_THRESHOLD } from './docgen-crc.mjs'
20
20
  import { resolveRoot, scanForDocFiles } from './docgen-scan.mjs'
@@ -22,7 +22,7 @@ import { resolveRoot, scanForDocFiles } from './docgen-scan.mjs'
22
22
  /**
23
23
  * Парсить `--limit N` / `--from N` / прапори режимів для дозапуску великого прогону.
24
24
  * @param {string[]} argv аргументи
25
- * @returns {{ from: number, limit: number, overwrite: boolean, retryDegraded: boolean }} зріз і режими
25
+ * @returns {{ from: number, limit: number, overwrite: boolean }} зріз і режими
26
26
  */
27
27
  function parseGenArgs(argv) {
28
28
  const num = (flag, dflt) => {
@@ -32,65 +32,38 @@ function parseGenArgs(argv) {
32
32
  return {
33
33
  from: num('--from', 0),
34
34
  limit: num('--limit', Infinity),
35
- overwrite: argv.includes('--overwrite'),
36
- retryDegraded: argv.includes('--retry-degraded')
35
+ overwrite: argv.includes('--overwrite')
37
36
  }
38
37
  }
39
38
 
40
39
  /**
41
- * Цілі генерації за режимом:
42
- * - default → застарілі (stale);
43
- * - `--overwrite` усі;
44
- * - `--retry-degraded` свіжі за CRC, але зі `score < QUALITY_THRESHOLD`.
40
+ * Цілі генерації:
41
+ * - default → застарілі (stale) АБО degraded-доки, які ще не доретраювали при цьому CRC;
42
+ * - `--overwrite` усі.
43
+ * Degraded-док отримує рівно ОДИН доретрай на версію джерела: після невдалого доретраю
44
+ * (лишився degraded) штампується `retried: true` і його більше не чіпають до зміни джерела
45
+ * (нова версія → CRC-mismatch → stale → лічильник скидається). Конвеєр сходиться без прапора.
45
46
  * @param {string} root абсолютний корінь
46
47
  * @param {Array<object>} all результат scanForDocFiles
47
- * @param {{ overwrite: boolean, retryDegraded: boolean }} mode режими
48
+ * @param {{ overwrite: boolean }} mode режими
48
49
  * @returns {Array<object>} відфільтровані цілі
49
50
  */
50
- function selectTargets(root, all, { overwrite, retryDegraded }) {
51
- if (retryDegraded) {
52
- return all.filter(f => {
53
- if (f.stale) return false
54
- const { score } = readDocQuality(join(root, f.docPath))
55
- return score !== null && score < QUALITY_THRESHOLD
56
- })
57
- }
51
+ export function selectTargets(root, all, { overwrite }) {
58
52
  if (overwrite) return all
59
- return all.filter(f => f.stale)
60
- }
61
-
62
- /**
63
- * Preflight локального бекенда: для omlx-моделі — мінімальний chat-виклик.
64
- * @returns {string|null} текст фатальної проблеми або null якщо можна генерувати
65
- */
66
- export function preflightProblem() {
67
- if (!DEFAULT_LOCAL_MODEL) {
68
- return 'модель не задано. Вистав N_LOCAL_MIN_MODEL (напр. omlx/mlx-community--gemma-4-e4b-it-OptiQ-4bit) і повтори.'
69
- }
70
- if (pickBackend(DEFAULT_LOCAL_MODEL) !== 'omlx') return null
71
- const hc = omlxHealthCheck({ model: DEFAULT_LOCAL_MODEL })
72
- if (hc.ok) return null
73
- if (hc.reason === 'memory-guard') {
74
- return `omlx memory-guard: модель не влазить у динамічну стелю пам'яті (машина зайнята).\n Звільни пам'ять або повтори прогін пізніше.\n ${hc.detail}`
75
- }
76
- if (hc.reason === 'down') {
77
- return `omlx-сервер не відповідає. Запусти \`omlx serve\` і повтори.\n ${hc.detail}`
78
- }
79
- if (hc.reason === 'auth') {
80
- return `omlx вимагає API-ключ. Вистав N_CURSOR_OMLX_KEY (auth.api_key з ~/.omlx/settings.json).\n ${hc.detail}`
81
- }
82
- return `omlx помилка: ${hc.detail}`
53
+ return all.filter(f => {
54
+ if (f.stale) return true
55
+ const { score, retried } = readDocQuality(join(root, f.docPath))
56
+ return score !== null && score < QUALITY_THRESHOLD && !retried
57
+ })
83
58
  }
84
59
 
85
60
  /**
86
61
  * Текст-суфікс режиму для прогрес-рядка.
87
- * @param {{ overwrite: boolean, retryDegraded: boolean }} mode режими
88
- * @returns {string} ` (--overwrite)` / ` (--retry-degraded)` / порожній рядок
62
+ * @param {{ overwrite: boolean }} mode режими
63
+ * @returns {string} ` (--overwrite)` або порожній рядок
89
64
  */
90
- function modeSuffix({ overwrite, retryDegraded }) {
91
- if (overwrite) return ' (--overwrite)'
92
- if (retryDegraded) return ' (--retry-degraded)'
93
- return ''
65
+ function modeSuffix({ overwrite }) {
66
+ return overwrite ? ' (--overwrite)' : ''
94
67
  }
95
68
 
96
69
  /**
@@ -146,8 +119,13 @@ async function generateOne(file, root, progress, stats) {
146
119
  const result = await generateDoc(sourceAbs, { existingMd })
147
120
  const crc = crc32(readFileSync(sourceAbs))
148
121
  mkdirSync(dirname(docAbs), { recursive: true })
122
+ // retried: НЕ stale (отже це доретрай при тому ж CRC) і лишився degraded → штампуємо,
123
+ // щоб наступні `gen` його не чіпали до зміни джерела (сходимість без прапора).
124
+ const retried = !file.stale && result.degraded
149
125
  const quality =
150
- result.score === null ? null : { score: result.score, issues: result.degraded ? result.issues : [] }
126
+ result.score === null
127
+ ? null
128
+ : { score: result.score, issues: result.degraded ? result.issues : [], retried, judge: result.judge }
151
129
  writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality, result.model))
152
130
  stats.ok++
153
131
  if (result.degraded) {
@@ -189,7 +167,7 @@ function reportStats(stats) {
189
167
  for (const e of stats.skipped) console.log(` - ${e}`)
190
168
  }
191
169
  if (stats.degraded > 0) {
192
- console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor fix-doc-files --retry-degraded`)
170
+ console.log('Degraded-доки автоматично доретраюються наступним `gen` (один раз на версію джерела).')
193
171
  }
194
172
  }
195
173
 
@@ -200,22 +178,18 @@ function reportStats(stats) {
200
178
  */
201
179
  export async function runDocFilesGenCli(argv) {
202
180
  const root = resolveRoot(argv)
203
- const { from, limit, overwrite, retryDegraded } = parseGenArgs(argv)
181
+ const { from, limit, overwrite } = parseGenArgs(argv)
204
182
 
205
183
  const all = scanForDocFiles(root)
206
- const targets = selectTargets(root, all, { overwrite, retryDegraded }).slice(from, from + limit)
184
+ const targets = selectTargets(root, all, { overwrite }).slice(from, from + limit)
207
185
 
208
186
  if (targets.length === 0) {
209
- console.log(
210
- retryDegraded
211
- ? '✓ doc-files: degraded-док немає. Нічого переганяти.'
212
- : '✓ doc-files: усі файлові доки свіжі. Нічого генерувати.'
213
- )
187
+ console.log('✓ doc-files: усі файлові доки свіжі й не-degraded. Нічого генерувати.')
214
188
  return 0
215
189
  }
216
190
 
217
191
  return runGenerationBatch(targets, root, {
218
- headline: `📋 doc-files: до генерації ${targets.length} файл(ів)${modeSuffix({ overwrite, retryDegraded })}`
192
+ headline: `📋 doc-files: до генерації ${targets.length} файл(ів)${modeSuffix({ overwrite })}`
219
193
  })
220
194
  }
221
195
 
@@ -230,7 +204,7 @@ export async function runDocFilesGenCli(argv) {
230
204
  * @returns {Promise<number>} 0 — без помилок; 1 — фейл preflight або є помилки; 2 — systemic-abort
231
205
  */
232
206
  export async function runGenerationBatch(targets, root, { headline } = {}) {
233
- const problem = preflightProblem()
207
+ const problem = preflightLocalModel(DEFAULT_LOCAL_MODEL)
234
208
  if (problem) {
235
209
  console.error(`✗ fix-doc-files: ${problem}`)
236
210
  return 1
@@ -9,7 +9,7 @@ import { docPathForSource } from './docgen-scan.mjs'
9
9
  import { extractFacts } from './docgen-extract.mjs'
10
10
  import { extractAnchors, anchorTokens } from './docgen-extract-anchors.mjs'
11
11
  import { QUALITY_THRESHOLD } from './docgen-crc.mjs'
12
- import { JUDGE_ENABLED, judgeDoc, judgeFailsDoc } from './docgen-judge.mjs'
12
+ import { JUDGE_ENABLED, JUDGE_MODEL, judgeDoc, judgeFailsDoc } from './docgen-judge.mjs'
13
13
  import {
14
14
  oneShotMessages,
15
15
  sectionMessages,
@@ -455,7 +455,7 @@ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUA
455
455
  let judge = null
456
456
  if (JUDGE_ENABLED && score >= threshold) {
457
457
  try {
458
- judge = judgeDoc(src, r.md)
458
+ judge = { ...judgeDoc(src, r.md), model: JUDGE_MODEL }
459
459
  if (judgeFailsDoc(judge)) issues = [...issues, `judge:inaccurate:${judge.confidence}`]
460
460
  } catch (error) {
461
461
  issues = [...issues, `judge:error: ${error.message.slice(0, 80)}`]
@@ -234,7 +234,7 @@ function toRelSource(root, candidate) {
234
234
  /**
235
235
  * `doc-files check --degraded` — інформаційний список свіжих за CRC док зі
236
236
  * `score < QUALITY_THRESHOLD` (локальний конвеєр не дотягнув; ADR 260610-2228).
237
- * Не блокує (exit 0): degraded — борг для `gen --retry-degraded`, а не гейт.
237
+ * Не блокує (exit 0): degraded — борг, що автоматично доретраюється наступним `gen`, а не гейт.
238
238
  * @param {string} root абсолютний корінь
239
239
  * @returns {number} exit-код: завжди 0
240
240
  */
@@ -256,7 +256,7 @@ function runDegradedReport(root) {
256
256
  })
257
257
  .join('\n')
258
258
  console.log(
259
- `⚠ doc-files: degraded-док ${degraded.length} (score < ${QUALITY_THRESHOLD}):\n${list}\n→ перегенеруй: npx @nitra/cursor fix-doc-files --retry-degraded`
259
+ `⚠ doc-files: degraded-док ${degraded.length} (score < ${QUALITY_THRESHOLD}):\n${list}\n→ доретраюються автоматично наступним \`gen\` (один раз на версію джерела).`
260
260
  )
261
261
  return 0
262
262
  }
@@ -1,20 +1,33 @@
1
1
  /**
2
- * cspell у ланцюжку lint-text із omlx-автофіксом (point 4 спеки).
2
+ * cspell у ланцюжку lint-text із omlx-класифікацією (нова схема — спека
3
+ * docs/specs/2026-06-15-opportunistic-llm-fix-tier.md).
3
4
  *
4
- * cspell не має нативного `--fix`. У fix-режимі: детект (захоплення виводу) групування
5
- * знахідок по файлах per-file omlx-фікс справжніх одруків (`llmLintFix`) re-detect.
6
- * У read-only: лише детект (нуль мутацій). Валідні терміни omlx лишає їх ловить повторний
7
- * cspell (далі у словник `@nitra/cspell-dict`).
5
+ * cspell не має нативного `--fix`, а емпірично ~90% «Unknown word» на укр+тех-репо —
6
+ * валідні терміни, не одруки (вимір: 1406 знахідок / 292 файли, ~90% словникові
7
+ * кандидати). Тому fix-режим НЕ переписує файли (старий whole-file `llmLintFix`
8
+ * таймаутив/парс-фейливbounded-output принцип спеки), а **класифікує** знахідки:
9
+ * detect → omlx-класифікація distinct-слів (bounded JSON-вихід) → валідні слова
10
+ * авто-дописуються у `.cspell.json#words` (sorted/dedup, видно в diff) → ймовірні
11
+ * одруки лишаються списком на рев'ю (НЕ авто-виправляються — апплай небезпечний) →
12
+ * re-detect. read-only: лише детект (нуль мутацій).
13
+ *
14
+ * Гейт: валідні слова після дописування у словник зникають; нерозкласифіковані та
15
+ * typo лишаються → cspell повертає !=0 → exit 1 (людина доправляє одруки вручну).
8
16
  */
9
17
  import { spawnSync } from 'node:child_process'
18
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
19
+ import { join } from 'node:path'
10
20
 
11
21
  import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
12
- import { llmLintFix } from '../../../scripts/lib/fix/llm-lint-fix.mjs'
22
+ import { callLlm, preflightLocalModel } from '../../../lib/llm.mjs'
23
+
24
+ /** Слово у рядку cspell: `<file>:<line>:<col> - Unknown word (xxx)`. */
25
+ const UNKNOWN_WORD_RE = /Unknown word \(([^)]+)\)/u
26
+ /** Максимум distinct-слів під класифікацію за прогін (без тихого обрізання — логуємо надлишок). */
27
+ const MAX_CLASSIFY_WORDS = 80
13
28
 
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
29
+ /** Локальна fix-модель (рішення: єдиний knob `N_LOCAL_MIN_MODEL`). */
30
+ const fixModel = () => process.env.N_LOCAL_MIN_MODEL || ''
18
31
 
19
32
  /**
20
33
  * Запускає `cspell .` із захопленням виводу.
@@ -28,34 +41,76 @@ function detectCspell(cwd, bin) {
28
41
  }
29
42
 
30
43
  /**
31
- * Групує cspell-знахідки за файлом.
44
+ * Унікальні «Unknown word» зі stdout cspell.
32
45
  * @param {string} out вивід cspell
33
- * @returns {Map<string, string[]>} файл рядки знахідок
46
+ * @returns {string[]} distinct-слова у порядку першої появи
34
47
  */
35
- export function groupFindingsByFile(out) {
36
- /** @type {Map<string, string[]>} */
37
- const byFile = new Map()
48
+ export function unknownWords(out) {
49
+ const set = new Set()
38
50
  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())
51
+ const m = UNKNOWN_WORD_RE.exec(line)
52
+ if (m) set.add(m[1])
53
+ }
54
+ return [...set]
55
+ }
56
+
57
+ /**
58
+ * Промпт класифікації: для укр+тех-репо bias у «valid» (додати валідне слово безпечно,
59
+ * «виправити» валідне — шкода). Вихід bounded — JSON-масив вердиктів.
60
+ * @param {string[]} words distinct-слова
61
+ * @returns {string} prompt
62
+ */
63
+ function classifyPrompt(words) {
64
+ return [
65
+ 'You triage cspell "unknown word" findings for a Ukrainian + technical codebase.',
66
+ 'For each word decide:',
67
+ '- "valid": correct technical term, identifier, abbreviation, transliteration, jargon, or intentional Ukrainian word → dictionary candidate.',
68
+ '- "typo": a genuine misspelling of a real word.',
69
+ 'Default to "valid" when unsure (adding a real word to the dictionary is safe; "fixing" a valid word is harmful).',
70
+ 'Return ONLY a JSON array, no markdown fences: [{"w":"<word>","verdict":"valid"|"typo","fix":"<correction or null>"}]',
71
+ 'Words:',
72
+ ...words.map(w => `- ${w}`)
73
+ ].join('\n')
74
+ }
75
+
76
+ /**
77
+ * Витягує JSON-масив із відповіді моделі (бере від першої «[» до останньої «]» — зрізає прозу й markdown-обрамлення).
78
+ * @param {string} text відповідь
79
+ * @returns {Array<{w:string, verdict:string, fix:string|null}>|null} вердикти або null
80
+ */
81
+ function parseClassify(text) {
82
+ const start = text.indexOf('[')
83
+ const end = text.lastIndexOf(']')
84
+ if (start === -1 || end <= start) return null
85
+ try {
86
+ const arr = JSON.parse(text.slice(start, end + 1))
87
+ return Array.isArray(arr) ? arr : null
88
+ } catch {
89
+ return null
44
90
  }
45
- return byFile
46
91
  }
47
92
 
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(' ')
93
+ /**
94
+ * Дописує слова у `.cspell.json#words` (sorted/dedup) — видно в git diff для рев'ю.
95
+ * @param {string} cwd корінь
96
+ * @param {string[]} words валідні слова
97
+ * @returns {number} к-сть фактично доданих (нових) слів
98
+ */
99
+ export function appendWordsToDict(cwd, words) {
100
+ const cfgPath = join(cwd, '.cspell.json')
101
+ if (words.length === 0 || !existsSync(cfgPath)) return 0
102
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'))
103
+ const set = new Set(cfg.words)
104
+ const before = set.size
105
+ for (const w of words) set.add(w)
106
+ if (set.size === before) return 0
107
+ cfg.words = [...set].toSorted((a, b) => a.localeCompare(b))
108
+ writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`)
109
+ return set.size - before
110
+ }
56
111
 
57
112
  /**
58
- * cspell-крок lint-text з omlx-автофіксом.
113
+ * cspell-крок lint-text: класифікація → словник (нова схема).
59
114
  * @param {string} [cwd] корінь
60
115
  * @param {boolean} [readOnly] true → лише детект (нуль мутацій)
61
116
  * @returns {number} 0 — чисто; 1 — лишились знахідки / помилка середовища
@@ -74,30 +129,50 @@ export function runCspellText(cwd = process.cwd(), readOnly = false) {
74
129
  return first.code
75
130
  }
76
131
 
77
- // Fix-режим: omlx по файлах зі справжніми одруками.
78
- const byFile = groupFindingsByFile(first.out)
79
- const files = [...byFile.keys()]
80
- if (files.length === 0) {
132
+ // Fix-режим: класифікація знахідок (bounded JSON-вихід), валідні → у словник.
133
+ const model = fixModel()
134
+ const problem = preflightLocalModel(model)
135
+ if (problem) {
136
+ process.stdout.write(`⚠️ cspell: класифікацію пропущено (${problem})\n`)
137
+ process.stdout.write(first.out)
138
+ return first.code
139
+ }
140
+
141
+ const words = unknownWords(first.out)
142
+ const batch = words.slice(0, MAX_CLASSIFY_WORDS)
143
+ if (words.length > MAX_CLASSIFY_WORDS) {
144
+ process.stdout.write(`ℹ️ cspell: класифікація перших ${MAX_CLASSIFY_WORDS}/${words.length} слів (решта — наступний прогін)\n`)
145
+ }
146
+
147
+ let text
148
+ try {
149
+ text = callLlm([{ role: 'user', content: classifyPrompt(batch) }], model, { caller: 'cspell-classify', maxTokens: 4000 })
150
+ } catch (error) {
151
+ process.stdout.write(`⚠️ cspell: omlx-класифікація впала (${error.message}) — без авто-словника\n`)
81
152
  process.stdout.write(first.out)
82
153
  return first.code
83
154
  }
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`)
155
+
156
+ const parsed = parseClassify(text)
157
+ if (!parsed) {
158
+ process.stdout.write('⚠️ cspell: не вдалося розпарсити класифікацію — без авто-словника\n')
159
+ process.stdout.write(first.out)
160
+ return first.code
87
161
  }
88
162
 
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`)
163
+ const valid = parsed.filter(x => x.verdict === 'valid' && typeof x.w === 'string').map(x => x.w)
164
+ const typos = parsed.filter(x => x.verdict === 'typo' && typeof x.w === 'string')
165
+ const added = appendWordsToDict(cwd, valid)
166
+ process.stdout.write(`✓ cspell: +${added} валідних слів у .cspell.json (з ${valid.length} класифікованих)\n`)
167
+ if (typos.length > 0) {
168
+ process.stdout.write("⚠️ cspell: ймовірні одруки на рев'ю (НЕ виправлено авто):\n")
169
+ for (const t of typos) {
170
+ const arrow = t.fix ? ` → ${t.fix}` : ''
171
+ process.stdout.write(` - ${t.w}${arrow}\n`)
172
+ }
98
173
  }
99
174
 
100
- // Re-detect: що лишилось (валідні терміниу словник).
175
+ // Re-detect: валідні тепер у словникулишаються одруки/нерозкласифіковане → exit 1.
101
176
  const second = detectCspell(cwd, bin)
102
177
  if (second.code !== 0) process.stdout.write(second.out)
103
178
  return second.code