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