@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 +8 -0
- package/lib/llm.mjs +27 -0
- package/package.json +1 -1
- package/rules/doc-files/js/docgen-crc.mjs +20 -9
- package/rules/doc-files/js/docgen-files-batch.mjs +34 -60
- package/rules/doc-files/js/docgen-gen.mjs +2 -2
- package/rules/doc-files/js/docgen-scan.mjs +2 -2
- package/rules/text/lint/cspell-fix.mjs +122 -47
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
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* Degraded-маркер (ADR 260610-2228): якщо локальний конвеєр не дотягнув до порогу
|
|
11
11
|
* якості, дока все одно пишеться, а frontmatter додатково несе `score` (det-оцінка)
|
|
12
12
|
* та `issues` (коди проблем). CRC при цьому свіжий — Stop-гейт не блокує задачі через
|
|
13
|
-
* слабкість моделі; борг видимий через `check --degraded` і
|
|
14
|
-
*
|
|
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-оцінка
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
43
|
-
* - `--overwrite`
|
|
44
|
-
*
|
|
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
|
|
48
|
+
* @param {{ overwrite: boolean }} mode режими
|
|
48
49
|
* @returns {Array<object>} відфільтровані цілі
|
|
49
50
|
*/
|
|
50
|
-
function selectTargets(root, all, { overwrite
|
|
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 =>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
88
|
-
* @returns {string} ` (--overwrite)`
|
|
62
|
+
* @param {{ overwrite: boolean }} mode режими
|
|
63
|
+
* @returns {string} ` (--overwrite)` або порожній рядок
|
|
89
64
|
*/
|
|
90
|
-
function modeSuffix({ overwrite
|
|
91
|
-
|
|
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
|
|
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(
|
|
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
|
|
181
|
+
const { from, limit, overwrite } = parseGenArgs(argv)
|
|
204
182
|
|
|
205
183
|
const all = scanForDocFiles(root)
|
|
206
|
-
const targets = selectTargets(root, all, { overwrite
|
|
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
|
|
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 =
|
|
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 —
|
|
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→
|
|
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
|
|
2
|
+
* cspell у ланцюжку lint-text із omlx-класифікацією (нова схема — спека
|
|
3
|
+
* docs/specs/2026-06-15-opportunistic-llm-fix-tier.md).
|
|
3
4
|
*
|
|
4
|
-
* cspell не має нативного `--fix
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
15
|
-
const
|
|
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
|
-
*
|
|
44
|
+
* Унікальні «Unknown word» зі stdout cspell.
|
|
32
45
|
* @param {string} out вивід cspell
|
|
33
|
-
* @returns {
|
|
46
|
+
* @returns {string[]} distinct-слова у порядку першої появи
|
|
34
47
|
*/
|
|
35
|
-
export function
|
|
36
|
-
|
|
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 =
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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-режим:
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|