@7n/rules-lang-js 0.8.0 → 0.10.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/doc-files/docs/extractors.md +1 -1
  3. package/doc-files/docs/index.md +1 -0
  4. package/doc-files/docs/js-facts.md +47 -0
  5. package/doc-files/docs/units-js.md +1 -2
  6. package/doc-files/extractors.mjs +135 -12
  7. package/doc-files/js-facts.mjs +28 -0
  8. package/doc-files/units-js.mjs +26 -10
  9. package/package.json +1 -1
  10. package/rules/bun/package_json/package_json.mdc +3 -1
  11. package/rules/bun/package_json/package_json.rego +30 -1
  12. package/rules/npm-module/npm_package_json/npm_package_json.mdc +18 -3
  13. package/rules/npm-module/npm_package_json/npm_package_json.rego +42 -5
  14. package/rules/storybook/adopt/docs/index.md +9 -0
  15. package/rules/storybook/adopt/docs/main.md +46 -0
  16. package/rules/storybook/adopt/main.mjs +379 -0
  17. package/rules/storybook/hygiene/concern.json +4 -0
  18. package/rules/storybook/hygiene/docs/index.md +9 -0
  19. package/rules/storybook/hygiene/docs/main.md +45 -0
  20. package/rules/storybook/hygiene/main.mjs +254 -0
  21. package/rules/storybook/main.json +1 -0
  22. package/rules/storybook/main.mdc +60 -0
  23. package/rules/storybook/mocking/concern.json +3 -0
  24. package/rules/storybook/mocking/mocking.mdc +108 -0
  25. package/rules/storybook/scaffold/concern.json +5 -0
  26. package/rules/storybook/scaffold/docs/fix-scaffold.md +29 -0
  27. package/rules/storybook/scaffold/docs/index.md +10 -0
  28. package/rules/storybook/scaffold/docs/main.md +36 -0
  29. package/rules/storybook/scaffold/fix-scaffold.mjs +150 -0
  30. package/rules/storybook/scaffold/main.mjs +164 -0
  31. package/rules/storybook/scaffold/template/docs/index.md +10 -0
  32. package/rules/storybook/scaffold/template/docs/main.md +30 -0
  33. package/rules/storybook/scaffold/template/docs/preview.md +46 -0
  34. package/rules/storybook/scaffold/template/main.js +45 -0
  35. package/rules/storybook/scaffold/template/mocks/docs/gql-sse.md +36 -0
  36. package/rules/storybook/scaffold/template/mocks/docs/index.md +9 -0
  37. package/rules/storybook/scaffold/template/mocks/gql-sse.js +25 -0
  38. package/rules/storybook/scaffold/template/preview.js +47 -0
  39. package/rules/storybook/scope/concern.json +4 -0
  40. package/rules/storybook/scope/docs/index.md +9 -0
  41. package/rules/storybook/scope/docs/main.md +62 -0
  42. package/rules/storybook/scope/main.mjs +202 -0
  43. package/rules/storybook/vitest-config/concern.json +8 -0
  44. package/rules/storybook/vitest-config/docs/fix-vitest-config.md +38 -0
  45. package/rules/storybook/vitest-config/docs/index.md +10 -0
  46. package/rules/storybook/vitest-config/docs/main.md +72 -0
  47. package/rules/storybook/vitest-config/fix-vitest-config.mjs +340 -0
  48. package/rules/storybook/vitest-config/main.mjs +358 -0
  49. package/rules/storybook/vitest-config/template/docs/index.md +12 -0
  50. package/rules/storybook/vitest-config/template/docs/storybook-project-entry.md +34 -0
  51. package/rules/storybook/vitest-config/template/docs/unit-project-entry.md +30 -0
  52. package/rules/storybook/vitest-config/template/docs/vitest.config.baseline.md +29 -0
  53. package/rules/storybook/vitest-config/template/docs/vitest.stryker.config.baseline.md +31 -0
  54. package/rules/storybook/vitest-config/template/storybook-project-entry.js +22 -0
  55. package/rules/storybook/vitest-config/template/unit-project-entry.js +5 -0
  56. package/rules/storybook/vitest-config/template/vitest.config.baseline.mjs +37 -0
  57. package/rules/storybook/vitest-config/template/vitest.stryker.config.baseline.mjs +19 -0
  58. package/rules/storybook/vitest-config/vitest-config.mdc +33 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * T0-autofix для concern-а `storybook/vitest-config`: детерміноване приведення
