@nitra/cursor 3.25.1 → 3.27.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 +17 -0
- package/bin/n-cursor.js +29 -9
- package/package.json +1 -1
- package/rules/ga/docs/fix.md +16 -149
- package/rules/ga/js/docs/lint.md +12 -93
- package/rules/ga/js/docs/workflows.md +28 -213
- package/rules/ga/lint/docs/lint.md +24 -206
- package/skills/docgen/js/docgen-extract.mjs +158 -0
- package/skills/docgen/js/docgen-gen.mjs +334 -0
- package/skills/docgen/js/docgen-ignore.mjs +3 -1
- package/skills/docgen/js/docgen-prompts.mjs +88 -0
- package/skills/fix/SKILL.md +5 -31
- package/skills/fix/js/llm-worker.mjs +181 -0
- package/skills/fix/js/orchestrator.mjs +128 -0
- package/skills/fix/js/t0.mjs +229 -0
- package/skills/fix/meta.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-worker для n-fix оркестратора — C1 pattern:
|
|
3
|
+
* script збирає контекст (rule .mdc + файли з violation) →
|
|
4
|
+
* pi повертає JSON зі змінами →
|
|
5
|
+
* script застосовує.
|
|
6
|
+
*
|
|
7
|
+
* Всі LLM-виклики через `pi` (користувач налаштовує ключі самостійно).
|
|
8
|
+
* Tool-use не використовується — LLM отримує повний контекст у промпті.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
|
|
15
|
+
export const MODEL_HAIKU = 'claude-haiku-4-5-20251001'
|
|
16
|
+
export const MODEL_SONNET = 'claude-sonnet-4-6'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Витягує відносні шляхи файлів із violation output.
|
|
20
|
+
* Шукає патерни типу `path/to/file.ext` або `[ws] path/to/file.ext:123`.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} output violation output з fix check
|
|
23
|
+
* @returns {string[]} унікальні відносні шляхи
|
|
24
|
+
*/
|
|
25
|
+
function extractFilePaths(output) {
|
|
26
|
+
const seen = new Set()
|
|
27
|
+
const results = []
|
|
28
|
+
// Матчить шляхи: слово/крапка/тире, з розширенням, необов'язковий :рядок на кінці
|
|
29
|
+
const re = /(?:^|\s|\[[\w/]+\]\s)([\w./][\w./\-]*\.(?:json|js|mjs|ts|vue|yml|yaml|toml|mdc|md|sh|py))(?::\d+)?/gm
|
|
30
|
+
for (const m of output.matchAll(re)) {
|
|
31
|
+
const p = m[1]
|
|
32
|
+
if (!seen.has(p)) { seen.add(p); results.push(p) }
|
|
33
|
+
}
|
|
34
|
+
return results
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Будує prompt для pi: правило + порушення + поточний вміст файлів.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} ruleId
|
|
41
|
+
* @param {string} ruleMdc вміст .mdc-файлу правила
|
|
42
|
+
* @param {string} output violation output
|
|
43
|
+
* @param {Array<{path:string, content:string}>} files
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function buildPrompt(ruleId, ruleMdc, output, files) {
|
|
47
|
+
const filesBlock = files.length === 0
|
|
48
|
+
? '(no files identified)'
|
|
49
|
+
: files.map(f => `<file path="${f.path}">\n${f.content}\n</file>`).join('\n\n')
|
|
50
|
+
|
|
51
|
+
return [
|
|
52
|
+
`You fix project structure violations. Return ONLY valid JSON — no explanation, no markdown.`,
|
|
53
|
+
``,
|
|
54
|
+
`Rule (n-${ruleId}.mdc):`,
|
|
55
|
+
`---`,
|
|
56
|
+
ruleMdc,
|
|
57
|
+
`---`,
|
|
58
|
+
``,
|
|
59
|
+
`Violation output:`,
|
|
60
|
+
output,
|
|
61
|
+
``,
|
|
62
|
+
`Current file contents:`,
|
|
63
|
+
filesBlock,
|
|
64
|
+
``,
|
|
65
|
+
`Return JSON with this exact shape:`,
|
|
66
|
+
`{"changes":[{"path":"relative/path/to/file","content":"full corrected file content"}]}`,
|
|
67
|
+
``,
|
|
68
|
+
`Rules:`,
|
|
69
|
+
`- "path" is relative to the project root`,
|
|
70
|
+
`- "content" is the complete new file content (not a diff)`,
|
|
71
|
+
`- Only include files that actually need to change`,
|
|
72
|
+
`- If nothing can be fixed automatically, return {"changes":[],"error":"reason"}`,
|
|
73
|
+
].join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Запускає pi і повертає stdout як рядок.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} prompt
|
|
80
|
+
* @param {string} model
|
|
81
|
+
* @returns {{ text: string, error?: string }}
|
|
82
|
+
*/
|
|
83
|
+
function callPi(prompt, model) {
|
|
84
|
+
const r = spawnSync(
|
|
85
|
+
'pi',
|
|
86
|
+
['-p', prompt, '--model', model, '--no-session', '--mode', 'text', '--no-tools'],
|
|
87
|
+
{ encoding: 'utf8', timeout: 120_000 }
|
|
88
|
+
)
|
|
89
|
+
if (r.error) return { text: '', error: r.error.message }
|
|
90
|
+
if (r.status !== 0) {
|
|
91
|
+
const stderr = r.stderr?.slice(0, 300) ?? ''
|
|
92
|
+
return { text: '', error: `pi exit ${r.status}: ${stderr}` }
|
|
93
|
+
}
|
|
94
|
+
return { text: r.stdout?.trim() ?? '' }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Парсить JSON-відповідь від pi.
|
|
99
|
+
* pi може обгорнути JSON у ```json ... ```, тому пробуємо витягти.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} text
|
|
102
|
+
* @returns {{ changes: Array<{path:string,content:string}>, error?: string } | null}
|
|
103
|
+
*/
|
|
104
|
+
function parseResponse(text) {
|
|
105
|
+
// Спроба 1: прямий JSON
|
|
106
|
+
try { return JSON.parse(text) } catch { /* fallthrough */ }
|
|
107
|
+
|
|
108
|
+
// Спроба 2: витягти з ```json ... ```
|
|
109
|
+
const m = text.match(/```(?:json)?\s*([\s\S]*?)```/)
|
|
110
|
+
if (m) {
|
|
111
|
+
try { return JSON.parse(m[1].trim()) } catch { /* fallthrough */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Спроба 3: перший { ... } блок
|
|
115
|
+
const start = text.indexOf('{')
|
|
116
|
+
const end = text.lastIndexOf('}')
|
|
117
|
+
if (start !== -1 && end > start) {
|
|
118
|
+
try { return JSON.parse(text.slice(start, end + 1)) } catch { /* fallthrough */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* LLM-worker: виправляє одне rule-порушення через pi (C1 pattern).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} ruleId
|
|
128
|
+
* @param {string} violationOutput output з fix check для цього rule
|
|
129
|
+
* @param {string} projectRoot абсолютний шлях до кореня проєкту
|
|
130
|
+
* @param {{ model?: string }} opts
|
|
131
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
132
|
+
*/
|
|
133
|
+
export async function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
|
|
134
|
+
const model = opts.model ?? MODEL_HAIKU
|
|
135
|
+
|
|
136
|
+
// 1. Читаємо rule .mdc
|
|
137
|
+
const mdcPath = join(projectRoot, '.cursor', 'rules', `n-${ruleId}.mdc`)
|
|
138
|
+
const ruleMdc = existsSync(mdcPath) ? readFileSync(mdcPath, 'utf8') : '(rule file not found)'
|
|
139
|
+
|
|
140
|
+
// 2. Витягуємо файли з violation output і читаємо їх
|
|
141
|
+
const filePaths = extractFilePaths(violationOutput)
|
|
142
|
+
const files = filePaths
|
|
143
|
+
.map(p => {
|
|
144
|
+
const abs = join(projectRoot, p)
|
|
145
|
+
if (!existsSync(abs)) return null
|
|
146
|
+
try {
|
|
147
|
+
return { path: p, content: readFileSync(abs, 'utf8') }
|
|
148
|
+
} catch {
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
|
|
154
|
+
// 3. Будуємо prompt і викликаємо pi
|
|
155
|
+
const prompt = buildPrompt(ruleId, ruleMdc, violationOutput, files)
|
|
156
|
+
const { text, error: piError } = callPi(prompt, model)
|
|
157
|
+
|
|
158
|
+
if (piError) return { ok: false, error: piError }
|
|
159
|
+
if (!text) return { ok: false, error: 'pi returned empty response' }
|
|
160
|
+
|
|
161
|
+
// 4. Парсимо відповідь
|
|
162
|
+
const parsed = parseResponse(text)
|
|
163
|
+
if (!parsed) return { ok: false, error: `cannot parse pi response: ${text.slice(0, 200)}` }
|
|
164
|
+
if (parsed.error) return { ok: false, error: parsed.error }
|
|
165
|
+
|
|
166
|
+
const changes = parsed.changes ?? []
|
|
167
|
+
if (changes.length === 0) return { ok: false, error: 'pi returned no changes' }
|
|
168
|
+
|
|
169
|
+
// 5. Застосовуємо зміни
|
|
170
|
+
for (const change of changes) {
|
|
171
|
+
if (!change.path || typeof change.content !== 'string') continue
|
|
172
|
+
const abs = join(projectRoot, change.path)
|
|
173
|
+
try {
|
|
174
|
+
writeFileSync(abs, change.content, 'utf8')
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return { ok: false, error: `write ${change.path}: ${e.message}` }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { ok: true }
|
|
181
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Автономний оркестратор n-fix: convergence-loop без участі агента-LLM.
|
|
3
|
+
*
|
|
4
|
+
* Тири:
|
|
5
|
+
* T0 — детерміністична перевірка (runFixCheck, 0 LLM)
|
|
6
|
+
* T0-auto — regex-парсинг violation → програмний фікс (0 LLM)
|
|
7
|
+
* T1 — LLM через pi (haiku → sonnet ескалація)
|
|
8
|
+
* check-gate — re-run T0 після кожного тіру; loop до maxIter
|
|
9
|
+
*
|
|
10
|
+
* meta.json: { "orchestrator": true } — CLI маршрутизує `fix` сюди.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const HERE = fileURLToPath(new URL('.', import.meta.url))
|
|
18
|
+
const N_CURSOR_BIN = join(HERE, '../../../bin/n-cursor.js')
|
|
19
|
+
|
|
20
|
+
const DEFAULT_MAX_ITER = 3
|
|
21
|
+
const ESCALATE_AFTER = 2
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string[]} args CLI аргументи після 'fix'
|
|
25
|
+
* @param {string} cwd корінь проєкту
|
|
26
|
+
* @returns {Promise<number>} 0 = all clean, 1 = unresolved
|
|
27
|
+
*/
|
|
28
|
+
export async function runOrchestratorCli(args, cwd) {
|
|
29
|
+
const { runLlmWorker, MODEL_HAIKU, MODEL_SONNET } = await import('./llm-worker.mjs')
|
|
30
|
+
|
|
31
|
+
const maxIterIdx = args.indexOf('--max-iter')
|
|
32
|
+
const maxIter =
|
|
33
|
+
maxIterIdx !== -1
|
|
34
|
+
? Number(args[maxIterIdx + 1] ?? DEFAULT_MAX_ITER) || DEFAULT_MAX_ITER
|
|
35
|
+
: DEFAULT_MAX_ITER
|
|
36
|
+
const skipIdxs = new Set(maxIterIdx !== -1 ? [maxIterIdx, maxIterIdx + 1] : [])
|
|
37
|
+
const ruleFilter = args.filter((a, i) => !a.startsWith('-') && !skipIdxs.has(i))
|
|
38
|
+
|
|
39
|
+
console.log(`🔄 n-cursor fix`)
|
|
40
|
+
if (ruleFilter.length) console.log(` rules: ${ruleFilter.join(', ')}`)
|
|
41
|
+
|
|
42
|
+
/** @type {Map<string, number>} ruleId → кількість LLM-провалів підряд */
|
|
43
|
+
const failCount = new Map()
|
|
44
|
+
|
|
45
|
+
for (let iter = 1; iter <= maxIter; iter++) {
|
|
46
|
+
console.log(`\n── Ітерація ${iter}/${maxIter} ──`)
|
|
47
|
+
|
|
48
|
+
// ── T0: check ──
|
|
49
|
+
const state = runFixCheck(cwd, ruleFilter)
|
|
50
|
+
if (!state) {
|
|
51
|
+
console.error(`❌ fix: перевірка повернула помилку`)
|
|
52
|
+
return 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const failed = state.rules.filter(r => !r.ok)
|
|
56
|
+
if (failed.length === 0) {
|
|
57
|
+
console.log(`✅ fix: 0/${state.total} порушень`)
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(` ❌ ${failed.length}: ${failed.map(r => r.ruleId).join(', ')}`)
|
|
62
|
+
|
|
63
|
+
// ── T0-auto: детермінований фікс без LLM ──
|
|
64
|
+
spawnSync('bun', [N_CURSOR_BIN, 'fix-t0', ...ruleFilter], { cwd, stdio: 'inherit' })
|
|
65
|
+
|
|
66
|
+
const stateAfterT0 = runFixCheck(cwd, ruleFilter)
|
|
67
|
+
const failedAfterT0 = stateAfterT0?.rules.filter(r => !r.ok) ?? failed
|
|
68
|
+
if (failedAfterT0.length === 0) {
|
|
69
|
+
console.log(`✅ fix: всі правила закриті T0-auto`)
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── T1: LLM через pi ──
|
|
74
|
+
for (const rule of failedAfterT0) {
|
|
75
|
+
const prevFails = failCount.get(rule.ruleId) ?? 0
|
|
76
|
+
const model = prevFails >= ESCALATE_AFTER ? MODEL_SONNET : MODEL_HAIKU
|
|
77
|
+
const tier = prevFails >= ESCALATE_AFTER ? 'sonnet' : 'haiku'
|
|
78
|
+
|
|
79
|
+
console.log(`\n⚡ [${tier}] → ${rule.ruleId}`)
|
|
80
|
+
|
|
81
|
+
const result = await runLlmWorker(rule.ruleId, rule.output, cwd, { model })
|
|
82
|
+
|
|
83
|
+
if (result.ok) {
|
|
84
|
+
console.log(` ✅ закрито`)
|
|
85
|
+
failCount.delete(rule.ruleId)
|
|
86
|
+
} else {
|
|
87
|
+
failCount.set(rule.ruleId, prevFails + 1)
|
|
88
|
+
console.log(` ❌ (${prevFails + 1}× fail): ${result.error ?? ''}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Фінальна перевірка ──
|
|
94
|
+
const final = runFixCheck(cwd, ruleFilter)
|
|
95
|
+
const finalFailed = final?.rules.filter(r => !r.ok) ?? []
|
|
96
|
+
|
|
97
|
+
if (finalFailed.length === 0) {
|
|
98
|
+
console.log(`\n✅ fix: чисто`)
|
|
99
|
+
return 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(`\n❌ fix: ${finalFailed.length} unresolved після ${maxIter} ітерацій`)
|
|
103
|
+
console.log(` ${finalFailed.map(r => r.ruleId).join(', ')}`)
|
|
104
|
+
return 1
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Внутрішня check-gate: запускає fix-перевірки і повертає структурований результат.
|
|
109
|
+
* Не є публічним CLI — викликається лише оркестратором.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} cwd
|
|
112
|
+
* @param {string[]} ruleFilter
|
|
113
|
+
* @returns {{ total: number, failed: number, rules: Array<{ ruleId: string, ok: boolean, output: string }> } | null}
|
|
114
|
+
*/
|
|
115
|
+
function runFixCheck(cwd, ruleFilter = []) {
|
|
116
|
+
const r = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...ruleFilter], {
|
|
117
|
+
cwd,
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
timeout: 120_000,
|
|
120
|
+
})
|
|
121
|
+
const stdout = r.stdout?.trim()
|
|
122
|
+
if (!stdout) return null
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(stdout)
|
|
125
|
+
} catch {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T0-auto: детермінований рівень виправлень для n-fix оркестратора.
|
|
3
|
+
*
|
|
4
|
+
* Парсить `output` з `n-cursor fix --json` → застосовує програмний фікс без LLM.
|
|
5
|
+
* Умова застосування: violation-message містить конкретне цільове значення
|
|
6
|
+
* (назву файлу, рядок для вставки, ім'я залежності), яке можна видобути regex.
|
|
7
|
+
*
|
|
8
|
+
* Ієрархія: T0 (rm/create, знаний тип) → T0-auto (parse violation) → T1 (LLM).
|
|
9
|
+
* T0-auto запускається першим у конвергентному циклі; T1 — лише для решти.
|
|
10
|
+
*
|
|
11
|
+
* Публічний API:
|
|
12
|
+
* applyT0Auto(ruleId, violationOutput, cwd) → { applied: boolean, actions: string[] }
|
|
13
|
+
* listT0AutoRules() → string[] (ids що мають хоч один паттерн)
|
|
14
|
+
* runT0AutoCli(args, cwd) → Promise<number> (exit 0=clean, 1=violation)
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { spawnSync } from 'node:child_process'
|
|
19
|
+
import { dirname } from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Патерни T0-auto.
|
|
24
|
+
* Кожен паттерн: {
|
|
25
|
+
* id: string — унікальна назва паттерну (для логу)
|
|
26
|
+
* test: (output)=>bool — чи підходить цей output до паттерну
|
|
27
|
+
* apply: (match, cwd)=>{ ok: bool, action: string } — застосувати фікс
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
const PATTERNS = [
|
|
31
|
+
// ── vscode-ext-add ──────────────────────────────────────────────────────────
|
|
32
|
+
// Violation: «recommendations має містити "tsandall.opa"»
|
|
33
|
+
// Fix: додати рядок у .vscode/extensions.json#recommendations
|
|
34
|
+
{
|
|
35
|
+
id: 'vscode-ext-add',
|
|
36
|
+
test: out => /recommendations має містити "[^"]+"/.test(out),
|
|
37
|
+
apply: (out, cwd) => {
|
|
38
|
+
const matches = [...out.matchAll(/recommendations має містити "([^"]+)"/g)]
|
|
39
|
+
if (matches.length === 0) return { ok: false, action: 'no match' }
|
|
40
|
+
|
|
41
|
+
const extPath = join(cwd, '.vscode/extensions.json')
|
|
42
|
+
if (!existsSync(extPath)) {
|
|
43
|
+
return { ok: false, action: '.vscode/extensions.json не знайдено' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let parsed
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(readFileSync(extPath, 'utf8'))
|
|
49
|
+
} catch {
|
|
50
|
+
return { ok: false, action: '.vscode/extensions.json: невалідний JSON' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const recs = Array.isArray(parsed.recommendations) ? parsed.recommendations : []
|
|
54
|
+
const toAdd = matches.map(m => m[1]).filter(e => !recs.includes(e))
|
|
55
|
+
if (toAdd.length === 0) return { ok: false, action: 'вже є' }
|
|
56
|
+
|
|
57
|
+
parsed.recommendations = [...recs, ...toAdd]
|
|
58
|
+
writeFileSync(extPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8')
|
|
59
|
+
return { ok: true, action: `додано до extensions.json: ${toAdd.join(', ')}` }
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// ── rm-forbidden-file ────────────────────────────────────────────────────────
|
|
64
|
+
// Violation: «Знайдено заборонений файл: package-lock.json»
|
|
65
|
+
// Fix: видалити файл
|
|
66
|
+
{
|
|
67
|
+
id: 'rm-forbidden-file',
|
|
68
|
+
test: out => /Знайдено заборонений файл: \S+/.test(out),
|
|
69
|
+
apply: (out, cwd) => {
|
|
70
|
+
const matches = [...out.matchAll(/Знайдено заборонений файл: (\S+)/g)]
|
|
71
|
+
if (matches.length === 0) return { ok: false, action: 'no match' }
|
|
72
|
+
|
|
73
|
+
const removed = []
|
|
74
|
+
for (const m of matches) {
|
|
75
|
+
const filePath = join(cwd, m[1])
|
|
76
|
+
if (existsSync(filePath)) {
|
|
77
|
+
rmSync(filePath, { force: true })
|
|
78
|
+
removed.push(m[1])
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (removed.length === 0) return { ok: false, action: 'файлів не знайдено' }
|
|
82
|
+
return { ok: true, action: `видалено: ${removed.join(', ')}` }
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Застосовує всі T0-auto паттерни до одного violation-output.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} ruleId id правила (для логу)
|
|
91
|
+
* @param {string} violationOutput рядок з поля `output` у `fix --json`
|
|
92
|
+
* @param {string} cwd корінь проєкту
|
|
93
|
+
* @returns {{ applied: boolean, actions: string[] }}
|
|
94
|
+
*/
|
|
95
|
+
export function applyT0Auto(ruleId, violationOutput, cwd) {
|
|
96
|
+
const actions = []
|
|
97
|
+
let applied = false
|
|
98
|
+
|
|
99
|
+
for (const p of PATTERNS) {
|
|
100
|
+
if (!p.test(violationOutput)) continue
|
|
101
|
+
const result = p.apply(violationOutput, cwd)
|
|
102
|
+
actions.push(`[${p.id}] ${result.action}`)
|
|
103
|
+
if (result.ok) {
|
|
104
|
+
applied = true
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { applied, actions }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Повертає список id правил, для яких є хоча б один T0-auto паттерн
|
|
113
|
+
* (визначається по violation-output із `fix --json`).
|
|
114
|
+
*
|
|
115
|
+
* @param {{ ruleId: string, output: string }[]} failedRules
|
|
116
|
+
* @returns {string[]}
|
|
117
|
+
*/
|
|
118
|
+
export function filterT0AutoRules(failedRules) {
|
|
119
|
+
return failedRules
|
|
120
|
+
.filter(r => PATTERNS.some(p => p.test(r.output)))
|
|
121
|
+
.map(r => r.ruleId)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── CLI entry-point ──────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
127
|
+
/** Абсолютний шлях до npm/bin/n-cursor.js відносно цього файлу */
|
|
128
|
+
const N_CURSOR_BIN = join(HERE, '../../../bin/n-cursor.js')
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* CLI підкоманда `n-cursor fix-t0 [rule...]`.
|
|
132
|
+
* Запускає `fix --json`, застосовує T0-auto для кожного violation,
|
|
133
|
+
* повторно перевіряє check-gate, виводить підсумок.
|
|
134
|
+
*
|
|
135
|
+
* @param {string[]} args аргументи підкоманди (опційний список rule-ids)
|
|
136
|
+
* @param {string} cwd корінь проєкту
|
|
137
|
+
* @returns {Promise<number>} 0 — T0-auto закрив всі або немає порушень; 1 — лишились
|
|
138
|
+
*/
|
|
139
|
+
export async function runT0AutoCli(args, cwd) {
|
|
140
|
+
const ruleFilter = args.filter(a => !a.startsWith('--'))
|
|
141
|
+
const verbose = args.includes('--verbose') || args.includes('-v')
|
|
142
|
+
|
|
143
|
+
// 1. Запустити fix --json
|
|
144
|
+
const fixResult = spawnSync(
|
|
145
|
+
'bun',
|
|
146
|
+
[N_CURSOR_BIN, '_fix-check', ...ruleFilter],
|
|
147
|
+
{ cwd, encoding: 'utf8', timeout: 120_000 }
|
|
148
|
+
)
|
|
149
|
+
const raw = fixResult.stdout?.trim()
|
|
150
|
+
if (!raw) {
|
|
151
|
+
console.error(`n-cursor fix-t0: fix --json повернув порожній stdout`)
|
|
152
|
+
console.error(fixResult.stderr?.slice(0, 300) ?? '')
|
|
153
|
+
return 1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let fixJson
|
|
157
|
+
try {
|
|
158
|
+
fixJson = JSON.parse(raw)
|
|
159
|
+
} catch {
|
|
160
|
+
console.error(`n-cursor fix-t0: fix --json повернув невалідний JSON`)
|
|
161
|
+
return 1
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const failed = fixJson.rules.filter(r => !r.ok)
|
|
165
|
+
if (failed.length === 0) {
|
|
166
|
+
console.log(`✅ fix-t0: всі правила чисті — T0 не потрібен`)
|
|
167
|
+
return 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 2. Застосувати T0-auto
|
|
171
|
+
const applied = []
|
|
172
|
+
const skipped = []
|
|
173
|
+
for (const r of failed) {
|
|
174
|
+
const result = applyT0Auto(r.ruleId, r.output, cwd)
|
|
175
|
+
if (result.applied) {
|
|
176
|
+
applied.push({ ruleId: r.ruleId, actions: result.actions })
|
|
177
|
+
} else {
|
|
178
|
+
skipped.push(r.ruleId)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (applied.length === 0) {
|
|
183
|
+
console.log(`⏭️ fix-t0: T0-auto паттерн не підходить для: ${failed.map(r => r.ruleId).join(', ')}`)
|
|
184
|
+
return 1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 3. Вивести що зробили
|
|
188
|
+
for (const { ruleId, actions } of applied) {
|
|
189
|
+
console.log(`⚙️ ${ruleId}:`)
|
|
190
|
+
for (const a of actions) console.log(` ${a}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 4. Check-gate: перевірити лише ті правила, що ми чіпали
|
|
194
|
+
const recheck = spawnSync(
|
|
195
|
+
'bun',
|
|
196
|
+
[N_CURSOR_BIN, '_fix-check', ...applied.map(a => a.ruleId)],
|
|
197
|
+
{ cwd, encoding: 'utf8', timeout: 120_000 }
|
|
198
|
+
)
|
|
199
|
+
const recheckRaw = recheck.stdout?.trim()
|
|
200
|
+
if (!recheckRaw) {
|
|
201
|
+
console.error(`fix-t0: check-gate: fix --json повернув порожній stdout`)
|
|
202
|
+
return 1
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const recheckJson = JSON.parse(recheckRaw)
|
|
206
|
+
const stillFailed = recheckJson.rules.filter(r => !r.ok)
|
|
207
|
+
|
|
208
|
+
if (verbose) {
|
|
209
|
+
for (const r of recheckJson.rules) {
|
|
210
|
+
console.log(` ${r.ok ? '✅' : '❌'} ${r.ruleId}`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (stillFailed.length > 0) {
|
|
215
|
+
console.log(`❌ fix-t0 check-gate: ${stillFailed.map(r => r.ruleId).join(', ')} — лишились`)
|
|
216
|
+
if (skipped.length > 0) {
|
|
217
|
+
console.log(`⏭️ без T0 паттерну: ${skipped.join(', ')} → потрібен LLM`)
|
|
218
|
+
}
|
|
219
|
+
return 1
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const totalFixed = applied.length
|
|
223
|
+
const total = failed.length
|
|
224
|
+
console.log(
|
|
225
|
+
`✅ fix-t0: ${totalFixed}/${total} правил закрито T0-auto` +
|
|
226
|
+
(skipped.length > 0 ? `; ${skipped.join(', ')} → T1 (LLM)` : '')
|
|
227
|
+
)
|
|
228
|
+
return 0
|
|
229
|
+
}
|
package/skills/fix/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": "завжди", "worktree": true }
|
|
1
|
+
{ "auto": "завжди", "worktree": true, "orchestrator": true }
|