@nitra/cursor 11.0.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,19 @@
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
+
11
+ ## [11.1.0] - 2026-06-15
12
+
13
+ ### Changed
14
+
15
+ - doc-files lint-крок: opportunistic LLM-fix tier (спека docs/specs/2026-06-15-opportunistic-llm-fix-tier.md). У fix-by-default (не `--read-only`) крок тепер не лише детектить застарілі доки, а й намагається їх **згенерувати** локальною моделлю, якщо omlx піднято; недоступний omlx → fix пропускається з повідомленням і лишається exit 1 (гейт тримається, без false-green). `--read-only` (CI/hook) лишається суто детектом — нуль мутацій/LLM. Прапор `meta.json: llmFix:true` (схема `rule-meta.json`) — opt-in лише для контент-правил; логічні лінтери НЕ вмикати (LLM-правка коду може змінити поведінку). Спільне ядро генерації винесено в `runGenerationBatch`/`preflightProblem` (експорт з `docgen-files-batch.mjs`) — перевикористовують і батч-CLI `fix-doc-files`, і lint-крок.
16
+
3
17
  ## [11.0.0] - 2026-06-15
4
18
 
5
19
  ### Removed
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.0.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
- 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,27 +178,39 @@ 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
- const problem = preflightProblem()
191
+ return runGenerationBatch(targets, root, {
192
+ headline: `📋 doc-files: до генерації ${targets.length} файл(ів)${modeSuffix({ overwrite })}`
193
+ })
194
+ }
195
+
196
+ /**
197
+ * Спільне ядро генерації: preflight локального бекенда → послідовний прогін
198
+ * `targets` через `generateOne` з circuit-breaker'ом (K systemic-збоїв підряд →
199
+ * abort) → підсумковий звіт. Перевикористовують і батч-CLI (`runDocFilesGenCli`),
200
+ * і opportunistic lint-крок doc-files (scoped-набір змінених файлів).
201
+ * @param {Array<object>} targets елементи scanForDocFiles (sourcePath/docPath)
202
+ * @param {string} root абсолютний корінь
203
+ * @param {{ headline?: string }} [opts] headline — рядок-шапка прогону у stdout
204
+ * @returns {Promise<number>} 0 — без помилок; 1 — фейл preflight або є помилки; 2 — systemic-abort
205
+ */
206
+ export async function runGenerationBatch(targets, root, { headline } = {}) {
207
+ const problem = preflightLocalModel(DEFAULT_LOCAL_MODEL)
218
208
  if (problem) {
219
209
  console.error(`✗ fix-doc-files: ${problem}`)
220
210
  return 1
221
211
  }
222
212
 
223
- console.log(`📋 doc-files: до генерації ${targets.length} файл(ів)${modeSuffix({ overwrite, retryDegraded })}`)
213
+ if (headline) console.log(headline)
224
214
  const stats = { ok: 0, degraded: 0, err: 0, errors: [], skipped: [] }
225
215
 
226
216
  let done = 0
@@ -9,6 +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, JUDGE_MODEL, judgeDoc, judgeFailsDoc } from './docgen-judge.mjs'
12
13
  import {
13
14
  oneShotMessages,
14
15
  sectionMessages,
@@ -449,6 +450,18 @@ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUA
449
450
  }
450
451
  }
451
452
 
453
+ // Stage 3 (опц.): семантичний judge-гейт — лише за N_CURSOR_DOCGEN_JUDGE=1 і на
454
+ // доках, що ПРОЙШЛИ det-скорер (там ховаються false-positives). Scope: inaccurate.
455
+ let judge = null
456
+ if (JUDGE_ENABLED && score >= threshold) {
457
+ try {
458
+ judge = { ...judgeDoc(src, r.md), model: JUDGE_MODEL }
459
+ if (judgeFailsDoc(judge)) issues = [...issues, `judge:inaccurate:${judge.confidence}`]
460
+ } catch (error) {
461
+ issues = [...issues, `judge:error: ${error.message.slice(0, 80)}`]
462
+ }
463
+ }
464
+
452
465
  return {
453
466
  ...r,
454
467
  ms: Date.now() - t0,
@@ -456,7 +469,8 @@ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUA
456
469
  llmCalls: llmMeter.calls,
457
470
  score,
458
471
  issues,
459
- degraded: score < threshold,
472
+ judge,
473
+ degraded: score < threshold || judgeFailsDoc(judge),
460
474
  model
461
475
  }