3
+ * vitest-конфіга Vue-компонентної бібліотеки у скоупі Storybook до канону
4
+ * `test.projects` (`unit`+`storybook`, browser-mode лише chromium) і генерація
5
+ * ізольованого `vitest.stryker.config.*` (ADR Кластер 5).
6
+ *
7
+ * Стратегія — та сама, що й `test/stryker_config/fix-stryker_config.mjs`:
8
+ * читання/аналіз (oxc-parser) і запис лише тут; detector (`main.mjs`)
9
+ * read-only. Для вже наявного vitest-конфіга — точкові string-splice-и
10
+ * (insert-only), щоб НЕ переписати форматування/коментарі решти файлу;
11
+ * після splice — повторний parse і відкат при невалідному результаті.
12
+ */
13
+ import { existsSync } from 'node:fs'
14
+ import { readFile, writeFile } from 'node:fs/promises'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ import {
19
+ classifyProjects,
20
+ findProperty,
21
+ findTestObject,
22
+ LEADING_COMMA_RE,
23
+ parseModule,
24
+ REASON_STORYBOOK_PROJECT_MISSING,
25
+ REASON_STRYKER_CONFIG_MISSING,
26
+ REASON_UNIT_PROJECT_MISSING,
27
+ REASON_VITEST_CONFIG_MISSING,
28
+ resolveViteConfigName,
29
+ resolveVitestConfigPath,
30
+ storiesGlobForVitestConfig,
31
+ STORYBOOK_TEST_IMPORT,
32
+ strykerConfigPathFor,
33
+ WHITESPACE_ONLY_RE
34
+ } from './main.mjs'
35
+
36
+ const HERE = dirname(fileURLToPath(import.meta.url))
37
+ const TEMPLATE_DIR = join(HERE, 'template')
38
+ const STORIES_GLOB_TOKEN = '__STORYBOOK_STORIES_GLOB__'
39
+ const VITE_CONFIG_IMPORT_TOKEN = '__VITE_CONFIG_IMPORT__'
40
+
41
+ const TRIGGER_REASONS = new Set([
42
+ REASON_VITEST_CONFIG_MISSING,
43
+ REASON_STRYKER_CONFIG_MISSING,
44
+ REASON_UNIT_PROJECT_MISSING,
45
+ REASON_STORYBOOK_PROJECT_MISSING
46
+ ])
47
+
48
+ /**
49
+ * Витягує вміст `export default {...}` із template-модуля (сам файл — валідний
50
+ * модуль лише для JS-лінту цього репо; тут потрібен саме літерал об'єкта).
51
+ * @param {string} absTemplatePath абсолютний шлях template-файлу
52
+ * @returns {Promise<string>} текст `ObjectExpression` (без `export default`)
53
+ */
54
+ async function readTemplateExportedObject(absTemplatePath) {
55
+ const src = await readFile(absTemplatePath, 'utf8')
56
+ const parsed = parseModule(absTemplatePath, src)
57
+ const exportDecl = parsed.program.body.find(n => n.type === 'ExportDefaultDeclaration')
58
+ const decl = exportDecl?.declaration
59
+ if (!decl) throw new Error(`template без export default: ${absTemplatePath}`)
60
+ return src.slice(decl.start, decl.end)
61
+ }
62
+
63
+ /**
64
+ * Відступ properties/elements за рядком останнього — дефолт 2 пробіли.
65
+ * @param {string} src вихідний текст
66
+ * @param {Array<{start: number}>} items properties/elements контейнера
67
+ * @param {string} [fallback] дефолтний відступ
68
+ * @returns {string} рядок-відступ
69
+ */
70
+ function detectIndent(src, items, fallback = ' ') {
71
+ if (items.length > 0) {
72
+ const start = items.at(-1).start
73
+ const lineStart = src.lastIndexOf('\n', start - 1) + 1
74
+ const ws = src.slice(lineStart, start)
75
+ if (WHITESPACE_ONLY_RE.test(ws)) return ws
76
+ }
77
+ return fallback
78
+ }
79
+
80
+ /**
81
+ * Відступає всі рядки, крім першого (перший отримує відступ ззовні, при
82
+ * розміщенні у вставці).
83
+ * @param {string} text багаторядковий текст (перший рядок — база нульового відступу)
84
+ * @param {string} indent відступ для рядків 2..N
85
+ * @returns {string} відступлений текст
86
+ */
87
+ function indentBlock(text, indent) {
88
+ return text
89
+ .split('\n')
90
+ .map((line, i) => (i === 0 ? line : `${indent}${line}`))
91
+ .join('\n')
92
+ }
93
+
94
+ /**
95
+ * Застосовує точкові вставки до тексту (сортує за спаданням `pos`, щоб раніші
96
+ * offsets лишались валідними після вставок справа).
97
+ * @param {string} src вихідний текст
98
+ * @param {Array<{pos: number, text: string}>} edits вставки
99
+ * @returns {string} новий текст
100
+ */
101
+ function applyEdits(src, edits) {
102
+ let out = src
103
+ for (const e of edits.toSorted((a, b) => b.pos - a.pos)) {
104
+ out = out.slice(0, e.pos) + e.text + out.slice(e.pos)
105
+ }
106
+ return out
107
+ }
108
+
109
+ /**
110
+ * Вставка нової property (`projects: [...]`) в об'єкт-літерал перед `}`.
111
+ * Поважає trailing comma останньої property, коректно обробляє порожній об'єкт.
112
+ * @param {string} src вихідний текст
113
+ * @param {object} obj ObjectExpression node (`test`-блок)
114
+ * @param {string} indent відступ properties
115
+ * @param {string} propLine рядок нової property (без відступу й коми)
116
+ * @returns {{pos: number, text: string}} одна точкова вставка
117
+ */
118
+ function newPropertyEdit(src, obj, indent, propLine) {
119
+ const props = obj.properties
120
+ if (props.length === 0) {
121
+ return { pos: obj.start + 1, text: `\n${indent}${propLine}\n` }
122
+ }
123
+ const lastProp = props.at(-1)
124
+ const tail = src.slice(lastProp.end, obj.end - 1)
125
+ const commaMatch = tail.match(LEADING_COMMA_RE)
126
+ if (commaMatch) {
127
+ return { pos: lastProp.end + commaMatch[0].length, text: `\n${indent}${propLine}` }
128
+ }
129
+ return { pos: lastProp.end, text: `,\n${indent}${propLine}` }
130
+ }
131
+
132
+ /**
133
+ * Вставка елементів перед закривальною `]` масиву — універсально для порожнього
134
+ * й непорожнього масиву (комою керує наявність елементів).
135
+ * @param {object} arr ArrayExpression node
136
+ * @param {string} indent відступ рядка масиву (властивості, що його містить)
137
+ * @param {string[]} entries нові елементи (кожен — багаторядковий об'єкт-літерал)
138
+ * @returns {{pos: number, text: string}} одна точкова вставка
139
+ */
140
+ function arrayInsertBeforeClose(arr, indent, entries) {
141
+ const itemIndent = `${indent} `
142
+ const body = entries.map(e => indentBlock(e, itemIndent)).join(`,\n${itemIndent}`)
143
+ const prefix = arr.elements.length > 0 ? ',\n' : '\n'
144
+ return { pos: arr.end - 1, text: `${prefix}${itemIndent}${body}\n${indent}` }
145
+ }
146
+
147
+ /**
148
+ * Гарантує наявність import-у `storybookTest` на самому початку файлу — ESM
149
+ * толерантний до порядку import-ів, тож prepend завжди синтаксично коректний
150
+ * незалежно від наявних import-ів.
151
+ * @param {string} src вихідний текст
152
+ * @returns {string} текст з гарантованим import-ом
153
+ */
154
+ function ensureStorybookTestImport(src) {
155
+ return src.includes('@storybook/addon-vitest/vitest-plugin') ? src : `${STORYBOOK_TEST_IMPORT}${src}`
156
+ }
157
+
158
+ /**
159
+ * Будує рядок нової property `projects: [<unit>, <storybook>]` для вставки в
160
+ * порожній test-блок (де `projects` ще немає взагалі).
161
+ * @param {string} indent відступ properties test-блоку
162
+ * @param {string} unitEntry текст unit-проєкту (`ObjectExpression`, база нульового відступу)
163
+ * @param {string} storybookEntry текст storybook-проєкту (те саме)
164
+ * @returns {string} рядок `projects: [...]` (без завершальної коми)
165
+ */
166
+ function buildProjectsPropertyLine(indent, unitEntry, storybookEntry) {
167
+ const itemIndent = `${indent} `
168
+ return `projects: [\n${itemIndent}${indentBlock(unitEntry, itemIndent)},\n${itemIndent}${indentBlock(storybookEntry, itemIndent)}\n${indent}]`
169
+ }
170
+
171
+ /**
172
+ * Обчислює план правки для наявного `test.projects` (нова property, або
173
+ * відсутні unit/storybook-записи в наявний масив). `null` — augment
174
+ * неможливий/не потрібен (dynamic-масив або вже канонічно).
175
+ * @param {string} src вихідний текст vitest-конфіга
176
+ * @param {object} testObj `test`-ObjectExpression node
177
+ * @param {string} unitEntry текст unit-проєкту
178
+ * @param {string} storybookEntry текст storybook-проєкту
179
+ * @returns {{edit: {pos: number, text: string}, needsImport: boolean} | null} план правки
180
+ */
181
+ function planProjectsEdit(src, testObj, unitEntry, storybookEntry) {
182
+ const indent = detectIndent(src, testObj.properties)
183
+ const projectsProp = findProperty(testObj, 'projects')
184
+
185
+ if (projectsProp) {
186
+ if (projectsProp.value?.type !== 'ArrayExpression') return null // dynamic — projects-dynamic вже у детекторі
187
+ const { hasUnit, storybookSlice } = classifyProjects(src, projectsProp.value)
188
+ const missing = []
189
+ if (!hasUnit) missing.push(unitEntry)
190
+ let needsImport = false
191
+ if (!storybookSlice) {
192
+ missing.push(storybookEntry)
193
+ needsImport = true
194
+ }
195
+ if (missing.length === 0) return null
196
+ return { edit: arrayInsertBeforeClose(projectsProp.value, indent, missing), needsImport }
197
+ }
198
+
199
+ const propLine = buildProjectsPropertyLine(indent, unitEntry, storybookEntry)
200
+ return { edit: newPropertyEdit(src, testObj, indent, propLine), needsImport: true }
201
+ }
202
+
203
+ /**
204
+ * Обчислює новий вміст наявного vitest-конфіга: дописує `projects`
205
+ * (нову property або відсутні unit/storybook-записи в наявний масив).
206
+ * `null` — правку застосовувати не треба (усе вже канонічно).
207
+ * @param {string} absPkgDir абсолютний шлях кореня пакета
208
+ * @param {string} vitestConfigPath абсолютний шлях vitest-конфіга
209
+ * @returns {Promise<string | null>} новий вміст файлу або null
210
+ */
211
+ async function computeVitestConfigAugment(absPkgDir, vitestConfigPath) {
212
+ const src = await readFile(vitestConfigPath, 'utf8')
213
+ const parsed = parseModule(vitestConfigPath, src)
214
+ if (parsed.errors?.length) return null
215
+ const testObj = findTestObject(parsed.program)
216
+ if (!testObj) return null
217
+
218
+ const storiesGlob = storiesGlobForVitestConfig(absPkgDir)
219
+ const unitEntry = await readTemplateExportedObject(join(TEMPLATE_DIR, 'unit-project-entry.js'))
220
+ const storybookEntryRaw = await readTemplateExportedObject(join(TEMPLATE_DIR, 'storybook-project-entry.js'))
221
+ const storybookEntry = storybookEntryRaw.split(STORIES_GLOB_TOKEN).join(storiesGlob)
222
+
223
+ const plan = planProjectsEdit(src, testObj, unitEntry, storybookEntry)
224
+ if (!plan) return null
225
+
226
+ let next = applyEdits(src, [plan.edit])
227
+ if (plan.needsImport) next = ensureStorybookTestImport(next)
228
+
229
+ // Safety: результат має компілюватися; інакше не пишемо (fail-closed, як у test/stryker_config).
230
+ let recheck
231
+ try {
232
+ recheck = parseModule(vitestConfigPath, next)
233
+ } catch {
234
+ return null
235
+ }
236
+ if (recheck.errors?.length) return null
237
+
238
+ return next === src ? null : next
239
+ }
240
+
241
+ /**
242
+ * Генерує повністю новий `vitest.config.mjs` (unit+storybook projects) для
243
+ * пакета без жодного наявного vitest-конфіга. Експортовано — переюз у
244
+ * `adopt/main.mjs` (генерація лише для повністю відсутніх файлів).
245
+ * @param {string} absPkgDir абсолютний шлях кореня пакета
246
+ * @returns {Promise<{ path: string, content: string }>} шлях і вміст нового файлу
247
+ */
248
+ export async function buildFreshVitestConfig(absPkgDir) {
249
+ const template = await readFile(join(TEMPLATE_DIR, 'vitest.config.baseline.mjs'), 'utf8')
250
+ const storiesGlob = storiesGlobForVitestConfig(absPkgDir)
251
+ const viteConfigName = resolveViteConfigName(absPkgDir)
252
+ const content = template
253
+ .split(STORIES_GLOB_TOKEN)
254
+ .join(storiesGlob)
255
+ .split(VITE_CONFIG_IMPORT_TOKEN)
256
+ .join(viteConfigName)
257
+ return { path: join(absPkgDir, 'vitest.config.mjs'), content }
258
+ }
259
+
260
+ /**
261
+ * Генерує ізольований `vitest.stryker.config.*` (той самий basename/ext що
262
+ * й основний vitest-конфіг пакета) з canonical baseline-шаблону. Експортовано —
263
+ * переюз у `adopt/main.mjs`.
264
+ * @param {string} absPkgDir абсолютний шлях кореня пакета
265
+ * @returns {Promise<string>} вміст нового файлу
266
+ */
267
+ export async function buildStrykerConfig(absPkgDir) {
268
+ const template = await readFile(join(TEMPLATE_DIR, 'vitest.stryker.config.baseline.mjs'), 'utf8')
269
+ const viteConfigName = resolveViteConfigName(absPkgDir)
270
+ return template.split(VITE_CONFIG_IMPORT_TOKEN).join(viteConfigName)
271
+ }
272
+
273
+ /**
274
+ * Обробляє один пакет (rootDir) — обчислює й пише всі потрібні зміни:
275
+ * fresh vitest.config.mjs (якщо був відсутній), augment наявного конфіга,
276
+ * генерацію ізольованого vitest.stryker.config.*.
277
+ * @param {string} rootDir root dir пакета
278
+ * @param {string} absPkgDir абсолютний шлях кореня пакета
279
+ * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').FixContext} ctx fix-контекст
280
+ * @returns {Promise<string[]>} абсолютні шляхи змінених файлів
281
+ */
282
+ async function fixOnePackage(rootDir, absPkgDir, ctx) {
283
+ const touchedFiles = []
284
+ const existingVitestConfigPath = resolveVitestConfigPath(absPkgDir)
285
+ let vitestConfigPath = existingVitestConfigPath
286
+
287
+ if (existingVitestConfigPath) {
288
+ const augmented = await computeVitestConfigAugment(absPkgDir, existingVitestConfigPath)
289
+ if (augmented !== null) {
290
+ ctx.recordWrite?.(existingVitestConfigPath)
291
+ await writeFile(existingVitestConfigPath, augmented, 'utf8')
292
+ touchedFiles.push(existingVitestConfigPath)
293
+ }
294
+ } else {
295
+ const fresh = await buildFreshVitestConfig(absPkgDir)
296
+ ctx.recordWrite?.(fresh.path)
297
+ await writeFile(fresh.path, fresh.content, 'utf8')
298
+ touchedFiles.push(fresh.path)
299
+ vitestConfigPath = fresh.path
300
+ }
301
+
302
+ const strykerPath = strykerConfigPathFor(vitestConfigPath)
303
+ if (!existsSync(strykerPath)) {
304
+ const content = await buildStrykerConfig(absPkgDir)
305
+ ctx.recordWrite?.(strykerPath)
306
+ await writeFile(strykerPath, content, 'utf8')
307
+ touchedFiles.push(strykerPath)
308
+ }
309
+
310
+ return touchedFiles
311
+ }
312
+
313
+ /** @type {import('@7n/rules/scripts/lib/lint-surface/types.mjs').T0Pattern[]} */
314
+ export const patterns = [
315
+ {
316
+ id: 'storybook-vitest-config-fix',
317
+ test: violations => violations.some(v => TRIGGER_REASONS.has(v.reason)),
318
+ apply: async (violations, ctx) => {
319
+ const rootDirs = new Map()
320
+ for (const v of violations) {
321
+ if (!TRIGGER_REASONS.has(v.reason)) continue
322
+ const rootDir = v.data?.rootDir
323
+ if (typeof rootDir !== 'string') continue
324
+ if (!rootDirs.has(rootDir)) {
325
+ rootDirs.set(rootDir, rootDir === '.' ? ctx.cwd : join(ctx.cwd, rootDir))
326
+ }
327
+ }
328
+
329
+ const touchedFiles = []
330
+ for (const [rootDir, absPkgDir] of rootDirs) {
331
+ const files = await fixOnePackage(rootDir, absPkgDir, ctx)
332
+ touchedFiles.push(...files)
333
+ }
334
+
335
+ return touchedFiles.length > 0
336
+ ? { touchedFiles, message: `vitest-config storybook-канон: ${touchedFiles.join(', ')}` }
337
+ : { touchedFiles: [] }
338
+ }
339
+ }
340
+ ]