@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.
- package/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/rules/bun/package_json/package_json.mdc +3 -1
- package/rules/bun/package_json/package_json.rego +36 -1
- package/rules/npm-module/npm_package_json/npm_package_json.mdc +18 -3
- package/rules/npm-module/npm_package_json/npm_package_json.rego +42 -5
- package/rules/storybook/adopt/docs/index.md +9 -0
- package/rules/storybook/adopt/docs/main.md +45 -0
- package/rules/storybook/adopt/main.mjs +382 -0
- package/rules/storybook/ci/concern.json +15 -0
- package/rules/storybook/ci/docs/fix-ci.md +39 -0
- package/rules/storybook/ci/docs/index.md +10 -0
- package/rules/storybook/ci/docs/main.md +50 -0
- package/rules/storybook/ci/fix-ci.mjs +105 -0
- package/rules/storybook/ci/main.mjs +112 -0
- package/rules/storybook/ci/template/lint-storybook.yml.snippet.yml +41 -0
- package/rules/storybook/ci/template/setup-playwright-chromium.action.yml +34 -0
- package/rules/storybook/hygiene/concern.json +4 -0
- package/rules/storybook/hygiene/docs/index.md +9 -0
- package/rules/storybook/hygiene/docs/main.md +45 -0
- package/rules/storybook/hygiene/main.mjs +254 -0
- package/rules/storybook/main.json +1 -0
- package/rules/storybook/main.mdc +66 -0
- package/rules/storybook/mocking/concern.json +3 -0
- package/rules/storybook/mocking/mocking.mdc +132 -0
- package/rules/storybook/scaffold/concern.json +5 -0
- package/rules/storybook/scaffold/docs/fix-scaffold.md +29 -0
- package/rules/storybook/scaffold/docs/index.md +10 -0
- package/rules/storybook/scaffold/docs/main.md +36 -0
- package/rules/storybook/scaffold/fix-scaffold.mjs +150 -0
- package/rules/storybook/scaffold/main.mjs +196 -0
- package/rules/storybook/scaffold/template/docs/index.md +10 -0
- package/rules/storybook/scaffold/template/docs/main.md +29 -0
- package/rules/storybook/scaffold/template/docs/preview.md +46 -0
- package/rules/storybook/scaffold/template/main.js +92 -0
- package/rules/storybook/scaffold/template/mocks/docs/gql-sse.md +36 -0
- package/rules/storybook/scaffold/template/mocks/docs/index.md +9 -0
- package/rules/storybook/scaffold/template/mocks/gql-sse.js +25 -0
- package/rules/storybook/scaffold/template/preview.js +47 -0
- package/rules/storybook/scope/concern.json +4 -0
- package/rules/storybook/scope/docs/index.md +9 -0
- package/rules/storybook/scope/docs/main.md +62 -0
- package/rules/storybook/scope/main.mjs +202 -0
- package/rules/storybook/vitest-config/concern.json +8 -0
- package/rules/storybook/vitest-config/docs/fix-vitest-config.md +37 -0
- package/rules/storybook/vitest-config/docs/index.md +10 -0
- package/rules/storybook/vitest-config/docs/main.md +71 -0
- package/rules/storybook/vitest-config/fix-vitest-config.mjs +356 -0
- package/rules/storybook/vitest-config/main.mjs +389 -0
- package/rules/storybook/vitest-config/template/docs/index.md +12 -0
- package/rules/storybook/vitest-config/template/docs/storybook-project-entry.md +33 -0
- package/rules/storybook/vitest-config/template/docs/unit-project-entry.md +30 -0
- package/rules/storybook/vitest-config/template/docs/vitest.config.baseline.md +28 -0
- package/rules/storybook/vitest-config/template/docs/vitest.stryker.config.baseline.md +31 -0
- package/rules/storybook/vitest-config/template/storybook-project-entry.js +26 -0
- package/rules/storybook/vitest-config/template/unit-project-entry.js +5 -0
- package/rules/storybook/vitest-config/template/vitest.config.baseline.mjs +38 -0
- package/rules/storybook/vitest-config/template/vitest.stryker.config.baseline.mjs +19 -0
- 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
|
+
]
|