@nitra/cursor 3.23.1 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.24.0] - 2026-06-05
4
+
5
+ ### Added
6
+
7
+ - test/stryker_config: auto-augment існуючого stryker.config.mjs у Vue JS-roots — точкова вставка plugins/ignorers (vue-macros ignorer) string-splice-ом за AST-аналізом, зі збереженням полів і коментарів; idempotent, з graceful-skip для non-literal/динамічних default-export
8
+
3
9
  ## [3.23.1] - 2026-06-05
4
10
 
5
11
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.23.1",
3
+ "version": "3.24.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -10,6 +10,12 @@
10
10
  * `@vue/compiler-sfc` падає при компіляції SFC. Плагін копіюється у той самий
11
11
  * jsRoot як `stryker-vue-macros-ignorer.mjs`.
12
12
  *
13
+ * Augment (drift-hole): якщо у Vue-root `stryker.config.mjs` уже існував (проєкт
14
+ * мав non-vue config ще до 3.x Vue-підтримки), `ensureBaselineFile` його не
15
+ * перетирає — тож `augmentVueStrykerConfig` точково вставляє `plugins`/`ignorers`
16
+ * у наявний файл (string-splice за AST-аналізом), зберігаючи решту полів і
17
+ * коментарів. Idempotent: повторний прогон нічого не дублює.
18
+ *
13
19
  * Self-gating: концерн silently skips коли `js-lint` не enabled — це навмисно,
14
20
  * щоб не шуміти у single-language проєктах без JS coverage tooling.
15
21
  *
@@ -17,10 +23,12 @@
17
23
  * лишаються на Stryker defaults (`src/**\/*.{js,mjs,ts,jsx,tsx,cjs}`).
18
24
  */
19
25
  import { existsSync } from 'node:fs'
20
- import { copyFile, glob } from 'node:fs/promises'
26
+ import { copyFile, glob, readFile, writeFile } from 'node:fs/promises'
21
27
  import { dirname, join, relative } from 'node:path'
22
28
  import { fileURLToPath } from 'node:url'
23
29
 
30
+ import { parseSync } from 'oxc-parser'
31
+
24
32
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
25
33
  import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
26
34
  import { ensureGitignoreEntries } from '../../../scripts/utils/ensure-gitignore-entries.mjs'
