@nitra/cursor
Advanced tools
| /** | ||
| * Wire-trace LLM-викликів: будує й пише багатий JSONL-запис на кожен виклик | ||
| * `callLlm` (див. `npm/lib/llm.mjs`). Захоплює **обидва канали** — reasoning | ||
| * (думки моделі) і спостережуваний слід (request/response/usage/latency/retry). | ||
| * | ||
| * Дизайн-спека: `docs/specs/2026-06-10-omlx-wire-trace-capture-design.md`. | ||
| * | ||
| * Двошарова модель: | ||
| * - RAW (цей модуль) → `<cwd>/.n-cursor/llm-trace.jsonl` (gitignored, локальний, | ||
| * недеструктивна ротація) — сирий потік, доживає до батч-агрегації. | ||
| * - AGGREGATE (друга спека) → `docs/omlx-insights/` (коммітиться в git, назавжди). | ||
| * | ||
| * Always-on: пишеться завжди. `N_CURSOR_LLM_TRACE=0|false|off|no` — kill-switch; | ||
| * будь-яке інше значення — override-шлях замість дефолтного. | ||
| */ | ||
| import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs' | ||
| import { createHash } from 'node:crypto' | ||
| import { dirname, join } from 'node:path' | ||
| import { cwd, env } from 'node:process' | ||
| /** Ліміт символів на одне `message.content` у записі (захист обсягу/чутливості). */ | ||
| export const MAX_MSG_CHARS = 8000 | ||
| /** Поріг недеструктивної ротації активного файлу (байти). */ | ||
| export const ROTATE_BYTES = 50 * 1024 * 1024 | ||
| /** Значення `N_CURSOR_LLM_TRACE`, що вимикають трасування повністю. */ | ||
| const KILL_VALUES = new Set(['0', 'false', 'off', 'no']) | ||
| /** | ||
| * Шлях активного trace-файлу або `null`, якщо трасування вимкнено kill-switch-ем. | ||
| * Пріоритет: `N_CURSOR_LLM_TRACE` (kill-switch → null; інакше явний шлях) → | ||
| * дефолт `<cwd>/.n-cursor/llm-trace.jsonl` (корінь споживацького проєкту). | ||
| * @returns {string|null} абсолютний/відносний шлях до .jsonl або null | ||
| */ | ||
| export function tracePath() { | ||
| const override = env.N_CURSOR_LLM_TRACE | ||
| if (override !== undefined) { | ||
| if (KILL_VALUES.has(override.toLowerCase())) return null | ||
| if (override) return override | ||
| } | ||
| return join(cwd(), '.n-cursor', 'llm-trace.jsonl') | ||
| } | ||
| /** | ||
| * Обрізає кожне `message.content` до `MAX_MSG_CHARS` і рахує sha256 повного | ||
| * (необрізаного) масиву для дедуплікації. | ||
| * @param {Array<{role:string, content:string}>} messages вихідні messages | ||
| * @returns {{ messages: Array<{role:string, content:string}>, messages_sha256: string, messages_truncated: boolean }} обрізані messages, hash і прапор обрізки | ||
| */ | ||
| export function capMessages(messages) { | ||
| const src = messages ?? [] | ||
| let truncated = false | ||
| const capped = src.map(m => { | ||
| const content = m?.content ?? '' | ||
| if (content.length > MAX_MSG_CHARS) { | ||
| truncated = true | ||
| return { role: m.role, content: content.slice(0, MAX_MSG_CHARS) } | ||
| } | ||
| return { role: m?.role, content } | ||
| }) | ||
| const messages_sha256 = createHash('sha256').update(JSON.stringify(src)).digest('hex') | ||
| return { messages: capped, messages_sha256, messages_truncated: truncated } | ||
| } | ||
| /** | ||
| * Будує нормалізований trace-запис. Поля, яких backend не дає (pi: reasoning/ | ||
| * usage/finish_reason), лишаються `null` за побудовою. | ||
| * @param {object} i вхід | ||
| * @param {string} i.ts ISO-час завершення виклику | ||
| * @param {string} i.caller хто викликав (doc-files|fix|coverage|unknown) | ||
| * @param {'omlx'|'pi'} i.backend бекенд | ||
| * @param {string} i.model model-id | ||
| * @param {number} [i.temperature] температура | ||
| * @param {number} [i.maxTokens] ліміт виходу | ||
| * @param {Array<{role:string, content:string}>} i.messages messages запиту | ||
| * @param {string|null} [i.content] відповідь | ||
| * @param {string|null} [i.reasoning] думки моделі | ||
| * @param {string|null} [i.reasoningSource] джерело reasoning | ||
| * @param {string|null} [i.finishReason] finish_reason | ||
| * @param {object|null} [i.usage] usage verbatim | ||
| * @param {number} i.ms latency | ||
| * @param {number|null} [i.attempts] кількість спроб | ||
| * @param {boolean} i.ok успіх | ||
| * @param {string|null} [i.error] текст помилки | ||
| * @returns {object} JSONL-готовий запис | ||
| */ | ||
| export function buildTraceRecord(i) { | ||
| const capped = capMessages(i.messages) | ||
| return { | ||
| ts: i.ts, | ||
| caller: i.caller, | ||
| backend: i.backend, | ||
| model: i.model, | ||
| temperature: i.temperature ?? null, | ||
| max_tokens: i.maxTokens ?? null, | ||
| messages: capped.messages, | ||
| messages_sha256: capped.messages_sha256, | ||
| messages_truncated: capped.messages_truncated, | ||
| content: i.content ?? null, | ||
| reasoning: i.reasoning ?? null, | ||
| reasoning_source: i.reasoningSource ?? null, | ||
| finish_reason: i.finishReason ?? null, | ||
| usage: i.usage ?? null, | ||
| ms: i.ms, | ||
| attempts: i.attempts ?? null, | ||
| ok: i.ok, | ||
| error: i.error ?? null | ||
| } | ||
| } | ||
| /** | ||
| * Імʼя архіву для ротації: `llm-trace.jsonl` → `llm-trace.<seq>.jsonl` | ||
| * (нестандартні імена без `.jsonl` → `<file>.<seq>`). | ||
| * @param {string} file активний trace-файл | ||
| * @param {number} seq порядковий номер архіву | ||
| * @returns {string} шлях архіву | ||
| */ | ||
| function archiveName(file, seq) { | ||
| return file.endsWith('.jsonl') ? `${file.slice(0, -'.jsonl'.length)}.${seq}.jsonl` : `${file}.${seq}` | ||
| } | ||
| /** | ||
| * Недеструктивна ротація: якщо активний файл перевищує `ROTATE_BYTES`, | ||
| * перейменовує його в перший вільний `llm-trace.<seq>.jsonl` (без перезапису | ||
| * наявних архівів). Відсутній файл / помилка stat — no-op. | ||
| * @param {string} file активний trace-файл | ||
| */ | ||
| export function rotateIfNeeded(file) { | ||
| let size | ||
| try { | ||
| size = statSync(file).size | ||
| } catch { | ||
| return // файлу ще нема — нічого ротувати | ||
| } | ||
| if (size <= ROTATE_BYTES) return | ||
| let seq = 1 | ||
| while (existsSync(archiveName(file, seq))) seq++ | ||
| renameSync(file, archiveName(file, seq)) | ||
| } | ||
| /** | ||
| * Fail-safe запис одного trace-рядка. Резолвить шлях (kill-switch → no-op), | ||
| * ротує за потреби, створює теку, append-ить JSONL. Будь-яка помилка IO | ||
| * ковтається — трасування **ніколи** не ламає основний виклик. | ||
| * @param {object} record запис від `buildTraceRecord` | ||
| */ | ||
| export function writeTrace(record) { | ||
| const file = tracePath() | ||
| if (!file) return | ||
| try { | ||
| rotateIfNeeded(file) | ||
| mkdirSync(dirname(file), { recursive: true }) | ||
| appendFileSync(file, JSON.stringify(record) + '\n') | ||
| } catch { | ||
| // трейс не має ламати основний виклик | ||
| } | ||
| } |
+60
-47
@@ -10,11 +10,12 @@ /** | ||
| * | ||
| * Wire-trace (ADR 260610-1516/1524): якщо виставлено `N_CURSOR_LLM_TRACE=<file>`, | ||
| * кожен виклик append-ить один JSONL-рядок з бекендом, моделлю, тривалістю і | ||
| * розмірами prompt/output. Трейс fail-safe: помилка запису не ламає виклик. | ||
| * Wire-trace (спека 2026-06-10-omlx-wire-trace-capture-design): **always-on** | ||
| * багатий JSONL-запис на кожен виклик — обидва канали (reasoning + слід). Для | ||
| * omlx захоплює content/reasoning/usage/finish_reason/attempts; для pi — лише | ||
| * те, що CLI дає (rich-поля null). Деталі запису/шляху/ротації — `omlx-trace.mjs`. | ||
| */ | ||
| import { spawnSync } from 'node:child_process' | ||
| import { appendFileSync } from 'node:fs' | ||
| import { env } from 'node:process' | ||
| import { callOmlx, isOmlxModel } from './omlx.mjs' | ||
| import { callOmlxRaw, isOmlxModel } from './omlx.mjs' | ||
| import { buildTraceRecord, writeTrace } from './omlx-trace.mjs' | ||
@@ -34,16 +35,2 @@ /** Дефолтний timeout одного виклику (узгоджено з LOCAL_TIMEOUT доки-конвеєра). */ | ||
| /** | ||
| * Fail-safe append JSONL-рядка трейсу у файл з `N_CURSOR_LLM_TRACE`. | ||
| * @param {object} entry один запис трейсу | ||
| */ | ||
| function trace(entry) { | ||
| const file = env.N_CURSOR_LLM_TRACE | ||
| if (!file) return | ||
| try { | ||
| appendFileSync(file, JSON.stringify(entry) + '\n') | ||
| } catch { | ||
| // трейс не має ламати основний виклик | ||
| } | ||
| } | ||
| /** | ||
| * Виклик через `pi` CLI: messages конкатенуються у plain prompt | ||
@@ -69,38 +56,64 @@ * (pi не приймає messages-масив), tools вимкнено. | ||
| /** | ||
| * Універсальний LLM-виклик з маршрутизацією за префіксом model-id. | ||
| * Універсальний LLM-виклик з маршрутизацією за префіксом model-id і always-on | ||
| * wire-trace (обидва канали). | ||
| * @param {Array<{role:string, content:string}>} messages OpenAI-style messages (system зберігається на omlx) | ||
| * @param {string} model model-id; `omlx/<m>` → прямий HTTP, інакше → pi CLI | ||
| * @param {{ timeoutMs?: number, temperature?: number, maxTokens?: number, url?: string }} [opts] timeout, температура, ліміт виходу, override URL | ||
| * @param {{ timeoutMs?: number, temperature?: number, maxTokens?: number, url?: string, caller?: string }} [opts] timeout, температура, ліміт виходу, override URL, мітка викликача для trace | ||
| * @returns {string} текст відповіді (непорожній на omlx; pi може повернути '') | ||
| */ | ||
| export function callLlm(messages, model, opts = {}) { | ||
| const { timeoutMs = DEFAULT_TIMEOUT_MS, temperature = 0.2, maxTokens, url } = opts | ||
| const { timeoutMs = DEFAULT_TIMEOUT_MS, temperature = 0.2, maxTokens, url, caller } = opts | ||
| const backend = pickBackend(model) | ||
| const resolvedCaller = caller ?? env.N_CURSOR_TRACE_CALLER ?? 'unknown' | ||
| const t0 = Date.now() | ||
| const promptChars = messages.reduce((n, m) => n + (m.content?.length ?? 0), 0) | ||
| try { | ||
| const out = | ||
| backend === 'omlx' | ||
| ? callOmlx(messages, model, { url, timeoutMs, temperature, ...(maxTokens ? { maxTokens } : {}) }) | ||
| : callPi(messages, model, timeoutMs) | ||
| trace({ | ||
| ts: new Date().toISOString(), | ||
| backend, | ||
| model, | ||
| ms: Date.now() - t0, | ||
| promptChars, | ||
| outChars: out.length, | ||
| ok: true | ||
| }) | ||
| return out | ||
| let content | ||
| let reasoning = null | ||
| let reasoningSource = null | ||
| let finishReason = null | ||
| let usage = null | ||
| let attempts = 1 | ||
| if (backend === 'omlx') { | ||
| const raw = callOmlxRaw(messages, model, { url, timeoutMs, temperature, ...(maxTokens ? { maxTokens } : {}) }) | ||
| ;({ content, reasoning, reasoningSource, finishReason, usage, attempts } = raw) | ||
| } else { | ||
| content = callPi(messages, model, timeoutMs) | ||
| } | ||
| writeTrace( | ||
| buildTraceRecord({ | ||
| ts: new Date().toISOString(), | ||
| caller: resolvedCaller, | ||
| backend, | ||
| model, | ||
| temperature, | ||
| maxTokens, | ||
| messages, | ||
| content, | ||
| reasoning, | ||
| reasoningSource, | ||
| finishReason, | ||
| usage, | ||
| ms: Date.now() - t0, | ||
| attempts, | ||
| ok: true, | ||
| error: null | ||
| }) | ||
| ) | ||
| return content | ||
| } catch (error) { | ||
| trace({ | ||
| ts: new Date().toISOString(), | ||
| backend, | ||
| model, | ||
| ms: Date.now() - t0, | ||
| promptChars, | ||
| ok: false, | ||
| error: String(error.message).slice(0, 200) | ||
| }) | ||
| writeTrace( | ||
| buildTraceRecord({ | ||
| ts: new Date().toISOString(), | ||
| caller: resolvedCaller, | ||
| backend, | ||
| model, | ||
| temperature, | ||
| maxTokens, | ||
| messages, | ||
| ms: Date.now() - t0, | ||
| attempts: null, | ||
| ok: false, | ||
| error: String(error.message).slice(0, 200) | ||
| }) | ||
| ) | ||
| throw error | ||
@@ -130,3 +143,3 @@ } | ||
| try { | ||
| callOmlx([{ role: 'user', content: 'ok' }], model, { url, timeoutMs, maxTokens: 1, temperature: 0 }) | ||
| callOmlxRaw([{ role: 'user', content: 'ok' }], model, { url, timeoutMs, maxTokens: 1, temperature: 0 }) | ||
| return { ok: true, reason: null, detail: '' } | ||
@@ -133,0 +146,0 @@ } catch (error) { |
+49
-11
@@ -73,12 +73,38 @@ /** | ||
| /** | ||
| * Прямий HTTP-виклик до omlx через `curl` (spawnSync). Повертає текст | ||
| * `choices[0].message.content`. Ретраїть лише transient curl-помилки | ||
| * (18 = transfer closed, 52 = empty reply, 56 = recv failure). | ||
| * Витягує reasoning (думки моделі) з omlx-`message`. Джерела за пріоритетом: | ||
| * - `field` — окреме поле `message.reasoning_content` (Qwen3-Thinking тощо); | ||
| * - `think_tag` — `<think>…</think>` усередині `content` (інші thinking-моделі); | ||
| * - `truncated` — `finish_reason: "length"` зрізав думку в `content` до закриття | ||
| * тега → сирий reasoning лишився в `content` без `</think>`; | ||
| * - `null` — reasoning немає (не-thinking модель). | ||
| * @param {{content?:string, reasoning_content?:string}} message обʼєкт `choices[0].message` | ||
| * @param {string|null} finishReason `choices[0].finish_reason` | ||
| * @returns {{ reasoning: string|null, reasoningSource: 'field'|'think_tag'|'truncated'|null }} текст думок і його джерело | ||
| */ | ||
| const THINK_TAG_RE = /<think>([\s\S]*?)<\/think>/ | ||
| /** | ||
| * | ||
| */ | ||
| export function extractReasoning(message, finishReason) { | ||
| const field = message?.reasoning_content | ||
| if (field && field.trim()) return { reasoning: field, reasoningSource: 'field' } | ||
| const content = message?.content ?? '' | ||
| const m = content.match(THINK_TAG_RE) | ||
| if (m) return { reasoning: m[1].trim(), reasoningSource: 'think_tag' } | ||
| if (finishReason === 'length' && content.trim()) return { reasoning: content, reasoningSource: 'truncated' } | ||
| return { reasoning: null, reasoningSource: null } | ||
| } | ||
| /** | ||
| * Ядро прямого HTTP-виклику до omlx через `curl` (spawnSync). Повертає **багатий** | ||
| * обʼєкт: контент + reasoning + usage + finish_reason + кількість спроб. Ретраїть | ||
| * лише transient curl-помилки (18 = transfer closed, 52 = empty reply, 56 = recv failure). | ||
| * @param {Array<{role:string, content:string}>} messages OpenAI-messages (system+user збережено) | ||
| * @param {string} model model-id (з/без `omlx/`-префікса); порожній → дефолт | ||
| * @param {{ url?: string, timeoutMs?: number, temperature?: number, maxTokens?: number, fallbackModel?: string, apiKey?: string }} [opts] URL, timeout, температура, ліміт виходу, fallback-модель, API-ключ | ||
| * @returns {string} непорожній контент відповіді | ||
| * @returns {{ content:string, reasoning:string|null, reasoningSource:string|null, finishReason:string|null, usage:object|null, attempts:number }} багатий результат виклику | ||
| * @throws на curl-помилці, не-200 exit, поганому JSON чи порожньому контенті | ||
| */ | ||
| export function callOmlx(messages, model, opts = {}) { | ||
| export function callOmlxRaw(messages, model, opts = {}) { | ||
| const { | ||
@@ -140,10 +166,22 @@ url = env.N_CURSOR_OMLX_URL ?? DEFAULT_OMLX_URL, | ||
| if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`) | ||
| const content = j.choices?.[0]?.message?.content?.trim() ?? '' | ||
| if (!content) { | ||
| const finish = j.choices?.[0]?.finish_reason | ||
| throw new Error(`omlx empty content (finish=${finish})`) | ||
| } | ||
| return content | ||
| const message = j.choices?.[0]?.message ?? {} | ||
| const finishReason = j.choices?.[0]?.finish_reason ?? null | ||
| const content = message.content?.trim() ?? '' | ||
| if (!content) throw new Error(`omlx empty content (finish=${finishReason})`) | ||
| const { reasoning, reasoningSource } = extractReasoning(message, finishReason) | ||
| return { content, reasoning, reasoningSource, finishReason, usage: j.usage ?? null, attempts: attempt } | ||
| } | ||
| throw lastErr ?? new Error('omlx unknown failure') | ||
| } | ||
| /** | ||
| * Тонка обгортка над `callOmlxRaw` для споживачів, яким потрібен лише текст. | ||
| * Контракт незмінний: повертає непорожній `choices[0].message.content`. | ||
| * @param {Array<{role:string, content:string}>} messages OpenAI-messages | ||
| * @param {string} model model-id (з/без `omlx/`-префікса) | ||
| * @param {object} [opts] ті самі опції, що й у `callOmlxRaw` | ||
| * @returns {string} непорожній контент відповіді | ||
| */ | ||
| export function callOmlx(messages, model, opts = {}) { | ||
| return callOmlxRaw(messages, model, opts).content | ||
| } |
+1
-1
| { | ||
| "name": "@nitra/cursor", | ||
| "version": "5.2.1", | ||
| "version": "5.3.0", | ||
| "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
@@ -8,2 +8,14 @@ /** @see ./docs/tooling.md */ | ||
| // Зовнішні файли конфігу stylelint, які підхоплює cosmiconfig. Канон нових | ||
| // JS-конфігів — `.mjs`/`.cjs` (js-lint.mdc), legacy `.js` лишається валідним. | ||
| const STYLELINT_CONFIG_FILES = [ | ||
| '.stylelintrc.json', | ||
| '.stylelintrc.js', | ||
| '.stylelintrc.cjs', | ||
| '.stylelintrc.mjs', | ||
| 'stylelint.config.js', | ||
| 'stylelint.config.cjs', | ||
| 'stylelint.config.mjs' | ||
| ] | ||
| /** | ||
@@ -22,6 +34,3 @@ * Альтернатива полю `stylelint` у `package.json` — зовнішній файл конфігу. Якщо | ||
| const hasField = pkg.stylelint && typeof pkg.stylelint === 'object' | ||
| const hasExternalCfg = | ||
| existsSync(join(cwd, '.stylelintrc.json')) || | ||
| existsSync(join(cwd, '.stylelintrc.js')) || | ||
| existsSync(join(cwd, 'stylelint.config.js')) | ||
| const hasExternalCfg = STYLELINT_CONFIG_FILES.some(name => existsSync(join(cwd, name))) | ||
| if (hasField || hasExternalCfg) { | ||
@@ -28,0 +37,0 @@ pass('Конфіг stylelint є — у package.json або окремим файлом') |
| /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */ | ||
| export default { | ||
| testRunner: 'vitest', | ||
| vitest: { configFile: 'vitest.config.js' }, | ||
| vitest: { configFile: 'vitest.config.mjs' }, | ||
| // perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст | ||
@@ -6,0 +6,0 @@ // швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант). |
| /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */ | ||
| export default { | ||
| testRunner: 'vitest', | ||
| vitest: { configFile: 'vitest.config.js' }, | ||
| vitest: { configFile: 'vitest.config.mjs' }, | ||
| // perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст | ||
@@ -6,0 +6,0 @@ // швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант). |
@@ -21,2 +21,20 @@ /** @see ./docs/stryker_config.md */ | ||
| // Канонічна назва vitest-конфіга — `.mjs` (нові файли, js-lint.mdc); legacy | ||
| // `.js` лишається валідним. Перший знайдений виграє (.mjs пріоритетніший). | ||
| const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js'] | ||
| // Заміна literal `configFile` у скопійованому stryker-baseline на фактичне | ||
| // ім'я vitest-конфіга jsRoot-а (узгодження Stryker ↔ vitest). | ||
| const STRYKER_CONFIG_FILE_RE = /configFile: 'vitest\.config\.[cm]?js'/u | ||
| /** | ||
| * Визначає ім'я vitest-конфіга для jsRoot: існуючий `.mjs`/`.js` (якщо є), | ||
| * інакше дефолт `vitest.config.mjs` (нові файли — `.mjs`). Існуючий | ||
| * `vitest.config.js` лишається валідним (backward-compat), новий не плодиться. | ||
| * @param {string} jsRoot абсолютний шлях до workspace-каталогу | ||
| * @returns {string} ім'я vitest-конфіга | ||
| */ | ||
| function resolveVitestConfigName(jsRoot) { | ||
| return VITEST_CONFIG_NAMES.find(name => existsSync(join(jsRoot, name))) ?? 'vitest.config.mjs' | ||
| } | ||
| // Канонічні entries, які vue-варіант baseline тримає у `plugins`/`ignorers`. | ||
@@ -68,6 +86,7 @@ // Augment-крок (augmentVueStrykerConfig) дбає, щоб саме вони були присутні в | ||
| * @param {string} target абсолютний шлях, куди копіювати | ||
| * @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.js") | ||
| * @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.mjs") | ||
| * @param {(content: string) => string} [transform] опційне перетворення тексту baseline перед записом | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function ensureBaselineFile(reporter, cwd, baselinePath, target, label) { | ||
| async function ensureBaselineFile(reporter, cwd, baselinePath, target, label, transform) { | ||
| if (existsSync(target)) { | ||
@@ -77,3 +96,7 @@ reporter.pass(`${label} існує (${relative(cwd, target)})`) | ||
| } | ||
| await copyFile(baselinePath, target) | ||
| if (transform) { | ||
| await writeFile(target, transform(await readFile(baselinePath, 'utf8')), 'utf8') | ||
| } else { | ||
| await copyFile(baselinePath, target) | ||
| } | ||
| reporter.pass(`${label} створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`) | ||
@@ -347,3 +370,8 @@ } | ||
| const strykerBaseline = isVueRoot ? STRYKER_VUE_BASELINE_PATH : STRYKER_BASELINE_PATH | ||
| await ensureBaselineFile(reporter, cwd, strykerBaseline, strykerTarget, 'stryker.config.mjs') | ||
| // configFile у новоствореному baseline має вказувати на фактичний vitest-конфіг | ||
| // jsRoot-а (existing `.js`/`.mjs` або дефолтний `.mjs`). | ||
| const vitestName = resolveVitestConfigName(jsRoot) | ||
| await ensureBaselineFile(reporter, cwd, strykerBaseline, strykerTarget, 'stryker.config.mjs', content => | ||
| content.replace(STRYKER_CONFIG_FILE_RE, `configFile: '${vitestName}'`) | ||
| ) | ||
| if (isVueRoot) { | ||
@@ -361,3 +389,3 @@ if (!wasMissing) { | ||
| } | ||
| await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot, 'vitest.config.js'), 'vitest.config.js') | ||
| await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot, vitestName), vitestName) | ||
| } | ||
@@ -364,0 +392,0 @@ |
@@ -11,4 +11,8 @@ /** @see ./docs/vitest-config-pool-forks.md */ | ||
| // Канонічна назва — `.mjs` (нові файли, js-lint.mdc), але legacy `.js` лишається | ||
| // валідним. Перший знайдений виграє: `.mjs` пріоритетніший. | ||
| const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js'] | ||
| /** | ||
| * Перевіряє, що `vitest.config.js` (якщо існує) містить `pool: 'forks'`. | ||
| * Перевіряє, що `vitest.config.{mjs,js}` (якщо існує) містить `pool: 'forks'`. | ||
| * @param {string} [cwdParam] корінь репозиторію | ||
@@ -21,14 +25,14 @@ * @returns {Promise<number>} 0 — OK або skip, 1 — config без `pool: 'forks'` | ||
| const configPath = join(cwdParam, 'vitest.config.js') | ||
| if (!existsSync(configPath)) { | ||
| pass('vitest.config.js відсутній — pool-перевірку пропущено') | ||
| const configName = VITEST_CONFIG_NAMES.find(name => existsSync(join(cwdParam, name))) | ||
| if (!configName) { | ||
| pass('vitest.config.mjs/.js відсутній — pool-перевірку пропущено') | ||
| return reporter.getExitCode() | ||
| } | ||
| const body = await readFile(configPath, 'utf8') | ||
| const body = await readFile(join(cwdParam, configName), 'utf8') | ||
| if (POOL_FORKS_RE.test(body)) { | ||
| pass("vitest.config.js містить pool: 'forks' (test.mdc)") | ||
| pass(`${configName} містить pool: 'forks' (test.mdc)`) | ||
| } else { | ||
| fail( | ||
| "vitest.config.js має містити pool: 'forks' — defense-in-depth для race у process.cwd() між паралельними test files (test.mdc)" | ||
| `${configName} має містити pool: 'forks' — defense-in-depth для race у process.cwd() між паралельними test files (test.mdc)` | ||
| ) | ||
@@ -35,0 +39,0 @@ } |
@@ -14,7 +14,6 @@ /** | ||
| */ | ||
| import { spawnSync } from 'node:child_process' | ||
| import { join } from 'node:path' | ||
| import { CLOUD_MIN, resolveModel } from '../../lib/models.mjs' | ||
| import { callOmlx, isOmlxModel } from '../../lib/omlx.mjs' | ||
| import { callLlm } from '../../lib/llm.mjs' | ||
| import { deriveCacheKey, readCache, writeCache } from './cache.mjs' | ||
@@ -31,4 +30,3 @@ import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs' | ||
| /** | ||
| * Викликає LLM за model-id і повертає raw текст відповіді. | ||
| * `omlx/...` → прямий HTTP до omlx (text-only); решта → pi CLI. | ||
| * Викликає LLM через спільний `callLlm` (маршрут за префіксом model-id; wire-trace). | ||
| * @param {string} prompt текст промпта | ||
@@ -40,13 +38,3 @@ * @param {string} model provider/model-id, `omlx/...` або '' для pi-дефолту | ||
| function callModel(prompt, model) { | ||
| if (isOmlxModel(model)) { | ||
| return callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000 }) | ||
| } | ||
| const modelArgs = model ? ['--model', model] : [] | ||
| const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], { | ||
| encoding: 'utf8', | ||
| timeout: 60_000 | ||
| }) | ||
| if (r.error) throw new Error(`pi error: ${r.error.message}`) | ||
| if (r.status !== 0) throw new Error(`pi exit ${r.status}: ${r.stderr?.slice(0, 200) ?? ''}`) | ||
| return r.stdout?.trim() ?? '' | ||
| return callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000, caller: 'coverage' }) | ||
| } | ||
@@ -53,0 +41,0 @@ |
@@ -5,6 +5,5 @@ /** @see ./docs/llm-worker.md */ | ||
| import { join } from 'node:path' | ||
| import { spawnSync } from 'node:child_process' | ||
| import { env } from 'node:process' | ||
| import { resolveModel } from '../../../lib/models.mjs' | ||
| import { callOmlx, isOmlxModel } from '../../../lib/omlx.mjs' | ||
| import { callLlm } from '../../../lib/llm.mjs' | ||
@@ -17,2 +16,3 @@ // Тир за замовчуванням: min → avg при ескалації (каскад local→cloud). | ||
| const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/ | ||
| const API_KEY_RE = /api key/i | ||
@@ -92,4 +92,4 @@ /** | ||
| /** | ||
| * Викликає LLM за model-id і повертає текст відповіді. | ||
| * `omlx/...` → прямий HTTP до omlx (text-only, локально); решта → pi CLI. | ||
| * Викликає LLM через спільний `callLlm` (маршрут за префіксом model-id; wire-trace). | ||
| * Зберігає дружнє повідомлення про відсутній API-ключ для хмарних провайдерів. | ||
| * @param {string} prompt текст промпта | ||
@@ -100,18 +100,7 @@ * @param {string} model назва моделі (provider/id, `omlx/...` або '') | ||
| function callModel(prompt, model) { | ||
| if (isOmlxModel(model)) { | ||
| try { | ||
| return { text: callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 120_000 }) } | ||
| } catch (error) { | ||
| return { text: '', error: error.message } | ||
| } | ||
| } | ||
| const modelArgs = model ? ['--model', model] : [] | ||
| const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], { | ||
| encoding: 'utf8', | ||
| timeout: 120_000 | ||
| }) | ||
| if (r.error) return { text: '', error: r.error.message } | ||
| if (r.status !== 0) { | ||
| const stderr = r.stderr?.slice(0, 300) ?? '' | ||
| if (stderr.toLowerCase().includes('no api key') || stderr.toLowerCase().includes('api key')) { | ||
| try { | ||
| return { text: callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 120_000, caller: 'fix' }) } | ||
| } catch (error) { | ||
| const msg = String(error.message) | ||
| if (API_KEY_RE.test(msg)) { | ||
| const provider = model ? model.split('/')[0] : 'дефолтного провайдера' | ||
@@ -127,5 +116,4 @@ return { | ||
| } | ||
| return { text: '', error: `pi exit ${r.status}: ${stderr}` } | ||
| return { text: '', error: msg } | ||
| } | ||
| return { text: r.stdout?.trim() ?? '' } | ||
| } | ||
@@ -132,0 +120,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance 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 1 instance 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
5378493
0.28%782
0.13%35019
0.61%