@nitra/cursor 12.0.3 → 12.1.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 +7 -0
- package/bin/n-cursor.js +10 -1
- package/package.json +1 -1
- package/rules/lint/js/docs/orchestrate.md +2 -1
- package/rules/lint/js/orchestrate.mjs +5 -0
- package/scripts/lib/fix/analyze-escalation.mjs +315 -0
- package/scripts/lib/fix/docs/analyze-escalation.md +31 -0
- package/scripts/lib/fix/docs/escalation-log.md +27 -0
- package/scripts/lib/fix/docs/index.md +2 -0
- package/scripts/lib/fix/docs/llm-worker.md +8 -11
- package/scripts/lib/fix/docs/orchestrator.md +20 -11
- package/scripts/lib/fix/escalation-log.mjs +92 -0
- package/scripts/lib/fix/llm-worker.mjs +56 -20
- package/scripts/lib/fix/orchestrator.mjs +158 -55
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [12.1.0] - 2026-06-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- fix-конформність: драбина ескалації моделей (local-min → local-min+feedback → cloud-min → cloud-avg) із per-рунговим escalation-логом (.n-cursor/fix-escalation.jsonl, поле diagnosis) і кепом --max-avg; прибрано --max-iter/MODEL_HEAVY
|
|
8
|
+
- analyze-escalation: аналіз escalation-логу хмарною avg-моделлю (чанкінг + map-reduce) → markdown-звіт із пропозиціями нових T0-патернів / правок .mdc / змін скриптів; CLI n-cursor analyze-escalation і авто-хук наприкінці lint --full (kill-switch N_CURSOR_FIX_ANALYZE)
|
|
9
|
+
|
|
3
10
|
## [12.0.3] - 2026-06-18
|
|
4
11
|
|
|
5
12
|
### Fixed
|
package/bin/n-cursor.js
CHANGED
|
@@ -1599,6 +1599,15 @@ try {
|
|
|
1599
1599
|
|
|
1600
1600
|
break
|
|
1601
1601
|
}
|
|
1602
|
+
case 'analyze-escalation': {
|
|
1603
|
+
// n-cursor analyze-escalation — читає весь escalation-лог (.n-cursor/fix-escalation.jsonl),
|
|
1604
|
+
// чанкує й просить хмарну avg-модель запропонувати, як зменшити LLM-залежність fix-
|
|
1605
|
+
// конформності (нові T0-патерни / правки .mdc / зміни скриптів). Звіт → markdown.
|
|
1606
|
+
const { runEscalationAnalysisCli } = await import('../scripts/lib/fix/analyze-escalation.mjs')
|
|
1607
|
+
process.exitCode = await runEscalationAnalysisCli(args)
|
|
1608
|
+
|
|
1609
|
+
break
|
|
1610
|
+
}
|
|
1602
1611
|
case 'taze': {
|
|
1603
1612
|
// n-cursor taze diff — read-only semver-diff package.json ↔ package.json.taze-bak
|
|
1604
1613
|
// (root + воркспейси) для скілу n-taze: скрипт класифікує major-оновлення,
|
|
@@ -1697,7 +1706,7 @@ try {
|
|
|
1697
1706
|
default: {
|
|
1698
1707
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1699
1708
|
console.error(
|
|
1700
|
-
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, adr-normalize-local, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, change, release, skill, trace, doc-aggregate`
|
|
1709
|
+
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, adr-normalize-local, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, analyze-escalation, taze, start-check, change, release, skill, trace, doc-aggregate`
|
|
1701
1710
|
)
|
|
1702
1711
|
process.exitCode = 1
|
|
1703
1712
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: orchestrate.mjs
|
|
4
4
|
resource: npm/rules/lint/js/orchestrate.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 69bc0daf
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
score: 100
|
|
9
9
|
---
|
|
@@ -15,6 +15,7 @@ docgen:
|
|
|
15
15
|
Поведінка
|
|
16
16
|
selectLintRules вибирає і сортує ідентифікатори правил на основі їхнього обсягу дії (`per-file` або `full`) та прапорця `--full`.
|
|
17
17
|
runLint запускає оркестрацію лінтування: або виконує перевірку конформності для заданих правил, або ітерує по алфавітно відсортованих правилах, запускаючи лінтер для змінених файлів (за замовчуванням), або виконує перевірку конформності всього репозиторію при використанні прапорця `--full`.
|
|
18
|
+
У режимі `--full` без `--read-only` після конформність-фази runLint викликає escalation-аналітику (`analyze-escalation.mjs`): фіксує зсув escalation-логу до фази, після — аналізує записи саме цього прогону. Аналіз не впливає на exit-код lint.
|
|
18
19
|
|
|
19
20
|
## Публічний API
|
|
20
21
|
|
|
@@ -131,7 +131,12 @@ export async function runLint(opts = {}) {
|
|
|
131
131
|
// Конформність-фаза (поглинула `fix`): whole-repo, лише у `--full`. Кастомний rulesDir
|
|
132
132
|
// (юніт-тести селектора) — реальний пакет недоступний, тож пропускаємо.
|
|
133
133
|
if (full && opts.rulesDir === undefined) {
|
|
134
|
+
// Escalation-аналітика: фіксуємо зсув логу ДО конформності, після — аналізуємо
|
|
135
|
+
// записи саме цього прогону (fix-режим). Аналіз не має валити lint.
|
|
136
|
+
const { escalationLogSize, maybeAnalyzeEscalation } = await import('../../../scripts/lib/fix/analyze-escalation.mjs')
|
|
137
|
+
const escOffset = readOnly ? 0 : escalationLogSize()
|
|
134
138
|
const conformanceCode = await runConformance(cwd, readOnly, log)
|
|
139
|
+
if (!readOnly) maybeAnalyzeEscalation(cwd, escOffset, log)
|
|
135
140
|
if (conformanceCode !== 0) return conformanceCode
|
|
136
141
|
}
|
|
137
142
|
return 0
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Аналітика escalation-логу (спека 2026-06-19-fix-escalation-analysis-design).
|
|
3
|
+
*
|
|
4
|
+
* Читає записи рунгів драбини (`escalation-log.mjs`) — за один прогін (від байтового
|
|
5
|
+
* зсуву) або весь лог, — ділить на чанки за бюджетом символів і просить хмарну
|
|
6
|
+
* **avg**-модель проаналізувати: як зменшити LLM-залежність fix-конформності.
|
|
7
|
+
* Мета аналізу — конкретні правки пакета `@nitra/cursor`:
|
|
8
|
+
* (A) новий ДЕТЕРМІНОВАНИЙ T0-патерн (`t0.mjs`) — прибирає LLM зовсім;
|
|
9
|
+
* (B) уточнення `.mdc`-інструкцій правила, щоб локальна min-модель влучала з першого рунга;
|
|
10
|
+
* (C) зміна скрипта/чека в пакеті.
|
|
11
|
+
* Результат — markdown-звіт у `.n-cursor/fix-escalation-analysis.md` (append із timestamp).
|
|
12
|
+
*
|
|
13
|
+
* Викликається CLI `n-cursor analyze-escalation` (весь лог) і наприкінці `lint --full`
|
|
14
|
+
* (записи цього прогону). Кожен виклик моделі йде через спільний `callLlm` (wire-trace).
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { cwd as processCwd, env } from 'node:process'
|
|
19
|
+
|
|
20
|
+
import { callLlm } from '../../../lib/llm.mjs'
|
|
21
|
+
import { CLOUD_AVG } from '../../../lib/models.mjs'
|
|
22
|
+
import { escalationLogPath } from './escalation-log.mjs'
|
|
23
|
+
|
|
24
|
+
/** Значення `N_CURSOR_FIX_ANALYZE`, що вимикають авто-аналіз наприкінці lint. */
|
|
25
|
+
const KILL_VALUES = new Set(['0', 'false', 'off', 'no'])
|
|
26
|
+
/** Бюджет символів на один чанк (щоб великий лог не перевищив контекст моделі). */
|
|
27
|
+
const DEFAULT_CHUNK_CHARS = 40_000
|
|
28
|
+
/** Timeout одного аналітичного виклику (мс) — аналіз може бути об'ємним. */
|
|
29
|
+
const ANALYZE_TIMEOUT_MS = 180_000
|
|
30
|
+
|
|
31
|
+
/** No-op логер за замовчуванням. */
|
|
32
|
+
const NOOP_LOG = () => {
|
|
33
|
+
/* тихо */
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Спільна мета-інструкція для аналітичних викликів.
|
|
38
|
+
*/
|
|
39
|
+
const GOAL = [
|
|
40
|
+
`You analyze logs from @nitra/cursor's automated rule-conformance fixer ("fix").`,
|
|
41
|
+
`Each record = one attempt by a model to fix a rule-conformance violation on a rung of`,
|
|
42
|
+
`an escalation ladder. Fields: ruleId; tier (local-min|local-min-retry|cloud-min|cloud-avg);`,
|
|
43
|
+
`model; callOk (model call+apply succeeded); recheckOk (rule PASSED after this rung — "did it help");`,
|
|
44
|
+
`callError; diagnosis (model's self-stated reason a prior attempt failed); remainingViolation.`,
|
|
45
|
+
``,
|
|
46
|
+
`Goal: reduce LLM dependence and time-to-green. For RECURRING patterns recommend CONCRETE changes`,
|
|
47
|
+
`to the @nitra/cursor package, in priority order:`,
|
|
48
|
+
`(A) a new DETERMINISTIC T0-auto pattern for npm/scripts/lib/fix/t0.mjs — give a regex that matches`,
|
|
49
|
+
` the violation output + the mechanical fix. PREFERRED: removes the LLM entirely.`,
|
|
50
|
+
`(B) a clarification to a rule's .mdc instructions so the LOCAL min-model succeeds on the FIRST rung.`,
|
|
51
|
+
`(C) a script/check change elsewhere in the package.`,
|
|
52
|
+
`Prioritise rules that escalated to cloud (cloud-min/cloud-avg) or failed repeatedly — they cost most.`,
|
|
53
|
+
`Ignore rules resolved at local-min with no retry — they already work.`
|
|
54
|
+
].join('\n')
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Чи увімкнено авто-аналіз наприкінці lint (default — так; kill-switch `N_CURSOR_FIX_ANALYZE`).
|
|
58
|
+
* @returns {boolean} true, якщо аналіз дозволено
|
|
59
|
+
*/
|
|
60
|
+
export function analysisEnabled() {
|
|
61
|
+
const v = env.N_CURSOR_FIX_ANALYZE
|
|
62
|
+
if (v === undefined) return true
|
|
63
|
+
return !KILL_VALUES.has(v.toLowerCase())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Розмір escalation-логу в байтах (0, якщо файлу немає/вимкнено) — для since-offset.
|
|
68
|
+
* @param {string|null} [path] шлях логу
|
|
69
|
+
* @returns {number} розмір у байтах
|
|
70
|
+
*/
|
|
71
|
+
export function escalationLogSize(path = escalationLogPath()) {
|
|
72
|
+
if (!path) return 0
|
|
73
|
+
try {
|
|
74
|
+
return statSync(path).size
|
|
75
|
+
} catch {
|
|
76
|
+
return 0
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Читає записи escalation-логу від байтового зсуву (default 0 — весь лог). Зсув завжди
|
|
82
|
+
* на межі рядка (захоплюється після завершеного append), тож мультибайтні символи не б'ються.
|
|
83
|
+
* Биті JSON-рядки пропускаються.
|
|
84
|
+
* @param {string|null} path шлях логу
|
|
85
|
+
* @param {number} [sinceOffset] байтовий зсув початку читання
|
|
86
|
+
* @returns {object[]} розпарсені записи
|
|
87
|
+
*/
|
|
88
|
+
export function readEscalationRecords(path, sinceOffset = 0) {
|
|
89
|
+
if (!path) return []
|
|
90
|
+
let buf
|
|
91
|
+
try {
|
|
92
|
+
buf = readFileSync(path)
|
|
93
|
+
} catch {
|
|
94
|
+
return []
|
|
95
|
+
}
|
|
96
|
+
const text = (sinceOffset > 0 ? buf.subarray(sinceOffset) : buf).toString('utf8')
|
|
97
|
+
const out = []
|
|
98
|
+
for (const line of text.split('\n')) {
|
|
99
|
+
const t = line.trim()
|
|
100
|
+
if (!t) continue
|
|
101
|
+
try {
|
|
102
|
+
out.push(JSON.parse(t))
|
|
103
|
+
} catch {
|
|
104
|
+
/* битий рядок — пропускаємо */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Стискає запис до полів, важливих для аналізу (без ts/ms-шуму).
|
|
112
|
+
* @param {object} r сирий запис рунга
|
|
113
|
+
* @returns {object} компактний запис
|
|
114
|
+
*/
|
|
115
|
+
function summarizeRecord(r) {
|
|
116
|
+
return {
|
|
117
|
+
ruleId: r.ruleId,
|
|
118
|
+
tier: r.tier,
|
|
119
|
+
model: r.model,
|
|
120
|
+
callOk: r.callOk,
|
|
121
|
+
recheckOk: r.recheckOk,
|
|
122
|
+
callError: r.callError ?? null,
|
|
123
|
+
diagnosis: r.diagnosis ?? null,
|
|
124
|
+
remainingViolation: r.remainingViolation ?? null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Ділить записи на чанки так, щоб JSON кожного чанка не перевищував `maxChars`.
|
|
130
|
+
* Працює на стиснених записах (саме вони йдуть у prompt).
|
|
131
|
+
* @param {object[]} records сирі записи
|
|
132
|
+
* @param {number} [maxChars] бюджет символів на чанк
|
|
133
|
+
* @returns {object[][]} чанки стиснених записів
|
|
134
|
+
*/
|
|
135
|
+
export function chunkRecords(records, maxChars = DEFAULT_CHUNK_CHARS) {
|
|
136
|
+
const items = records.map(r => summarizeRecord(r))
|
|
137
|
+
const chunks = []
|
|
138
|
+
let cur = []
|
|
139
|
+
let size = 0
|
|
140
|
+
for (const it of items) {
|
|
141
|
+
const len = JSON.stringify(it).length + 1
|
|
142
|
+
if (cur.length > 0 && size + len > maxChars) {
|
|
143
|
+
chunks.push(cur)
|
|
144
|
+
cur = []
|
|
145
|
+
size = 0
|
|
146
|
+
}
|
|
147
|
+
cur.push(it)
|
|
148
|
+
size += len
|
|
149
|
+
}
|
|
150
|
+
if (cur.length > 0) chunks.push(cur)
|
|
151
|
+
return chunks
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Prompt для аналізу одного чанка.
|
|
156
|
+
* @param {object[]} items стиснені записи чанка
|
|
157
|
+
* @param {number} idx індекс чанка (0-based)
|
|
158
|
+
* @param {number} total всього чанків
|
|
159
|
+
* @returns {string} текст prompt
|
|
160
|
+
*/
|
|
161
|
+
function buildChunkPrompt(items, idx, total) {
|
|
162
|
+
return [
|
|
163
|
+
GOAL,
|
|
164
|
+
``,
|
|
165
|
+
`Log chunk ${idx + 1}/${total} (${items.length} records):`,
|
|
166
|
+
JSON.stringify(items),
|
|
167
|
+
``,
|
|
168
|
+
`Return concise markdown: per recommendation — target ruleId, type (A/B/C), and the concrete change`,
|
|
169
|
+
`(for A: the regex + mechanical fix; for B: the exact .mdc clarification; for C: the script change).`
|
|
170
|
+
].join('\n')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Prompt для злиття часткових аналізів у фінальний звіт.
|
|
175
|
+
* @param {string[]} partials часткові аналізи чанків
|
|
176
|
+
* @returns {string} текст prompt
|
|
177
|
+
*/
|
|
178
|
+
function buildSynthesisPrompt(partials) {
|
|
179
|
+
return [
|
|
180
|
+
GOAL,
|
|
181
|
+
``,
|
|
182
|
+
`Below are partial analyses of separate log chunks. Merge, dedupe and prioritise into ONE report.`,
|
|
183
|
+
``,
|
|
184
|
+
partials.map((p, i) => `--- chunk ${i + 1} ---\n${p}`).join('\n\n'),
|
|
185
|
+
``,
|
|
186
|
+
`Return the final markdown report: recommendations ordered highest-impact first.`
|
|
187
|
+
].join('\n')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Безпечний виклик моделі: ковтає помилку у `null` (аналіз не має валити lint).
|
|
192
|
+
* @param {(messages: object[], model: string, opts: object) => string} call функція callLlm
|
|
193
|
+
* @param {string} prompt текст prompt
|
|
194
|
+
* @param {string} model model-id
|
|
195
|
+
* @returns {string|null} текст відповіді або null
|
|
196
|
+
*/
|
|
197
|
+
function safeCall(call, prompt, model) {
|
|
198
|
+
try {
|
|
199
|
+
const text = call([{ role: 'user', content: prompt }], model, { timeoutMs: ANALYZE_TIMEOUT_MS, caller: 'fix-analyze' })
|
|
200
|
+
return text || null
|
|
201
|
+
} catch {
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Аналізує записи: чанкінг → виклик avg-моделі по чанках → синтез (якщо чанків >1).
|
|
208
|
+
* Синхронний (callLlm — spawnSync-based).
|
|
209
|
+
* @param {object[]} records записи escalation-логу
|
|
210
|
+
* @param {{ model?: string, callLlm?: (messages: object[], model: string, opts: object) => string, log?: (s: string) => void, maxChars?: number }} [opts]
|
|
211
|
+
* `model` — модель (default `CLOUD_AVG`); `callLlm` — інжекція для тестів; `log` — логер
|
|
212
|
+
* @returns {{ report: string|null, chunks: number, reason: string }} звіт і метадані
|
|
213
|
+
*/
|
|
214
|
+
export function analyzeEscalations(records, opts = {}) {
|
|
215
|
+
const model = opts.model ?? CLOUD_AVG
|
|
216
|
+
const call = opts.callLlm ?? callLlm
|
|
217
|
+
const log = opts.log ?? NOOP_LOG
|
|
218
|
+
const maxChars = opts.maxChars ?? DEFAULT_CHUNK_CHARS
|
|
219
|
+
|
|
220
|
+
if (records.length === 0) return { report: null, chunks: 0, reason: 'no-records' }
|
|
221
|
+
if (!model) return { report: null, chunks: 0, reason: 'no-cloud-avg-model' }
|
|
222
|
+
|
|
223
|
+
const chunks = chunkRecords(records, maxChars)
|
|
224
|
+
const partials = []
|
|
225
|
+
for (const [i, chunk] of chunks.entries()) {
|
|
226
|
+
log(` 🔎 escalation-analysis: чанк ${i + 1}/${chunks.length} (${chunk.length} записів)`)
|
|
227
|
+
const text = safeCall(call, buildChunkPrompt(chunk, i, chunks.length), model)
|
|
228
|
+
if (text) partials.push(text)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (partials.length === 0) return { report: null, chunks: chunks.length, reason: 'empty-responses' }
|
|
232
|
+
const report = partials.length === 1 ? partials[0] : safeCall(call, buildSynthesisPrompt(partials), model)
|
|
233
|
+
return { report, chunks: chunks.length, reason: report ? 'ok' : 'empty-responses' }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Шлях markdown-звіту аналізу.
|
|
238
|
+
* @param {string} [cwd] корінь
|
|
239
|
+
* @returns {string} шлях .n-cursor/fix-escalation-analysis.md
|
|
240
|
+
*/
|
|
241
|
+
export function analysisReportPath(cwd = processCwd()) {
|
|
242
|
+
return join(cwd, '.n-cursor', 'fix-escalation-analysis.md')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Дописує звіт у markdown-файл із timestamp-заголовком.
|
|
247
|
+
* @param {string} report текст звіту
|
|
248
|
+
* @param {string} cwd корінь
|
|
249
|
+
* @param {string} ts ISO-час
|
|
250
|
+
* @returns {string} шлях файлу
|
|
251
|
+
*/
|
|
252
|
+
export function writeAnalysisReport(report, cwd, ts) {
|
|
253
|
+
const path = analysisReportPath(cwd)
|
|
254
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
255
|
+
appendFileSync(path, `\n## Аналіз ${ts}\n\n${report}\n`, 'utf8')
|
|
256
|
+
return path
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Спільний шлях: аналіз записів → запис звіту → лог-підсумок.
|
|
261
|
+
* @param {object[]} records записи
|
|
262
|
+
* @param {string} cwd корінь
|
|
263
|
+
* @param {(s: string) => void} log логер
|
|
264
|
+
* @returns {number} 0 — ок/пропуск, 1 — модель не дала звіт
|
|
265
|
+
*/
|
|
266
|
+
function analyzeAndReport(records, cwd, log) {
|
|
267
|
+
const res = analyzeEscalations(records, { log })
|
|
268
|
+
if (res.reason === 'no-records') {
|
|
269
|
+
log('ℹ️ escalation-analysis: немає записів для аналізу.')
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
if (res.reason === 'no-cloud-avg-model') {
|
|
273
|
+
log('⚠️ escalation-analysis: N_CLOUD_AVG_MODEL не заданий — аналіз пропущено.')
|
|
274
|
+
return 0
|
|
275
|
+
}
|
|
276
|
+
if (!res.report) {
|
|
277
|
+
log('⚠️ escalation-analysis: модель не повернула звіт.')
|
|
278
|
+
return 1
|
|
279
|
+
}
|
|
280
|
+
const reportPath = writeAnalysisReport(res.report, cwd, new Date().toISOString())
|
|
281
|
+
log(`📝 escalation-analysis: звіт → ${reportPath} (${res.chunks} чанк(и))`)
|
|
282
|
+
return 0
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* CLI `n-cursor analyze-escalation` — аналізує ВЕСЬ escalation-лог і пише звіт.
|
|
287
|
+
* @param {string[]} _args аргументи (зарезервовано)
|
|
288
|
+
* @param {string} [cwd] корінь
|
|
289
|
+
* @returns {number} exit code
|
|
290
|
+
*/
|
|
291
|
+
export function runEscalationAnalysisCli(_args, cwd = processCwd()) {
|
|
292
|
+
const records = readEscalationRecords(escalationLogPath(), 0)
|
|
293
|
+
return analyzeAndReport(records, cwd, s => console.log(s))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Хук наприкінці `lint --full` (non-read-only): аналізує записи ЦЬОГО прогону
|
|
298
|
+
* (від `sinceOffset`). Gated: kill-switch, наявність cloud-avg, наявність записів.
|
|
299
|
+
* Помилки не валять lint.
|
|
300
|
+
* @param {string} cwd корінь
|
|
301
|
+
* @param {number} sinceOffset байтовий зсув логу перед прогоном
|
|
302
|
+
* @param {(s: string) => void} log логер
|
|
303
|
+
* @returns {void}
|
|
304
|
+
*/
|
|
305
|
+
export function maybeAnalyzeEscalation(cwd, sinceOffset, log) {
|
|
306
|
+
if (!analysisEnabled()) return
|
|
307
|
+
const records = readEscalationRecords(escalationLogPath(), sinceOffset)
|
|
308
|
+
if (records.length === 0) return
|
|
309
|
+
if (!CLOUD_AVG) {
|
|
310
|
+
log('\nℹ️ escalation-analysis: були LLM-ескалації, але N_CLOUD_AVG_MODEL не заданий — аналіз пропущено.\n')
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
log('\n🔬 escalation-analysis: аналізую ескалації цього прогону…\n')
|
|
314
|
+
analyzeAndReport(records, cwd, log)
|
|
315
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: analyze-escalation.mjs
|
|
4
|
+
resource: npm/scripts/lib/fix/analyze-escalation.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: f802e47f
|
|
7
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
+
score: 100
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Аналітика escalation-логу fix-конформності. Читає записи рунгів драбини (`escalation-log.mjs`) — весь лог або записи одного прогону (від байтового зсуву), — ділить на чанки за бюджетом символів і просить хмарну **avg**-модель запропонувати, як зменшити LLM-залежність: нові детерміновані T0-патерни, уточнення `.mdc`-інструкцій або зміни скриптів пакета. Результат — markdown-звіт у `.n-cursor/fix-escalation-analysis.md` (append із timestamp). Викликається CLI `n-cursor analyze-escalation` (весь лог) і наприкінці `lint --full` (записи прогону).
|
|
12
|
+
|
|
13
|
+
## Поведінка
|
|
14
|
+
|
|
15
|
+
`readEscalationRecords` читає JSONL від байтового зсуву (зсув на межі рядка — мультибайт не б'ється; биті рядки пропускаються); `escalationLogSize` дає зсув для scope «цей прогін». `chunkRecords` стискає записи й ділить на чанки, щоб JSON кожного не перевищив бюджет. `analyzeEscalations` (синхронний — callLlm spawnSync-based) робить виклик avg-моделі по кожному чанку, а за кількох чанків — фінальний синтез; помилки моделі ковтаються в `null` (аналіз не валить lint). `maybeAnalyzeEscalation` — хук lint: gated kill-switch `N_CURSOR_FIX_ANALYZE`, наявністю `CLOUD_AVG` і записів.
|
|
16
|
+
|
|
17
|
+
## Публічний API
|
|
18
|
+
|
|
19
|
+
- `analysisEnabled()` — чи дозволено авто-аналіз (kill-switch `N_CURSOR_FIX_ANALYZE`).
|
|
20
|
+
- `escalationLogSize(path?)` — розмір логу в байтах (since-offset).
|
|
21
|
+
- `readEscalationRecords(path, sinceOffset?)` — записи від зсуву.
|
|
22
|
+
- `chunkRecords(records, maxChars?)` — чанки стиснених записів.
|
|
23
|
+
- `analyzeEscalations(records, opts?)` — `{ report, chunks, reason }`; `opts.callLlm` інжектовний.
|
|
24
|
+
- `analysisReportPath(cwd?)` / `writeAnalysisReport(report, cwd, ts)` — шлях/запис звіту.
|
|
25
|
+
- `runEscalationAnalysisCli(args, cwd?)` — CLI: весь лог → звіт.
|
|
26
|
+
- `maybeAnalyzeEscalation(cwd, sinceOffset, log)` — хук наприкінці `lint --full`.
|
|
27
|
+
|
|
28
|
+
## Гарантії поведінки
|
|
29
|
+
|
|
30
|
+
- Звертається до мережі лише при виклику avg-моделі (через pi/omlx за префіксом model-id).
|
|
31
|
+
- Помилки виклику моделі перехоплює (fail-safe): аналіз не впливає на exit-код lint.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: escalation-log.mjs
|
|
4
|
+
resource: npm/scripts/lib/fix/escalation-log.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 07ae959f
|
|
7
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
+
score: 100
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Append-only JSONL-лог драбини ескалації конформність-фіксу. Один запис на рунг драбини: модель, чи виклик удався, чи правило стало зеленим після рунга («чи допомогло»), залишковий violation і само-аналіз моделі (`diagnosis`). Доповнює always-on wire-trace — той знає вміст викликів, але не результат re-check; join — за полем `caller` (`fix:<rule>:<rung>`).
|
|
12
|
+
|
|
13
|
+
## Поведінка
|
|
14
|
+
|
|
15
|
+
`escalationLogPath` резолвить шлях: `N_CURSOR_FIX_ESCALATION_LOG` як kill-switch (`0|false|off|no` → лог вимкнено, повертає `null`) або як явний шлях; інакше дефолт `<cwd>/.n-cursor/fix-escalation.jsonl`.
|
|
16
|
+
|
|
17
|
+
`logEscalation` дописує один JSONL-рядок (no-op, якщо лог вимкнено). Поля `remainingViolation` і `diagnosis` обрізаються до межі; `recheckOk` обнуляє `remainingViolation`. Помилки запису ковтаються — лог діагностичний і не має валити сам фікс.
|
|
18
|
+
|
|
19
|
+
## Публічний API
|
|
20
|
+
|
|
21
|
+
- `escalationLogPath()` — шлях активного логу або `null`, якщо вимкнено.
|
|
22
|
+
- `logEscalation(rec)` — дописує запис рунга у JSONL.
|
|
23
|
+
|
|
24
|
+
## Гарантії поведінки
|
|
25
|
+
|
|
26
|
+
- Не звертається до мережі.
|
|
27
|
+
- Перехоплює помилки запису і не пропускає винятків назовні (fail-safe).
|
|
@@ -8,6 +8,8 @@ resource: npm/scripts/lib/fix/
|
|
|
8
8
|
|
|
9
9
|
| Файл | Тип |
|
|
10
10
|
|---|---|
|
|
11
|
+
| [analyze-escalation.mjs](analyze-escalation.md) | JS Module |
|
|
12
|
+
| [escalation-log.mjs](escalation-log.md) | JS Module |
|
|
11
13
|
| [llm-fix-apply.mjs](llm-fix-apply.md) | JS Module |
|
|
12
14
|
| [llm-lint-fix.mjs](llm-lint-fix.md) | JS Module |
|
|
13
15
|
| [llm-worker.mjs](llm-worker.md) | JS Module |
|
|
@@ -3,27 +3,24 @@ type: JS Module
|
|
|
3
3
|
title: llm-worker.mjs
|
|
4
4
|
resource: npm/scripts/lib/fix/llm-worker.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: de9eb68c
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
score: 100
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
Модуль
|
|
11
|
+
Модуль виправляє одне rule-порушення конформності через LLM: читає `.mdc` правила, дістає файли з violation-output, будує prompt і застосовує згенеровані зміни. Підтримує **feedback-режим** драбини ескалації — у повторний виклик передається контекст попереднього рунга, а модель спершу формулює `diagnosis` («чому попередня спроба не вдалася»), тоді виправляє.
|
|
12
12
|
|
|
13
13
|
## Поведінка
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
`runLlmWorker` читає `.mdc` правила, читає файли з violation-output, будує prompt і викликає модель через спільний `callLlm` (з міткою `caller` для wire-trace). Відповідь очікується як JSON `{diagnosis, changes}`; зміни застосовуються до файлів повним вмістом.
|
|
16
|
+
|
|
17
|
+
Якщо переданий `feedback`, prompt додає блок попереднього рунга (модель, попередньо змінені файли, помилка) і просить діагностувати причину невдачі у полі `diagnosis`. Результат повертається з `changes` і `diagnosis` навіть при невдачі — щоб драбина (`orchestrator.mjs`) їх залогувала і прокинула далі.
|
|
18
18
|
|
|
19
19
|
## Публічний API
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
MODEL_HEAVY — Важка модель для складних обчислень.
|
|
23
|
-
runLlmWorker — Виправляє одне порушення правила, використовуючи шаблон C1.
|
|
21
|
+
- `runLlmWorker(ruleId, violationOutput, projectRoot, opts)` — виправляє одне порушення; `opts`: `model`, `feedback`, `caller`. Повертає `{ ok, error, changes, diagnosis }`.
|
|
24
22
|
|
|
25
23
|
## Гарантії поведінки
|
|
26
24
|
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
- Не звертається до мережі.
|
|
25
|
+
- Перехоплює помилки виклику моделі й не пропускає винятків назовні (fail-safe): дружнє повідомлення для відсутнього API-ключа хмарного провайдера.
|
|
26
|
+
- Звертається до мережі під час виклику моделі (omlx HTTP або pi CLI, залежно від префікса model-id).
|
|
@@ -3,24 +3,33 @@ type: JS Module
|
|
|
3
3
|
title: orchestrator.mjs
|
|
4
4
|
resource: npm/scripts/lib/fix/orchestrator.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 78dfe86b
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
score: 100
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
Модуль керує процесом валідації та виправлення правил. Він
|
|
11
|
+
Модуль керує процесом валідації та виправлення конформності правил. Він перевіряє правила, застосовує детермінований механізм (T0), а нерозв'язані порушення проводить по **драбині ескалації моделей**: локальна min-модель → той самий локальний тир із feedback → хмарна min → хмарна avg. Кожен крок драбини логується для подальшого аналізу. Функція `runOrchestratorCli` запускає процес.
|
|
12
12
|
|
|
13
13
|
## Поведінка
|
|
14
14
|
|
|
15
|
-
1. `runOrchestratorCli` виконує початкову перевірку
|
|
16
|
-
2.
|
|
17
|
-
3.
|
|
18
|
-
4.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
1. `runOrchestratorCli` виконує початкову перевірку правил. Якщо порушень немає — успіх.
|
|
16
|
+
2. Один прохід детермінованого фіксу (T0) зменшує набір порушень без LLM.
|
|
17
|
+
3. `buildLadder` будує драбину з доступних тирів (`N_LOCAL_MIN_MODEL`, `N_CLOUD_MIN_MODEL`, `N_CLOUD_AVG_MODEL`); незадані тири відсіюються. Якщо драбина порожня — процес завершується з ознакою нерозв'язаних порушень.
|
|
18
|
+
4. `escalateRule` проводить кожне правило по драбині до першого зеленого re-check:
|
|
19
|
+
- рунг `local-min` — перший прохід без feedback;
|
|
20
|
+
- рунг `local-min-retry` — той самий локальний тир, але з feedback попереднього рунга (попередні зміни + залишковий violation);
|
|
21
|
+
- рунги `cloud-min` / `cloud-avg` — хмарні моделі (через pi), теж із feedback.
|
|
22
|
+
5. Достроковий вихід драбини: systemic-помилка локального тиру пропускає рунги тієї ж моделі; відсутній API-ключ на хмарному обриває драбину; вичерпаний avg-кеп пропускає avg-рунг (із записом у лог).
|
|
23
|
+
6. Після обробки всіх правил — фінальна перевірка. Усі чисті → успіх; інакше — ознака нерозв'язаних.
|
|
24
|
+
|
|
25
|
+
## Публічний API
|
|
26
|
+
|
|
27
|
+
- `buildLadder({ localMin, cloudMin, cloudAvg })` — будує драбину рунгів із наявних тирів.
|
|
28
|
+
- `escalateRule(rule, cwd, deps)` — проводить одне правило по драбині; повертає `{ resolved, avgUsed }`. Залежності (`worker`, `check`, `clock`) інжектовні для тестів.
|
|
29
|
+
- `parseOrchestratorArgs(args)` — парсить `--max-avg N` (кеп на хмарні avg-виклики) і фільтр правил.
|
|
30
|
+
- `runOrchestratorCli(args, cwd)` — точка входу; повертає `0` (усе чисто) або `1` (нерозв'язані).
|
|
22
31
|
|
|
23
32
|
## Гарантії поведінки
|
|
24
33
|
|
|
25
|
-
-
|
|
26
|
-
-
|
|
34
|
+
- Кожен рунг драбини дописується в escalation-лог (`escalation-log.mjs`): модель, чи виклик удався, чи правило стало зеленим, залишковий violation і само-аналіз моделі.
|
|
35
|
+
- Звертається до мережі лише на хмарних рунгах драбини (через pi).
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only JSONL-лог ескалації конформність-фіксу (спека
|
|
3
|
+
* 2026-06-19-fix-escalation-cascade-design). Один запис **на рунг драбини** —
|
|
4
|
+
* фіксує `model`, `withFeedback`, чи виклик удався (`callOk`/`callError`), чи
|
|
5
|
+
* правило стало зеленим після цього рунга (`recheckOk` = «чи допомогло»),
|
|
6
|
+
* залишковий violation і `diagnosis` (само-аналіз моделі «чому не вдалося»).
|
|
7
|
+
*
|
|
8
|
+
* Це доповнення до always-on wire-trace (`lib/omlx-trace.mjs`): trace знає
|
|
9
|
+
* `messages`/`reasoning`/`usage` кожного виклику, але **не** знає результату
|
|
10
|
+
* re-check — а саме «чи допомогло» й потрібне для пост-аналізу драбини. Join із
|
|
11
|
+
* trace — за полем `caller` (`fix:<rule>:<rung>`), яке цей модуль і формує.
|
|
12
|
+
*
|
|
13
|
+
* Шлях — дзеркало `tracePath()`: `N_CURSOR_FIX_ESCALATION_LOG` (kill-switch
|
|
14
|
+
* `0|false|off|no` → не писати; інакше явний шлях) → дефолт
|
|
15
|
+
* `<cwd>/.n-cursor/fix-escalation.jsonl`.
|
|
16
|
+
*/
|
|
17
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { cwd, env } from 'node:process'
|
|
20
|
+
|
|
21
|
+
/** Значення `N_CURSOR_FIX_ESCALATION_LOG`, що вимикають лог повністю. */
|
|
22
|
+
const KILL_VALUES = new Set(['0', 'false', 'off', 'no'])
|
|
23
|
+
|
|
24
|
+
/** Межа обрізки `remainingViolation`/`diagnosis` у записі (символів). */
|
|
25
|
+
const MAX_FIELD_CHARS = 2000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Шлях активного escalation-логу або `null`, якщо вимкнено kill-switch-ем.
|
|
29
|
+
* @returns {string|null} шлях до .jsonl або null
|
|
30
|
+
*/
|
|
31
|
+
export function escalationLogPath() {
|
|
32
|
+
const override = env.N_CURSOR_FIX_ESCALATION_LOG
|
|
33
|
+
if (override !== undefined) {
|
|
34
|
+
if (KILL_VALUES.has(override.toLowerCase())) return null
|
|
35
|
+
if (override) return override
|
|
36
|
+
}
|
|
37
|
+
return join(cwd(), '.n-cursor', 'fix-escalation.jsonl')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Обрізає рядок до `MAX_FIELD_CHARS` (null/undefined → null).
|
|
42
|
+
* @param {string|null|undefined} s вхід
|
|
43
|
+
* @returns {string|null} обрізаний рядок або null
|
|
44
|
+
*/
|
|
45
|
+
function cap(s) {
|
|
46
|
+
if (s === null || s === undefined) return null
|
|
47
|
+
return s.length > MAX_FIELD_CHARS ? s.slice(0, MAX_FIELD_CHARS) : s
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Дописує один запис рунга у JSONL-лог (no-op, якщо вимкнено). Помилки запису
|
|
52
|
+
* ковтаються — лог діагностичний, не має валити сам фікс.
|
|
53
|
+
* @param {object} rec запис рунга
|
|
54
|
+
* @param {string} rec.ts ISO-час завершення рунга
|
|
55
|
+
* @param {string} rec.ruleId id правила
|
|
56
|
+
* @param {number} rec.rung індекс рунга драбини (0-based)
|
|
57
|
+
* @param {string} rec.tier мітка тиру (`local-min`|`local-min-retry`|`cloud-min`|`cloud-avg`)
|
|
58
|
+
* @param {string} rec.model model-id (порожній → pi-дефолт)
|
|
59
|
+
* @param {boolean} rec.withFeedback чи передавався feedback попереднього рунга
|
|
60
|
+
* @param {boolean} rec.callOk чи виклик моделі+apply удався
|
|
61
|
+
* @param {string|null} rec.callError помилка виклику (null, якщо callOk)
|
|
62
|
+
* @param {boolean} rec.recheckOk чи правило стало зеленим після рунга («чи допомогло»)
|
|
63
|
+
* @param {string|null} rec.remainingViolation залишковий violation (null, якщо recheckOk)
|
|
64
|
+
* @param {string|null} rec.diagnosis само-аналіз моделі «чому попередній рунг не вдався»
|
|
65
|
+
* @param {number} rec.ms тривалість рунга (мс)
|
|
66
|
+
* @returns {void}
|
|
67
|
+
*/
|
|
68
|
+
export function logEscalation(rec) {
|
|
69
|
+
const path = escalationLogPath()
|
|
70
|
+
if (!path) return
|
|
71
|
+
const line =
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
ts: rec.ts,
|
|
74
|
+
ruleId: rec.ruleId,
|
|
75
|
+
rung: rec.rung,
|
|
76
|
+
tier: rec.tier,
|
|
77
|
+
model: rec.model,
|
|
78
|
+
withFeedback: rec.withFeedback,
|
|
79
|
+
callOk: rec.callOk,
|
|
80
|
+
callError: cap(rec.callError),
|
|
81
|
+
recheckOk: rec.recheckOk,
|
|
82
|
+
remainingViolation: rec.recheckOk ? null : cap(rec.remainingViolation),
|
|
83
|
+
diagnosis: cap(rec.diagnosis),
|
|
84
|
+
ms: rec.ms
|
|
85
|
+
}) + '\n'
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
88
|
+
appendFileSync(path, line, 'utf8')
|
|
89
|
+
} catch {
|
|
90
|
+
/* лог діагностичний — ковтаємо помилки запису */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -7,10 +7,10 @@ import { resolveModel } from '../../../lib/models.mjs'
|
|
|
7
7
|
import { callLlm } from '../../../lib/llm.mjs'
|
|
8
8
|
import { applyChanges, parseChangesResponse, readFilesForFix } from './llm-fix-apply.mjs'
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// Дефолтна модель, коли викликач не задав `opts.model` (legacy/прямі виклики).
|
|
11
|
+
// Драбина ескалації (`orchestrator.mjs`) завжди передає модель рунга явно, тож
|
|
12
|
+
// тут лишається тільки fallback на min-тир. Перевизначення — `N_CURSOR_FIX_MODEL`.
|
|
13
|
+
const MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
|
|
14
14
|
|
|
15
15
|
const API_KEY_RE = /api key/i
|
|
16
16
|
|
|
@@ -49,13 +49,36 @@ function extractFilePaths(output) {
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Будує prompt для pi: правило + порушення + поточний вміст файлів.
|
|
52
|
+
* Будує опційний feedback-блок драбини ескалації: попередній рунг застосував
|
|
53
|
+
* зміни, але re-check лишився червоним. Просимо модель спершу (в полі `diagnosis`)
|
|
54
|
+
* сформулювати, **чому** попередня спроба не задовольнила правило, тоді виправити.
|
|
55
|
+
* @param {{ previousModel?: string, previousChanges?: Array<{path:string}>, previousError?: string|null } | null} feedback контекст попереднього рунга
|
|
56
|
+
* @returns {string[]} рядки prompt-блоку (порожній масив, якщо feedback немає)
|
|
57
|
+
*/
|
|
58
|
+
function buildFeedbackBlock(feedback) {
|
|
59
|
+
if (!feedback) return []
|
|
60
|
+
const changedPaths = (feedback.previousChanges ?? []).map(c => c.path).filter(Boolean)
|
|
61
|
+
return [
|
|
62
|
+
``,
|
|
63
|
+
`A PREVIOUS attempt (model: ${feedback.previousModel || 'pi'}) did NOT resolve this violation.`,
|
|
64
|
+
changedPaths.length > 0
|
|
65
|
+
? `Previously changed files: ${changedPaths.join(', ')}`
|
|
66
|
+
: `The previous attempt produced no usable changes.`,
|
|
67
|
+
feedback.previousError ? `Previous attempt error: ${feedback.previousError}` : ``,
|
|
68
|
+
`The violation output below is what STILL fails after that attempt.`,
|
|
69
|
+
`In the "diagnosis" field, briefly state WHY the previous attempt failed, then provide a corrected fix.`
|
|
70
|
+
].filter(line => line !== ``)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
52
74
|
* @param {string} ruleId ID правила
|
|
53
75
|
* @param {string} ruleMdc вміст .mdc-файлу правила
|
|
54
76
|
* @param {string} output violation output
|
|
55
77
|
* @param {Array<{path:string, content:string}>} files прочитані файли (path + content)
|
|
78
|
+
* @param {{ previousModel?: string, previousChanges?: Array<{path:string}>, previousError?: string|null } | null} [feedback] контекст попереднього рунга драбини (для retry-with-feedback)
|
|
56
79
|
* @returns {string} текст промпта для pi
|
|
57
80
|
*/
|
|
58
|
-
function buildPrompt(ruleId, ruleMdc, output, files) {
|
|
81
|
+
function buildPrompt(ruleId, ruleMdc, output, files, feedback = null) {
|
|
59
82
|
const filesBlock =
|
|
60
83
|
files.length === 0
|
|
61
84
|
? '(no files identified)'
|
|
@@ -63,6 +86,7 @@ function buildPrompt(ruleId, ruleMdc, output, files) {
|
|
|
63
86
|
|
|
64
87
|
return [
|
|
65
88
|
`You fix project structure violations. Return ONLY valid JSON — no explanation, no markdown.`,
|
|
89
|
+
...buildFeedbackBlock(feedback),
|
|
66
90
|
``,
|
|
67
91
|
`Rule (n-${ruleId}.mdc):`,
|
|
68
92
|
`---`,
|
|
@@ -76,13 +100,14 @@ function buildPrompt(ruleId, ruleMdc, output, files) {
|
|
|
76
100
|
filesBlock,
|
|
77
101
|
``,
|
|
78
102
|
`Return JSON with this exact shape:`,
|
|
79
|
-
`{"changes":[{"path":"relative/path/to/file","content":"full corrected file content"}]}`,
|
|
103
|
+
`{"diagnosis":"short reason the rule fails (or why prior attempt failed); empty string if first attempt","changes":[{"path":"relative/path/to/file","content":"full corrected file content"}]}`,
|
|
80
104
|
``,
|
|
81
105
|
`Rules:`,
|
|
82
106
|
`- "path" is relative to the project root`,
|
|
83
107
|
`- "content" is the complete new file content (not a diff)`,
|
|
84
108
|
`- Only include files that actually need to change`,
|
|
85
|
-
`-
|
|
109
|
+
`- "diagnosis" is plain text inside the JSON — do NOT emit prose outside the JSON`,
|
|
110
|
+
`- If nothing can be fixed automatically, return {"diagnosis":"...","changes":[],"error":"reason"}`
|
|
86
111
|
].join('\n')
|
|
87
112
|
}
|
|
88
113
|
|
|
@@ -91,11 +116,12 @@ function buildPrompt(ruleId, ruleMdc, output, files) {
|
|
|
91
116
|
* Зберігає дружнє повідомлення про відсутній API-ключ для хмарних провайдерів.
|
|
92
117
|
* @param {string} prompt текст промпта
|
|
93
118
|
* @param {string} model назва моделі (provider/id, `omlx/...` або '')
|
|
119
|
+
* @param {string} caller мітка викликача для wire-trace (`fix:<rule>:<rung>`)
|
|
94
120
|
* @returns {{ text: string, error?: string }} текст відповіді або повідомлення про помилку
|
|
95
121
|
*/
|
|
96
|
-
function callModel(prompt, model) {
|
|
122
|
+
function callModel(prompt, model, caller) {
|
|
97
123
|
try {
|
|
98
|
-
return { text: callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 120_000, caller
|
|
124
|
+
return { text: callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 120_000, caller }) }
|
|
99
125
|
} catch (error) {
|
|
100
126
|
const msg = String(error.message)
|
|
101
127
|
if (API_KEY_RE.test(msg)) {
|
|
@@ -115,14 +141,21 @@ function callModel(prompt, model) {
|
|
|
115
141
|
|
|
116
142
|
/**
|
|
117
143
|
* LLM-worker: виправляє одне rule-порушення через pi (C1 pattern).
|
|
144
|
+
* Повертає `changes`/`diagnosis` навіть при невдачі — драбина ескалації
|
|
145
|
+
* (`orchestrator.mjs`) логує їх і прокидає як feedback у наступний рунг.
|
|
118
146
|
* @param {string} ruleId ID правила
|
|
119
147
|
* @param {string} violationOutput output з fix check для цього rule
|
|
120
148
|
* @param {string} projectRoot абсолютний шлях до кореня проєкту
|
|
121
|
-
* @param {{ model?: string
|
|
122
|
-
*
|
|
149
|
+
* @param {{ model?: string, feedback?: object|null, caller?: string }} opts опції:
|
|
150
|
+
* `model` — перевизначення моделі; `feedback` — контекст попереднього рунга
|
|
151
|
+
* драбини (retry-with-feedback); `caller` — мітка для wire-trace
|
|
152
|
+
* @returns {{ ok: boolean, error?: string, changes: Array<{path:string}>, diagnosis: string|null }}
|
|
153
|
+
* статус виправлення, помилка, запропоновані зміни і само-аналіз моделі
|
|
123
154
|
*/
|
|
124
155
|
export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
|
|
125
156
|
const model = opts.model ?? MODEL
|
|
157
|
+
const feedback = opts.feedback ?? null
|
|
158
|
+
const caller = opts.caller ?? 'fix'
|
|
126
159
|
|
|
127
160
|
// 1. Читаємо rule .mdc
|
|
128
161
|
const mdcPath = join(projectRoot, '.cursor', 'rules', `n-${ruleId}.mdc`)
|
|
@@ -132,20 +165,23 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
|
|
|
132
165
|
const files = readFilesForFix(extractFilePaths(violationOutput), projectRoot)
|
|
133
166
|
|
|
134
167
|
// 3. Будуємо prompt і викликаємо модель
|
|
135
|
-
const prompt = buildPrompt(ruleId, ruleMdc, violationOutput, files)
|
|
136
|
-
const { text, error: modelError } = callModel(prompt, model)
|
|
168
|
+
const prompt = buildPrompt(ruleId, ruleMdc, violationOutput, files, feedback)
|
|
169
|
+
const { text, error: modelError } = callModel(prompt, model, caller)
|
|
137
170
|
|
|
138
|
-
if (modelError) return { ok: false, error: modelError }
|
|
139
|
-
if (!text) return { ok: false, error: 'model returned empty response' }
|
|
171
|
+
if (modelError) return { ok: false, error: modelError, changes: [], diagnosis: null }
|
|
172
|
+
if (!text) return { ok: false, error: 'model returned empty response', changes: [], diagnosis: null }
|
|
140
173
|
|
|
141
174
|
// 4. Парсимо відповідь
|
|
142
175
|
const parsed = parseChangesResponse(text)
|
|
143
|
-
if (!parsed)
|
|
144
|
-
|
|
145
|
-
|
|
176
|
+
if (!parsed) {
|
|
177
|
+
return { ok: false, error: `cannot parse pi response: ${text.slice(0, 200)}`, changes: [], diagnosis: null }
|
|
178
|
+
}
|
|
179
|
+
const diagnosis = typeof parsed.diagnosis === 'string' && parsed.diagnosis ? parsed.diagnosis : null
|
|
146
180
|
const changes = parsed.changes ?? []
|
|
147
|
-
if (
|
|
181
|
+
if (parsed.error) return { ok: false, error: parsed.error, changes, diagnosis }
|
|
182
|
+
if (changes.length === 0) return { ok: false, error: 'pi returned no changes', changes, diagnosis }
|
|
148
183
|
|
|
149
184
|
// 5. Застосовуємо зміни
|
|
150
|
-
|
|
185
|
+
const applied = applyChanges(changes, projectRoot)
|
|
186
|
+
return { ...applied, changes, diagnosis }
|
|
151
187
|
}
|
|
@@ -2,22 +2,137 @@
|
|
|
2
2
|
|
|
3
3
|
import { runFixCheck } from './run-fix-check.mjs'
|
|
4
4
|
import { runT0AutoCli } from './t0.mjs'
|
|
5
|
+
import { logEscalation } from './escalation-log.mjs'
|
|
6
|
+
import { runLlmWorker } from './llm-worker.mjs'
|
|
7
|
+
import { classifyOmlxError } from '../../../lib/llm.mjs'
|
|
8
|
+
import { CLOUD_AVG, CLOUD_MIN, LOCAL_MIN } from '../../../lib/models.mjs'
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Дефолтний кеп на виклики хмарної avg-моделі за один прогін (щоб драбина на N
|
|
12
|
+
* правил не спалила потужну модель). Перевизначення: `--max-avg N`.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_MAX_AVG = 3
|
|
15
|
+
|
|
16
|
+
/** Маркер дружнього повідомлення про відсутній API-ключ (з `llm-worker.callModel`). */
|
|
17
|
+
const NO_KEY_RE = /немає ключа|api key/i
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Будує драбину ескалації за наявними тирами (спека 2026-06-19-fix-escalation-cascade):
|
|
21
|
+
* 1. `local-min` — `N_LOCAL_MIN_MODEL`, перший прохід;
|
|
22
|
+
* 2. `local-min-retry` — той самий локальний, але з feedback попереднього рунга;
|
|
23
|
+
* 3. `cloud-min` — `N_CLOUD_MIN_MODEL` (через pi), з feedback;
|
|
24
|
+
* 4. `cloud-avg` — `N_CLOUD_AVG_MODEL` (через pi), з feedback, під avg-кепом.
|
|
25
|
+
* Рунги з незаданим тиром (`''`) відсіюються — драбина стискається до доступних.
|
|
26
|
+
* @param {{ localMin: string, cloudMin: string, cloudAvg: string }} models тири з env
|
|
27
|
+
* @returns {Array<{ tier: string, model: string, feedback: boolean, local: boolean, isAvg: boolean }>} драбина
|
|
28
|
+
*/
|
|
29
|
+
export function buildLadder({ localMin, cloudMin, cloudAvg }) {
|
|
30
|
+
return [
|
|
31
|
+
{ tier: 'local-min', model: localMin, feedback: false, local: true, isAvg: false },
|
|
32
|
+
{ tier: 'local-min-retry', model: localMin, feedback: true, local: true, isAvg: false },
|
|
33
|
+
{ tier: 'cloud-min', model: cloudMin, feedback: true, local: false, isAvg: false },
|
|
34
|
+
{ tier: 'cloud-avg', model: cloudAvg, feedback: true, local: false, isAvg: true }
|
|
35
|
+
].filter(r => r.model)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Рішення після провального рунга: чи обірвати драбину / пропустити модель.
|
|
40
|
+
* - `break` — відсутній API-ключ на хмарному (інші хмарні рунги теж без ключа);
|
|
41
|
+
* - `skip-model` — systemic-помилка локального тиру (memory-guard/auth/down): повтор
|
|
42
|
+
* тієї ж моделі марний → пропустити рунги з цим model.
|
|
43
|
+
* @param {{ local: boolean }} rung поточний рунг
|
|
44
|
+
* @param {string|null|undefined} error помилка виклику worker
|
|
45
|
+
* @returns {'break'|'skip-model'|null} дія для драбини
|
|
46
|
+
*/
|
|
47
|
+
function decideAfterFailure(rung, error) {
|
|
48
|
+
if (!error) return null
|
|
49
|
+
if (NO_KEY_RE.test(error)) return 'break'
|
|
50
|
+
if (rung.local && classifyOmlxError(error) === 'systemic') return 'skip-model'
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Проводить ОДНЕ правило по драбині ескалації до першого зеленого re-check.
|
|
56
|
+
* Кожен рунг: виклик worker (з feedback від попереднього) → re-check цього правила →
|
|
57
|
+
* запис у escalation-лог («чи допомогло» + diagnosis). Достроковий вихід — `decideAfterFailure`
|
|
58
|
+
* (обрив на no-key, пропуск моделі на systemic) і вичерпаний avg-кеп (залогувати, не мовчки).
|
|
59
|
+
* @param {{ ruleId: string, output: string }} rule провальне правило з violation-output
|
|
60
|
+
* @param {string} cwd корінь проєкту
|
|
61
|
+
* @param {{
|
|
62
|
+
* ladder: Array<{tier:string,model:string,feedback:boolean,local:boolean,isAvg:boolean}>,
|
|
63
|
+
* worker: { runLlmWorker: (ruleId: string, violation: string, cwd: string, opts: object) => object },
|
|
64
|
+
* check: (rules: string[], cwd: string) => Promise<{rules: Array<{ruleId:string,ok:boolean,output:string}>}>,
|
|
65
|
+
* avgBudget: number,
|
|
66
|
+
* clock?: () => number,
|
|
67
|
+
* log?: (s: string) => void
|
|
68
|
+
* }} deps інжектовані залежності (worker/check/clock — для тестів)
|
|
69
|
+
* @returns {Promise<{ resolved: boolean, avgUsed: number }>} чи закрито правило і скільки avg-викликів витрачено
|
|
70
|
+
*/
|
|
71
|
+
export async function escalateRule(rule, cwd, deps) {
|
|
72
|
+
const { ladder, worker, check, avgBudget } = deps
|
|
73
|
+
const clock = deps.clock ?? (() => Date.now())
|
|
74
|
+
const log = deps.log ?? (s => console.log(s))
|
|
75
|
+
const record = base => logEscalation({ ts: new Date(clock()).toISOString(), ruleId: rule.ruleId, ...base })
|
|
76
|
+
|
|
77
|
+
let feedback = null
|
|
78
|
+
let currentViolation = rule.output
|
|
79
|
+
const skipModels = new Set()
|
|
80
|
+
let avgUsed = 0
|
|
81
|
+
|
|
82
|
+
for (const [idx, rung] of ladder.entries()) {
|
|
83
|
+
if (skipModels.has(rung.model)) continue
|
|
84
|
+
|
|
85
|
+
const common = { rung: idx, tier: rung.tier, model: rung.model, withFeedback: rung.feedback }
|
|
86
|
+
if (rung.isAvg && avgBudget - avgUsed <= 0) {
|
|
87
|
+
record({ ...common, callOk: false, callError: 'cloud-avg cap reached', recheckOk: false, remainingViolation: currentViolation, diagnosis: null, ms: 0 })
|
|
88
|
+
log(` ⏭️ ${rule.ruleId}: ${rung.tier} пропущено (avg-кеп вичерпано)`)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const startedAt = clock()
|
|
93
|
+
const res = worker.runLlmWorker(rule.ruleId, currentViolation, cwd, {
|
|
94
|
+
model: rung.model,
|
|
95
|
+
feedback: rung.feedback ? feedback : null,
|
|
96
|
+
caller: `fix:${rule.ruleId}:${rung.tier}`
|
|
97
|
+
})
|
|
98
|
+
if (rung.isAvg) avgUsed++
|
|
99
|
+
|
|
100
|
+
const recheck = await check([rule.ruleId], cwd)
|
|
101
|
+
const recheckOk = recheck.rules.every(r => r.ok)
|
|
102
|
+
const remaining = recheckOk ? '' : (recheck.rules.find(r => !r.ok)?.output ?? '')
|
|
103
|
+
record({ ...common, callOk: res.ok, callError: res.error ?? null, recheckOk, remainingViolation: remaining, diagnosis: res.diagnosis ?? null, ms: clock() - startedAt })
|
|
104
|
+
|
|
105
|
+
if (recheckOk) {
|
|
106
|
+
log(` ✅ ${rung.tier} (${rung.model || 'pi'}): ${rule.ruleId}`)
|
|
107
|
+
return { resolved: true, avgUsed }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const hint = res.error ? ` ❌ ${res.error.slice(0, 120)}` : ' ❌ досі порушено'
|
|
111
|
+
log(` ⚡ ${rung.tier} (${rung.model || 'pi'}): ${rule.ruleId}${hint}`)
|
|
112
|
+
|
|
113
|
+
// Feedback для наступного рунга + оновлений violation.
|
|
114
|
+
feedback = { previousModel: rung.model, previousChanges: res.changes ?? [], previousError: res.error ?? null }
|
|
115
|
+
currentViolation = remaining || currentViolation
|
|
116
|
+
|
|
117
|
+
const action = decideAfterFailure(rung, res.error)
|
|
118
|
+
if (action === 'break') break
|
|
119
|
+
if (action === 'skip-model') skipModels.add(rung.model)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { resolved: false, avgUsed }
|
|
123
|
+
}
|
|
8
124
|
|
|
9
125
|
/**
|
|
10
|
-
* Парсить `--max-
|
|
126
|
+
* Парсить `--max-avg N` і збирає rule-filter (позиційні аргументи без прапорців).
|
|
11
127
|
* @param {string[]} args CLI аргументи після 'fix'
|
|
12
|
-
* @returns {{
|
|
128
|
+
* @returns {{ maxAvg: number, ruleFilter: string[] }} avg-кеп і фільтр правил
|
|
13
129
|
*/
|
|
14
|
-
function parseOrchestratorArgs(args) {
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
return { maxIter, ruleFilter }
|
|
130
|
+
export function parseOrchestratorArgs(args) {
|
|
131
|
+
const idx = args.indexOf('--max-avg')
|
|
132
|
+
const maxAvg = idx === -1 ? DEFAULT_MAX_AVG : Number(args[idx + 1] ?? DEFAULT_MAX_AVG) || DEFAULT_MAX_AVG
|
|
133
|
+
const skip = new Set(idx === -1 ? [] : [idx, idx + 1])
|
|
134
|
+
const ruleFilter = args.filter((a, i) => !a.startsWith('-') && !skip.has(i))
|
|
135
|
+
return { maxAvg, ruleFilter }
|
|
21
136
|
}
|
|
22
137
|
|
|
23
138
|
/**
|
|
@@ -40,77 +155,65 @@ async function runT0Step(cwd, ruleFilter, failed) {
|
|
|
40
155
|
return failedAfterT0
|
|
41
156
|
}
|
|
42
157
|
|
|
43
|
-
/**
|
|
44
|
-
* Крок T1: LLM через pi для кожного правила, з ескалацією моделі за провалами.
|
|
45
|
-
* @param {Array<{ ruleId: string, output: string }>} failed правила до фіксу
|
|
46
|
-
* @param {string} cwd корінь проєкту
|
|
47
|
-
* @param {Map<string, number>} failCount ruleId → кількість провалів підряд (мутується)
|
|
48
|
-
* @param {{ runLlmWorker: (ruleId: string, output: string, projectRoot: string, opts: {model: string}) => Promise<{ok: boolean, error?: string}>, MODEL: string, MODEL_HEAVY: string }} worker воркер і моделі
|
|
49
|
-
* @returns {Promise<void>}
|
|
50
|
-
*/
|
|
51
|
-
async function runLlmStep(failed, cwd, failCount, { runLlmWorker, MODEL, MODEL_HEAVY }) {
|
|
52
|
-
for (const rule of failed) {
|
|
53
|
-
const prevFails = failCount.get(rule.ruleId) ?? 0
|
|
54
|
-
const model = prevFails >= ESCALATE_AFTER ? MODEL_HEAVY : MODEL
|
|
55
|
-
const label = model || 'pi'
|
|
56
|
-
|
|
57
|
-
const result = await runLlmWorker(rule.ruleId, rule.output, cwd, { model })
|
|
58
|
-
|
|
59
|
-
if (result.ok) {
|
|
60
|
-
console.log(` ⚡ LLM (${label}): ${rule.ruleId}`)
|
|
61
|
-
failCount.delete(rule.ruleId)
|
|
62
|
-
} else {
|
|
63
|
-
failCount.set(rule.ruleId, prevFails + 1)
|
|
64
|
-
const hint = (result.error ?? '').slice(0, 200)
|
|
65
|
-
console.log(` ⚡ LLM (${label}): ${rule.ruleId} ❌ ${hint}`)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
158
|
/**
|
|
71
159
|
* @param {string[]} args CLI аргументи після 'fix'
|
|
72
160
|
* @param {string} cwd корінь проєкту
|
|
73
161
|
* @returns {Promise<number>} 0 = all clean, 1 = unresolved
|
|
74
162
|
*/
|
|
75
163
|
export async function runOrchestratorCli(args, cwd) {
|
|
76
|
-
const worker =
|
|
77
|
-
const {
|
|
78
|
-
|
|
79
|
-
/** @type {Map<string, number>} ruleId → кількість LLM-провалів підряд */
|
|
80
|
-
const failCount = new Map()
|
|
164
|
+
const worker = { runLlmWorker }
|
|
165
|
+
const { maxAvg, ruleFilter } = parseOrchestratorArgs(args)
|
|
166
|
+
const ladder = buildLadder({ localMin: LOCAL_MIN, cloudMin: CLOUD_MIN, cloudAvg: CLOUD_AVG })
|
|
81
167
|
|
|
82
168
|
// ── Перша перевірка (тихо) ──
|
|
83
169
|
const initial = await runFixCheck(ruleFilter, cwd)
|
|
84
170
|
let failed = initial.rules.filter(r => !r.ok)
|
|
85
171
|
const total = initial.total
|
|
86
172
|
|
|
87
|
-
// Нічого не зламано — коротка відповідь
|
|
88
173
|
if (failed.length === 0) {
|
|
89
174
|
console.log(`✅ fix: ${total} правил — все чисто`)
|
|
90
175
|
return 0
|
|
91
176
|
}
|
|
92
177
|
|
|
93
|
-
// Є порушення — показуємо прогрес
|
|
94
178
|
console.log(`🔄 fix: ${failed.length}/${total} порушень (${failed.map(r => r.ruleId).join(', ')})`)
|
|
95
179
|
if (ruleFilter.length) console.log(` filter: ${ruleFilter.join(', ')}`)
|
|
96
180
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
181
|
+
// ── T0-auto (детермінований, без LLM) ──
|
|
182
|
+
failed = await runT0Step(cwd, ruleFilter, failed)
|
|
183
|
+
if (failed.length === 0) {
|
|
184
|
+
console.log(`✅ fix: ${total} правил — все чисто`)
|
|
185
|
+
return 0
|
|
186
|
+
}
|
|
100
187
|
|
|
101
|
-
|
|
188
|
+
// ── LLM-драбина ескалації на правило ──
|
|
189
|
+
if (ladder.length === 0) {
|
|
190
|
+
console.log(
|
|
191
|
+
`❌ fix: ${failed.length} порушень потребують LLM, але жоден тир не заданий ` +
|
|
192
|
+
`(N_LOCAL_MIN_MODEL / N_CLOUD_MIN_MODEL / N_CLOUD_AVG_MODEL)`
|
|
193
|
+
)
|
|
194
|
+
return 1
|
|
195
|
+
}
|
|
196
|
+
console.log(` драбина: ${ladder.map(r => r.tier).join(' → ')} (avg-кеп: ${maxAvg})`)
|
|
102
197
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
198
|
+
let avgBudget = maxAvg
|
|
199
|
+
for (const rule of failed) {
|
|
200
|
+
const { avgUsed } = await escalateRule(rule, cwd, {
|
|
201
|
+
ladder,
|
|
202
|
+
worker,
|
|
203
|
+
check: runFixCheck,
|
|
204
|
+
avgBudget
|
|
205
|
+
})
|
|
206
|
+
avgBudget -= avgUsed
|
|
107
207
|
}
|
|
108
208
|
|
|
109
|
-
|
|
209
|
+
// ── Фінальна перевірка ──
|
|
210
|
+
const finalCheck = await runFixCheck(ruleFilter, cwd)
|
|
211
|
+
const stillFailed = finalCheck.rules.filter(r => !r.ok)
|
|
212
|
+
if (stillFailed.length === 0) {
|
|
110
213
|
console.log(`✅ fix: ${total} правил — все чисто`)
|
|
111
214
|
return 0
|
|
112
215
|
}
|
|
113
216
|
|
|
114
|
-
console.log(`❌ fix: ${
|
|
217
|
+
console.log(`❌ fix: ${stillFailed.length} невирішених — ${stillFailed.map(r => r.ruleId).join(', ')}`)
|
|
115
218
|
return 1
|
|
116
219
|
}
|