@nitra/cursor 12.0.3 → 12.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 +13 -0
- package/bin/n-cursor.js +10 -1
- package/package.json +1 -1
- package/rules/lint/js/docs/orchestrate.md +4 -2
- package/rules/lint/js/orchestrate.mjs +61 -23
- package/rules/release/docs/release.md +14 -212
- package/rules/tool-surface/docs/fix.md +32 -0
- package/rules/tool-surface/docs/index.md +11 -0
- package/scripts/lib/adr/docs/normalize-pipeline.md +29 -20
- package/scripts/lib/docs/worktree-notice.md +14 -15
- 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-fix-apply.md +2 -2
- package/scripts/lib/fix/docs/llm-worker.md +8 -11
- package/scripts/lib/fix/docs/orchestrator.md +20 -11
- package/scripts/lib/fix/docs/t0.md +5 -6
- package/scripts/lib/fix/escalation-log.mjs +92 -0
- package/scripts/lib/fix/llm-fix-apply.mjs +7 -3
- package/scripts/lib/fix/llm-worker.mjs +56 -20
- package/scripts/lib/fix/orchestrator.mjs +158 -55
- package/scripts/lib/fix/t0.mjs +53 -7
|
@@ -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
|
}
|
package/scripts/lib/fix/t0.mjs
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
/** @see ./docs/t0.md */
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
2
3
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
4
|
import { join } from 'node:path'
|
|
4
5
|
|
|
5
6
|
import { runFixCheck } from './run-fix-check.mjs'
|
|
7
|
+
import { writeChange } from '../../../rules/release/change.mjs'
|
|
6
8
|
|
|
7
9
|
const REC_REQUIRE_RE = /recommendations має містити "[^"]+"/
|
|
8
10
|
const REC_MATCH_ALL_RE = /recommendations має містити "([^"]+)"/g
|
|
9
11
|
const FORBIDDEN_FILE_RE = /Знайдено заборонений файл: \S+/
|
|
10
12
|
const FORBIDDEN_FILE_MATCH_ALL_RE = /Знайдено заборонений файл: (\S+)/g
|
|
13
|
+
// Конформність changelog: «<ws>: є релевантні зміни, але немає change-файлу».
|
|
14
|
+
const MISSING_CHANGE_RE = /є релевантні зміни, але немає change-файлу/
|
|
15
|
+
const MISSING_CHANGE_MATCH_ALL_RE = /(?:^|\s)([\w./@-]+): є релевантні зміни, але немає change-файлу/gm
|
|
16
|
+
/** Дефолти autofix-створеного change-файлу (узгоджено з n-changelog.mdc / consistency.mjs). */
|
|
17
|
+
const CHANGE_BUMP = 'patch'
|
|
18
|
+
const CHANGE_SECTION = 'Changed'
|
|
19
|
+
const CHANGE_FALLBACK_MESSAGE = 'оновлення'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Опис для авто-створеного change-файлу: subject останнього коміту, інакше fallback.
|
|
23
|
+
* @param {string} cwd корінь репозиторію
|
|
24
|
+
* @returns {string} непорожній опис
|
|
25
|
+
*/
|
|
26
|
+
function autoChangeMessage(cwd) {
|
|
27
|
+
const r = spawnSync('git', ['log', '-1', '--format=%s'], { cwd, encoding: 'utf8' })
|
|
28
|
+
const subject = r.status === 0 ? (r.stdout ?? '').trim() : ''
|
|
29
|
+
return subject || CHANGE_FALLBACK_MESSAGE
|
|
30
|
+
}
|
|
11
31
|
|
|
12
32
|
/**
|
|
13
33
|
* Патерни T0-auto.
|
|
@@ -71,6 +91,31 @@ const PATTERNS = [
|
|
|
71
91
|
if (removed.length === 0) return { ok: false, action: 'файлів не знайдено' }
|
|
72
92
|
return { ok: true, action: `видалено: ${removed.join(', ')}` }
|
|
73
93
|
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// ── changelog-create-change-file ─────────────────────────────────────────────
|
|
97
|
+
// Violation: «<ws>: є релевантні зміни, але немає change-файлу»
|
|
98
|
+
// Fix: створити change-файл через канонічну `writeChange` (без LLM) — той самий
|
|
99
|
+
// механізм, що autofix changelog-конформності. Прибирає ескалацію в хмару на цьому кейсі.
|
|
100
|
+
{
|
|
101
|
+
id: 'changelog-create-change-file',
|
|
102
|
+
test: out => MISSING_CHANGE_RE.test(out),
|
|
103
|
+
apply: async (out, cwd) => {
|
|
104
|
+
const workspaces = Array.from(out.matchAll(MISSING_CHANGE_MATCH_ALL_RE), m => m[1])
|
|
105
|
+
if (workspaces.length === 0) return { ok: false, action: 'no match' }
|
|
106
|
+
|
|
107
|
+
const message = autoChangeMessage(cwd)
|
|
108
|
+
const created = []
|
|
109
|
+
for (const ws of workspaces) {
|
|
110
|
+
try {
|
|
111
|
+
const rel = await writeChange({ bump: CHANGE_BUMP, section: CHANGE_SECTION, message, ws, cwd })
|
|
112
|
+
created.push(ws === '.' ? rel : join(ws, rel))
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { ok: false, action: `writeChange ${ws}: ${error.message}` }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, action: `створено change-файл (${CHANGE_BUMP}/${CHANGE_SECTION}): ${created.join(', ')}` }
|
|
118
|
+
}
|
|
74
119
|
}
|
|
75
120
|
]
|
|
76
121
|
|
|
@@ -79,15 +124,16 @@ const PATTERNS = [
|
|
|
79
124
|
* @param {string} ruleId id правила (для логу)
|
|
80
125
|
* @param {string} violationOutput рядок з поля `output` у `fix --json`
|
|
81
126
|
* @param {string} cwd корінь проєкту
|
|
82
|
-
* @returns {{ applied: boolean, actions: string[] }} результат: чи щось застосовано і список дій
|
|
127
|
+
* @returns {Promise<{ applied: boolean, actions: string[] }>} результат: чи щось застосовано і список дій
|
|
83
128
|
*/
|
|
84
|
-
export function applyT0Auto(ruleId, violationOutput, cwd) {
|
|
129
|
+
export async function applyT0Auto(ruleId, violationOutput, cwd) {
|
|
85
130
|
const actions = []
|
|
86
131
|
let applied = false
|
|
87
132
|
|
|
88
133
|
for (const p of PATTERNS) {
|
|
89
134
|
if (!p.test(violationOutput)) continue
|
|
90
|
-
|
|
135
|
+
// Патерн може бути sync ({ok,action}) або async (Promise) — await нормалізує обидва.
|
|
136
|
+
const result = await p.apply(violationOutput, cwd)
|
|
91
137
|
actions.push(`[${p.id}] ${result.action}`)
|
|
92
138
|
if (result.ok) {
|
|
93
139
|
applied = true
|
|
@@ -113,13 +159,13 @@ export function filterT0AutoRules(failedRules) {
|
|
|
113
159
|
* Застосовує T0-auto до кожного провального правила, розділяючи на applied/skipped.
|
|
114
160
|
* @param {Array<{ ruleId: string, output: string }>} failed провальні правила
|
|
115
161
|
* @param {string} cwd корінь проєкту
|
|
116
|
-
* @returns {{ applied: Array<{ ruleId: string, actions: string[] }>, skipped: string[] }} застосовані й пропущені
|
|
162
|
+
* @returns {Promise<{ applied: Array<{ ruleId: string, actions: string[] }>, skipped: string[] }>} застосовані й пропущені
|
|
117
163
|
*/
|
|
118
|
-
function applyT0ToFailed(failed, cwd) {
|
|
164
|
+
async function applyT0ToFailed(failed, cwd) {
|
|
119
165
|
const applied = []
|
|
120
166
|
const skipped = []
|
|
121
167
|
for (const r of failed) {
|
|
122
|
-
const result = applyT0Auto(r.ruleId, r.output, cwd)
|
|
168
|
+
const result = await applyT0Auto(r.ruleId, r.output, cwd)
|
|
123
169
|
if (result.applied) {
|
|
124
170
|
applied.push({ ruleId: r.ruleId, actions: result.actions })
|
|
125
171
|
} else {
|
|
@@ -150,7 +196,7 @@ export async function runT0AutoCli(args, cwd) {
|
|
|
150
196
|
}
|
|
151
197
|
|
|
152
198
|
// 2. Застосувати T0-auto
|
|
153
|
-
const { applied, skipped } = applyT0ToFailed(failed, cwd)
|
|
199
|
+
const { applied, skipped } = await applyT0ToFailed(failed, cwd)
|
|
154
200
|
|
|
155
201
|
if (applied.length === 0) {
|
|
156
202
|
console.log(`⏭️ fix-t0: T0-auto паттерн не підходить для: ${failed.map(r => r.ruleId).join(', ')}`)
|