@nitra/cursor
Advanced tools
| /** | ||
| * cspell у ланцюжку lint-text із omlx-автофіксом (point 4 спеки). | ||
| * | ||
| * cspell не має нативного `--fix`. У fix-режимі: детект (захоплення виводу) → групування | ||
| * знахідок по файлах → per-file omlx-фікс справжніх одруків (`llmLintFix`) → re-detect. | ||
| * У read-only: лише детект (нуль мутацій). Валідні терміни omlx лишає — їх ловить повторний | ||
| * cspell (далі — у словник `@nitra/cspell-dict`). | ||
| */ | ||
| import { spawnSync } from 'node:child_process' | ||
| import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs' | ||
| import { llmLintFix } from '../../../scripts/lib/fix/llm-lint-fix.mjs' | ||
| /** Рядок cspell: `<file>:<line>:<col> - Unknown word (xxx)`. */ | ||
| const CSPELL_LINE_RE = /^(.+?):\d+:\d+\s+-\s+Unknown word/u | ||
| /** Максимум файлів під omlx-фікс за прогін (без тихого обрізання — логуємо надлишок). */ | ||
| const MAX_FIX_FILES = 25 | ||
| /** | ||
| * Запускає `cspell .` із захопленням виводу. | ||
| * @param {string} cwd корінь | ||
| * @param {string} bin шлях до cspell (npx/локальний) | ||
| * @returns {{ code:number, out:string }} код + обʼєднаний stdout/stderr | ||
| */ | ||
| function detectCspell(cwd, bin) { | ||
| const r = spawnSync(bin, ['cspell', '.'], { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, env: process.env }) | ||
| return { code: typeof r.status === 'number' ? r.status : 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` } | ||
| } | ||
| /** | ||
| * Групує cspell-знахідки за файлом. | ||
| * @param {string} out вивід cspell | ||
| * @returns {Map<string, string[]>} файл → рядки знахідок | ||
| */ | ||
| export function groupFindingsByFile(out) { | ||
| /** @type {Map<string, string[]>} */ | ||
| const byFile = new Map() | ||
| for (const line of out.split('\n')) { | ||
| const m = CSPELL_LINE_RE.exec(line.trim()) | ||
| if (!m) continue | ||
| const file = m[1] | ||
| if (!byFile.has(file)) byFile.set(file, []) | ||
| byFile.get(file).push(line.trim()) | ||
| } | ||
| return byFile | ||
| } | ||
| const CSPELL_INSTRUCTION = [ | ||
| 'Correct genuine spelling typos in the file(s).', | ||
| 'Each flagged "Unknown word" is listed below.', | ||
| 'ONLY fix obvious misspellings of real words.', | ||
| 'If a flagged token is a valid identifier, technical term, abbreviation, proper noun, URL,', | ||
| 'or an intentional non-English word, leave it UNCHANGED (it will be added to the dictionary).', | ||
| 'Preserve all code, formatting, and unrelated text exactly.' | ||
| ].join(' ') | ||
| /** | ||
| * cspell-крок lint-text з omlx-автофіксом. | ||
| * @param {string} [cwd] корінь | ||
| * @param {boolean} [readOnly] true → лише детект (нуль мутацій) | ||
| * @returns {number} 0 — чисто; 1 — лишились знахідки / помилка середовища | ||
| */ | ||
| export function runCspellText(cwd = process.cwd(), readOnly = false) { | ||
| const bin = resolveCmd('npx') | ||
| if (!bin) { | ||
| process.stderr.write('❌ npx не знайдено в PATH (cspell).\n') | ||
| return 1 | ||
| } | ||
| const first = detectCspell(cwd, bin) | ||
| if (first.code === 0) return 0 | ||
| if (readOnly) { | ||
| process.stdout.write(first.out) | ||
| return first.code | ||
| } | ||
| // Fix-режим: omlx по файлах зі справжніми одруками. | ||
| const byFile = groupFindingsByFile(first.out) | ||
| const files = [...byFile.keys()] | ||
| if (files.length === 0) { | ||
| process.stdout.write(first.out) | ||
| return first.code | ||
| } | ||
| const targets = files.slice(0, MAX_FIX_FILES) | ||
| if (files.length > MAX_FIX_FILES) { | ||
| process.stdout.write(`ℹ️ cspell: omlx-фікс перших ${MAX_FIX_FILES}/${files.length} файлів (решта — наступний прогін)\n`) | ||
| } | ||
| for (const file of targets) { | ||
| const res = llmLintFix({ | ||
| tool: 'cspell', | ||
| instruction: CSPELL_INSTRUCTION, | ||
| findings: byFile.get(file).join('\n'), | ||
| filePaths: [file], | ||
| projectRoot: cwd | ||
| }) | ||
| process.stdout.write(res.ok ? ` ⚡ cspell omlx-фікс: ${file}\n` : ` ⚠️ cspell omlx-фікс пропущено (${file}): ${res.error}\n`) | ||
| } | ||
| // Re-detect: що лишилось (валідні терміни → у словник). | ||
| const second = detectCspell(cwd, bin) | ||
| if (second.code !== 0) process.stdout.write(second.out) | ||
| return second.code | ||
| } |
| /** | ||
| * Спільне ядро LLM-фіксу: парс відповіді `{changes:[{path,content}]}`, читання файлів | ||
| * під фікс і застосування змін. Використовують і `llm-worker.mjs` (конформність), і | ||
| * `llm-lint-fix.mjs` (per-tool лінтер-фіксери) — щоб не дублювати парс/apply (knip/jscpd). | ||
| */ | ||
| import { existsSync, readFileSync, writeFileSync } from 'node:fs' | ||
| import { join } from 'node:path' | ||
| const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/ | ||
| /** | ||
| * Парсить JSON-відповідь моделі: прямий JSON → ```json-блок``` → перший `{…}`-блок. | ||
| * @param {string} text сирий текст відповіді | ||
| * @returns {{ changes?: Array<{path:string,content:string}>, error?: string } | null} патч або null | ||
| */ | ||
| export function parseChangesResponse(text) { | ||
| try { | ||
| return JSON.parse(text) | ||
| } catch { | ||
| /* fallthrough */ | ||
| } | ||
| const block = text.match(JSON_CODE_BLOCK_RE) | ||
| if (block) { | ||
| try { | ||
| return JSON.parse(block[1].trim()) | ||
| } catch { | ||
| /* fallthrough */ | ||
| } | ||
| } | ||
| const start = text.indexOf('{') | ||
| const end = text.lastIndexOf('}') | ||
| if (start !== -1 && end > start) { | ||
| try { | ||
| return JSON.parse(text.slice(start, end + 1)) | ||
| } catch { | ||
| /* fallthrough */ | ||
| } | ||
| } | ||
| return null | ||
| } | ||
| /** | ||
| * Читає існуючі файли за відносними шляхами у форму `{path, content}` (для prompt). | ||
| * @param {string[]} filePaths відносні шляхи від кореня | ||
| * @param {string} projectRoot абсолютний корінь | ||
| * @returns {Array<{path:string, content:string}>} наявні файли з вмістом | ||
| */ | ||
| export function readFilesForFix(filePaths, projectRoot) { | ||
| return filePaths | ||
| .map(p => { | ||
| const abs = join(projectRoot, p) | ||
| if (!existsSync(abs)) return null | ||
| try { | ||
| return { path: p, content: readFileSync(abs, 'utf8') } | ||
| } catch { | ||
| return null | ||
| } | ||
| }) | ||
| .filter(Boolean) | ||
| } | ||
| /** | ||
| * Застосовує `changes` до ФС (повний вміст файлу, не diff). | ||
| * @param {Array<{path:string, content:string}>} changes зміни | ||
| * @param {string} projectRoot абсолютний корінь | ||
| * @returns {{ ok: boolean, error?: string }} статус | ||
| */ | ||
| export function applyChanges(changes, projectRoot) { | ||
| for (const change of changes) { | ||
| if (!change.path || typeof change.content !== 'string') continue | ||
| try { | ||
| writeFileSync(join(projectRoot, change.path), change.content, 'utf8') | ||
| } catch (error) { | ||
| return { ok: false, error: `write ${change.path}: ${error.message}` } | ||
| } | ||
| } | ||
| return { ok: true } | ||
| } |
| /** | ||
| * Per-tool omlx-фікс лінтер-знахідок (point 4 спеки lint-orchestrator-fix-readonly). | ||
| * | ||
| * Для detect-only тулів без нативного `--fix` (cspell, knip, actionlint, v8r тощо): читає | ||
| * уражені файли, просить omlx виправити за tool-специфічною інструкцією, застосовує `{changes}`. | ||
| * Re-detect (перевірка, що знахідка закрита) — на стороні caller (convergence-патерн). | ||
| * | ||
| * Маршрут моделі — через `callLlm` за префіксом: `omlx/<model>` → локальний HTTP (дефолт | ||
| * `resolveModel('min')`); cloud — фолбек каскаду. Парс/застосування — спільне ядро `llm-fix-apply`. | ||
| */ | ||
| import { env } from 'node:process' | ||
| import { resolveModel } from '../../../lib/models.mjs' | ||
| import { callLlm } from '../../../lib/llm.mjs' | ||
| import { applyChanges, parseChangesResponse, readFilesForFix } from './llm-fix-apply.mjs' | ||
| /** Дефолтний локальний тир (omlx); env `N_CURSOR_FIX_MODEL` перекриває. */ | ||
| const DEFAULT_MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min') | ||
| /** | ||
| * Будує prompt для omlx: tool-інструкція + знахідки + повний вміст файлів. | ||
| * @param {string} tool назва тула (cspell/knip/…) | ||
| * @param {string} instruction що саме виправити (tool-специфічно) | ||
| * @param {string} findings сирий вивід тула (знахідки) | ||
| * @param {Array<{path:string, content:string}>} files файли під фікс | ||
| * @returns {string} prompt | ||
| */ | ||
| function buildLintFixPrompt(tool, instruction, findings, files) { | ||
| const filesBlock = files.map(f => `<file path="${f.path}">\n${f.content}\n</file>`).join('\n\n') | ||
| return [ | ||
| `You fix ${tool} lint findings. Return ONLY valid JSON — no explanation, no markdown.`, | ||
| ``, | ||
| `Task: ${instruction}`, | ||
| ``, | ||
| `${tool} findings:`, | ||
| findings, | ||
| ``, | ||
| `Current file contents:`, | ||
| filesBlock, | ||
| ``, | ||
| `Return JSON with this exact shape:`, | ||
| `{"changes":[{"path":"relative/path","content":"full corrected file content"}]}`, | ||
| ``, | ||
| `Rules:`, | ||
| `- "path" is relative to the project root (use the path from the <file> tag)`, | ||
| `- "content" is the COMPLETE new file content (not a diff)`, | ||
| `- Only include files that actually need to change; preserve everything unrelated verbatim`, | ||
| `- If nothing should be auto-fixed, return {"changes":[],"error":"reason"}` | ||
| ].join('\n') | ||
| } | ||
| /** | ||
| * Виправляє лінтер-знахідки через omlx і застосовує зміни. | ||
| * @param {{ tool:string, instruction:string, findings:string, filePaths:string[], projectRoot:string, model?:string }} opts параметри | ||
| * @returns {{ ok:boolean, error?:string, fixed:string[] }} статус + список змінених шляхів | ||
| */ | ||
| export function llmLintFix({ tool, instruction, findings, filePaths, projectRoot, model }) { | ||
| const m = model ?? DEFAULT_MODEL | ||
| const files = readFilesForFix(filePaths, projectRoot) | ||
| if (files.length === 0) return { ok: false, error: 'no readable files to fix', fixed: [] } | ||
| let text | ||
| try { | ||
| text = callLlm([{ role: 'user', content: buildLintFixPrompt(tool, instruction, findings, files) }], m, { | ||
| timeoutMs: 120_000, | ||
| caller: `lint:${tool}` | ||
| }) | ||
| } catch (error) { | ||
| return { ok: false, error: String(error.message), fixed: [] } | ||
| } | ||
| const parsed = parseChangesResponse(text) | ||
| if (!parsed) return { ok: false, error: `cannot parse omlx response: ${String(text).slice(0, 200)}`, fixed: [] } | ||
| if (parsed.error) return { ok: false, error: parsed.error, fixed: [] } | ||
| const changes = (parsed.changes ?? []).filter(c => c.path && typeof c.content === 'string') | ||
| if (changes.length === 0) return { ok: false, error: 'omlx returned no changes', fixed: [] } | ||
| const applied = applyChanges(changes, projectRoot) | ||
| if (!applied.ok) return { ok: false, error: applied.error, fixed: [] } | ||
| return { ok: true, fixed: changes.map(c => c.path) } | ||
| } |
+1
-1
| { | ||
| "name": "@nitra/cursor", | ||
| "version": "9.2.0", | ||
| "version": "9.3.0", | ||
| "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
@@ -27,2 +27,3 @@ /** | ||
| import { ensureTool } from '../../../scripts/lib/ensure-tool.mjs' | ||
| import { runCspellText } from './cspell-fix.mjs' | ||
| import { runDotenvLinter } from './run-dotenv-linter.mjs' | ||
@@ -110,3 +111,4 @@ import { runShellcheckText } from './run-shellcheck.mjs' | ||
| const cspellCode = runLintStep('cspell', 'npx', ['cspell', '.']) | ||
| console.log(`\n▶ cspell (${readOnly ? 'перевірка' : 'omlx-автофікс одруків + перевірка'})`) | ||
| const cspellCode = runCspellText(process.cwd(), readOnly) | ||
| if (cspellCode !== 0) return cspellCode | ||
@@ -113,0 +115,0 @@ |
| /** @see ./docs/llm-worker.md */ | ||
| import { existsSync, readFileSync, writeFileSync } from 'node:fs' | ||
| import { existsSync, readFileSync } from 'node:fs' | ||
| import { join } from 'node:path' | ||
@@ -8,2 +8,3 @@ import { env } from 'node:process' | ||
| import { callLlm } from '../../../lib/llm.mjs' | ||
| import { applyChanges, parseChangesResponse, readFilesForFix } from './llm-fix-apply.mjs' | ||
@@ -15,3 +16,2 @@ // Тир за замовчуванням: min → avg при ескалації (каскад local→cloud). | ||
| const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/ | ||
| const API_KEY_RE = /api key/i | ||
@@ -119,40 +119,2 @@ | ||
| /** | ||
| * Парсить JSON-відповідь від моделі. | ||
| * Модель може обгорнути JSON у ```json ... ```, тому пробуємо витягти. | ||
| * @param {string} text сирий текст відповіді | ||
| * @returns {{ changes: Array<{path:string,content:string}>, error?: string } | null} розпарсений патч або null | ||
| */ | ||
| function parseResponse(text) { | ||
| // Спроба 1: прямий JSON | ||
| try { | ||
| return JSON.parse(text) | ||
| } catch { | ||
| /* fallthrough */ | ||
| } | ||
| // Спроба 2: витягти з ```json ... ``` | ||
| const m = text.match(JSON_CODE_BLOCK_RE) | ||
| if (m) { | ||
| try { | ||
| return JSON.parse(m[1].trim()) | ||
| } catch { | ||
| /* fallthrough */ | ||
| } | ||
| } | ||
| // Спроба 3: перший { ... } блок | ||
| const start = text.indexOf('{') | ||
| const end = text.lastIndexOf('}') | ||
| if (start !== -1 && end > start) { | ||
| try { | ||
| return JSON.parse(text.slice(start, end + 1)) | ||
| } catch { | ||
| /* fallthrough */ | ||
| } | ||
| } | ||
| return null | ||
| } | ||
| /** | ||
| * LLM-worker: виправляє одне rule-порушення через pi (C1 pattern). | ||
@@ -173,14 +135,3 @@ * @param {string} ruleId ID правила | ||
| // 2. Витягуємо файли з violation output і читаємо їх | ||
| const filePaths = extractFilePaths(violationOutput) | ||
| const files = filePaths | ||
| .map(p => { | ||
| const abs = join(projectRoot, p) | ||
| if (!existsSync(abs)) return null | ||
| try { | ||
| return { path: p, content: readFileSync(abs, 'utf8') } | ||
| } catch { | ||
| return null | ||
| } | ||
| }) | ||
| .filter(Boolean) | ||
| const files = readFilesForFix(extractFilePaths(violationOutput), projectRoot) | ||
@@ -195,3 +146,3 @@ // 3. Будуємо prompt і викликаємо модель | ||
| // 4. Парсимо відповідь | ||
| const parsed = parseResponse(text) | ||
| const parsed = parseChangesResponse(text) | ||
| if (!parsed) return { ok: false, error: `cannot parse pi response: ${text.slice(0, 200)}` } | ||
@@ -204,13 +155,3 @@ if (parsed.error) return { ok: false, error: parsed.error } | ||
| // 5. Застосовуємо зміни | ||
| for (const change of changes) { | ||
| if (!change.path || typeof change.content !== 'string') continue | ||
| const abs = join(projectRoot, change.path) | ||
| try { | ||
| writeFileSync(abs, change.content, 'utf8') | ||
| } catch (error) { | ||
| return { ok: false, error: `write ${change.path}: ${error.message}` } | ||
| } | ||
| } | ||
| return { ok: true } | ||
| return applyChanges(changes, projectRoot) | ||
| } |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
4488752
0.23%808
0.37%36329
0.53%26
4%