@nitra/cursor 5.2.1 → 5.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 +7 -0
- package/lib/llm.mjs +60 -47
- package/lib/omlx-trace.mjs +158 -0
- package/lib/omlx.mjs +49 -11
- package/package.json +1 -1
- package/rules/js-bun-db/js-bun-db.mdc +7 -7
- package/rules/js-lint/js-lint.mdc +14 -1
- package/rules/js-run/js-run.mdc +16 -16
- package/rules/style-lint/js/tooling.mjs +13 -4
- package/rules/style-lint/style-lint.mdc +1 -1
- package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +1 -1
- package/rules/test/js/data/stryker_config/stryker.config.vue.baseline.mjs +1 -1
- package/rules/test/js/stryker_config.mjs +33 -5
- package/rules/test/js/vitest-config-pool-forks.mjs +11 -7
- package/rules/test/test.mdc +9 -9
- package/rules/vue/vue.mdc +6 -6
- package/scripts/coverage-classify/index.mjs +3 -15
- package/skills/fix/js/llm-worker.mjs +10 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.3.0] - 2026-06-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- js-lint: нові JS-файли створюються з явним розширенням .mjs/.cjs (не .js); приклади нового вихідного коду в js-run/js-bun-db/vue переведено на .mjs. test: підтримка vitest.config.mjs — pool-forks і stryker_config приймають .mjs/.js (новий канон .mjs, legacy .js не дублюється), stryker.configFile приводиться до фактичного імені. style-lint: чекер розпізнає stylelint.config.mjs/.cjs та .stylelintrc.mjs/.cjs.
|
|
8
|
+
- llm wire-trace: always-on багатий JSONL-запис на кожен `callLlm` (обидва канали — reasoning + слід) у `<cwd>/.n-cursor/llm-trace.jsonl` (gitignored, недеструктивна ротація 50 MB, kill-switch `N_CURSOR_LLM_TRACE=0`); `callOmlxRaw` дістає reasoning_content/usage/finish_reason/attempts (`callOmlx` лишається `string`-обгорткою); `fix`+`coverage-classify` мігровано з прямого `callOmlx`/`pi`-spawn на спільний `callLlm` (caller-мітка). Спека: docs/specs/2026-06-10-omlx-wire-trace-capture-design.md
|
|
9
|
+
|
|
3
10
|
## [5.2.1] - 2026-06-11
|
|
4
11
|
|
|
5
12
|
### Changed
|
package/lib/llm.mjs
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Жодних env-перемикачів бекенда: рядок моделі сам визначає транспорт.
|
|
9
9
|
*
|
|
10
|
-
* Wire-trace (
|
|
11
|
-
* кожен виклик
|
|
12
|
-
*
|
|
10
|
+
* Wire-trace (спека 2026-06-10-omlx-wire-trace-capture-design): **always-on**
|
|
11
|
+
* багатий JSONL-запис на кожен виклик — обидва канали (reasoning + слід). Для
|
|
12
|
+
* omlx захоплює content/reasoning/usage/finish_reason/attempts; для pi — лише
|
|
13
|
+
* те, що CLI дає (rich-поля null). Деталі запису/шляху/ротації — `omlx-trace.mjs`.
|
|
13
14
|
*/
|
|
14
15
|
import { spawnSync } from 'node:child_process'
|
|
15
|
-
import { appendFileSync } from 'node:fs'
|
|
16
16
|
import { env } from 'node:process'
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { callOmlxRaw, isOmlxModel } from './omlx.mjs'
|
|
19
|
+
import { buildTraceRecord, writeTrace } from './omlx-trace.mjs'
|
|
19
20
|
|
|
20
21
|
/** Дефолтний timeout одного виклику (узгоджено з LOCAL_TIMEOUT доки-конвеєра). */
|
|
21
22
|
const DEFAULT_TIMEOUT_MS = 120_000
|
|
@@ -29,20 +30,6 @@ export function pickBackend(model) {
|
|
|
29
30
|
return isOmlxModel(model) ? 'omlx' : 'pi'
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
* Fail-safe append JSONL-рядка трейсу у файл з `N_CURSOR_LLM_TRACE`.
|
|
34
|
-
* @param {object} entry один запис трейсу
|
|
35
|
-
*/
|
|
36
|
-
function trace(entry) {
|
|
37
|
-
const file = env.N_CURSOR_LLM_TRACE
|
|
38
|
-
if (!file) return
|
|
39
|
-
try {
|
|
40
|
-
appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
41
|
-
} catch {
|
|
42
|
-
// трейс не має ламати основний виклик
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
33
|
/**
|
|
47
34
|
* Виклик через `pi` CLI: messages конкатенуються у plain prompt
|
|
48
35
|
* (pi не приймає messages-масив), tools вимкнено.
|
|
@@ -64,42 +51,68 @@ function callPi(messages, model, timeoutMs) {
|
|
|
64
51
|
}
|
|
65
52
|
|
|
66
53
|
/**
|
|
67
|
-
* Універсальний LLM-виклик з маршрутизацією за префіксом model-id
|
|
54
|
+
* Універсальний LLM-виклик з маршрутизацією за префіксом model-id і always-on
|
|
55
|
+
* wire-trace (обидва канали).
|
|
68
56
|
* @param {Array<{role:string, content:string}>} messages OpenAI-style messages (system зберігається на omlx)
|
|
69
57
|
* @param {string} model model-id; `omlx/<m>` → прямий HTTP, інакше → pi CLI
|
|
70
|
-
* @param {{ timeoutMs?: number, temperature?: number, maxTokens?: number, url?: string }} [opts] timeout, температура, ліміт виходу, override URL
|
|
58
|
+
* @param {{ timeoutMs?: number, temperature?: number, maxTokens?: number, url?: string, caller?: string }} [opts] timeout, температура, ліміт виходу, override URL, мітка викликача для trace
|
|
71
59
|
* @returns {string} текст відповіді (непорожній на omlx; pi може повернути '')
|
|
72
60
|
*/
|
|
73
61
|
export function callLlm(messages, model, opts = {}) {
|
|
74
|
-
const { timeoutMs = DEFAULT_TIMEOUT_MS, temperature = 0.2, maxTokens, url } = opts
|
|
62
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, temperature = 0.2, maxTokens, url, caller } = opts
|
|
75
63
|
const backend = pickBackend(model)
|
|
64
|
+
const resolvedCaller = caller ?? env.N_CURSOR_TRACE_CALLER ?? 'unknown'
|
|
76
65
|
const t0 = Date.now()
|
|
77
|
-
const promptChars = messages.reduce((n, m) => n + (m.content?.length ?? 0), 0)
|
|
78
66
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
model,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
let content
|
|
68
|
+
let reasoning = null
|
|
69
|
+
let reasoningSource = null
|
|
70
|
+
let finishReason = null
|
|
71
|
+
let usage = null
|
|
72
|
+
let attempts = 1
|
|
73
|
+
if (backend === 'omlx') {
|
|
74
|
+
const raw = callOmlxRaw(messages, model, { url, timeoutMs, temperature, ...(maxTokens ? { maxTokens } : {}) })
|
|
75
|
+
;({ content, reasoning, reasoningSource, finishReason, usage, attempts } = raw)
|
|
76
|
+
} else {
|
|
77
|
+
content = callPi(messages, model, timeoutMs)
|
|
78
|
+
}
|
|
79
|
+
writeTrace(
|
|
80
|
+
buildTraceRecord({
|
|
81
|
+
ts: new Date().toISOString(),
|
|
82
|
+
caller: resolvedCaller,
|
|
83
|
+
backend,
|
|
84
|
+
model,
|
|
85
|
+
temperature,
|
|
86
|
+
maxTokens,
|
|
87
|
+
messages,
|
|
88
|
+
content,
|
|
89
|
+
reasoning,
|
|
90
|
+
reasoningSource,
|
|
91
|
+
finishReason,
|
|
92
|
+
usage,
|
|
93
|
+
ms: Date.now() - t0,
|
|
94
|
+
attempts,
|
|
95
|
+
ok: true,
|
|
96
|
+
error: null
|
|
97
|
+
})
|
|
98
|
+
)
|
|
99
|
+
return content
|
|
93
100
|
} catch (error) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
writeTrace(
|
|
102
|
+
buildTraceRecord({
|
|
103
|
+
ts: new Date().toISOString(),
|
|
104
|
+
caller: resolvedCaller,
|
|
105
|
+
backend,
|
|
106
|
+
model,
|
|
107
|
+
temperature,
|
|
108
|
+
maxTokens,
|
|
109
|
+
messages,
|
|
110
|
+
ms: Date.now() - t0,
|
|
111
|
+
attempts: null,
|
|
112
|
+
ok: false,
|
|
113
|
+
error: String(error.message).slice(0, 200)
|
|
114
|
+
})
|
|
115
|
+
)
|
|
103
116
|
throw error
|
|
104
117
|
}
|
|
105
118
|
}
|
|
@@ -124,7 +137,7 @@ const AUTH_ERROR_MARKER = 'authentication_error'
|
|
|
124
137
|
export function omlxHealthCheck(opts = {}) {
|
|
125
138
|
const { url, model = '', timeoutMs = DEFAULT_TIMEOUT_MS } = opts
|
|
126
139
|
try {
|
|
127
|
-
|
|
140
|
+
callOmlxRaw([{ role: 'user', content: 'ok' }], model, { url, timeoutMs, maxTokens: 1, temperature: 0 })
|
|
128
141
|
return { ok: true, reason: null, detail: '' }
|
|
129
142
|
} catch (error) {
|
|
130
143
|
const detail = String(error.message)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-trace LLM-викликів: будує й пише багатий JSONL-запис на кожен виклик
|
|
3
|
+
* `callLlm` (див. `npm/lib/llm.mjs`). Захоплює **обидва канали** — reasoning
|
|
4
|
+
* (думки моделі) і спостережуваний слід (request/response/usage/latency/retry).
|
|
5
|
+
*
|
|
6
|
+
* Дизайн-спека: `docs/specs/2026-06-10-omlx-wire-trace-capture-design.md`.
|
|
7
|
+
*
|
|
8
|
+
* Двошарова модель:
|
|
9
|
+
* - RAW (цей модуль) → `<cwd>/.n-cursor/llm-trace.jsonl` (gitignored, локальний,
|
|
10
|
+
* недеструктивна ротація) — сирий потік, доживає до батч-агрегації.
|
|
11
|
+
* - AGGREGATE (друга спека) → `docs/omlx-insights/` (коммітиться в git, назавжди).
|
|
12
|
+
*
|
|
13
|
+
* Always-on: пишеться завжди. `N_CURSOR_LLM_TRACE=0|false|off|no` — kill-switch;
|
|
14
|
+
* будь-яке інше значення — override-шлях замість дефолтного.
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs'
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { cwd, env } from 'node:process'
|
|
20
|
+
|
|
21
|
+
/** Ліміт символів на одне `message.content` у записі (захист обсягу/чутливості). */
|
|
22
|
+
export const MAX_MSG_CHARS = 8000
|
|
23
|
+
|
|
24
|
+
/** Поріг недеструктивної ротації активного файлу (байти). */
|
|
25
|
+
export const ROTATE_BYTES = 50 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
/** Значення `N_CURSOR_LLM_TRACE`, що вимикають трасування повністю. */
|
|
28
|
+
const KILL_VALUES = new Set(['0', 'false', 'off', 'no'])
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Шлях активного trace-файлу або `null`, якщо трасування вимкнено kill-switch-ем.
|
|
32
|
+
* Пріоритет: `N_CURSOR_LLM_TRACE` (kill-switch → null; інакше явний шлях) →
|
|
33
|
+
* дефолт `<cwd>/.n-cursor/llm-trace.jsonl` (корінь споживацького проєкту).
|
|
34
|
+
* @returns {string|null} абсолютний/відносний шлях до .jsonl або null
|
|
35
|
+
*/
|
|
36
|
+
export function tracePath() {
|
|
37
|
+
const override = env.N_CURSOR_LLM_TRACE
|
|
38
|
+
if (override !== undefined) {
|
|
39
|
+
if (KILL_VALUES.has(override.toLowerCase())) return null
|
|
40
|
+
if (override) return override
|
|
41
|
+
}
|
|
42
|
+
return join(cwd(), '.n-cursor', 'llm-trace.jsonl')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Обрізає кожне `message.content` до `MAX_MSG_CHARS` і рахує sha256 повного
|
|
47
|
+
* (необрізаного) масиву для дедуплікації.
|
|
48
|
+
* @param {Array<{role:string, content:string}>} messages вихідні messages
|
|
49
|
+
* @returns {{ messages: Array<{role:string, content:string}>, messages_sha256: string, messages_truncated: boolean }} обрізані messages, hash і прапор обрізки
|
|
50
|
+
*/
|
|
51
|
+
export function capMessages(messages) {
|
|
52
|
+
const src = messages ?? []
|
|
53
|
+
let truncated = false
|
|
54
|
+
const capped = src.map(m => {
|
|
55
|
+
const content = m?.content ?? ''
|
|
56
|
+
if (content.length > MAX_MSG_CHARS) {
|
|
57
|
+
truncated = true
|
|
58
|
+
return { role: m.role, content: content.slice(0, MAX_MSG_CHARS) }
|
|
59
|
+
}
|
|
60
|
+
return { role: m?.role, content }
|
|
61
|
+
})
|
|
62
|
+
const messages_sha256 = createHash('sha256').update(JSON.stringify(src)).digest('hex')
|
|
63
|
+
return { messages: capped, messages_sha256, messages_truncated: truncated }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Будує нормалізований trace-запис. Поля, яких backend не дає (pi: reasoning/
|
|
68
|
+
* usage/finish_reason), лишаються `null` за побудовою.
|
|
69
|
+
* @param {object} i вхід
|
|
70
|
+
* @param {string} i.ts ISO-час завершення виклику
|
|
71
|
+
* @param {string} i.caller хто викликав (doc-files|fix|coverage|unknown)
|
|
72
|
+
* @param {'omlx'|'pi'} i.backend бекенд
|
|
73
|
+
* @param {string} i.model model-id
|
|
74
|
+
* @param {number} [i.temperature] температура
|
|
75
|
+
* @param {number} [i.maxTokens] ліміт виходу
|
|
76
|
+
* @param {Array<{role:string, content:string}>} i.messages messages запиту
|
|
77
|
+
* @param {string|null} [i.content] відповідь
|
|
78
|
+
* @param {string|null} [i.reasoning] думки моделі
|
|
79
|
+
* @param {string|null} [i.reasoningSource] джерело reasoning
|
|
80
|
+
* @param {string|null} [i.finishReason] finish_reason
|
|
81
|
+
* @param {object|null} [i.usage] usage verbatim
|
|
82
|
+
* @param {number} i.ms latency
|
|
83
|
+
* @param {number|null} [i.attempts] кількість спроб
|
|
84
|
+
* @param {boolean} i.ok успіх
|
|
85
|
+
* @param {string|null} [i.error] текст помилки
|
|
86
|
+
* @returns {object} JSONL-готовий запис
|
|
87
|
+
*/
|
|
88
|
+
export function buildTraceRecord(i) {
|
|
89
|
+
const capped = capMessages(i.messages)
|
|
90
|
+
return {
|
|
91
|
+
ts: i.ts,
|
|
92
|
+
caller: i.caller,
|
|
93
|
+
backend: i.backend,
|
|
94
|
+
model: i.model,
|
|
95
|
+
temperature: i.temperature ?? null,
|
|
96
|
+
max_tokens: i.maxTokens ?? null,
|
|
97
|
+
messages: capped.messages,
|
|
98
|
+
messages_sha256: capped.messages_sha256,
|
|
99
|
+
messages_truncated: capped.messages_truncated,
|
|
100
|
+
content: i.content ?? null,
|
|
101
|
+
reasoning: i.reasoning ?? null,
|
|
102
|
+
reasoning_source: i.reasoningSource ?? null,
|
|
103
|
+
finish_reason: i.finishReason ?? null,
|
|
104
|
+
usage: i.usage ?? null,
|
|
105
|
+
ms: i.ms,
|
|
106
|
+
attempts: i.attempts ?? null,
|
|
107
|
+
ok: i.ok,
|
|
108
|
+
error: i.error ?? null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Імʼя архіву для ротації: `llm-trace.jsonl` → `llm-trace.<seq>.jsonl`
|
|
114
|
+
* (нестандартні імена без `.jsonl` → `<file>.<seq>`).
|
|
115
|
+
* @param {string} file активний trace-файл
|
|
116
|
+
* @param {number} seq порядковий номер архіву
|
|
117
|
+
* @returns {string} шлях архіву
|
|
118
|
+
*/
|
|
119
|
+
function archiveName(file, seq) {
|
|
120
|
+
return file.endsWith('.jsonl') ? `${file.slice(0, -'.jsonl'.length)}.${seq}.jsonl` : `${file}.${seq}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Недеструктивна ротація: якщо активний файл перевищує `ROTATE_BYTES`,
|
|
125
|
+
* перейменовує його в перший вільний `llm-trace.<seq>.jsonl` (без перезапису
|
|
126
|
+
* наявних архівів). Відсутній файл / помилка stat — no-op.
|
|
127
|
+
* @param {string} file активний trace-файл
|
|
128
|
+
*/
|
|
129
|
+
export function rotateIfNeeded(file) {
|
|
130
|
+
let size
|
|
131
|
+
try {
|
|
132
|
+
size = statSync(file).size
|
|
133
|
+
} catch {
|
|
134
|
+
return // файлу ще нема — нічого ротувати
|
|
135
|
+
}
|
|
136
|
+
if (size <= ROTATE_BYTES) return
|
|
137
|
+
let seq = 1
|
|
138
|
+
while (existsSync(archiveName(file, seq))) seq++
|
|
139
|
+
renameSync(file, archiveName(file, seq))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Fail-safe запис одного trace-рядка. Резолвить шлях (kill-switch → no-op),
|
|
144
|
+
* ротує за потреби, створює теку, append-ить JSONL. Будь-яка помилка IO
|
|
145
|
+
* ковтається — трасування **ніколи** не ламає основний виклик.
|
|
146
|
+
* @param {object} record запис від `buildTraceRecord`
|
|
147
|
+
*/
|
|
148
|
+
export function writeTrace(record) {
|
|
149
|
+
const file = tracePath()
|
|
150
|
+
if (!file) return
|
|
151
|
+
try {
|
|
152
|
+
rotateIfNeeded(file)
|
|
153
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
154
|
+
appendFileSync(file, JSON.stringify(record) + '\n')
|
|
155
|
+
} catch {
|
|
156
|
+
// трейс не має ламати основний виклик
|
|
157
|
+
}
|
|
158
|
+
}
|
package/lib/omlx.mjs
CHANGED
|
@@ -70,16 +70,42 @@ export function omlxModelId(model) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
* `
|
|
75
|
-
*
|
|
73
|
+
* Витягує reasoning (думки моделі) з omlx-`message`. Джерела за пріоритетом:
|
|
74
|
+
* - `field` — окреме поле `message.reasoning_content` (Qwen3-Thinking тощо);
|
|
75
|
+
* - `think_tag` — `<think>…</think>` усередині `content` (інші thinking-моделі);
|
|
76
|
+
* - `truncated` — `finish_reason: "length"` зрізав думку в `content` до закриття
|
|
77
|
+
* тега → сирий reasoning лишився в `content` без `</think>`;
|
|
78
|
+
* - `null` — reasoning немає (не-thinking модель).
|
|
79
|
+
* @param {{content?:string, reasoning_content?:string}} message обʼєкт `choices[0].message`
|
|
80
|
+
* @param {string|null} finishReason `choices[0].finish_reason`
|
|
81
|
+
* @returns {{ reasoning: string|null, reasoningSource: 'field'|'think_tag'|'truncated'|null }} текст думок і його джерело
|
|
82
|
+
*/
|
|
83
|
+
const THINK_TAG_RE = /<think>([\s\S]*?)<\/think>/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
*
|
|
87
|
+
*/
|
|
88
|
+
export function extractReasoning(message, finishReason) {
|
|
89
|
+
const field = message?.reasoning_content
|
|
90
|
+
if (field && field.trim()) return { reasoning: field, reasoningSource: 'field' }
|
|
91
|
+
const content = message?.content ?? ''
|
|
92
|
+
const m = content.match(THINK_TAG_RE)
|
|
93
|
+
if (m) return { reasoning: m[1].trim(), reasoningSource: 'think_tag' }
|
|
94
|
+
if (finishReason === 'length' && content.trim()) return { reasoning: content, reasoningSource: 'truncated' }
|
|
95
|
+
return { reasoning: null, reasoningSource: null }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Ядро прямого HTTP-виклику до omlx через `curl` (spawnSync). Повертає **багатий**
|
|
100
|
+
* обʼєкт: контент + reasoning + usage + finish_reason + кількість спроб. Ретраїть
|
|
101
|
+
* лише transient curl-помилки (18 = transfer closed, 52 = empty reply, 56 = recv failure).
|
|
76
102
|
* @param {Array<{role:string, content:string}>} messages OpenAI-messages (system+user збережено)
|
|
77
103
|
* @param {string} model model-id (з/без `omlx/`-префікса); порожній → дефолт
|
|
78
104
|
* @param {{ url?: string, timeoutMs?: number, temperature?: number, maxTokens?: number, fallbackModel?: string, apiKey?: string }} [opts] URL, timeout, температура, ліміт виходу, fallback-модель, API-ключ
|
|
79
|
-
* @returns {string}
|
|
105
|
+
* @returns {{ content:string, reasoning:string|null, reasoningSource:string|null, finishReason:string|null, usage:object|null, attempts:number }} багатий результат виклику
|
|
80
106
|
* @throws на curl-помилці, не-200 exit, поганому JSON чи порожньому контенті
|
|
81
107
|
*/
|
|
82
|
-
export function
|
|
108
|
+
export function callOmlxRaw(messages, model, opts = {}) {
|
|
83
109
|
const {
|
|
84
110
|
url = env.N_CURSOR_OMLX_URL ?? DEFAULT_OMLX_URL,
|
|
85
111
|
timeoutMs = 60_000,
|
|
@@ -136,12 +162,24 @@ export function callOmlx(messages, model, opts = {}) {
|
|
|
136
162
|
throw new Error(`omlx bad json: ${r.stdout?.slice(0, 200) ?? ''}`)
|
|
137
163
|
}
|
|
138
164
|
if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`)
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
return content
|
|
165
|
+
const message = j.choices?.[0]?.message ?? {}
|
|
166
|
+
const finishReason = j.choices?.[0]?.finish_reason ?? null
|
|
167
|
+
const content = message.content?.trim() ?? ''
|
|
168
|
+
if (!content) throw new Error(`omlx empty content (finish=${finishReason})`)
|
|
169
|
+
const { reasoning, reasoningSource } = extractReasoning(message, finishReason)
|
|
170
|
+
return { content, reasoning, reasoningSource, finishReason, usage: j.usage ?? null, attempts: attempt }
|
|
145
171
|
}
|
|
146
172
|
throw lastErr ?? new Error('omlx unknown failure')
|
|
147
173
|
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Тонка обгортка над `callOmlxRaw` для споживачів, яким потрібен лише текст.
|
|
177
|
+
* Контракт незмінний: повертає непорожній `choices[0].message.content`.
|
|
178
|
+
* @param {Array<{role:string, content:string}>} messages OpenAI-messages
|
|
179
|
+
* @param {string} model model-id (з/без `omlx/`-префікса)
|
|
180
|
+
* @param {object} [opts] ті самі опції, що й у `callOmlxRaw`
|
|
181
|
+
* @returns {string} непорожній контент відповіді
|
|
182
|
+
*/
|
|
183
|
+
export function callOmlx(messages, model, opts = {}) {
|
|
184
|
+
return callOmlxRaw(messages, model, opts).content
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
|
|
3
3
|
globs: "**/package.json,**/src/conn/**"
|
|
4
4
|
alwaysApply: false
|
|
5
|
-
version: '1.
|
|
5
|
+
version: '1.15'
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## Підтримувані версії баз даних
|
|
@@ -300,10 +300,10 @@ const rows = await sql.unsafe(query, values)
|
|
|
300
300
|
|
|
301
301
|
Дефолтний експорт `sql` з `'bun'` сам читає змінні середовища (`DATABASE_URL`, `POSTGRES_URL`, `MYSQL_URL`, `PGHOST`/`PGUSER`/... та `MYSQL_HOST`/`MYSQL_USER`/...) і керує пулом — окремий `Pool` як у `pg` створювати не треба.
|
|
302
302
|
|
|
303
|
-
Для явного конфігу — `new SQL(...)` як **singleton** на рівні модуля, а не на кожен запит. Файл кладеться у `src/conn/db.
|
|
303
|
+
Для явного конфігу — `new SQL(...)` як **singleton** на рівні модуля, а не на кожен запит. Файл кладеться у `src/conn/db.mjs` і експортує іменовані константи `pgWrite` (основний запис) та `pgRead` (read-only replica), щоб glob `**/src/conn/**` у правилах покривав ці файли:
|
|
304
304
|
|
|
305
305
|
```javascript
|
|
306
|
-
// src/conn/db.
|
|
306
|
+
// src/conn/db.mjs
|
|
307
307
|
import { SQL } from 'bun'
|
|
308
308
|
|
|
309
309
|
export const pgWrite = new SQL({
|
|
@@ -454,16 +454,16 @@ ${pgRead.array(ids, 'int4')}
|
|
|
454
454
|
|
|
455
455
|
OXC formatter (oxfmt ≥ 0.49) примусово розгортає будь-який `CallExpression`, де перший аргумент є `CallExpression` з callback, у багаторядковий блок — незалежно від `printWidth`. Тому `pgWrite.array(arr.map(r => r.field), 'type')` всередині tagged template literal завжди стає 4-рядковим блоком. `col(arr, 'field')` (перший аргумент — identifier, другий — string literal) цей тригер не зачіпає і лишається однорядковим.
|
|
456
456
|
|
|
457
|
-
Канонічне місце хелпера — `src/utils/col.
|
|
457
|
+
Канонічне місце хелпера — `src/utils/col.mjs` (або `src/conn/col.mjs` залежно від структури проєкту):
|
|
458
458
|
|
|
459
459
|
```javascript
|
|
460
|
-
// src/utils/col.
|
|
460
|
+
// src/utils/col.mjs
|
|
461
461
|
export const col = (arr, key) => arr.map(r => r[key])
|
|
462
462
|
```
|
|
463
463
|
|
|
464
464
|
```javascript
|
|
465
|
-
import { pgWrite } from '#src/conn/db.
|
|
466
|
-
import { col } from '#src/utils/col.
|
|
465
|
+
import { pgWrite } from '#src/conn/db.mjs'
|
|
466
|
+
import { col } from '#src/utils/col.mjs'
|
|
467
467
|
|
|
468
468
|
// ❌ oxfmt розгортає на 4+ рядки незалежно від printWidth
|
|
469
469
|
${pgWrite.array(rows.map(r => r.id), 'int4')}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Перевірка JavaScript коду
|
|
3
3
|
globs: "**/{.oxlintrc.json,eslint.config.js,.jscpd.json,knip.json,package.json},**/*.{js,mjs,cjs,jsx,ts,tsx}"
|
|
4
4
|
alwaysApply: false
|
|
5
|
-
version: '1.
|
|
5
|
+
version: '1.30'
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
**oxlint**, **ESLint**, **jscpd**, **knip**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`**, **`bunx knip`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config`** — версія не нижче канонічного мінімуму зі snippet нижче (semver-поріг, єдине джерело істини) (з **3.8.0** правило `no-restricted-syntax` забороняє `for...in`; з **3.9.2** у `getConfig` вбудовано ignore для **`**/adr/**`** — ADR-документи не валідуються ESLint, локально цей glob додавати не потрібно; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint). Dependency-політику CI-етапу (`@e18e/eslint-plugin` і oxlint/eslint/jscpd/knip окремо не додавати) винесено в `js-lint-ci`.
|
|
@@ -23,6 +23,19 @@ version: '1.29'
|
|
|
23
23
|
|
|
24
24
|
Канон `type` + `scripts.lint-js` (substring requirement) і мінімальна `@nitra/eslint-config` (semver-поріг `devDependencies`): [package.json.snippet.json](./policy/package_json/template/package.json.snippet.json)
|
|
25
25
|
|
|
26
|
+
## Розширення нових файлів — `.mjs` / `.cjs`, не `.js`
|
|
27
|
+
|
|
28
|
+
**Нові** JS-файли створюй з явним розширенням модуля:
|
|
29
|
+
|
|
30
|
+
- **`.mjs`** — для ESM (типовий випадок);
|
|
31
|
+
- **`.cjs`** — для CommonJS, де він справді потрібен.
|
|
32
|
+
|
|
33
|
+
Голий **`.js`** для нового файлу **заборонено**. Розширення `.js` інтерпретується як ESM чи CJS лише за полем `package.json#type`, тож той самий файл читається по-різному залежно від пакета. Явне `.mjs`/`.cjs` робить тип модуля однозначним **без читання `package.json`** — навіть якщо `type` зміниться або файл перемістять в інший пакет. Це доповнює вимогу `"type": "module"` вище: `type` лишається каноном для всього дерева, а розширення нового файлу прибирає залежність від нього.
|
|
34
|
+
|
|
35
|
+
Стосується **backend і frontend** — будь-який новий вихідний файл: `src/`, тести `*.test.*`, `scripts/`, `src/conn/` тощо.
|
|
36
|
+
|
|
37
|
+
**Існуючі `.js` лишаються як є** — масово перейменовувати не треба; це конвенція для нового коду. Автоматичної перевірки тут немає: stateless-скан не відрізнить новий файл від існуючого, тож `.js` нікого не фейлить.
|
|
38
|
+
|
|
26
39
|
У `.vscode/extensions.json` `recommendations` мають містити `dbaeumer.vscode-eslint`, `github.vscode-github-actions`, `oxc.oxc-vscode`: [extensions.json.snippet.json](./policy/vscode_extensions/template/extensions.json.snippet.json)
|
|
27
40
|
|
|
28
41
|
У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/rules/js-lint/js/data/tooling/oxlint-canonical.json`** (plugins, jsPlugins з **`@e18e/eslint-plugin`**, categories, повний набір **rules** із канону — додаткові записи в **`rules`** дозволені; також **`settings`**, **`env`**, **`globals`**). Поле **`ignorePatterns`** працює як **`rules`**: канонічні патерни з **`oxlint-canonical.json`** (наразі **`**/schema.graphql`**, **`**/auto-imports.d.ts`**) мають бути присутні, додаткові локальні glob-и дозволені. Канон **`oxlint-canonical.json`** — source-of-truth, редагується напряму; у споживачі оновлюється копіюванням файлу з репозиторію пакета. Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.8.0**), oxlint підвантажує його з **`node_modules`**.
|
package/rules/js-run/js-run.mdc
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
|
|
3
3
|
globs: "**/package.json,**/jsconfig.json,**/src/**/*.{js,mjs,cjs,ts,tsx}"
|
|
4
4
|
alwaysApply: false
|
|
5
|
-
version: '1.
|
|
5
|
+
version: '1.12'
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## Область застосування
|
|
@@ -97,7 +97,7 @@ import sql from 'mssql'
|
|
|
97
97
|
import { GraphQLClient } from '@nitra/graphql-request'
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
то ці підключення повинні бути винесені в окремий файл, наприклад `/src/conn/pg.
|
|
100
|
+
то ці підключення повинні бути винесені в окремий файл, наприклад `/src/conn/pg.mjs`, в package.json повинні бути додано аліас:
|
|
101
101
|
|
|
102
102
|
```json
|
|
103
103
|
{
|
|
@@ -110,7 +110,7 @@ import { GraphQLClient } from '@nitra/graphql-request'
|
|
|
110
110
|
|
|
111
111
|
так виглядатиме підключення до PostgreSQL в коді:
|
|
112
112
|
|
|
113
|
-
```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.
|
|
113
|
+
```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.mjs"
|
|
114
114
|
import { checkEnv, env } from '@nitra/check-env'
|
|
115
115
|
import { SQL } from 'bun'
|
|
116
116
|
|
|
@@ -140,7 +140,7 @@ export const graphQLClientSmart = new GraphQLClient(env.QL, {
|
|
|
140
140
|
а в коді повинно бути використано:
|
|
141
141
|
|
|
142
142
|
```js
|
|
143
|
-
import { pool } from '#conn/pg.
|
|
143
|
+
import { pool } from '#conn/pg.mjs'
|
|
144
144
|
|
|
145
145
|
// або
|
|
146
146
|
|
|
@@ -152,24 +152,24 @@ import { gql, graphQLClient } from '@nitra/graphql-request'
|
|
|
152
152
|
Назва файла в `src/conn/` має одразу повідомляти, **до чого** підключаємось і **в якому режимі**:
|
|
153
153
|
|
|
154
154
|
- **GraphQL** — префікс `ql-`, далі ідентифікатор endpoint:
|
|
155
|
-
- `src/conn/ql-contract.
|
|
156
|
-
- `src/conn/ql-smart.
|
|
155
|
+
- `src/conn/ql-contract.mjs`
|
|
156
|
+
- `src/conn/ql-smart.mjs`
|
|
157
157
|
- **PostgreSQL** — префікс `pg-`, далі тип підключення (репліка vs мастер): `read` або `write`:
|
|
158
|
-
- `src/conn/pg-read.
|
|
159
|
-
- `src/conn/pg-write.
|
|
158
|
+
- `src/conn/pg-read.mjs`
|
|
159
|
+
- `src/conn/pg-write.mjs`
|
|
160
160
|
- **PostgreSQL до кількох БД** — додатково ідентифікатор підключення після типу:
|
|
161
|
-
- `src/conn/pg-read-smart.
|
|
162
|
-
- `src/conn/pg-write-contract.
|
|
163
|
-
- **MySQL** — префікс `mysql-` за тією ж схемою (`mysql-read.
|
|
164
|
-
- **MSSQL** — префікс `mssql-` за тією ж схемою (`mssql-read.
|
|
161
|
+
- `src/conn/pg-read-smart.mjs`
|
|
162
|
+
- `src/conn/pg-write-contract.mjs`
|
|
163
|
+
- **MySQL** — префікс `mysql-` за тією ж схемою (`mysql-read.mjs`, `mysql-write-<id>.mjs` тощо).
|
|
164
|
+
- **MSSQL** — префікс `mssql-` за тією ж схемою (`mssql-read.mjs`, `mssql-write-<id>.mjs` тощо). Хоча npm-пакет один (`mssql`), а драйвер MS SQL Server під капотом T-SQL — у файловій назві відрізняємо MS SQL Server від MySQL, бо це різні СУБД, різні діалекти, різні рантаймні залежності. Якщо проєкт історично використовує `mysql-…` для MSSQL-підключень — він валідний і далі (для backward-compat), але новий код пишемо з префіксом `mssql-`.
|
|
165
165
|
|
|
166
|
-
Підключення до БД **обов'язково** має бути ідентифіковано як `read` (репліка) або `write` (мастер). Якщо з імені змінної оточення (наприклад, `env.PG_CONN`) це не очевидно — визнач режим за операціями в коді: якщо немає операцій зміни даних (`INSERT`/`UPDATE`/`DELETE`/DDL) — це `pg-read.
|
|
166
|
+
Підключення до БД **обов'язково** має бути ідентифіковано як `read` (репліка) або `write` (мастер). Якщо з імені змінної оточення (наприклад, `env.PG_CONN`) це не очевидно — визнач режим за операціями в коді: якщо немає операцій зміни даних (`INSERT`/`UPDATE`/`DELETE`/DDL) — це `pg-read.mjs`, інакше `pg-write.mjs`.
|
|
167
167
|
|
|
168
168
|
### Експорти у файлах `src/conn/`
|
|
169
169
|
|
|
170
170
|
У файлах підключень **заборонений** `export default`. Експорт має бути **іменований** і збігатися з назвою файла в camelCase.
|
|
171
171
|
|
|
172
|
-
Приклад — `src/conn/ql-smart.
|
|
172
|
+
Приклад — `src/conn/ql-smart.mjs`:
|
|
173
173
|
|
|
174
174
|
```javascript title="❌ Так не можна"
|
|
175
175
|
export default new GraphQLClient(env.SMART_QL, {
|
|
@@ -187,13 +187,13 @@ export const qlSmart = new GraphQLClient(env.SMART_QL, {
|
|
|
187
187
|
})
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
Відповідно: `pg-read.
|
|
190
|
+
Відповідно: `pg-read.mjs` → `export const pgRead = …`, `pg-write-contract.mjs` → `export const pgWriteContract = …`, `ql-contract.mjs` → `export const qlContract = …`.
|
|
191
191
|
|
|
192
192
|
## CheckEnv
|
|
193
193
|
|
|
194
194
|
Усі змінні оточення, які використовуються в коді, повинні бути перевірені за допомогою `checkEnv` з пакету `@nitra/check-env`. Це гарантує, що всі необхідні змінні оточення встановлені перед запуском програми.
|
|
195
195
|
|
|
196
|
-
```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.
|
|
196
|
+
```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.mjs"
|
|
197
197
|
import { checkEnv, env } from '@nitra/check-env'
|
|
198
198
|
import { SQL } from 'bun'
|
|
199
199
|
|
|
@@ -5,6 +5,18 @@ import { join } from 'node:path'
|
|
|
5
5
|
|
|
6
6
|
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
7
7
|
|
|
8
|
+
// Зовнішні файли конфігу stylelint, які підхоплює cosmiconfig. Канон нових
|
|
9
|
+
// JS-конфігів — `.mjs`/`.cjs` (js-lint.mdc), legacy `.js` лишається валідним.
|
|
10
|
+
const STYLELINT_CONFIG_FILES = [
|
|
11
|
+
'.stylelintrc.json',
|
|
12
|
+
'.stylelintrc.js',
|
|
13
|
+
'.stylelintrc.cjs',
|
|
14
|
+
'.stylelintrc.mjs',
|
|
15
|
+
'stylelint.config.js',
|
|
16
|
+
'stylelint.config.cjs',
|
|
17
|
+
'stylelint.config.mjs'
|
|
18
|
+
]
|
|
19
|
+
|
|
8
20
|
/**
|
|
9
21
|
* Альтернатива полю `stylelint` у `package.json` — зовнішній файл конфігу. Якщо
|
|
10
22
|
* поля немає і файлу немає, фейлимося; якщо є хоч щось — пропускаємо. Поле
|
|
@@ -18,10 +30,7 @@ async function checkStylelintConfigPresence(reporter, cwd) {
|
|
|
18
30
|
if (!existsSync(pkgPath)) return
|
|
19
31
|
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
20
32
|
const hasField = pkg.stylelint && typeof pkg.stylelint === 'object'
|
|
21
|
-
const hasExternalCfg =
|
|
22
|
-
existsSync(join(cwd, '.stylelintrc.json')) ||
|
|
23
|
-
existsSync(join(cwd, '.stylelintrc.js')) ||
|
|
24
|
-
existsSync(join(cwd, 'stylelint.config.js'))
|
|
33
|
+
const hasExternalCfg = STYLELINT_CONFIG_FILES.some(name => existsSync(join(cwd, name)))
|
|
25
34
|
if (hasField || hasExternalCfg) {
|
|
26
35
|
pass('Конфіг stylelint є — у package.json або окремим файлом')
|
|
27
36
|
} else {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
|
|
2
2
|
export default {
|
|
3
3
|
testRunner: 'vitest',
|
|
4
|
-
vitest: { configFile: 'vitest.config.
|
|
4
|
+
vitest: { configFile: 'vitest.config.mjs' },
|
|
5
5
|
// perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст
|
|
6
6
|
// швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант).
|
|
7
7
|
coverageAnalysis: 'perTest',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
|
|
2
2
|
export default {
|
|
3
3
|
testRunner: 'vitest',
|
|
4
|
-
vitest: { configFile: 'vitest.config.
|
|
4
|
+
vitest: { configFile: 'vitest.config.mjs' },
|
|
5
5
|
// perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст
|
|
6
6
|
// швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант).
|
|
7
7
|
coverageAnalysis: 'perTest',
|
|
@@ -18,6 +18,24 @@ const STRYKER_VUE_PLUGIN_PATH = join(HERE, 'data', 'stryker_config', 'stryker-vu
|
|
|
18
18
|
const STRYKER_VUE_PLUGIN_FILENAME = 'stryker-vue-macros-ignorer.mjs'
|
|
19
19
|
const VITEST_BASELINE_PATH = join(HERE, 'data', 'vitest_config', 'vitest.config.baseline.js')
|
|
20
20
|
|
|
21
|
+
// Канонічна назва vitest-конфіга — `.mjs` (нові файли, js-lint.mdc); legacy
|
|
22
|
+
// `.js` лишається валідним. Перший знайдений виграє (.mjs пріоритетніший).
|
|
23
|
+
const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js']
|
|
24
|
+
// Заміна literal `configFile` у скопійованому stryker-baseline на фактичне
|
|
25
|
+
// ім'я vitest-конфіга jsRoot-а (узгодження Stryker ↔ vitest).
|
|
26
|
+
const STRYKER_CONFIG_FILE_RE = /configFile: 'vitest\.config\.[cm]?js'/u
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Визначає ім'я vitest-конфіга для jsRoot: існуючий `.mjs`/`.js` (якщо є),
|
|
30
|
+
* інакше дефолт `vitest.config.mjs` (нові файли — `.mjs`). Існуючий
|
|
31
|
+
* `vitest.config.js` лишається валідним (backward-compat), новий не плодиться.
|
|
32
|
+
* @param {string} jsRoot абсолютний шлях до workspace-каталогу
|
|
33
|
+
* @returns {string} ім'я vitest-конфіга
|
|
34
|
+
*/
|
|
35
|
+
function resolveVitestConfigName(jsRoot) {
|
|
36
|
+
return VITEST_CONFIG_NAMES.find(name => existsSync(join(jsRoot, name))) ?? 'vitest.config.mjs'
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
// Канонічні entries, які vue-варіант baseline тримає у `plugins`/`ignorers`.
|
|
22
40
|
// Augment-крок (augmentVueStrykerConfig) дбає, щоб саме вони були присутні в
|
|
23
41
|
// уже-існуючому `stryker.config.mjs` Vue-root-а. Нову property пишемо у
|
|
@@ -64,15 +82,20 @@ async function hasVueFiles(jsRoot) {
|
|
|
64
82
|
* @param {string} cwd корінь проєкту (для relative-шляхів у логах)
|
|
65
83
|
* @param {string} baselinePath абсолютний шлях до canonical baseline
|
|
66
84
|
* @param {string} target абсолютний шлях, куди копіювати
|
|
67
|
-
* @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.
|
|
85
|
+
* @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.mjs")
|
|
86
|
+
* @param {(content: string) => string} [transform] опційне перетворення тексту baseline перед записом
|
|
68
87
|
* @returns {Promise<void>}
|
|
69
88
|
*/
|
|
70
|
-
async function ensureBaselineFile(reporter, cwd, baselinePath, target, label) {
|
|
89
|
+
async function ensureBaselineFile(reporter, cwd, baselinePath, target, label, transform) {
|
|
71
90
|
if (existsSync(target)) {
|
|
72
91
|
reporter.pass(`${label} існує (${relative(cwd, target)})`)
|
|
73
92
|
return
|
|
74
93
|
}
|
|
75
|
-
|
|
94
|
+
if (transform) {
|
|
95
|
+
await writeFile(target, transform(await readFile(baselinePath, 'utf8')), 'utf8')
|
|
96
|
+
} else {
|
|
97
|
+
await copyFile(baselinePath, target)
|
|
98
|
+
}
|
|
76
99
|
reporter.pass(`${label} створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
|
|
77
100
|
}
|
|
78
101
|
|
|
@@ -341,7 +364,12 @@ export async function check(cwd = process.cwd()) {
|
|
|
341
364
|
// і саме тут augment закриває drift-hole.
|
|
342
365
|
const wasMissing = !existsSync(strykerTarget)
|
|
343
366
|
const strykerBaseline = isVueRoot ? STRYKER_VUE_BASELINE_PATH : STRYKER_BASELINE_PATH
|
|
344
|
-
|
|
367
|
+
// configFile у новоствореному baseline має вказувати на фактичний vitest-конфіг
|
|
368
|
+
// jsRoot-а (existing `.js`/`.mjs` або дефолтний `.mjs`).
|
|
369
|
+
const vitestName = resolveVitestConfigName(jsRoot)
|
|
370
|
+
await ensureBaselineFile(reporter, cwd, strykerBaseline, strykerTarget, 'stryker.config.mjs', content =>
|
|
371
|
+
content.replace(STRYKER_CONFIG_FILE_RE, `configFile: '${vitestName}'`)
|
|
372
|
+
)
|
|
345
373
|
if (isVueRoot) {
|
|
346
374
|
if (!wasMissing) {
|
|
347
375
|
await augmentVueStrykerConfig(reporter, cwd, jsRoot)
|
|
@@ -354,7 +382,7 @@ export async function check(cwd = process.cwd()) {
|
|
|
354
382
|
STRYKER_VUE_PLUGIN_FILENAME
|
|
355
383
|
)
|
|
356
384
|
}
|
|
357
|
-
await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot,
|
|
385
|
+
await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot, vitestName), vitestName)
|
|
358
386
|
}
|
|
359
387
|
|
|
360
388
|
// Гарантуємо що тест-артефакти (Stryker output, lcov HTML-звіт) ніколи не
|
|
@@ -8,8 +8,12 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
|
8
8
|
/** Subтring-pattern: `pool: 'forks'` або `pool: "forks"` (з опційним whitespace). */
|
|
9
9
|
const POOL_FORKS_RE = /pool\s*:\s*['"]forks['"]/u
|
|
10
10
|
|
|
11
|
+
// Канонічна назва — `.mjs` (нові файли, js-lint.mdc), але legacy `.js` лишається
|
|
12
|
+
// валідним. Перший знайдений виграє: `.mjs` пріоритетніший.
|
|
13
|
+
const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js']
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
|
-
* Перевіряє, що `vitest.config.js` (якщо існує) містить `pool: 'forks'`.
|
|
16
|
+
* Перевіряє, що `vitest.config.{mjs,js}` (якщо існує) містить `pool: 'forks'`.
|
|
13
17
|
* @param {string} [cwdParam] корінь репозиторію
|
|
14
18
|
* @returns {Promise<number>} 0 — OK або skip, 1 — config без `pool: 'forks'`
|
|
15
19
|
*/
|
|
@@ -17,18 +21,18 @@ export async function check(cwdParam = process.cwd()) {
|
|
|
17
21
|
const reporter = createCheckReporter()
|
|
18
22
|
const { pass, fail } = reporter
|
|
19
23
|
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
22
|
-
pass('vitest.config.js відсутній — pool-перевірку пропущено')
|
|
24
|
+
const configName = VITEST_CONFIG_NAMES.find(name => existsSync(join(cwdParam, name)))
|
|
25
|
+
if (!configName) {
|
|
26
|
+
pass('vitest.config.mjs/.js відсутній — pool-перевірку пропущено')
|
|
23
27
|
return reporter.getExitCode()
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
const body = await readFile(
|
|
30
|
+
const body = await readFile(join(cwdParam, configName), 'utf8')
|
|
27
31
|
if (POOL_FORKS_RE.test(body)) {
|
|
28
|
-
pass(
|
|
32
|
+
pass(`${configName} містить pool: 'forks' (test.mdc)`)
|
|
29
33
|
} else {
|
|
30
34
|
fail(
|
|
31
|
-
|
|
35
|
+
`${configName} має містити pool: 'forks' — defense-in-depth для race у process.cwd() між паралельними test files (test.mdc)`
|
|
32
36
|
)
|
|
33
37
|
}
|
|
34
38
|
|
package/rules/test/test.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.
|
|
3
|
-
version: '2.
|
|
4
|
-
globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
|
|
2
|
+
description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.mjs (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
|
|
3
|
+
version: '2.8'
|
|
4
|
+
globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -71,15 +71,15 @@ Recursive globs ловлять файли всередині `tests/` так с
|
|
|
71
71
|
- Усі FS-операції у тесті — через `join(dir, …)` і `writeJson(join(dir, …), …)` / `ensureDir(join(dir, …))` (хелпери валідують `isAbsolute`).
|
|
72
72
|
- Усі child-процеси — `execFile(bin, args, { cwd: dir })`, `spawnSync(bin, args, { cwd: dir })`.
|
|
73
73
|
- Concern-функції правил — `await check(dir)`, `await applies(dir)`, `await fix(dir)`; усі production функції приймають перший параметр `cwd = process.cwd()` (default зберігає CLI-сумісність).
|
|
74
|
-
- `vitest.config.js` додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
|
|
74
|
+
- `vitest.config.mjs` (або legacy `vitest.config.js`) додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
|
|
75
75
|
|
|
76
76
|
Це **обов'язково** і для тестів пакета `@nitra/cursor`, і для кожного проєкту-споживача. Триплет перевірок:
|
|
77
77
|
|
|
78
78
|
- **`no-process-chdir`** (`rules/test/js/no-process-chdir.mjs`) — сканує `**/*.test.{js,mjs}` і падає з ❌ на будь-яке вживання `process.chdir(`.
|
|
79
79
|
- **`no-relative-fs-path`** (`rules/test/js/no-relative-fs-path.mjs`) — AST-сканер (`oxc-parser`): знаходить виклики FS-функцій із `node:fs`/`node:fs/promises` (`writeFile`, `copyFile`, `mkdir`, `readFile`, `existsSync`, `rename`, `symlink`, `cp`, … включно з `*Sync`-варіантами та `writeJson`/`ensureDir`-хелперами), де path-аргумент — це **string literal** без префікса `/`, `\`, `file:`, `http(s):`, `data:`, чи Windows-disk-letter `C:\`. Виклики `copyFile`/`rename`/`symlink`/`link`/`cp` перевіряють обидва path-аргументи. Виклики з обчисленим path (`join(dir, …)`, змінна, template-literal з виразом) пропускаються. Виловив би інцидент v1.28.0 у `tests/check-rule-fixtures.test.mjs` (`copyFile(src, 'default.conf.template')` → файл у production tree).
|
|
80
|
-
- **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.js`. Defense-in-depth.
|
|
80
|
+
- **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.mjs` (або legacy `vitest.config.js`; `.mjs` пріоритетніший). Defense-in-depth.
|
|
81
81
|
|
|
82
|
-
Canonical `vitest.config.
|
|
82
|
+
Canonical `vitest.config.mjs` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root; нові файли — `.mjs`, наявний `vitest.config.js` лишається валідним і не дублюється).
|
|
83
83
|
|
|
84
84
|
## Console mocking у тестах
|
|
85
85
|
|
|
@@ -144,7 +144,7 @@ test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним
|
|
|
144
144
|
|
|
145
145
|
## Налаштування mutation-testing
|
|
146
146
|
|
|
147
|
-
Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` + `vitest.config.
|
|
147
|
+
Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` + `vitest.config.mjs` у **кожному** JS-root проєкту: у кожному workspace з власним `package.json` (або в корені для single-package). У monorepo з `workspaces: ['app', 'scripts']` отримаєте `app/stryker.config.mjs` + `app/vitest.config.mjs` і `scripts/stryker.config.mjs` + `scripts/vitest.config.mjs`. Якщо у JS-root уже лежить legacy `vitest.config.js` — він лишається валідним, новий `.mjs` поряд не створюється, а `vitest.configFile` у скопійованому `stryker.config.mjs` приводиться до фактичного імені.
|
|
148
148
|
|
|
149
149
|
Канон Stryker config (Vitest runner + perTest): [stryker.config.baseline.mjs](./js/data/stryker_config/stryker.config.baseline.mjs)
|
|
150
150
|
|
|
@@ -160,7 +160,7 @@ JS-root без `.vue` отримує дефолтний baseline без `plugins
|
|
|
160
160
|
|
|
161
161
|
### Vitest baseline та `package.json#scripts`
|
|
162
162
|
|
|
163
|
-
Поряд зі Stryker концерн `stryker_config` без дублювання копіює `vitest.config.
|
|
163
|
+
Поряд зі Stryker концерн `stryker_config` без дублювання копіює `vitest.config.mjs` (тільки якщо немає ні `.mjs`, ні legacy `.js`). Canonical: [vitest.config.baseline.js](./js/data/vitest_config/vitest.config.baseline.js) — `environment: 'node'`, `coverage.provider: 'v8'` з lcov+text-summary репортами, `include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}']` (підхоплює обидві розкладки — тести у `tests/`-піддиректоріях і top-level integration suites у `<root>/tests/`).
|
|
164
164
|
|
|
165
165
|
У `package.json#scripts` має бути `"test": "vitest run"` (canonical contains-substring `vitest` — допустимо `vitest run` та інші локальні розширення); опційно — `"test:watch": "vitest"`.
|
|
166
166
|
|
|
@@ -168,7 +168,7 @@ JS-root без `.vue` отримує дефолтний baseline без `plugins
|
|
|
168
168
|
|
|
169
169
|
### Frontend-варіант (Vue/Vite + happy-dom)
|
|
170
170
|
|
|
171
|
-
Для проєктів зі своїм `vite.config.js` `vitest.config.
|
|
171
|
+
Для проєктів зі своїм `vite.config.js` `vitest.config.mjs` має повторно використовувати vite-плагіни та aliases і перемкнути `environment` на `'happy-dom'` (або `'jsdom'`):
|
|
172
172
|
|
|
173
173
|
```js
|
|
174
174
|
import { defineConfig, mergeConfig } from 'vitest/config'
|
package/rules/vue/vue.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Vue
|
|
3
|
-
version: '2.
|
|
3
|
+
version: '2.2'
|
|
4
4
|
globs: "**/*.vue"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -44,14 +44,14 @@ const folderStructure = `
|
|
|
44
44
|
assets/
|
|
45
45
|
public/
|
|
46
46
|
App.vue
|
|
47
|
-
main.
|
|
47
|
+
main.mjs
|
|
48
48
|
`
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
### Найменування файлів
|
|
52
52
|
|
|
53
53
|
- **SFC:** імена файлів компонентів у **PascalCase** починаючи з букви N(`NMyWidget.vue`).
|
|
54
|
-
- **Інші JS-модулі:** узгоджено **kebab-case** (`date-utils.
|
|
54
|
+
- **Інші JS-модулі:** узгоджено **kebab-case** (`date-utils.mjs`).
|
|
55
55
|
|
|
56
56
|
### Модулі та архітектура
|
|
57
57
|
|
|
@@ -116,9 +116,9 @@ const additionalInstructions = `
|
|
|
116
116
|
|
|
117
117
|
### Тестування
|
|
118
118
|
|
|
119
|
-
- **Unit + Component / DOM:** **Vitest** (`vitest`) + **Vue Test Utils** з **happy-dom** як DOM-середовищем. Це канон, узгоджений з `test.mdc` (Stryker з vitest-runner + `perTest`-аналіз покриття). `vitest.config.
|
|
119
|
+
- **Unit + Component / DOM:** **Vitest** (`vitest`) + **Vue Test Utils** з **happy-dom** як DOM-середовищем. Це канон, узгоджений з `test.mdc` (Stryker з vitest-runner + `perTest`-аналіз покриття). `vitest.config.mjs` повторно використовує `vite.config.js` через `mergeConfig` і перемикає `environment` на `'happy-dom'`:
|
|
120
120
|
|
|
121
|
-
```js title="vitest.config.
|
|
121
|
+
```js title="vitest.config.mjs"
|
|
122
122
|
import { defineConfig, mergeConfig } from 'vitest/config'
|
|
123
123
|
import viteConfig from './vite.config.js'
|
|
124
124
|
|
|
@@ -590,7 +590,7 @@ function getTr() {
|
|
|
590
590
|
Називай store за назвою сторінки або компонента — `customerPageStore`, `routePageStore` тощо. На сторінці звертайся до нього через змінну `pageStore`.
|
|
591
591
|
|
|
592
592
|
```javascript
|
|
593
|
-
// store/customerPage.
|
|
593
|
+
// store/customerPage.mjs
|
|
594
594
|
export const useCustomerPageStore = defineStore('customerPage', {
|
|
595
595
|
state: () => ({
|
|
596
596
|
filterName: '',
|
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* решта → pi CLI. Якщо omlx-Tier 1 недоступний, помилка падає в той самий catch
|
|
12
12
|
* і класифікація відкочується на хмарний Tier 2 через pi.
|
|
13
13
|
*/
|
|
14
|
-
import { spawnSync } from 'node:child_process'
|
|
15
14
|
import { join } from 'node:path'
|
|
16
15
|
|
|
17
16
|
import { CLOUD_MIN, resolveModel } from '../../lib/models.mjs'
|
|
18
|
-
import {
|
|
17
|
+
import { callLlm } from '../../lib/llm.mjs'
|
|
19
18
|
import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
|
|
20
19
|
import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
|
|
21
20
|
import { parseVerdict } from './verdict-schema.mjs'
|
|
@@ -27,25 +26,14 @@ const FALLBACK_VERDICT = {
|
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
/**
|
|
30
|
-
* Викликає LLM за model-id
|
|
31
|
-
* `omlx/...` → прямий HTTP до omlx (text-only); решта → pi CLI.
|
|
29
|
+
* Викликає LLM через спільний `callLlm` (маршрут за префіксом model-id; wire-trace).
|
|
32
30
|
* @param {string} prompt текст промпта
|
|
33
31
|
* @param {string} model provider/model-id, `omlx/...` або '' для pi-дефолту
|
|
34
32
|
* @returns {string} текст відповіді моделі
|
|
35
33
|
* @throws якщо backend недоступний або повертає помилку
|
|
36
34
|
*/
|
|
37
35
|
function callModel(prompt, model) {
|
|
38
|
-
|
|
39
|
-
return callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000 })
|
|
40
|
-
}
|
|
41
|
-
const modelArgs = model ? ['--model', model] : []
|
|
42
|
-
const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
|
|
43
|
-
encoding: 'utf8',
|
|
44
|
-
timeout: 60_000
|
|
45
|
-
})
|
|
46
|
-
if (r.error) throw new Error(`pi error: ${r.error.message}`)
|
|
47
|
-
if (r.status !== 0) throw new Error(`pi exit ${r.status}: ${r.stderr?.slice(0, 200) ?? ''}`)
|
|
48
|
-
return r.stdout?.trim() ?? ''
|
|
36
|
+
return callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000, caller: 'coverage' })
|
|
49
37
|
}
|
|
50
38
|
|
|
51
39
|
/**
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import { spawnSync } from 'node:child_process'
|
|
6
5
|
import { env } from 'node:process'
|
|
7
6
|
import { resolveModel } from '../../../lib/models.mjs'
|
|
8
|
-
import {
|
|
7
|
+
import { callLlm } from '../../../lib/llm.mjs'
|
|
9
8
|
|
|
10
9
|
// Тир за замовчуванням: min → avg при ескалації (каскад local→cloud).
|
|
11
10
|
// Перевизначення через N_CURSOR_FIX_MODEL / N_CURSOR_FIX_MODEL_HEAVY.
|
|
@@ -13,6 +12,7 @@ export const MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
|
|
|
13
12
|
export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ?? resolveModel('avg')
|
|
14
13
|
|
|
15
14
|
const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/
|
|
15
|
+
const API_KEY_RE = /api key/i
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Витягує відносні шляхи файлів із violation output.
|
|
@@ -87,29 +87,18 @@ function buildPrompt(ruleId, ruleMdc, output, files) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* Викликає LLM за model-id
|
|
91
|
-
*
|
|
90
|
+
* Викликає LLM через спільний `callLlm` (маршрут за префіксом model-id; wire-trace).
|
|
91
|
+
* Зберігає дружнє повідомлення про відсутній API-ключ для хмарних провайдерів.
|
|
92
92
|
* @param {string} prompt текст промпта
|
|
93
93
|
* @param {string} model назва моделі (provider/id, `omlx/...` або '')
|
|
94
94
|
* @returns {{ text: string, error?: string }} текст відповіді або повідомлення про помилку
|
|
95
95
|
*/
|
|
96
96
|
function callModel(prompt, model) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
const modelArgs = model ? ['--model', model] : []
|
|
105
|
-
const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
|
|
106
|
-
encoding: 'utf8',
|
|
107
|
-
timeout: 120_000
|
|
108
|
-
})
|
|
109
|
-
if (r.error) return { text: '', error: r.error.message }
|
|
110
|
-
if (r.status !== 0) {
|
|
111
|
-
const stderr = r.stderr?.slice(0, 300) ?? ''
|
|
112
|
-
if (stderr.toLowerCase().includes('no api key') || stderr.toLowerCase().includes('api key')) {
|
|
97
|
+
try {
|
|
98
|
+
return { text: callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 120_000, caller: 'fix' }) }
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const msg = String(error.message)
|
|
101
|
+
if (API_KEY_RE.test(msg)) {
|
|
113
102
|
const provider = model ? model.split('/')[0] : 'дефолтного провайдера'
|
|
114
103
|
return {
|
|
115
104
|
text: '',
|
|
@@ -120,9 +109,8 @@ function callModel(prompt, model) {
|
|
|
120
109
|
].join(' ')
|
|
121
110
|
}
|
|
122
111
|
}
|
|
123
|
-
return { text: '', error:
|
|
112
|
+
return { text: '', error: msg }
|
|
124
113
|
}
|
|
125
|
-
return { text: r.stdout?.trim() ?? '' }
|
|
126
114
|
}
|
|
127
115
|
|
|
128
116
|
/**
|