@@ -33,6 +41,20 @@ const STRYKER_VUE_PLUGIN_PATH = join(HERE, 'data', 'stryker_config', 'stryker-vu
33
41
  const STRYKER_VUE_PLUGIN_FILENAME = 'stryker-vue-macros-ignorer.mjs'
34
42
  const VITEST_BASELINE_PATH = join(HERE, 'data', 'vitest_config', 'vitest.config.baseline.js')
35
43
 
44
+ // Канонічні entries, які vue-варіант baseline тримає у `plugins`/`ignorers`.
45
+ // Augment-крок (augmentVueStrykerConfig) дбає, щоб саме вони були присутні в
46
+ // уже-існуючому `stryker.config.mjs` Vue-root-а. Нову property пишемо у
47
+ // canonical-порядку; у наявний масив лише дописуємо відсутні entries в кінець
48
+ // (Stryker нечутливий до порядку plugins/ignorers).
49
+ const VITEST_RUNNER_PLUGIN = '@stryker-mutator/vitest-runner'
50
+ const VUE_MACROS_PLUGIN = './stryker-vue-macros-ignorer.mjs'
51
+ const VUE_MACROS_IGNORER = 'vue-macros'
52
+
53
+ // Module-scope (prefer-static-regex): рядок-відступ цілком whitespace; leading
54
+ // кома (можливо з whitespace) після останньої property об'єкта.
55
+ const INDENT_WS_RE = /^\s*$/u
56
+ const LEADING_COMMA_RE = /^\s*,/u
57
+
36
58
  // Тест-артефакти для .gitignore (подвійний-зірочка-префікс — для monorepo workspaces):
37
59
  // - `**/reports/stryker/` — увесь каталог Stryker-output-у (`tempDirName` backup'и,
38
60
  // mutation.json, HTML/dashboard-репорти якщо користувач додасть інші reporter-и).
@@ -77,6 +99,229 @@ async function ensureBaselineFile(reporter, cwd, baselinePath, target, label) {
77
99
  reporter.pass(`${label} створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
78
100
  }
79
101
 
102
+ /**
103
+ * Огортає рядкове значення в single-quotes для вставки у JS-масив. Канонічні
104
+ * entries (`@stryker-mutator/...`, `vue-macros`, `./stryker-...`) не містять
105
+ * лапок, тож escaping не потрібен.
106
+ * @param {string} s рядкове значення
107
+ * @returns {string} `'<s>'`
108
+ */
109
+ function quote(s) {
110
+ return `'${s}'`
111
+ }
112
+
113
+ /**
114
+ * Знаходить `export default { … }` як ObjectExpression. Повертає null, якщо
115
+ * default-export відсутній або не є object-literal (factory/функція/змінна) —
116
+ * augment у такому разі не чіпає файл.
117
+ * @param {{body: Array<{type: string, declaration?: {type: string}}>}} program oxc Program node
118
+ * @returns {object | null} ObjectExpression node або null
119
+ */
120
+ function findDefaultExportObject(program) {
121
+ const exp = program.body.find(n => n.type === 'ExportDefaultDeclaration')
122
+ const decl = exp?.declaration
123
+ return decl && decl.type === 'ObjectExpression' ? decl : null
124
+ }
125
+
126
+ /**
127
+ * Аналізує property `name` об'єкта: чи присутній, чи це чистий масив рядкових
128
+ * літералів і які значення вже містить. `dynamic: true` сигналить, що масив —
129
+ * computed (spread / non-string element / не ArrayExpression), і зливати його
130
+ * небезпечно.
131
+ * @param {object} obj ObjectExpression node
132
+ * @param {string} name ім'я property ('plugins' | 'ignorers')
133
+ * @returns {{prop: object|null, array: object|null, values: string[], dynamic: boolean}} стан property
134
+ */
135
+ function analyzeArrayProperty(obj, name) {
136
+ const prop = obj.properties.find(
137
+ p => p.type === 'Property' && !p.computed && p.key && (p.key.name === name || p.key.value === name)
138
+ )
139
+ if (!prop) return { prop: null, array: null, values: [], dynamic: false }
140
+ const value = prop.value
141
+ if (!value || value.type !== 'ArrayExpression') return { prop, array: null, values: [], dynamic: true }
142
+ const values = []
143
+ for (const el of value.elements) {
144
+ if (!el || el.type !== 'Literal' || typeof el.value !== 'string') {
145
+ return { prop, array: value, values: [], dynamic: true }
146
+ }
147
+ values.push(el.value)
148
+ }
149
+ return { prop, array: value, values, dynamic: false }
150
+ }
151
+
152
+ /**
153
+ * Вставка відсутніх рядкових елементів у вже існуючий масив (append перед `]`).
154
+ * Порожній масив → елементи між `[` `]`; непорожній → `, '<item>'` після
155
+ * останнього елемента (trailing comma, якщо вже є, лишається валідною).
156
+ * @param {object} arr ArrayExpression node
157
+ * @param {string[]} values поточні значення масиву
158
+ * @param {string[]} missing значення, яких бракує (вже у потрібному порядку)
159
+ * @returns {{pos: number, text: string}} одна точкова вставка
160
+ */
161
+ function arrayAppendEdit(arr, values, missing) {
162
+ if (values.length === 0) {
163
+ return { pos: arr.end - 1, text: missing.map(v => quote(v)).join(', ') }
164
+ }
165
+ const lastEl = arr.elements.at(-1)
166
+ return { pos: lastEl.end, text: missing.map(v => `, ${quote(v)}`).join('') }
167
+ }
168
+
169
+ /**
170
+ * Визначає відступ properties об'єкта за рядком останньої property (для нових
171
+ * рядків `plugins`/`ignorers`). Дефолт — 2 пробіли.
172
+ * @param {string} src вихідний текст конфіга
173
+ * @param {object} obj ObjectExpression node
174
+ * @returns {string} рядок-відступ (whitespace)
175
+ */
176
+ function detectIndent(src, obj) {
177
+ const props = obj.properties
178
+ if (props.length > 0) {
179
+ const start = props.at(-1).start
180
+ const lineStart = src.lastIndexOf('\n', start - 1) + 1
181
+ const ws = src.slice(lineStart, start)
182
+ if (INDENT_WS_RE.test(ws)) return ws
183
+ }
184
+ return ' '
185
+ }
186
+
187
+ /**
188
+ * Вставка нових properties (`plugins`/`ignorers`) у object-literal перед його
189
+ * закривальною `}`. Поважає trailing comma останньої property й коректно
190
+ * обробляє порожній об'єкт `{}`.
191
+ * @param {string} src вихідний текст конфіга
192
+ * @param {object} obj ObjectExpression node
193
+ * @param {string} indent відступ properties
194
+ * @param {string[]} lines рядки нових properties (без відступу й коми), напр. `plugins: [...]`
195
+ * @returns {{pos: number, text: string}} одна точкова вставка
196
+ */
197
+ function newPropertyEdit(src, obj, indent, lines) {
198
+ const block = lines.join(`,\n${indent}`)
199
+ const props = obj.properties
200
+ if (props.length === 0) {
201
+ return { pos: obj.start + 1, text: `\n${indent}${block}\n` }
202
+ }
203
+ const lastProp = props.at(-1)
204
+ const tail = src.slice(lastProp.end, obj.end - 1)
205
+ const commaMatch = tail.match(LEADING_COMMA_RE)
206
+ if (commaMatch) {
207
+ return { pos: lastProp.end + commaMatch[0].length, text: `\n${indent}${block}` }
208
+ }
209
+ return { pos: lastProp.end, text: `,\n${indent}${block}` }
210
+ }
211
+
212
+ /**
213
+ * Застосовує точкові вставки до тексту. Сортує за спаданням `pos`, щоб ранні
214
+ * offsets лишались валідними після вставок справа.
215
+ * @param {string} src вихідний текст
216
+ * @param {Array<{pos: number, text: string}>} edits вставки
217
+ * @returns {string} новий текст
218
+ */
219
+ function applyEdits(src, edits) {
220
+ let out = src
221
+ for (const e of edits.toSorted((a, b) => b.pos - a.pos)) {
222
+ out = out.slice(0, e.pos) + e.text + out.slice(e.pos)
223
+ }
224
+ return out
225
+ }
226
+
227
+ /**
228
+ * Augment-крок для вже-існуючого `stryker.config.mjs` у Vue JS-root:
229
+ * реєструє локальний `vue-macros` ignorer-плагін (`plugins`/`ignorers`), якщо
230
+ * його ще немає. Закриває drift-hole для проєктів, які мали non-vue config ще
231
+ * до 3.x Vue-підтримки — `ensureBaselineFile` такий файл idempotent-skip-ить,
232
+ * тож baseline-секцій `plugins`/`ignorers` він мовчки не отримує, і Stryker
233
+ * падає у dry-run з `defineProps()` error.
234
+ *
235
+ * Стратегія: oxc-parser — лише для **аналізу** (де у source-тексті
236
+ * default-export object, які properties/offsets уже є). Зміни — точкові
237
+ * string-splice-и у вихідному тексті (insert items), щоб НЕ переписати
238
+ * форматування й коментарі користувача (oxc serializer їх не зберігає). Після
239
+ * splice — повторний parse: якщо результат не компілюється → відкат і fail.
240
+ * @param {ReturnType<typeof createCheckReporter>} reporter check-reporter
241
+ * @param {string} cwd корінь проєкту (для relative-шляхів у логах)
242
+ * @param {string} jsRoot абсолютний шлях до Vue workspace-каталогу
243
+ * @returns {Promise<void>}
244
+ */
245
+ async function augmentVueStrykerConfig(reporter, cwd, jsRoot) {
246
+ const target = join(jsRoot, 'stryker.config.mjs')
247
+ const rel = relative(cwd, target)
248
+ const src = await readFile(target, 'utf8')
249
+
250
+ let result
251
+ try {
252
+ result = parseSync(target, src, { lang: 'js', sourceType: 'module' })
253
+ } catch (error) {
254
+ reporter.fail(`stryker.config.mjs не парситься (${rel}): ${error.message} — augment скіпнуто`)
255
+ return
256
+ }
257
+ if (result.errors?.length) {
258
+ const msg = result.errors[0]?.message ?? 'syntax error'
259
+ reporter.fail(`stryker.config.mjs має syntax error (${rel}): ${msg} — augment скіпнуто`)
260
+ return
261
+ }
262
+
263
+ const obj = findDefaultExportObject(result.program)
264
+ if (!obj) {
265
+ reporter.fail(
266
+ `stryker.config.mjs has non-literal default export (${rel}) — augment скіпнуто, ` +
267
+ 'додай вручну plugins/ignorers згідно stryker.config.vue.baseline.mjs'
268
+ )
269
+ return
270
+ }
271
+
272
+ const plugins = analyzeArrayProperty(obj, 'plugins')
273
+ const ignorers = analyzeArrayProperty(obj, 'ignorers')
274
+ if (plugins.dynamic || ignorers.dynamic) {
275
+ reporter.fail(
276
+ `stryker.config.mjs: plugins/ignorers — динамічний вираз (spread/computed) (${rel}) — ` +
277
+ 'augment скіпнуто, додай vue-macros ignorer вручну згідно stryker.config.vue.baseline.mjs'
278
+ )
279
+ return
280
+ }
281
+
282
+ const edits = []
283
+ const newPropLines = []
284
+ for (const [name, state, required] of [
285
+ ['plugins', plugins, [VITEST_RUNNER_PLUGIN, VUE_MACROS_PLUGIN]],
286
+ ['ignorers', ignorers, [VUE_MACROS_IGNORER]]
287
+ ]) {
288
+ const missing = required.filter(v => !state.values.includes(v))
289
+ if (state.array) {
290
+ if (missing.length > 0) edits.push(arrayAppendEdit(state.array, state.values, missing))
291
+ } else {
292
+ newPropLines.push(`${name}: [${required.map(v => quote(v)).join(', ')}]`)
293
+ }
294
+ }
295
+ if (newPropLines.length > 0) {
296
+ edits.push(newPropertyEdit(src, obj, detectIndent(src, obj), newPropLines))
297
+ }
298
+
299
+ if (edits.length === 0) {
300
+ reporter.pass(`vue-macros ignorer уже зареєстровано (${rel})`)
301
+ return
302
+ }
303
+
304
+ const next = applyEdits(src, edits)
305
+
306
+ // Safety: результат має компілюватися. Якщо string-splice дав невалідний JS
307
+ // (errors або виняток парсера на патологічному вводі) — відкат (не пишемо) і
308
+ // fail, щоб користувач не лишився зі зламаним конфігом.
309
+ let recheck
310
+ try {
311
+ recheck = parseSync(target, next, { lang: 'js', sourceType: 'module' })
312
+ } catch (error) {
313
+ reporter.fail(`stryker.config.mjs: augment дав некоректний результат (${rel}): ${error.message} — відкат, додай вручну`)
314
+ return
315
+ }
316
+ if (recheck.errors?.length) {
317
+ reporter.fail(`stryker.config.mjs: augment дав некоректний результат (${rel}) — відкат, додай вручну`)
318
+ return
319
+ }
320
+
321
+ await writeFile(target, next, 'utf8')
322
+ reporter.pass(`vue-macros ignorer додано у stryker.config.mjs (${rel}) (test.mdc)`)
323
+ }
324
+
80
325
  /**
81
326
  * @param {string} [cwd] корінь проєкту (default: `process.cwd()` — CLI-сумісність)
82
327
  * @returns {Promise<number>} 0 — OK або silently skipped, 1 — порушення
@@ -110,9 +355,18 @@ export async function check(cwd = process.cwd()) {
110
355
 
111
356
  for (const jsRoot of jsRoots) {
112
357
  const isVueRoot = await hasVueFiles(jsRoot)
358
+ const strykerTarget = join(jsRoot, 'stryker.config.mjs')
359
+ // Зчитуємо ДО ensureBaselineFile: чи файл уже існував. Якщо ні — baseline
360
+ // (vue-варіант для Vue-root) копіюється з уже-присутніми plugins/ignorers,
361
+ // augment не потрібен. Якщо існував — ensureBaselineFile idempotent-skip-ить,
362
+ // і саме тут augment закриває drift-hole.
363
+ const wasMissing = !existsSync(strykerTarget)
113
364
  const strykerBaseline = isVueRoot ? STRYKER_VUE_BASELINE_PATH : STRYKER_BASELINE_PATH
114
- await ensureBaselineFile(reporter, cwd, strykerBaseline, join(jsRoot, 'stryker.config.mjs'), 'stryker.config.mjs')
365
+ await ensureBaselineFile(reporter, cwd, strykerBaseline, strykerTarget, 'stryker.config.mjs')
115
366
  if (isVueRoot) {
367
+ if (!wasMissing) {
368
+ await augmentVueStrykerConfig(reporter, cwd, jsRoot)
369
+ }
116
370
  await ensureBaselineFile(
117
371
  reporter,
118
372
  cwd,
@@ -156,6 +156,8 @@ test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним
156
156
 
157
157
  JS-root без `.vue` отримує дефолтний baseline без `plugins`/`ignorers` (backward-compatible). Обидва файли копіюються idempotent — наявний `stryker.config.mjs` / `stryker-vue-macros-ignorer.mjs` не перетирається.
158
158
 
159
+ **Augment існуючого config.** Якщо у Vue JS-root `stryker.config.mjs` **уже лежить** (наприклад, після апгрейду з версії без Vue-підтримки — `ensureBaselineFile` його idempotent-skip-ить і baseline-секцій `plugins`/`ignorers` він мовчки не отримує, а Stryker падає у dry-run з `defineProps()` error), концерн `stryker_config` точково вставляє у наявний файл `plugins: [..., './stryker-vue-macros-ignorer.mjs']` і `ignorers: ['vue-macros']`, зберігши решту полів і коментарів. Редагування — string-splice за AST-аналізом (oxc-parser — лише для пошуку offsets, не для re-serialize), тож форматування й коментарі не переписуються; idempotent — повторний `fix test` не дублює entries. Якщо `plugins`/`ignorers` уже частково є — добавляються лише відсутні entries. Якщо `export default` — **не** object-literal (factory/функція/змінна) або масиви динамічні (spread/computed), augment пропускається з вимогою додати плагін вручну згідно [`stryker.config.vue.baseline.mjs`](./js/data/stryker_config/stryker.config.vue.baseline.mjs).
160
+
159
161
  ### Vitest baseline та `package.json#scripts`
160
162
 
161
163
  Поряд зі Stryker концерн `stryker_config` без дублювання копіює `vitest.config.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/`).