462
476
  }
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Usage:
16
16
  * node docgen-judge-measure.mjs <file1> <file2> ...
17
- * MEASURE_CACHE=/tmp/x N_CURSOR_DOCGEN_JUDGE_MODEL=openai-codex/gpt-5.4 node docgen-judge-measure.mjs ...
17
+ * MEASURE_CACHE=/tmp/x N_CLOUD_MIN_MODEL=openai-codex/gpt-5.4 node docgen-judge-measure.mjs ...
18
18
  */
19
19
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
20
20
  import { createHash } from 'node:crypto'
@@ -25,7 +25,7 @@ import { QUALITY_THRESHOLD } from './docgen-crc.mjs'
25
25
 
26
26
  const env = process.env
27
27
  const GEN_MODEL = env.N_LOCAL_MIN_MODEL ?? 'omlx/gemma-4-e4b-it-OptiQ-4bit'
28
- const JUDGE_MODEL = env.N_CURSOR_DOCGEN_JUDGE_MODEL ?? 'openai-codex/gpt-5.4-mini'
28
+ const JUDGE_MODEL = env.N_CLOUD_MIN_MODEL ?? 'openai-codex/gpt-5.4-mini'
29
29
  const THRESHOLD = Number(env.N_CURSOR_DOC_FILES_THRESHOLD ?? QUALITY_THRESHOLD) || 70
30
30
  const CACHE_DIR = env.MEASURE_CACHE ?? '/tmp/docgen-judge-measure'
31
31
  const JUDGE_TIMEOUT = Number(env.MEASURE_JUDGE_TIMEOUT_MS ?? 120_000)
@@ -48,7 +48,12 @@ function cacheSet(key, val) {
48
48
  writeFileSync(join(CACHE_DIR, key + '.json'), JSON.stringify(val))
49
49
  }
50
50
 
