@nitra/cursor 9.2.0 → 9.3.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [9.3.0] - 2026-06-14
4
+
5
+ ### Added
6
+
7
+ - per-tool omlx-фіксер (point 4): cspell autofix одруків через omlx у fix-режимі lint-text; спільне ядро llm-fix-apply (parse/read/apply, перевикористане llm-worker); generic llm-lint-fix; re-detect лишає валідні терміни для словника. Відновлено канон lint-text.yml --read-only (відкочений паралельним агентом)
8
+
3
9
  ## [9.2.0] - 2026-06-14
4
10
 
5
11
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "9.2.0",
3
+ "version": "9.3.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * cspell у ланцюжку lint-text із omlx-автофіксом (point 4 спеки).
3
+ *
4
+ * cspell не має нативного `--fix`. У fix-режимі: детект (захоплення виводу) → групування
5
+ * знахідок по файлах → per-file omlx-фікс справжніх одруків (`llmLintFix`) → re-detect.
6
+ * У read-only: лише детект (нуль мутацій). Валідні терміни omlx лишає — їх ловить повторний
7
+ * cspell (далі — у словник `@nitra/cspell-dict`).
8
+ */
9
+ import { spawnSync } from 'node:child_process'
10
+
11
+ import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
12
+ import { llmLintFix } from '../../../scripts/lib/fix/llm-lint-fix.mjs'
13
+
14
+ /** Рядок cspell: `<file>:<line>:<col> - Unknown word (xxx)`. */
15
+ const CSPELL_LINE_RE = /^(.+?):\d+:\d+\s+-\s+Unknown word/u
16
+ /** Максимум файлів під omlx-фікс за прогін (без тихого обрізання — логуємо надлишок). */
17
+ const MAX_FIX_FILES = 25
18
+
19
+ /**
20
+ * Запускає `cspell .` із захопленням виводу.
21
+ * @param {string} cwd корінь
22
+ * @param {string} bin шлях до cspell (npx/локальний)
23
+ * @returns {{ code:number, out:string }} код + обʼєднаний stdout/stderr
24
+ */
25
+ function detectCspell(cwd, bin) {
26
+ const r = spawnSync(bin, ['cspell', '.'], { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, env: process.env })
27
+ return { code: typeof r.status === 'number' ? r.status : 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }
28
+ }
29
+
30
+ /**
31
+ * Групує cspell-знахідки за файлом.
32
+ * @param {string} out вивід cspell
33
+ * @returns {Map<string, string[]>} файл → рядки знахідок
34
+ */
35
+ export function groupFindingsByFile(out) {
36
+ /** @type {Map<string, string[]>} */
37
+ const byFile = new Map()
38
+ for (const line of out.split('\n')) {
39
+ const m = CSPELL_LINE_RE.exec(line.trim())
40
+ if (!m) continue
41
+ const file = m[1]
42
+ if (!byFile.has(file)) byFile.set(file, [])
43
+ byFile.get(file).push(line.trim())
44
+ }
45
+ return byFile
46
+ }
47
+
48
+ const CSPELL_INSTRUCTION = [
49
+ 'Correct genuine spelling typos in the file(s).',
50
+ 'Each flagged "Unknown word" is listed below.',
51
+ 'ONLY fix obvious misspellings of real words.',
52
+ 'If a flagged token is a valid identifier, technical term, abbreviation, proper noun, URL,',
53
+ 'or an intentional non-English word, leave it UNCHANGED (it will be added to the dictionary).',
54
+ 'Preserve all code, formatting, and unrelated text exactly.'
55
+ ].join(' ')
56
+
57
+ /**
58
+ * cspell-крок lint-text з omlx-автофіксом.
59
+ * @param {string} [cwd] корінь
60
+ * @param {boolean} [readOnly] true → лише детект (нуль мутацій)
61
+ * @returns {number} 0 — чисто; 1 — лишились знахідки / помилка середовища
62
+ */
63
+ export function runCspellText(cwd = process.cwd(), readOnly = false) {
64
+ const bin = resolveCmd('npx')
65
+ if (!bin) {
66
+ process.stderr.write('❌ npx не знайдено в PATH (cspell).\n')
67
+ return 1
68
+ }
69
+
70
+ const first = detectCspell(cwd, bin)
71
+ if (first.code === 0) return 0
72
+ if (readOnly) {
73
+ process.stdout.write(first.out)
74
+ return first.code
75
+ }
76
+
77
+ // Fix-режим: omlx по файлах зі справжніми одруками.
78
+ const byFile = groupFindingsByFile(first.out)
79
+ const files = [...byFile.keys()]
80
+ if (files.length === 0) {
81
+ process.stdout.write(first.out)
82
+ return first.code
83
+ }
84
+ const targets = files.slice(0, MAX_FIX_FILES)
85
+ if (files.length > MAX_FIX_FILES) {
86
+ process.stdout.write(`ℹ️ cspell: omlx-фікс перших ${MAX_FIX_FILES}/${files.length} файлів (решта — наступний прогін)\n`)
87
+ }
88
+
89
+ for (const file of targets) {
90
+ const res = llmLintFix({
91
+ tool: 'cspell',
92
+ instruction: CSPELL_INSTRUCTION,
93
+ findings: byFile.get(file).join('\n'),
94
+ filePaths: [file],
95
+ projectRoot: cwd
96
+ })
97
+ process.stdout.write(res.ok ? ` ⚡ cspell omlx-фікс: ${file}\n` : ` ⚠️ cspell omlx-фікс пропущено (${file}): ${res.error}\n`)
98
+ }
99
+
100
+ // Re-detect: що лишилось (валідні терміни → у словник).
101
+ const second = detectCspell(cwd, bin)
102
+ if (second.code !== 0) process.stdout.write(second.out)
103
+ return second.code
104
+ }
@@ -24,6 +24,7 @@ import { runLintStep } from '../../../scripts/lib/run-lint-step.mjs'
24
24
  import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
