@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 +14 -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 +50 -60
- package/rules/doc-files/js/docgen-gen.mjs +15 -1
- package/rules/doc-files/js/docgen-judge-measure.mjs +19 -9
- package/rules/doc-files/js/docgen-judge.mjs +72 -0
- package/rules/doc-files/js/docgen-scan.mjs +2 -2
- package/rules/doc-files/js/docs/docgen-judge-measure.md +12 -11
- package/rules/doc-files/js/docs/docgen-judge.md +35 -0
- package/rules/doc-files/js/lint.mjs +34 -14
- package/rules/doc-files/meta.json +1 -1
- package/rules/text/lint/cspell-fix.mjs +122 -47
- package/schemas/rule-meta.json +4 -0
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
|
@@ -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
|
-
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,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
|
|
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, {
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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 (
|
|
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 (
|
|
92
|
-
if (gen.score
|
|
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 (
|
|
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 —
|
|
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,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
docgen:
|
|
3
3
|
source: npm/rules/doc-files/js/docgen-judge-measure.mjs
|
|
4
|
-
crc:
|
|
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
|
-
Файл
|
|
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.
|
|
25
|
-
9.
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
86
|
+
* Збирає застарілі (missing ∪ crc-mismatch) описи у scope кроку.
|
|
83
87
|
* @param {string[] | undefined} files quick: лише ці файли; undefined: весь репозиторій
|
|
84
|
-
* @param {string}
|
|
85
|
-
* @returns {
|
|
88
|
+
* @param {string} cwd корінь репо
|
|
89
|
+
* @returns {Array<{sourcePath:string, docPath:string, reason:string|null}>} stale-описи (готові як targets генерації)
|
|
86
90
|
*/
|
|
87
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
package/schemas/rule-meta.json
CHANGED
|
@@ -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": [
|