51
- /** Генерує (з кешем за хешем src). */
51
+ /**
52
+ * Генерує док (з кешем за хешем src).
53
+ * @param {string} file абсолютний шлях джерела
54
+ * @param {string} src вміст файлу
55
+ * @returns {{md: string, score: number|null, issues: string[], degraded: boolean, cached: boolean}} результат генерації
56
+ */
52
57
  function genCached(file, src) {
53
58
  const key = 'gen-' + sha(GEN_MODEL + '\0' + src)
54
59
  const hit = cacheGet(key)
@@ -59,7 +64,12 @@ function genCached(file, src) {
59
64
  return { ...out, cached: false }
60
65
  }
61
66
 
62
- /** Судить (з кешем за хешем src+doc). */
67
+ /**
68
+ * Судить док сильною моделлю (з кешем за хешем src+doc).
69
+ * @param {string} src вміст вихідного файлу
70
+ * @param {string} doc згенерована документація
71
+ * @returns {{verdict: string, confidence: number, reason: string, offending?: string[], cached: boolean}} verdict судді
72
+ */
63
73
  function judgeCached(src, doc) {
64
74
  const key = 'judge-' + sha(JUDGE_MODEL + '\0' + src + '\0' + doc)
65
75
  const hit = cacheGet(key)
@@ -67,7 +77,7 @@ function judgeCached(src, doc) {
67
77
  const user = `SOURCE FILE:\n\`\`\`\n${src.slice(0, 12000)}\n\`\`\`\n\nGENERATED DOC:\n\`\`\`md\n${doc.slice(0, 8000)}\n\`\`\`\n\nReturn the JSON verdict.`
68
78
  const raw = callLlm([{ role: 'system', content: SYSTEM }, { role: 'user', content: user }], JUDGE_MODEL, { timeoutMs: JUDGE_TIMEOUT, temperature: 0 })
69
79
  const a = raw.indexOf('{'), b = raw.lastIndexOf('}')
70
- if (a < 0 || b < 0) throw new Error('no JSON in judge reply: ' + raw.slice(0, 160))
80
+ if (a === -1 || b === -1) throw new Error('no JSON in judge reply: ' + raw.slice(0, 160))
71
81
  const v = JSON.parse(raw.slice(a, b + 1))
72
82
  cacheSet(key, v)
73
83
  return { ...v, cached: false }
@@ -85,11 +95,11 @@ function main() {
85
95
  for (const [i, file] of files.entries()) {
86
96
  const tag = `(${i + 1}/${files.length}) ${file}`
87
97
  let src
88
- try { src = readFileSync(file, 'utf8') } catch (e) { console.error(`[skip] ${tag}: read ${e.message}`); continue }
98
+ try { src = readFileSync(file, 'utf8') } catch (error) { console.error(`[skip] ${tag}: read ${error.message}`); continue }
89
99
 
90
100
  let gen
91
- try { gen = genCached(file, src) } catch (e) { console.error(`[gen-err] ${tag}: ${e.message.slice(0, 120)}`); rows.push({ file, error: 'gen', detail: e.message.slice(0, 200) }); continue }
92
- if (gen.score == null) { console.error(`[unsupported] ${tag}`); rows.push({ file, score: null, unsupported: true }); continue }
101
+ try { gen = genCached(file, src) } catch (error) { console.error(`[gen-err] ${tag}: ${error.message.slice(0, 120)}`); rows.push({ file, error: 'gen', detail: error.message.slice(0, 200) }); continue }
102
+ if (gen.score === null) { console.error(`[unsupported] ${tag}`); rows.push({ file, score: null, unsupported: true }); continue }
93
103
 
94
104
  const passed = gen.score >= THRESHOLD
95
105
  const row = { file, score: gen.score, degraded: gen.degraded, passed, genCached: gen.cached }
@@ -100,7 +110,7 @@ function main() {
100
110
  const v = judgeCached(src, gen.md)
101
111
  row.verdict = v.verdict; row.confidence = v.confidence; row.reason = v.reason; row.offending = v.offending; row.judgeCached = v.cached
102
112
  console.error(` [judge${v.cached ? '*' : ''}] ${v.verdict} (${v.confidence}) — ${(v.reason || '').slice(0, 90)}`)
103
- } catch (e) { row.judgeError = e.message.slice(0, 200); console.error(` [judge-err] ${e.message.slice(0, 120)}`) }
113
+ } catch (error) { row.judgeError = error.message.slice(0, 200); console.error(` [judge-err] ${error.message.slice(0, 120)}`) }
104
114
  }
105
115
  rows.push(row)
106
116
  }
@@ -0,0 +1,72 @@
1
+ /** @see ./docs/docgen-judge.md */
2
+ /**
3
+ * docgen-judge — опціональний семантичний verdict-гейт (spec
4
+ * `docs/specs/2026-06-14-docgen-judge-design.md`).
5
+ *
6
+ * Доповнює детермінований `scoreDoc`: ловить `inaccurate`-доки (твердження, що
7
+ * суперечать джерелу), яких структурно-лексичний скорер не бачить у принципі.
8
+ * Активується АВТОМАТИЧНО, якщо задано `N_CLOUD_MIN_MODEL` (бо суддя потребує
9
+ * сильнішої за генератор хмарної моделі — без неї судити нема чим). Працює лише на
10
+ * доках що ПРОЙШЛИ det-скорер (`score ≥ threshold`) — саме там ховаються
11
+ * false-positives. Scope строго `inaccurate` (вимір показав generic=0%). Без
12
+ * `N_CLOUD_MIN_MODEL` → 0 змін поведінки. Патерн дзеркалить `scripts/coverage-classify`.
13
+ */
14
+ import { env } from 'node:process'
15
+ import { callLlm } from '../../../lib/llm.mjs'
16
+ import { CLOUD_MIN } from '../../../lib/models.mjs'
17
+
18
+ /** Модель-суддя = `N_CLOUD_MIN_MODEL` (хмарний cloud-min tier). */
19
+ export const JUDGE_MODEL = CLOUD_MIN
20
+ /** Гейт активується АВТОМАТИЧНО, коли задано `N_CLOUD_MIN_MODEL` (без нього нема надійного судді). */
21
+ export const JUDGE_ENABLED = Boolean(CLOUD_MIN)
22
+ /** Мін. впевненість, щоб verdict `inaccurate` позначив док як degraded. */
23
+ export const JUDGE_CONFIDENCE = Number(env.N_CURSOR_DOCGEN_JUDGE_THRESHOLD ?? 0.7) || 0.7
24
+
25
+ const JUDGE_SYSTEM = `You are a strict technical-documentation reviewer. You receive a SOURCE file and an auto-generated Markdown DOC describing it. Classify the DOC into exactly one verdict:
26
+ - "accurate": specific to THIS file AND every factual claim is supported by the source.
27
+ - "generic": vague/boilerplate; could describe almost any file of this kind.
28
+ - "inaccurate": contains at least one claim NOT supported by, or contradicted by, the source code (e.g. wrong return behavior, false "no network"/"read-only", invented symbols/fields).
29
+ Prefer "inaccurate" if any claim is wrong. Respond with ONLY a JSON object, no prose:
30
+ {"verdict":"accurate|generic|inaccurate","confidence":0.0-1.0,"reason":"<10-300 chars>"}`
31
+
32
+ const VERDICTS = new Set(['accurate', 'generic', 'inaccurate'])
33
+
34
+ /**
35
+ * Витягує й валідує verdict-JSON із сирої відповіді LLM (як `parseVerdict` у coverage-classify).
36
+ * @param {string} rawText сира текстова відповідь судді
37
+ * @returns {{verdict: string, confidence: number, reason: string}} провалідований verdict
38
+ * @throws {Error} якщо JSON відсутній/невалідний або не відповідає схемі
39
+ */
40
+ export function parseDocVerdict(rawText) {
41
+ const a = rawText.indexOf('{')
42
+ const b = rawText.lastIndexOf('}')
43
+ if (a === -1 || b === -1) throw new Error('judge: no JSON object in response')
44
+ const v = JSON.parse(rawText.slice(a, b + 1))
45
+ if (!VERDICTS.has(v.verdict)) throw new Error(`judge: bad verdict "${v.verdict}"`)
46
+ if (typeof v.confidence !== 'number' || v.confidence < 0 || v.confidence > 1) {
47
+ throw new Error('judge: bad confidence')
48
+ }
49
+ return { verdict: v.verdict, confidence: v.confidence, reason: String(v.reason ?? '').slice(0, 500) }
50
+ }
51
+
52
+ /**
53
+ * Судить згенерований док сильною моделлю проти джерела.
54
+ * @param {string} src вміст вихідного файлу
55
+ * @param {string} doc згенерована документація
56
+ * @param {{model?: string, timeoutMs?: number}} [opts] override моделі/таймауту
57
+ * @returns {{verdict: string, confidence: number, reason: string}} verdict судді
58
+ */
59
+ export function judgeDoc(src, doc, { model = JUDGE_MODEL, timeoutMs = 120_000 } = {}) {
60
+ const user = `SOURCE FILE:\n\`\`\`\n${src.slice(0, 12_000)}\n\`\`\`\n\nGENERATED DOC:\n\`\`\`md\n${doc.slice(0, 8000)}\n\`\`\`\n\nReturn the JSON verdict.`
61
+ const raw = callLlm([{ role: 'system', content: JUDGE_SYSTEM }, { role: 'user', content: user }], model, { timeoutMs, temperature: 0 })
62
+ return parseDocVerdict(raw)
63
+ }
64
+
65
+ /**
66
+ * Чи позначає verdict док як degraded (лише `inaccurate` із достатньою впевненістю).
67
+ * @param {{verdict: string, confidence: number}|null} verdict verdict судді або null
68
+ * @returns {boolean} true якщо док треба вважати degraded через семантичну неточність
69
+ */
70
+ export function judgeFailsDoc(verdict) {
71
+ return verdict !== null && verdict.verdict === 'inaccurate' && verdict.confidence >= JUDGE_CONFIDENCE
72
+ }
@@ -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,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/doc-files/js/docgen-judge-measure.mjs
4
- crc: b40b626c
4
+ crc: 7f72e520
5
5
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
6
6
  score: 100
7
7
  ---
@@ -10,20 +10,21 @@ docgen:
10
10
 
11
11
  ## Огляд
12
12
 
13
- Файл аналізує вміст визначеного списку файлів, створюючи технічну документацію на основі вихідного коду. Процес спирається на конфігурацію, визначену в report.json. Для кожного файлу відбувається кешування результатів у межах прогону. Якість згенерованої документації оцінюється хмарною моделлю шляхом порівняння з пороговими значеннями, заданими в report.json. Результати аналізу агрегуються, обчислюється відсоток хибнопозитивних спрацювань та зберігаються у report.json.
13
+ Файл зчитує список файлів для аналізу, спираючись на конфігурацію `report.json`. Для кожного файлу генерується технічна документація за допомогою локальної моделі. Після генерації документація оцінюється хмарною моделлю. Результати оцінки кешуються у межах прогону, а потім збираються у звіт. Звіт зберігається у `report.json` та виводиться у консоль.
14
14
 
15
15
  ## Поведінка
16
16
 
17
- 1. Зчитує список файлів для аналізу.
17
+ 1. Зчитує список файлів для аналізу з командного рядка.
18
18
  2. Для кожного файлу зчитує його вміст.
19
- 3. Генерує технічну документацію для файлу, використовуючи кеш за вмістом вихідного коду.
20
- 4. Якщо документація згенерована, перевіряє її якість за встановленим порогом.
21
- 5. Якщо якість документації відповідає порогу, передає вміст вихідного коду та згенеровану документацію для оцінки.
22
- 6. Оцінює документацію за допомогою сильної хмарної моделі, використовуючи кеш за вмістом вихідного коду та документації.
23
- 7. Збирає результати для кожного файлу.
24
- 8. Агрегує результати, обчислюючи відсоток хибнопозитивних спрацювань (false-positive rate) серед документів, які пройшли порогову перевірку та були оцінені.
25
- 9. Зберігає повний звіт у файл `report.json` у директорії кешу.
26
- 10. Виводить консольний звіт про результати вимірювання.
19
+ 3. Генерує документацію для файлу, використовуючи локальну модель. Результат кешується за хешем вмісту джерела.
20
+ 4. Якщо генерація документації неможлива або не пройшла встановлений поріг якості, фіксує це як помилку або непідтримуваний файл.
21
+ 5. Якщо документація пройшла поріг якості, вона передається для оцінки.
22
+ 6. Оцінює згенеровану документацію за допомогою потужної хмарної моделі. Результат оцінки кешується за хешем вмісту джерела та документації.
23
+ 7. Якщо оцінка неможлива, фіксує помилку оцінки.
24
+ 8. Збирає результати для всіх файлів.
25
+ 9. Обчислює звіт, включаючи загальну кількість файлів, кількість успішно згенерованих та оцінених, а також відсоток хибнопозитивних результатів (false-positive rate).
26
+ 10. Зберігає повний звіт у файл `report.json` у каталозі кешу.
27
+ 11. Виводить консольний звіт з ключовими показниками.
27
28
 
28
29
  ## Гарантії поведінки
29
30
 
@@ -0,0 +1,35 @@
1
+ ---
2
+ docgen:
3
+ source: npm/rules/doc-files/js/docgen-judge.mjs
4
+ crc: c6ab093a
5
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
6
+ score: 100
7
+ ---
8
+
9
+ # docgen-judge.mjs
10
+
11
+ ## Огляд
12
+
13
+ Модуль оцінює згенеровану документацію, порівнюючи її з вихідним файлом за допомогою великої мовної моделі. Це дозволяє визначити відповідність та якість документації. Модуль надає функції для запуску оцінки (`judgeDoc`, `judgeFailsDoc`), отримання результатів (`parseDocVerdict`), визначення моделі (`JUDGE_MODEL`), перевірки статусу оцінювача (`JUDGE_ENABLED`) та отримання рівня впевненості (`JUDGE_CONFIDENCE`).
14
+
15
+ ## Поведінка
16
+
17
+ JUDGE_MODEL — Вказує модель, яку використовує суддя для оцінки документації.
18
+ JUDGE_ENABLED — Позначає, чи активна функціональність судді.
19
+ JUDGE_CONFIDENCE — Визначає мінімальний рівень впевненості, необхідний для позначення документації як деградованої.
20
+ parseDocVerdict — Витягує та валідує об'єкт з оцінкою документації з сирого текстового виводу судді.
21
+ judgeDoc — Виконує оцінку згенерованої документації проти вмісту вихідного файлу за допомогою великої мовної моделі.
22
+ judgeFailsDoc — Визначає, чи слід вважати документацію деградованою на основі оцінки судді.
23
+
24
+ ## Публічний API
25
+
26
+ JUDGE_MODEL — Визначає модель-суддю як `N_CLOUD_MIN_MODEL` (хмарний мінімальний рівень).
27
+ JUDGE_ENABLED — Автоматично вмикає механізм судді, якщо обрано `N_CLOUD_MIN_MODEL`.
28
+ JUDGE_CONFIDENCE — Встановлює мінімальний рівень впевненості, необхідний для позначення документа як погіршеного (degraded) за неточним вердиктом.
29
+ parseDocVerdict — Витягує та перевіряє структуру вердикту у форматі JSON із сирих даних від великої мовної моделі.
30
+ judgeDoc — Оцінює згенерований документ потужною моделлю, порівнюючи його з вихідним джерелом.
31
+ judgeFailsDoc — Визначає, чи повинен документ бути позначений як погіршений (degraded) на підставі вердикту, якщо він неточний і має достатню впевненість.
32
+
33
+ ## Гарантії поведінки
34
+
35
+ - Read-only: не виконує операцій запису (ФС/БД).
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Адаптер агрегатора `n-cursor lint` для правила doc-files.
2
+ * Адаптер агрегатора `n-cursor lint` для правила doc-files (opportunistic LLM-fix
3
+ * tier, спека docs/specs/2026-06-15-opportunistic-llm-fix-tier.md).
3
4
  *
4
5
  * Quick-фаза отримує список змінених файлів і мапить їх у пари в **обидва** боки:
5
6
  * - змінене **джерело** (`.js/.mjs/.ts/.vue/.py/.rs`) → перевірка його доки `<dir>/docs/<stem>.md`;
@@ -7,8 +8,11 @@
7
8
  * (той самий stem у каталозі над текою `docs`).
8
9
  * Ci-фаза (files === undefined) проганяє повний скан дерева.
9
10
  *
10
- * Порушення — `missing` ∪ `crc-mismatch` (детермінований CRC-детект, 0 LLM-токенів);
11
- * degraded не блокує. Exit 1 є stale; 0 — все свіже (конвенція агрегатора).
11
+ * Детект — `missing` ∪ `crc-mismatch` (детермінований CRC, 0 LLM-токенів); degraded не блокує.
12
+ * Поведінка за осями (правило має `meta.json: llmFix:true`):
13
+ * - `readOnly` (CI/hook): **лише детект** — нуль мутацій/LLM, exit 1 на stale (детермінований гейт);
14
+ * - fix-by-default + omlx **піднято**: opportunistic-генерація stale-доків → re-detect → 0 якщо полагоджено;
15
+ * - fix-by-default + omlx **недоступно**: fix пропущено (повідомлення) + exit 1 — гейт тримається, без false-green.
12
16
  */
13
17
  import { join, dirname, basename, extname } from 'node:path'
14
18
  import { existsSync, readdirSync } from 'node:fs'
@@ -79,18 +83,34 @@ function reportStale(stale) {
79
83
  }
80
84
 
81
85
  /**
82
- * Крок агрегатора lint для doc-files.
86
+ * Збирає застарілі (missing crc-mismatch) описи у scope кроку.
83
87
  * @param {string[] | undefined} files quick: лише ці файли; undefined: весь репозиторій
84
- * @param {string} [cwd] корінь репо
85
- * @returns {Promise<number>} 0 OK, 1 — є застарілі доки
88
+ * @param {string} cwd корінь репо
89
+ * @returns {Array<{sourcePath:string, docPath:string, reason:string|null}>} stale-описи (готові як targets генерації)
86
90
  */
87
- export function lint(files, cwd = process.cwd()) {
88
- if (files === undefined) {
89
- const stale = scanForDocFiles(cwd).filter(f => f.stale)
90
- return Promise.resolve(reportStale(stale))
91
- }
91
+ function collectStale(files, cwd) {
92
+ if (files === undefined) return scanForDocFiles(cwd).filter(f => f.stale)
92
93
  const sources = sourcesFromChanged(files, cwd)
93
- if (sources.length === 0) return Promise.resolve(0)
94
- const stale = sources.map(src => describeFile(cwd, src)).filter(f => f.stale)
95
- return Promise.resolve(reportStale(stale))
94
+ return sources.map(src => describeFile(cwd, src)).filter(f => f.stale)
95
+ }
96
+
97
+ /**
98
+ * Крок агрегатора lint для doc-files (opportunistic LLM-fix tier).
99
+ * @param {string[] | undefined} files quick: лише ці файли; undefined: весь репозиторій
100
+ * @param {string} [cwd] корінь репо
101
+ * @param {{ readOnly?: boolean }} [opts] readOnly: лише детект (CI/hook), без мутацій/LLM
102
+ * @returns {Promise<number>} 0 — доки свіжі; 1 — є застарілі (детект, fix пропущено чи помилка генерації)
103
+ */
104
+ export async function lint(files, cwd = process.cwd(), { readOnly = false } = {}) {
105
+ const stale = collectStale(files, cwd)
106
+ if (stale.length === 0) return 0
107
+ if (readOnly) return reportStale(stale)
108
+
109
+ // fix-by-default: opportunistic-генерація через спільне ядро (preflight omlx →
110
+ // батч із circuit-breaker'ом). omlx недоступний → runGenerationBatch друкує причину
111
+ // й повертає !=0; ми re-detect'имо й через reportStale віддаємо exit 1 (гейт тримається).
112
+ process.stdout.write(`ℹ️ doc-files: ${stale.length} застарілих — пробую авто-фікс (omlx)…\n`)
113
+ const { runGenerationBatch } = await import('./docgen-files-batch.mjs')
114
+ await runGenerationBatch(stale, cwd, { headline: `📋 doc-files: генерація ${stale.length} файл(ів)` })
115
+ return reportStale(collectStale(files, cwd))
96
116
  }
@@ -1 +1 @@
1
- { "auto": "завжди", "lint": "per-file" }
1
+ { "auto": "завжди", "lint": "per-file", "llmFix": true }
@@ -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
@@ -11,6 +11,10 @@
11
11
  "enum": ["per-file", "full"],
12
12
  "description": "Scope lint-кроку: per-file (декомпозиція по змінених файлах, дельта vs origin) або full (нероздільно крос-файловий, лише `lint --full`)."
13
13
  },
14
+ "llmFix": {
15
+ "type": "boolean",
16
+ "description": "Opt-in opportunistic LLM-fix: у fix-by-default (не --read-only) lint-крок намагається виправити порушення локальною моделлю (omlx), якщо її піднято; інакше пропускає й лишає exit 1. Лише для контент-правил (doc-files, cspell) — НЕ для логічних лінтерів (зміна коду може змінити поведінку). Деталі: docs/specs/2026-06-15-opportunistic-llm-fix-tier.md."
17
+ },
14
18
  "auto": {
15
19
  "description": "Умова автоактивації правила: \"завжди\", масив id правил-залежностей, glob, або іменований предикат.",
16
20
  "oneOf": [