25
25
  import { runStandardLint } from '../../../scripts/lib/run-standard-lint.mjs'
26
26
  import { ensureTool } from '../../../scripts/lib/ensure-tool.mjs'
27
+ import { runCspellText } from './cspell-fix.mjs'
27
28
  import { runDotenvLinter } from './run-dotenv-linter.mjs'
28
29
  import { runShellcheckText } from './run-shellcheck.mjs'
29
30
  import { runV8rWithGlobs } from './run-v8r.mjs'
@@ -106,7 +107,8 @@ function runLintTextSteps(readOnly = false) {
106
107
  // patch потрібен лише для авто-фіксу shellcheck; у read-only пропускаємо preflight.
107
108
  if (!readOnly && !preflight(PATCH_PREFLIGHT)) return 1
108
109
 
109
- const cspellCode = runLintStep('cspell', 'npx', ['cspell', '.'])
110
+ console.log(`\n▶ cspell (${readOnly ? 'перевірка' : 'omlx-автофікс одруків + перевірка'})`)
111
+ const cspellCode = runCspellText(process.cwd(), readOnly)
110
112
  if (cspellCode !== 0) return cspellCode
111
113
 
112
114
  console.log(`\n▶ shellcheck (${readOnly ? 'перевірка' : 'авто-фікс + фінальна перевірка'} *.sh)`)
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Спільне ядро LLM-фіксу: парс відповіді `{changes:[{path,content}]}`, читання файлів
3
+ * під фікс і застосування змін. Використовують і `llm-worker.mjs` (конформність), і
4
+ * `llm-lint-fix.mjs` (per-tool лінтер-фіксери) — щоб не дублювати парс/apply (knip/jscpd).
5
+ */
6
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/
10
+
11
+ /**
12
+ * Парсить JSON-відповідь моделі: прямий JSON → ```json-блок``` → перший `{…}`-блок.
13
+ * @param {string} text сирий текст відповіді
14
+ * @returns {{ changes?: Array<{path:string,content:string}>, error?: string } | null} патч або null
15
+ */
16
+ export function parseChangesResponse(text) {
17
+ try {
18
+ return JSON.parse(text)
19
+ } catch {
20
+ /* fallthrough */
21
+ }
22
+ const block = text.match(JSON_CODE_BLOCK_RE)
23
+ if (block) {
24
+ try {
25
+ return JSON.parse(block[1].trim())
26
+ } catch {
27
+ /* fallthrough */
28
+ }
29
+ }
30
+ const start = text.indexOf('{')
31
+ const end = text.lastIndexOf('}')
32
+ if (start !== -1 && end > start) {
33
+ try {
34
+ return JSON.parse(text.slice(start, end + 1))
35
+ } catch {
36
+ /* fallthrough */
37
+ }
38
+ }
39
+ return null
40
+ }
41
+
42
+ /**
43
+ * Читає існуючі файли за відносними шляхами у форму `{path, content}` (для prompt).
44
+ * @param {string[]} filePaths відносні шляхи від кореня
45
+ * @param {string} projectRoot абсолютний корінь
46
+ * @returns {Array<{path:string, content:string}>} наявні файли з вмістом
47
+ */
48
+ export function readFilesForFix(filePaths, projectRoot) {
49
+ return filePaths
50
+ .map(p => {
51
+ const abs = join(projectRoot, p)
52
+ if (!existsSync(abs)) return null
53
+ try {
54
+ return { path: p, content: readFileSync(abs, 'utf8') }
55
+ } catch {
56
+ return null
57
+ }
58
+ })
59
+ .filter(Boolean)
60
+ }
61
+
62
+ /**
63
+ * Застосовує `changes` до ФС (повний вміст файлу, не diff).
64
+ * @param {Array<{path:string, content:string}>} changes зміни
65
+ * @param {string} projectRoot абсолютний корінь
66
+ * @returns {{ ok: boolean, error?: string }} статус
67
+ */
68
+ export function applyChanges(changes, projectRoot) {
69
+ for (const change of changes) {
70
+ if (!change.path || typeof change.content !== 'string') continue
71
+ try {
72
+ writeFileSync(join(projectRoot, change.path), change.content, 'utf8')
73
+ } catch (error) {
74
+ return { ok: false, error: `write ${change.path}: ${error.message}` }
75
+ }
76
+ }
77
+ return { ok: true }
78
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Per-tool omlx-фікс лінтер-знахідок (point 4 спеки lint-orchestrator-fix-readonly).
3
+ *
4
+ * Для detect-only тулів без нативного `--fix` (cspell, knip, actionlint, v8r тощо): читає
5
+ * уражені файли, просить omlx виправити за tool-специфічною інструкцією, застосовує `{changes}`.
6
+ * Re-detect (перевірка, що знахідка закрита) — на стороні caller (convergence-патерн).
7
+ *
8
+ * Маршрут моделі — через `callLlm` за префіксом: `omlx/<model>` → локальний HTTP (дефолт
9
+ * `resolveModel('min')`); cloud — фолбек каскаду. Парс/застосування — спільне ядро `llm-fix-apply`.
10
+ */
11
+ import { env } from 'node:process'
12
+
13
+ import { resolveModel } from '../../../lib/models.mjs'
14
+ import { callLlm } from '../../../lib/llm.mjs'
15
+ import { applyChanges, parseChangesResponse, readFilesForFix } from './llm-fix-apply.mjs'
16
+
17
+ /** Дефолтний локальний тир (omlx); env `N_CURSOR_FIX_MODEL` перекриває. */
18
+ const DEFAULT_MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
19
+
20
+ /**
21
+ * Будує prompt для omlx: tool-інструкція + знахідки + повний вміст файлів.
22
+ * @param {string} tool назва тула (cspell/knip/…)
23
+ * @param {string} instruction що саме виправити (tool-специфічно)
24
+ * @param {string} findings сирий вивід тула (знахідки)
25
+ * @param {Array<{path:string, content:string}>} files файли під фікс
26
+ * @returns {string} prompt
27
+ */
28
+ function buildLintFixPrompt(tool, instruction, findings, files) {
29
+ const filesBlock = files.map(f => `<file path="${f.path}">\n${f.content}\n</file>`).join('\n\n')
30
+ return [
31
+ `You fix ${tool} lint findings. Return ONLY valid JSON — no explanation, no markdown.`,
32
+ ``,
33
+ `Task: ${instruction}`,
34
+ ``,
35
+ `${tool} findings:`,
36
+ findings,
37
+ ``,
38
+ `Current file contents:`,
39
+ filesBlock,
40
+ ``,
41
+ `Return JSON with this exact shape:`,
42
+ `{"changes":[{"path":"relative/path","content":"full corrected file content"}]}`,
43
+ ``,
44
+ `Rules:`,
45
+ `- "path" is relative to the project root (use the path from the <file> tag)`,
46
+ `- "content" is the COMPLETE new file content (not a diff)`,
47
+ `- Only include files that actually need to change; preserve everything unrelated verbatim`,
48
+ `- If nothing should be auto-fixed, return {"changes":[],"error":"reason"}`
49
+ ].join('\n')
50
+ }
51
+
52
+ /**
53
+ * Виправляє лінтер-знахідки через omlx і застосовує зміни.
54
+ * @param {{ tool:string, instruction:string, findings:string, filePaths:string[], projectRoot:string, model?:string }} opts параметри
55
+ * @returns {{ ok:boolean, error?:string, fixed:string[] }} статус + список змінених шляхів
56
+ */
57
+ export function llmLintFix({ tool, instruction, findings, filePaths, projectRoot, model }) {
58
+ const m = model ?? DEFAULT_MODEL
59
+ const files = readFilesForFix(filePaths, projectRoot)
60
+ if (files.length === 0) return { ok: false, error: 'no readable files to fix', fixed: [] }
61
+
62
+ let text
63
+ try {
64
+ text = callLlm([{ role: 'user', content: buildLintFixPrompt(tool, instruction, findings, files) }], m, {
65
+ timeoutMs: 120_000,
66
+ caller: `lint:${tool}`
67
+ })
68
+ } catch (error) {
69
+ return { ok: false, error: String(error.message), fixed: [] }
70
+ }
71
+
72
+ const parsed = parseChangesResponse(text)
73
+ if (!parsed) return { ok: false, error: `cannot parse omlx response: ${String(text).slice(0, 200)}`, fixed: [] }
74
+ if (parsed.error) return { ok: false, error: parsed.error, fixed: [] }
75
+
76
+ const changes = (parsed.changes ?? []).filter(c => c.path && typeof c.content === 'string')
77
+ if (changes.length === 0) return { ok: false, error: 'omlx returned no changes', fixed: [] }
78
+
79
+ const applied = applyChanges(changes, projectRoot)
80
+ if (!applied.ok) return { ok: false, error: applied.error, fixed: [] }
81
+ return { ok: true, fixed: changes.map(c => c.path) }
82
+ }
@@ -1,17 +1,17 @@
1
1
  /** @see ./docs/llm-worker.md */
2
2
 
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { env } from 'node:process'
6
6
  import { resolveModel } from '../../../lib/models.mjs'
7
7
  import { callLlm } from '../../../lib/llm.mjs'
8
+ import { applyChanges, parseChangesResponse, readFilesForFix } from './llm-fix-apply.mjs'
8
9
 
9
10
  // Тир за замовчуванням: min → avg при ескалації (каскад local→cloud).
10
11
  // Перевизначення через N_CURSOR_FIX_MODEL / N_CURSOR_FIX_MODEL_HEAVY.
11
12
  export const MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
12
13
  export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ?? resolveModel('avg')
13
14
 
14
- const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/
15
15
  const API_KEY_RE = /api key/i
16
16
 
17
17
  /**
@@ -113,44 +113,6 @@ function callModel(prompt, model) {
113
113
  }
114
114
  }
115
115
 
116
- /**
117
- * Парсить JSON-відповідь від моделі.
118
- * Модель може обгорнути JSON у ```json ... ```, тому пробуємо витягти.
119
- * @param {string} text сирий текст відповіді
120
- * @returns {{ changes: Array<{path:string,content:string}>, error?: string } | null} розпарсений патч або null
121
- */
122
- function parseResponse(text) {
123
- // Спроба 1: прямий JSON
124
- try {
125
- return JSON.parse(text)
126
- } catch {
127
- /* fallthrough */
128
- }
129
-
130
- // Спроба 2: витягти з ```json ... ```
131
- const m = text.match(JSON_CODE_BLOCK_RE)
132
- if (m) {
133
- try {
134
- return JSON.parse(m[1].trim())
135
- } catch {
136
- /* fallthrough */
137
- }
138
- }
139
-
140
- // Спроба 3: перший { ... } блок
141
- const start = text.indexOf('{')
142
- const end = text.lastIndexOf('}')
143
- if (start !== -1 && end > start) {
144
- try {
145
- return JSON.parse(text.slice(start, end + 1))
146
- } catch {
147
- /* fallthrough */
148
- }
149
- }
150
-
151
- return null
152
- }
153
-
154
116
  /**
155
117
  * LLM-worker: виправляє одне rule-порушення через pi (C1 pattern).
156
118
  * @param {string} ruleId ID правила
@@ -167,18 +129,7 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
167
129
  const ruleMdc = existsSync(mdcPath) ? readFileSync(mdcPath, 'utf8') : '(rule file not found)'
168
130
 
169
131
  // 2. Витягуємо файли з violation output і читаємо їх
170
- const filePaths = extractFilePaths(violationOutput)
171
- const files = filePaths
172
- .map(p => {
173
- const abs = join(projectRoot, p)
174
- if (!existsSync(abs)) return null
175
- try {
176
- return { path: p, content: readFileSync(abs, 'utf8') }
177
- } catch {
178
- return null
179
- }
180
- })
181
- .filter(Boolean)
132
+ const files = readFilesForFix(extractFilePaths(violationOutput), projectRoot)
182
133
 
183
134
  // 3. Будуємо prompt і викликаємо модель
184
135
  const prompt = buildPrompt(ruleId, ruleMdc, violationOutput, files)
@@ -188,7 +139,7 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
188
139
  if (!text) return { ok: false, error: 'model returned empty response' }
189
140
 
190
141
  // 4. Парсимо відповідь
191
- const parsed = parseResponse(text)
142
+ const parsed = parseChangesResponse(text)
192
143
  if (!parsed) return { ok: false, error: `cannot parse pi response: ${text.slice(0, 200)}` }
193
144
  if (parsed.error) return { ok: false, error: parsed.error }
194
145
 
@@ -196,15 +147,5 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
196
147
  if (changes.length === 0) return { ok: false, error: 'pi returned no changes' }
197
148
 
198
149
  // 5. Застосовуємо зміни
199
- for (const change of changes) {
200
- if (!change.path || typeof change.content !== 'string') continue
201
- const abs = join(projectRoot, change.path)
202
- try {
203
- writeFileSync(abs, change.content, 'utf8')
204
- } catch (error) {
205
- return { ok: false, error: `write ${change.path}: ${error.message}` }
206
- }
207
- }
208
-
209
- return { ok: true }
150
+ return applyChanges(changes, projectRoot)
210
151
  }