@7n/rules-lang-js 0.6.0 → 0.7.1

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +3 -2
  3. package/rules/js-bun-redis/lib/docs/redis-imports.md +2 -2
  4. package/rules/style/admin_table/docs/main.md +1 -1
  5. package/rules/style/admin_table/main.mjs +2 -2
  6. package/rules/style/gap/docs/main.md +1 -1
  7. package/rules/style/gap/main.mjs +2 -2
  8. package/rules/style/lint/docs/fix-lint.md +1 -1
  9. package/rules/style/lint/docs/main.md +1 -1
  10. package/rules/style/lint/fix-lint.mjs +1 -1
  11. package/rules/style/lint/main.mjs +18 -6
  12. package/rules/style/quasar_fixes/docs/main.md +1 -1
  13. package/rules/style/quasar_fixes/main.mjs +2 -2
  14. package/rules/style/tooling/docs/fix-tooling.md +1 -1
  15. package/rules/style/tooling/docs/main.md +1 -1
  16. package/rules/style/tooling/fix-tooling.mjs +1 -1
  17. package/rules/style/tooling/main.mjs +2 -2
  18. package/rules/test/docs/index.md +11 -0
  19. package/rules/test/lib/collect-test-file-offenders.mjs +49 -0
  20. package/rules/test/lib/docs/collect-test-file-offenders.md +29 -0
  21. package/rules/test/lib/docs/index.md +9 -0
  22. package/rules/test/location/concern.json +8 -0
  23. package/rules/test/location/docs/index.md +11 -0
  24. package/rules/test/location/docs/main.md +36 -0
  25. package/rules/test/location/location.mdc +52 -0
  26. package/rules/test/location/main.mjs +70 -0
  27. package/rules/test/main.json +1 -0
  28. package/rules/test/main.mdc +26 -0
  29. package/rules/test/no-bun-test-import/concern.json +7 -0
  30. package/rules/test/no-bun-test-import/docs/fix-no-bun-test-import.md +30 -0
  31. package/rules/test/no-bun-test-import/docs/index.md +10 -0
  32. package/rules/test/no-bun-test-import/docs/main.md +35 -0
  33. package/rules/test/no-bun-test-import/fix-no-bun-test-import.mjs +51 -0
  34. package/rules/test/no-bun-test-import/main.mjs +105 -0
  35. package/rules/test/no-bun-test-import/no-bun-test-import.mdc +9 -0
  36. package/rules/test/no-console-store-restore/concern.json +7 -0
  37. package/rules/test/no-console-store-restore/docs/index.md +11 -0
  38. package/rules/test/no-console-store-restore/docs/main.md +44 -0
  39. package/rules/test/no-console-store-restore/main.mjs +55 -0
  40. package/rules/test/no-console-store-restore/no-console-store-restore.mdc +11 -0
  41. package/rules/test/no-process-chdir/concern.json +7 -0
  42. package/rules/test/no-process-chdir/docs/index.md +11 -0
  43. package/rules/test/no-process-chdir/docs/main.md +32 -0
  44. package/rules/test/no-process-chdir/main.mjs +40 -0
  45. package/rules/test/no-process-chdir/no-process-chdir.mdc +15 -0
  46. package/rules/test/no-relative-fs-path/concern.json +7 -0
  47. package/rules/test/no-relative-fs-path/docs/index.md +11 -0
  48. package/rules/test/no-relative-fs-path/docs/main.md +34 -0
  49. package/rules/test/no-relative-fs-path/main.mjs +239 -0
  50. package/rules/test/no-relative-fs-path/no-relative-fs-path.mdc +22 -0
  51. package/rules/test/package_json/concern.json +11 -0
  52. package/rules/test/package_json/package_json.mdc +18 -0
  53. package/rules/test/package_json/package_json.rego +25 -0
  54. package/rules/test/package_json/template/package.json.contains.json +6 -0
  55. package/rules/test/sandbox-aware-test/concern.json +7 -0
  56. package/rules/test/sandbox-aware-test/docs/index.md +11 -0
  57. package/rules/test/sandbox-aware-test/docs/main.md +55 -0
  58. package/rules/test/sandbox-aware-test/main.mjs +89 -0
  59. package/rules/test/sandbox-aware-test/sandbox-aware-test.mdc +28 -0
  60. package/rules/test/stryker_config/concern.json +8 -0
  61. package/rules/test/stryker_config/data/stryker_config/docs/index.md +13 -0
  62. package/rules/test/stryker_config/data/stryker_config/docs/stryker-vue-macros-ignorer.md +31 -0
  63. package/rules/test/stryker_config/data/stryker_config/docs/stryker.config.baseline.md +31 -0
  64. package/rules/test/stryker_config/data/stryker_config/docs/stryker.config.vue.baseline.md +35 -0
  65. package/rules/test/stryker_config/data/stryker_config/stryker-vue-macros-ignorer.mjs +48 -0
  66. package/rules/test/stryker_config/data/stryker_config/stryker.config.baseline.mjs +18 -0
  67. package/rules/test/stryker_config/data/stryker_config/stryker.config.vue.baseline.mjs +23 -0
  68. package/rules/test/stryker_config/data/vitest_config/docs/index.md +11 -0
  69. package/rules/test/stryker_config/data/vitest_config/docs/vitest.config.baseline.md +35 -0
  70. package/rules/test/stryker_config/data/vitest_config/vitest.config.baseline.js +22 -0
  71. package/rules/test/stryker_config/docs/fix-stryker_config.md +41 -0
  72. package/rules/test/stryker_config/docs/index.md +12 -0
  73. package/rules/test/stryker_config/docs/main.md +38 -0
  74. package/rules/test/stryker_config/fix-stryker_config.mjs +77 -0
  75. package/rules/test/stryker_config/main.mjs +504 -0
  76. package/rules/test/stryker_config/stryker_config.mdc +26 -0
  77. package/rules/test/vitest-api-conventions/concern.json +7 -0
  78. package/rules/test/vitest-api-conventions/docs/index.md +9 -0
  79. package/rules/test/vitest-api-conventions/docs/main.md +40 -0
  80. package/rules/test/vitest-api-conventions/main.mjs +153 -0
  81. package/rules/test/vitest-api-conventions/vitest-api-conventions.mdc +129 -0
  82. package/rules/test/vitest-config-pool-forks/concern.json +7 -0
  83. package/rules/test/vitest-config-pool-forks/docs/index.md +11 -0
  84. package/rules/test/vitest-config-pool-forks/docs/main.md +42 -0
  85. package/rules/test/vitest-config-pool-forks/main.mjs +41 -0
  86. package/rules/test/vitest-config-pool-forks/vitest-config-pool-forks.mdc +34 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * @see ./docs/stryker_config.md
3
+ *
4
+ * Read-only detector: планує (НЕ виконує) копіювання stryker/vitest baseline-ів,
5
+ * vue-plugin-файла, augment існуючого Vue-config-а та `.gitignore`-entries.
6
+ * Кожна потрібна зміна стає violation із `data` (опис дії для T0). Запис робить
7
+ * окремий T0-fix (`fix-stryker_config.mjs`) — `lint --no-fix` не мутує дерево.
8
+ * Планувальник (`planStrykerActions`) і константи шляхів спільні для detector/T0.
9
+ */
10
+ import { existsSync } from 'node:fs'
11
+ import { glob, readFile } from 'node:fs/promises'
12
+ import { dirname, join, relative } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ import { parseSync } from 'oxc-parser'
16
+
17
+ import { createViolationReporter } from '@7n/rules/scripts/lib/lint-surface/violation-reporter.mjs'
18
+ import { readNRulesConfigLite } from '@7n/rules/scripts/lib/read-n-rules-config-lite.mjs'
19
+ import { resolveAllJsRoots } from '@7n/rules/scripts/utils/resolve-js-root.mjs'
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url))
22
+ export const STRYKER_BASELINE_PATH = join(HERE, 'data', 'stryker_config', 'stryker.config.baseline.mjs')
23
+ export const STRYKER_VUE_BASELINE_PATH = join(HERE, 'data', 'stryker_config', 'stryker.config.vue.baseline.mjs')
24
+ export const STRYKER_VUE_PLUGIN_PATH = join(HERE, 'data', 'stryker_config', 'stryker-vue-macros-ignorer.mjs')
25
+ const STRYKER_VUE_PLUGIN_FILENAME = 'stryker-vue-macros-ignorer.mjs'
26
+ export const VITEST_BASELINE_PATH = join(HERE, 'data', 'vitest_config', 'vitest.config.baseline.js')
27
+
28
+ /** Стабільні reasons. */
29
+ export const STRYKER_CONFIG_MISSING = 'stryker-config-missing'
30
+ export const STRYKER_VUE_AUGMENT = 'stryker-vue-augment'
31
+ export const STRYKER_VUE_AUGMENT_FAIL = 'stryker-vue-augment-fail'
32
+ export const GITIGNORE_MISSING = 'gitignore-missing'
33
+
34
+ // Канонічна назва vitest-конфіга — `.mjs` (нові файли, js.mdc); legacy
35
+ // `.js` лишається валідним. Перший знайдений виграє (.mjs пріоритетніший).
36
+ const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js']
37
+ // Заміна literal `configFile` у скопійованому stryker-baseline на фактичне
38
+ // ім'я vitest-конфіга jsRoot-а (узгодження Stryker ↔ vitest).
39
+ const STRYKER_CONFIG_FILE_RE = /configFile: 'vitest\.config\.[cm]?js'/u
40
+
41
+ /**
42
+ * Визначає ім'я vitest-конфіга для jsRoot: існуючий `.mjs`/`.js` (якщо є),
43
+ * інакше дефолт `vitest.config.mjs` (нові файли — `.mjs`). Існуючий
44
+ * `vitest.config.js` лишається валідним (backward-compat), новий не плодиться.
45
+ * @param {string} jsRoot абсолютний шлях до workspace-каталогу
46
+ * @returns {string} ім'я vitest-конфіга
47
+ */
48
+ function resolveVitestConfigName(jsRoot) {
49
+ return VITEST_CONFIG_NAMES.find(name => existsSync(join(jsRoot, name))) ?? 'vitest.config.mjs'
50
+ }
51
+
52
+ // Канонічні entries, які vue-варіант baseline тримає у `plugins`/`ignorers`.
53
+ // Augment-крок (augmentVueStrykerConfig) дбає, щоб саме вони були присутні в
54
+ // уже-існуючому `stryker.config.mjs` Vue-root-а. Нову property пишемо у
55
+ // canonical-порядку; у наявний масив лише дописуємо відсутні entries в кінець
56
+ // (Stryker нечутливий до порядку plugins/ignorers).
57
+ const VITEST_RUNNER_PLUGIN = '@stryker-mutator/vitest-runner'
58
+ const VUE_MACROS_PLUGIN = './stryker-vue-macros-ignorer.mjs'
59
+ const VUE_MACROS_IGNORER = 'vue-macros'
60
+
61
+ // Module-scope (prefer-static-regex): рядок-відступ цілком whitespace; leading
62
+ // кома (можливо з whitespace) після останньої property об'єкта.
63
+ const INDENT_WS_RE = /^\s*$/u
64
+ const LEADING_COMMA_RE = /^\s*,/u
65
+
66
+ // Тест-артефакти для .gitignore (подвійний-зірочка-префікс — для monorepo workspaces):
67
+ // - `**/reports/stryker/` — увесь каталог Stryker-output-у (`tempDirName` backup'и,
68
+ // mutation.json, HTML/dashboard-репорти якщо користувач додасть інші reporter-и).
69
+ // - `**/coverage/` — весь output vitest v8 coverage (`lcov.info` + HTML `lcov-report/`).
70
+ // Ефемерний: регенерується кожним прогоном; фінальні метрики живуть у `COVERAGE.md`.
71
+ // Gitignore не заважає `@7n/test coverage` читати `lcov.info` у тому ж прогоні.
72
+ // Покриваємо каталогами замість перелічування під-патернів.
73
+ const TEST_GITIGNORE_ENTRIES = ['**/reports/stryker/', '**/coverage/']
74
+
75
+ // .vue detection: scope — `<jsRoot>/src/**/*.vue` (як і Stryker mutate defaults для src/);
76
+ // skip build-артефактів і чужих node_modules, щоб не вмикати vue-варіант через transitive deps.
77
+ const VUE_GLOB_PATTERN = 'src/**/*.vue'
78
+ const VUE_GLOB_IGNORE = ['**/node_modules/**', '**/dist/**', '**/reports/**']
79
+
80
+ /**
81
+ * Чи містить jsRoot хоч один `.vue` файл під `src/` (skipping node_modules/dist/reports).
82
+ * @param {string} jsRoot абсолютний шлях до workspace-каталогу
83
+ * @returns {Promise<boolean>} true якщо знайдено хоча б один `.vue`
84
+ */
85
+ async function hasVueFiles(jsRoot) {
86
+ for await (const _rel of glob(VUE_GLOB_PATTERN, { cwd: jsRoot, exclude: VUE_GLOB_IGNORE })) {
87
+ return true
88
+ }
89
+ return false
90
+ }
91
+
92
+ /**
93
+ * Опис однієї дії-запису baseline-файла (для T0). Читання baseline і запис робить
94
+ * T0; detector лише планує. `transformKey` — необов'язковий маркер, який трансформ
95
+ * застосувати до тексту baseline (T0 мапить ключ на функцію); `null` = copy as-is.
96
+ * @typedef {object} BaselineAction
97
+ * @property {'baseline'} kind дискримінатор виду дії (завжди `'baseline'`).
98
+ * @property {string} baselinePath абсолютний шлях canonical baseline
99
+ * @property {string} target абсолютний шлях, куди писати
100
+ * @property {string} label людиночитна мітка
101
+ * @property {{ re: string, replacement: string }} [transform] string-replace над текстом baseline
102
+ */
103
+
104
+ /**
105
+ * Будує BaselineAction, якщо target ще не існує (idempotent). Read-only.
106
+ * @param {string} baselinePath абсолютний шлях до canonical baseline
107
+ * @param {string} target абсолютний шлях, куди копіювати
108
+ * @param {string} label мітка ("stryker.config.mjs" / "vitest.config.mjs")
109
+ * @param {{ re: string, replacement: string }} [transform] опційний string-replace baseline-тексту
110
+ * @returns {BaselineAction | null} дія або null, якщо файл уже є
111
+ */
112
+ function planBaselineFile(baselinePath, target, label, transform) {
113
+ if (existsSync(target)) return null
114
+ /** @type {BaselineAction} */
115
+ const action = { kind: 'baseline', baselinePath, target, label }
116
+ if (transform) action.transform = transform
117
+ return action
118
+ }
119
+
120
+ /**
121
+ * Огортає рядкове значення в single-quotes для вставки у JS-масив. Канонічні
122
+ * entries (`@stryker-mutator/...`, `vue-macros`, `./stryker-...`) не містять
123
+ * лапок, тож escaping не потрібен.
124
+ * @param {string} s рядкове значення
125
+ * @returns {string} `'<s>'`
126
+ */
127
+ function quote(s) {
128
+ return `'${s}'`
129
+ }
130
+
131
+ /**
132
+ * Знаходить `export default { … }` як ObjectExpression. Повертає null, якщо
133
+ * default-export відсутній або не є object-literal (factory/функція/змінна) —
134
+ * augment у такому разі не чіпає файл.
135
+ * @param {{body: Array<{type: string, declaration?: {type: string}}>}} program oxc Program node
136
+ * @returns {object | null} ObjectExpression node або null
137
+ */
138
+ function findDefaultExportObject(program) {
139
+ const exp = program.body.find(n => n.type === 'ExportDefaultDeclaration')
140
+ const decl = exp?.declaration
141
+ return decl && decl.type === 'ObjectExpression' ? decl : null
142
+ }
143
+
144
+ /**
145
+ * Аналізує property `name` об'єкта: чи присутній, чи це чистий масив рядкових
146
+ * літералів і які значення вже містить. `dynamic: true` сигналить, що масив —
147
+ * computed (spread / non-string element / не ArrayExpression), і зливати його
148
+ * небезпечно.
149
+ * @param {object} obj ObjectExpression node
150
+ * @param {string} name ім'я property ('plugins' | 'ignorers')
151
+ * @returns {{prop: object|null, array: object|null, values: string[], dynamic: boolean}} стан property
152
+ */
153
+ function analyzeArrayProperty(obj, name) {
154
+ const prop = obj.properties.find(
155
+ p => p.type === 'Property' && !p.computed && p.key && (p.key.name === name || p.key.value === name)
156
+ )
157
+ if (!prop) return { prop: null, array: null, values: [], dynamic: false }
158
+ const value = prop.value
159
+ if (!value || value.type !== 'ArrayExpression') return { prop, array: null, values: [], dynamic: true }
160
+ const values = []
161
+ for (const el of value.elements) {
162
+ if (!el || el.type !== 'Literal' || typeof el.value !== 'string') {
163
+ return { prop, array: value, values: [], dynamic: true }
164
+ }
165
+ values.push(el.value)
166
+ }
167
+ return { prop, array: value, values, dynamic: false }
168
+ }
169
+
170
+ /**
171
+ * Вставка відсутніх рядкових елементів у вже існуючий масив (append перед `]`).
172
+ * Порожній масив → елементи між `[` `]`; непорожній → `, '<item>'` після
173
+ * останнього елемента (trailing comma, якщо вже є, лишається валідною).
174
+ * @param {object} arr ArrayExpression node
175
+ * @param {string[]} values поточні значення масиву
176
+ * @param {string[]} missing значення, яких бракує (вже у потрібному порядку)
177
+ * @returns {{pos: number, text: string}} одна точкова вставка
178
+ */
179
+ function arrayAppendEdit(arr, values, missing) {
180
+ if (values.length === 0) {
181
+ return { pos: arr.end - 1, text: missing.map(v => quote(v)).join(', ') }
182
+ }
183
+ const lastEl = arr.elements.at(-1)
184
+ return { pos: lastEl.end, text: missing.map(v => `, ${quote(v)}`).join('') }
185
+ }
186
+
187
+ /**
188
+ * Визначає відступ properties об'єкта за рядком останньої property (для нових
189
+ * рядків `plugins`/`ignorers`). Дефолт — 2 пробіли.
190
+ * @param {string} src вихідний текст конфіга
191
+ * @param {object} obj ObjectExpression node
192
+ * @returns {string} рядок-відступ (whitespace)
193
+ */
194
+ function detectIndent(src, obj) {
195
+ const props = obj.properties
196
+ if (props.length > 0) {
197
+ const start = props.at(-1).start
198
+ const lineStart = src.lastIndexOf('\n', start - 1) + 1
199
+ const ws = src.slice(lineStart, start)
200
+ if (INDENT_WS_RE.test(ws)) return ws
201
+ }
202
+ return ' '
203
+ }
204
+
205
+ /**
206
+ * Вставка нових properties (`plugins`/`ignorers`) у object-literal перед його
207
+ * закривальною `}`. Поважає trailing comma останньої property й коректно
208
+ * обробляє порожній об'єкт `{}`.
209
+ * @param {string} src вихідний текст конфіга
210
+ * @param {object} obj ObjectExpression node
211
+ * @param {string} indent відступ properties
212
+ * @param {string[]} lines рядки нових properties (без відступу й коми), напр. `plugins: [...]`
213
+ * @returns {{pos: number, text: string}} одна точкова вставка
214
+ */
215
+ function newPropertyEdit(src, obj, indent, lines) {
216
+ const block = lines.join(`,\n${indent}`)
217
+ const props = obj.properties
218
+ if (props.length === 0) {
219
+ return { pos: obj.start + 1, text: `\n${indent}${block}\n` }
220
+ }
221
+ const lastProp = props.at(-1)
222
+ const tail = src.slice(lastProp.end, obj.end - 1)
223
+ const commaMatch = tail.match(LEADING_COMMA_RE)
224
+ if (commaMatch) {
225
+ return { pos: lastProp.end + commaMatch[0].length, text: `\n${indent}${block}` }
226
+ }
227
+ return { pos: lastProp.end, text: `,\n${indent}${block}` }
228
+ }
229
+
230
+ /**
231
+ * Застосовує точкові вставки до тексту. Сортує за спаданням `pos`, щоб ранні
232
+ * offsets лишались валідними після вставок справа.
233
+ * @param {string} src вихідний текст
234
+ * @param {Array<{pos: number, text: string}>} edits вставки
235
+ * @returns {string} новий текст
236
+ */
237
+ function applyEdits(src, edits) {
238
+ let out = src
239
+ for (const e of edits.toSorted((a, b) => b.pos - a.pos)) {
240
+ out = out.slice(0, e.pos) + e.text + out.slice(e.pos)
241
+ }
242
+ return out
243
+ }
244
+
245
+ /**
246
+ * Augment-крок для вже-існуючого `stryker.config.mjs` у Vue JS-root:
247
+ * реєструє локальний `vue-macros` ignorer-плагін (`plugins`/`ignorers`), якщо
248
+ * його ще немає. Закриває drift-hole для проєктів, які мали non-vue config ще
249
+ * до 3.x Vue-підтримки — `ensureBaselineFile` такий файл idempotent-skip-ить,
250
+ * тож baseline-секцій `plugins`/`ignorers` він мовчки не отримує, і Stryker
251
+ * падає у dry-run з `defineProps()` error.
252
+ *
253
+ * Стратегія: oxc-parser — лише для **аналізу** (де у source-тексті
254
+ * default-export object, які properties/offsets уже є). Зміни — точкові
255
+ * string-splice-и у вихідному тексті (insert items), щоб НЕ переписати
256
+ * форматування й коментарі користувача (oxc serializer їх не зберігає). Після
257
+ * splice — повторний parse: якщо результат не компілюється → відкат і fail.
258
+ * @param {string} cwd корінь проєкту (для relative-шляхів у логах)
259
+ * @param {string} jsRoot абсолютний шлях до Vue workspace-каталогу
260
+ * @returns {Promise<{ ok: false, message: string } | { ok: true, target: string, content: string | null }>}
261
+ * `ok:false` — augment неможливий (fail-violation); `ok:true, content:null` — no-op;
262
+ * `ok:true, content:string` — обчислений новий вміст для запису T0-ом
263
+ */
264
+ export async function planVueAugment(cwd, jsRoot) {
265
+ const target = join(jsRoot, 'stryker.config.mjs')
266
+ const rel = relative(cwd, target)
267
+ const src = await readFile(target, 'utf8')
268
+
269
+ let result
270
+ try {
271
+ result = parseSync(target, src, { lang: 'js', sourceType: 'module' })
272
+ } catch (error) {
273
+ return { ok: false, message: `stryker.config.mjs не парситься (${rel}): ${error.message} — augment скіпнуто` }
274
+ }
275
+ if (result.errors?.length) {
276
+ const msg = result.errors[0]?.message ?? 'syntax error'
277
+ return { ok: false, message: `stryker.config.mjs має syntax error (${rel}): ${msg} — augment скіпнуто` }
278
+ }
279
+
280
+ const obj = findDefaultExportObject(result.program)
281
+ if (!obj) {
282
+ return {
283
+ ok: false,
284
+ message:
285
+ `stryker.config.mjs has non-literal default export (${rel}) — augment скіпнуто, ` +
286
+ 'додай вручну plugins/ignorers згідно stryker.config.vue.baseline.mjs'
287
+ }
288
+ }
289
+
290
+ const plugins = analyzeArrayProperty(obj, 'plugins')
291
+ const ignorers = analyzeArrayProperty(obj, 'ignorers')
292
+ if (plugins.dynamic || ignorers.dynamic) {
293
+ return {
294
+ ok: false,
295
+ message:
296
+ `stryker.config.mjs: plugins/ignorers — динамічний вираз (spread/computed) (${rel}) — ` +
297
+ 'augment скіпнуто, додай vue-macros ignorer вручну згідно stryker.config.vue.baseline.mjs'
298
+ }
299
+ }
300
+
301
+ const edits = []
302
+ const newPropLines = []
303
+ for (const [name, state, required] of [
304
+ ['plugins', plugins, [VITEST_RUNNER_PLUGIN, VUE_MACROS_PLUGIN]],
305
+ ['ignorers', ignorers, [VUE_MACROS_IGNORER]]
306
+ ]) {
307
+ const missing = required.filter(v => !state.values.includes(v))
308
+ if (state.array) {
309
+ if (missing.length > 0) edits.push(arrayAppendEdit(state.array, state.values, missing))
310
+ } else {
311
+ newPropLines.push(`${name}: [${required.map(v => quote(v)).join(', ')}]`)
312
+ }
313
+ }
314
+ if (newPropLines.length > 0) {
315
+ edits.push(newPropertyEdit(src, obj, detectIndent(src, obj), newPropLines))
316
+ }
317
+
318
+ if (edits.length === 0) return { ok: true, target, content: null }
319
+
320
+ const next = applyEdits(src, edits)
321
+
322
+ // Safety: результат має компілюватися. Якщо string-splice дав невалідний JS
323
+ // (errors або виняток парсера на патологічному вводі) — fail (не пишемо), щоб
324
+ // користувач не лишився зі зламаним конфігом.
325
+ let recheck
326
+ try {
327
+ recheck = parseSync(target, next, { lang: 'js', sourceType: 'module' })
328
+ } catch (error) {
329
+ return {
330
+ ok: false,
331
+ message: `stryker.config.mjs: augment дав некоректний результат (${rel}): ${error.message} — відкат, додай вручну`
332
+ }
333
+ }
334
+ if (recheck.errors?.length) {
335
+ return {
336
+ ok: false,
337
+ message: `stryker.config.mjs: augment дав некоректний результат (${rel}) — відкат, додай вручну`
338
+ }
339
+ }
340
+
341
+ return { ok: true, target, content: next }
342
+ }
343
+
344
+ /** Header-коментар для секції тест-артефактів у `.gitignore`. */
345
+ export const GITIGNORE_SECTION_LABEL = 'Test artifacts: Stryker + coverage (test.mdc)'
346
+
347
+ /**
348
+ * Read-only: чи відсутні якісь із `TEST_GITIGNORE_ENTRIES` у кореневому `.gitignore`.
349
+ * Дублює дешеву перевірку `ensureGitignoreEntries` без запису.
350
+ * @param {string} cwd корінь репо
351
+ * @returns {Promise<string[]>} відсутні entries (порожній — нічого додавати)
352
+ */
353
+ async function missingGitignoreEntries(cwd) {
354
+ const gitignorePath = join(cwd, '.gitignore')
355
+ const existing = existsSync(gitignorePath) ? await readFile(gitignorePath, 'utf8') : ''
356
+ const lines = new Set(existing.split('\n').map(l => l.trim()))
357
+ return TEST_GITIGNORE_ENTRIES.filter(e => !lines.has(e))
358
+ }
359
+
360
+ /**
361
+ * @typedef {object} StrykerPlan
362
+ * @property {string | null} fatal fail-message, що зупиняє план (missing baseline / no root)
363
+ * @property {BaselineAction[]} baselineActions copy-baseline дії (stryker/vitest/vue-plugin)
364
+ * @property {Array<{ target: string, content: string }>} augmentWrites augment-записи (computed content)
365
+ * @property {string[]} augmentFails augment-fail повідомлення (read-only diagnostics)
366
+ * @property {string[]} gitignoreMissing відсутні `.gitignore`-entries
367
+ */
368
+
369
+ /**
370
+ * Vue-специфічні дії для одного js-root: augment існуючого stryker-конфіга (drift-hole)
371
+ * та baseline vue-plugin. Мутує `plan` (append до baselineActions/augmentWrites/augmentFails).
372
+ * @param {StrykerPlan} plan план, що накопичує дії
373
+ * @param {string} cwd корінь репо
374
+ * @param {string} jsRoot корінь js-workspace
375
+ * @param {boolean} wasMissing чи stryker.config.mjs був відсутній до планування
376
+ * @returns {Promise<void>} завершення після append
377
+ */
378
+ async function planVueRootActions(plan, cwd, jsRoot, wasMissing) {
379
+ if (!wasMissing) {
380
+ const res = await planVueAugment(cwd, jsRoot)
381
+ if (!res.ok) {
382
+ plan.augmentFails.push(res.message)
383
+ } else if (res.content !== null) {
384
+ plan.augmentWrites.push({ target: res.target, content: res.content })
385
+ }
386
+ }
387
+ const pluginAction = planBaselineFile(
388
+ STRYKER_VUE_PLUGIN_PATH,
389
+ join(jsRoot, STRYKER_VUE_PLUGIN_FILENAME),
390
+ STRYKER_VUE_PLUGIN_FILENAME
391
+ )
392
+ if (pluginAction) plan.baselineActions.push(pluginAction)
393
+ }
394
+
395
+ /**
396
+ * Планує baseline/augment-дії для одного js-root. Мутує `plan`.
397
+ * @param {StrykerPlan} plan план, що накопичує дії
398
+ * @param {string} cwd корінь репо
399
+ * @param {string} jsRoot корінь js-workspace
400
+ * @returns {Promise<void>} завершення після append
401
+ */
402
+ async function planJsRootActions(plan, cwd, jsRoot) {
403
+ const isVueRoot = await hasVueFiles(jsRoot)
404
+ const strykerTarget = join(jsRoot, 'stryker.config.mjs')
405
+ // Чи файл уже існує (до будь-якого запису). Якщо ні — baseline (vue-варіант для
406
+ // Vue-root) уже містить plugins/ignorers, augment не потрібен. Якщо існував —
407
+ // baseline idempotent-skip, і augment закриває drift-hole.
408
+ const wasMissing = !existsSync(strykerTarget)
409
+ const strykerBaseline = isVueRoot ? STRYKER_VUE_BASELINE_PATH : STRYKER_BASELINE_PATH
410
+ const vitestName = resolveVitestConfigName(jsRoot)
411
+ const strykerAction = planBaselineFile(strykerBaseline, strykerTarget, 'stryker.config.mjs', {
412
+ re: STRYKER_CONFIG_FILE_RE.source,
413
+ replacement: `configFile: '${vitestName}'`
414
+ })
415
+ if (strykerAction) plan.baselineActions.push(strykerAction)
416
+
417
+ if (isVueRoot) {
418
+ await planVueRootActions(plan, cwd, jsRoot, wasMissing)
419
+ }
420
+ const vitestAction = planBaselineFile(VITEST_BASELINE_PATH, join(jsRoot, vitestName), vitestName)
421
+ if (vitestAction) plan.baselineActions.push(vitestAction)
422
+ }
423
+
424
+ /**
425
+ * Чистий планувальник (read-only): обчислює всі потрібні зміни для stryker_config
426
+ * без жодного запису. Спільний для detector-а (→ violations) і T0-fix (→ writes).
427
+ * @param {string} cwd корінь репо
428
+ * @returns {Promise<StrykerPlan>} план змін (baseline/augment/gitignore) без запису.
429
+ */
430
+ export async function planStrykerActions(cwd) {
431
+ /** @type {StrykerPlan} */
432
+ const plan = { fatal: null, baselineActions: [], augmentWrites: [], augmentFails: [], gitignoreMissing: [] }
433
+
434
+ const jsRoots = await resolveAllJsRoots(cwd)
435
+ if (jsRoots.length === 0) {
436
+ plan.fatal = 'test: js enabled, але кореневий package.json не знайдено (test.mdc)'
437
+ return plan
438
+ }
439
+
440
+ for (const baselinePath of [
441
+ STRYKER_BASELINE_PATH,
442
+ STRYKER_VUE_BASELINE_PATH,
443
+ STRYKER_VUE_PLUGIN_PATH,
444
+ VITEST_BASELINE_PATH
445
+ ]) {
446
+ if (!existsSync(baselinePath)) {
447
+ plan.fatal = `canonical baseline не знайдено (${baselinePath}) — перевстанови @7n/rules`
448
+ return plan
449
+ }
450
+ }
451
+
452
+ for (const jsRoot of jsRoots) {
453
+ await planJsRootActions(plan, cwd, jsRoot)
454
+ }
455
+
456
+ plan.gitignoreMissing = await missingGitignoreEntries(cwd)
457
+ return plan
458
+ }
459
+
460
+ /**
461
+ * Виконує планувальник і транслює план у pass/fail-звіт лінту.
462
+ * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст лінту (cwd, репортер).
463
+ * @returns {Promise<import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintResult>} результат перевірки з pass/fail.
464
+ */
465
+ export async function lint(ctx) {
466
+ const reporter = createViolationReporter(ctx)
467
+ const cwd = ctx.cwd
468
+ const config = await readNRulesConfigLite(cwd)
469
+
470
+ // Self-gate: js має бути enabled
471
+ if (!config.rules.includes('js') || config.disableRules.includes('js')) {
472
+ return reporter.result()
473
+ }
474
+
475
+ const plan = await planStrykerActions(cwd)
476
+ if (plan.fatal) {
477
+ reporter.fail(plan.fatal)
478
+ return reporter.result()
479
+ }
480
+
481
+ for (const a of plan.baselineActions) {
482
+ reporter.fail(
483
+ `${a.label} відсутній (${relative(cwd, a.target)}) — запусти \`npx @7n/rules lint test\` для canonical baseline (test.mdc)`,
484
+ { reason: STRYKER_CONFIG_MISSING, file: relative(cwd, a.target) }
485
+ )
486
+ }
487
+ for (const w of plan.augmentWrites) {
488
+ reporter.fail(
489
+ `vue-macros ignorer не зареєстровано у stryker.config.mjs (${relative(cwd, w.target)}) — запусти \`npx @7n/rules lint test\` (test.mdc)`,
490
+ { reason: STRYKER_VUE_AUGMENT, file: relative(cwd, w.target) }
491
+ )
492
+ }
493
+ for (const msg of plan.augmentFails) {
494
+ reporter.fail(msg, STRYKER_VUE_AUGMENT_FAIL)
495
+ }
496
+ if (plan.gitignoreMissing.length > 0) {
497
+ reporter.fail(
498
+ `.gitignore: бракує тест-патернів (${plan.gitignoreMissing.join(', ')}) — запусти \`npx @7n/rules lint test\` (test.mdc)`,
499
+ GITIGNORE_MISSING
500
+ )
501
+ }
502
+
503
+ return reporter.result()
504
+ }
@@ -0,0 +1,26 @@
1
+ ## Налаштування mutation-testing: Stryker + Vitest baseline
2
+
3
+ Якщо у `.n-rules.json#rules` присутнє правило `js` — правило `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`.
4
+
5
+ Якщо у JS-root уже лежить legacy `vitest.config.js` — він лишається валідним, новий `.mjs` поряд не створюється, а `vitest.configFile` у скопійованому `stryker.config.mjs` приводиться до фактичного імені.
6
+
7
+ Канон Stryker config (Vitest runner + perTest): [stryker.config.baseline.mjs](./data/stryker_config/stryker.config.baseline.mjs)
8
+
9
+ ### Vue SFC (`<script setup>` macros)
10
+
11
+ Якщо у JS-root знайдено бодай один `.vue` під `src/` (skip `node_modules`/`dist`/`reports`) — концерн ставить **vue-варіант** baseline ([`stryker.config.vue.baseline.mjs`](./data/stryker_config/stryker.config.vue.baseline.mjs)) замість звичайного і додатково копіює локальний Stryker `Ignore`-плагін [`stryker-vue-macros-ignorer.mjs`](./data/stryker_config/stryker-vue-macros-ignorer.mjs) поряд із конфігом.
12
+
13
+ Плагін реєструється як `plugins: ['@stryker-mutator/vitest-runner', './stryker-vue-macros-ignorer.mjs']` + `ignorers: ['vue-macros']` і виключає з мутацій виклики `<script setup>`-макросів: **`defineProps`**, **`defineEmits`**, **`defineModel`**, **`defineSlots`**, **`defineExpose`**, **`defineOptions`**. Без плагіна Stryker огортає аргументи макроса у coverage-тернарник (`stryMutAct_9fa48(...) ? {} : (stryCov_9fa48(...), {...})`), а `@vue/compiler-sfc` падає з `defineProps() in <script setup> cannot reference locally declared variables` — макроси мають бути статично-аналізованими на етапі compile-sfc.
14
+
15
+ JS-root без `.vue` отримує дефолтний baseline без `plugins`/`ignorers` (backward-compatible). Обидва файли копіюються idempotent — наявний `stryker.config.mjs` / `stryker-vue-macros-ignorer.mjs` не перетирається.
16
+
17
+ **Augment існуючого config.** Якщо у Vue JS-root `stryker.config.mjs` **уже лежить** (наприклад, після апгрейду з версії без Vue-підтримки), концерн `stryker_config` точково вставляє у наявний файл `plugins: [..., './stryker-vue-macros-ignorer.mjs']` і `ignorers: ['vue-macros']`, зберігши решту полів і коментарів. Редагування — string-splice за AST-аналізом (oxc-parser — лише для пошуку offsets, не для re-serialize), тож форматування й коментарі не переписуються; idempotent — повторний `fix test` не дублює entries. Якщо `export default` — **не** object-literal (factory/функція/змінна) або масиви динамічні (spread/computed), augment пропускається з вимогою додати плагін вручну згідно [`stryker.config.vue.baseline.mjs`](./data/stryker_config/stryker.config.vue.baseline.mjs).
18
+
19
+ ### `.gitignore` тест-артефактів
20
+
21
+ Концерн `stryker_config` без дублювання додає у кореневий `.gitignore` тест-патерни:
22
+
23
+ - `**/reports/stryker/` — увесь каталог Stryker-output-у (backup'и `tempDirName`, `mutation.json`, HTML/dashboard-репорти якщо додасте інші reporter-и).
24
+ - `**/coverage/` — весь output vitest v8 coverage (`lcov.info` + HTML `lcov-report/`). Ефемерний: регенерується кожним прогоном, фінальні метрики живуть у `COVERAGE.md`.
25
+
26
+ Це запобігає випадковому коміту build-артефактів.
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
+ "lint": {
4
+ "scope": "full",
5
+ "glob": ["**/*.test.mjs", "**/*.test.js"]
6
+ }
7
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/rules/test/vitest-api-conventions
4
+ resource: plugins/lang-js/rules/test/vitest-api-conventions/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | -------------------- | --------- |
9
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,40 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.mjs
4
+ resource: plugins/lang-js/rules/test/vitest-api-conventions/main.mjs
5
+ docgen:
6
+ crc: 638031f8
7
+ ---
8
+
9
+ ## Огляд
10
+
11
+ Детектор concern-а `vitest-api-conventions`: перевіряє, що жоден тестовий файл проєкту
12
+ не викликає `expect(...).toBe(...)` з об'єктним чи масивним літералом як першим аргументом.
13
+ Така перевірка завжди хибна незалежно від вмісту (reference equality на новоствореному
14
+ значенні) — канонічна заміна для об'єктів/масивів — `toEqual` (deep equality).
15
+
16
+ ## Поведінка
17
+
18
+ Обходить дерево проєкту (пропускаючи `node_modules` і шляхи з cursor-ignore) і збирає всі
19
+ файли з іменем `*.test.mjs`/`*.test.js`. У кожному файлі шукає виклики `.toBe(`, чий перший
20
+ аргумент — саме об'єктний (`{...}`) чи масивний (`[...]`) літерал: сканує парність дужок
21
+ з урахуванням рядкових і template-літералів усередині, щоб не збитись на дужки в рядках.
22
+ Виклик рахується порушенням лише якщо одразу після закриваючої дужки літерала (з пропуском
23
+ пробілів) іде дужка, що закриває сам виклик, `)` — тобто ланцюжок на кшталт
24
+ `.toBe([...].join('\n'))` не матчиться, бо результат виклику — рядок-примітив, а не
25
+ посилання на масив/обʼєкт.
26
+
27
+ Для кожного знайденого випадку формує порушення з файлом (відносний шлях від cwd) і
28
+ номером рядка.
29
+
30
+ ## Публічний API
31
+
32
+ `lint(ctx)` — читає всі тестові файли проєкту (`ctx.cwd`), перевіряє їх на заборонений
33
+ патерн `toBe` з об'єктним/масивним літералом і повертає список порушень
34
+ (`{ file, message, reason }` на кожен знайдений виклик), або порожній результат, якщо
35
+ жоден тестовий файл не порушує конвенцію (test.mdc, vitest-api-conventions).
36
+
37
+ ## Гарантії поведінки
38
+
39
+ - Read-only: не виконує операцій запису (ФС/БД) — лише читає файли й повертає порушення.
40
+ - Сканує лише `*.test.mjs`/`*.test.js`; інші файли (в т.ч. `node_modules`) ігноруються.