@7n/rules-lang-js 0.10.0 → 0.12.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 (42) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +1 -1
  3. package/rules/bun/package_json/package_json.mdc +2 -2
  4. package/rules/bun/package_json/package_json.rego +26 -10
  5. package/rules/npm-module/npm_package_json/npm_package_json.mdc +1 -1
  6. package/rules/npm-module/npm_package_json/npm_package_json.rego +14 -5
  7. package/rules/storybook/adopt/docs/main.md +1 -2
  8. package/rules/storybook/adopt/main.mjs +37 -5
  9. package/rules/storybook/ci/concern.json +15 -0
  10. package/rules/storybook/ci/docs/fix-ci.md +39 -0
  11. package/rules/storybook/ci/docs/index.md +10 -0
  12. package/rules/storybook/ci/docs/main.md +50 -0
  13. package/rules/storybook/ci/fix-ci.mjs +105 -0
  14. package/rules/storybook/ci/main.mjs +112 -0
  15. package/rules/storybook/ci/template/lint-storybook.yml.snippet.yml +41 -0
  16. package/rules/storybook/ci/template/setup-playwright-chromium.action.yml +34 -0
  17. package/rules/storybook/hygiene/concern.json +1 -1
  18. package/rules/storybook/main.json +1 -1
  19. package/rules/storybook/main.mdc +24 -3
  20. package/rules/storybook/mocking/mocking.mdc +24 -0
  21. package/rules/storybook/scaffold/concern.json +1 -1
  22. package/rules/storybook/scaffold/docs/fix-scaffold.md +1 -1
  23. package/rules/storybook/scaffold/docs/main.md +1 -1
  24. package/rules/storybook/scaffold/fix-scaffold.mjs +50 -0
  25. package/rules/storybook/scaffold/main.mjs +68 -6
  26. package/rules/storybook/scaffold/template/docs/empty-vite.config.md +28 -0
  27. package/rules/storybook/scaffold/template/docs/index.md +5 -4
  28. package/rules/storybook/scaffold/template/docs/main.md +1 -2
  29. package/rules/storybook/scaffold/template/docs/preview.md +1 -2
  30. package/rules/storybook/scaffold/template/empty-vite.config.js +18 -0
  31. package/rules/storybook/scaffold/template/main.js +76 -6
  32. package/rules/storybook/scaffold/template/preview.js +13 -5
  33. package/rules/storybook/vitest-config/concern.json +1 -1
  34. package/rules/storybook/vitest-config/docs/fix-vitest-config.md +1 -2
  35. package/rules/storybook/vitest-config/docs/main.md +1 -2
  36. package/rules/storybook/vitest-config/fix-vitest-config.mjs +22 -6
  37. package/rules/storybook/vitest-config/main.mjs +33 -2
  38. package/rules/storybook/vitest-config/template/docs/storybook-project-entry.md +1 -2
  39. package/rules/storybook/vitest-config/template/docs/vitest.config.baseline.md +1 -2
  40. package/rules/storybook/vitest-config/template/storybook-project-entry.js +6 -2
  41. package/rules/storybook/vitest-config/template/vitest.config.baseline.mjs +2 -1
  42. package/rules/storybook/vitest-config/vitest-config.mdc +7 -3
@@ -0,0 +1,41 @@
1
+ name: Lint Storybook
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - dev
7
+ - main
8
+ paths:
9
+ - '**/*.stories.@(js|ts)'
10
+ - '**/.storybook/**'
11
+ pull_request:
12
+ branches:
13
+ - dev
14
+ - main
15
+
16
+ concurrency:
17
+ group: ${{ github.ref }}-${{ github.workflow }}
18
+ cancel-in-progress: true
19
+
20
+ jobs:
21
+ storybook-test:
22
+ runs-on: ubuntu-latest
23
+ permissions:
24
+ contents: read
25
+ strategy:
26
+ fail-fast: false
27
+ matrix:
28
+ package:
29
+ __STORYBOOK_CI_PACKAGE_DIRS__
30
+ steps:
31
+ - uses: actions/checkout@v6
32
+ with:
33
+ persist-credentials: false
34
+
35
+ - uses: ./.github/actions/setup-bun-deps
36
+
37
+ - uses: ./.github/actions/setup-playwright-chromium
38
+
39
+ - name: Storybook vitest project (${{ matrix.package }})
40
+ working-directory: ${{ matrix.package }}
41
+ run: bunx vitest run --project=storybook
@@ -0,0 +1,34 @@
1
+ # yaml-language-server: $schema=https://json.schemastore.org/github-action.json
2
+
3
+ name: Setup Playwright Chromium
4
+ description: >-
5
+ Кеш Playwright-браузерів (лише chromium, storybook.mdc ADR Кластер 5) для vitest
6
+ browser-mode storybook-проєкту. Викликати ПІСЛЯ ./.github/actions/setup-bun-deps
7
+ (потрібен node_modules/bun.lock для визначення версії).
8
+
9
+ runs:
10
+ using: composite
11
+ steps:
12
+ - name: Resolve Playwright version (bun.lock)
13
+ id: playwright-version
14
+ shell: bash
15
+ run: |
16
+ VERSION=$(grep -oE '"playwright(-core)?@[0-9]+\.[0-9]+\.[0-9]+"' bun.lock | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
17
+ if [ -z "$VERSION" ]; then
18
+ VERSION=$(node -p "require('playwright-core/package.json').version" 2>/dev/null || echo unknown)
19
+ fi
20
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
21
+
22
+ - name: Cache Playwright browsers (chromium)
23
+ id: playwright-cache
24
+ uses: actions/cache@v5
25
+ with:
26
+ path: |
27
+ ~/.cache/ms-playwright
28
+ ~/Library/Caches/ms-playwright
29
+ key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
30
+
31
+ - name: Install Playwright chromium (лише chromium, storybook.mdc)
32
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
33
+ shell: bash
34
+ run: bunx playwright install chromium --with-deps
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
- "lint": { "scope": "full", "glob": ["package.json", "**/*.vue", ".storybook/**"] }
3
+ "lint": { "scope": "full", "glob": ["**/package.json", "**/*.vue", "**/.storybook/**"] }
4
4
  }
@@ -1 +1 @@
1
- { "auto": { "glob": ["package.json", "**/*.vue", ".storybook/**"] } }
1
+ { "auto": { "glob": ["**/package.json", "**/*.vue", "**/.storybook/**"] } }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Канонічний Storybook для Vue-компонентних бібліотек — скафолд, vitest/Stryker, гігієна залежностей, рецепти мокання
3
3
  version: '1.0'
4
- globs: ["package.json", "**/*.vue", ".storybook/**"]
4
+ globs: ["**/package.json", "**/*.vue", "**/.storybook/**"]
5
5
  alwaysApply: false
6
6
  ---
7
7
 
@@ -31,18 +31,39 @@ alwaysApply: false
31
31
 
32
32
  - **Порядок Vite-плагінів фіксований**: `@vitejs/plugin-vue` **перед** `quasar()` — інакше Quasar-плагін не бачить SFC, уже скомпільований `plugin-vue`.
33
33
  - **Layout-детекція**: `src/components/` присутній → stories-glob звужується до нього; пласка структура (`src/` без `components/`) — ширший glob по всьому `src/`.
34
- - **`viteFinal`** зчитує `vite.config` самого пакета (той самий, що й для звичайного білду) і мерджить його плагіни в конфіг Storybook, **знімаючи** `vite-plugin-pages`/`vite-plugin-vue-layouts` — файлова маршрутизація належить додатку-споживачу, не ізольованому рендеру компонента.
34
+ - **`viteFinal`** зчитує `vite.config` самого пакета (той самий, що й для звичайного білду) і мерджить його плагіни в конфіг Storybook, **знімаючи** роутинг-плагіни файлової маршрутизації додатка-споживача (`vite-plugin-pages`, `unplugin-vue-router`, `vite-plugin-vue-layouts`, `vite-plugin-vue-layouts-next`)вони не мають сенсу в ізольованому рендері компонента. Плагіни `plugins`-масиву можуть бути `Promise`/вкладеними масивами (`VueMacros({ plugins: { vue: Vue() } })` — реальний стек пілотного консюмера повертає `Promise`, що резолвиться в масив плагінів) — фільтр спершу resolve/flatten-ить їх, інакше порівняння імені з `Promise`, що ще не резолвився, мовчки пропускає дублікат `@vitejs/plugin-vue`-трансформу (подвійна SFC-трансформація). Vue-трансформери фільтруються за сімейством імені (`vite:vue`-префікс або підрядок `vue-macros`), не лише буквальним `@vitejs/plugin-vue` — деталі й обґрунтування коментарем у `scaffold/template/main.js`.
35
+ - **`core.builder.options.viteConfigPath` на `.storybook/empty-vite.config.js` — ОБОВ'ЯЗКОВИЙ, не опційний.** Емпірично підтверджено (лише через `storybook build`, `dev --smoke-test` не ловить): без явного `viteConfigPath` `@storybook/builder-vite` сам через `loadConfigFromFile` знаходить `../vite.config.js` пакета ЩЕ ДО виклику `viteFinal` і домерджує його НЕФІЛЬТРОВАНІ плагіни в `storybookConfig` — фільтр у `viteFinal` тоді лише ДОДАЄ ще один `@vitejs/plugin-vue`, а не прибирає вже змерджений builder-vite дублікат (подвійна SFC-трансформація, `storybook build` падає на кожному `.vue`). `empty-vite.config.js` — канонічний порожній `defineConfig({})`-стенд-ін, генерується скафолдом поряд з `main.js`; окрема секція перевірки/adopt-діагностики (не частина `MAIN_JS_MARKERS` — main.js може бути канонічним, а сусідній файл видалено окремо).
35
36
  - **`staticDirs`** покриває `.storybook/public` — статичний asset для msw service worker (`preview.js`).
36
- - **`preview.js`**: повний `Quasar`-install (не тільки окремі компоненти) + `iconSet`+`iconMapFn`-комбо — без цієї пари внутрішні Quasar-компоненти (напр. стрілка `QSelect`) не резолвлять вбудовані іконки поза full CLI build. `msw-storybook-addon` ініціалізується з `onUnhandledRequest`-фільтром: **same-origin GET мовчки пропускається** (Vite HMR/asset-шум), решта — попередження (не білд-помилка — навмисно м'яко для хвилі 1).
37
+ - **`preview.js`**: повний `Quasar`-install (не тільки окремі компоненти) + `iconSet`+`iconMapFn`-комбо — без цієї пари внутрішні Quasar-компоненти (напр. стрілка `QSelect`) не резолвлять вбудовані іконки поза full CLI build. Обидва `iconSet` — підшляховий default-імпорт (`quasar/icon-set/svg-material-icons`, `quasar/icon-set/material-icons`), **НЕ** named-import `iconSet` напряму з пакета `quasar` — quasar 2.18.x не має такого runtime-binding (лише компонент `IconSet` з великої літери), `@quasar/vite-plugin`-transform падає на такому специфікаторі. `msw-storybook-addon` ініціалізується з `onUnhandledRequest`-фільтром: **same-origin GET мовчки пропускається** (Vite HMR/asset-шум), решта — попередження (не білд-помилка — навмисно м'яко для хвилі 1). Мережевий мок підключається через `loaders: [mswLoader]`, **не** `decorators: [mswDecorator]` — `mswDecorator` deprecated у `msw-storybook-addon` 2.x (буде видалений у наступному релізі), `mswLoader` виконується per-story до рендеру.
37
38
  - **`.storybook/mocks/gql-sse.js`**: єдиний канонічний хелпер `sseSubscription` для MSW-мокання Apollo-підписок через wire-протокол `graphql-sse` (`event: next\ndata: …`, distinct-connection mode) — переносити цю логіку в кожен пакет окремо заборонено, є одне джерело істини.
38
39
  - **`package.json#scripts.storybook`** — уніфікований скрипт, однаковий для всіх пакетів у скоупі (значення — `scaffold/main.mjs`, не дублюється тут).
39
40
 
40
41
  Перевірка присутності й ключових маркерів канону — `scaffold/main.mjs`; детерміноване відтворення відсутніх файлів із `scaffold/template/` — `scaffold/fix-scaffold.mjs` (fixability: `config` — канонічна форма одна, LLM у ланцюжку фіксу не потрібен).
41
42
 
43
+ ### knip-виключення для `.storybook/`-артефактів
44
+
45
+ Кожен adopt цього канону наступає на ту саму knip-проблему: `empty-vite.config.js` (динамічний шлях через `join(dirName, …)` у `main.js` — knip не резолвить `join`-конструйовані шляхи), `mocks/apollo.js` (точковий alias-мок, `mocking.mdc` — не кожен пакет його використовує) і `mocks/gql-sse.js` (helper, статично імпортується лише зі story-файлів, яких у щойно заскафолженому пакеті може ще не бути) регулярно фолсяться knip-ом як orphan-files. Канонічний фікс — **docs-only** сніпет, не автофікс: механізм `js/check` copy-once канону `knip.json` (`n-js.mdc`) свідомо **не ревалідує** вміст після першого створення (консюмер вільно кастомізує), тож програмний patch наявного `knip.json` консюмера конфліктував би з цим дизайн-рішенням і вимагав би нового merge-механізму замість наявного copy-if-missing. Додай вручну в `knip.json` консюмера (root або поряд з першим `.storybook/`):
46
+
47
+ ```json
48
+ {
49
+ "ignore": ["**/.storybook/empty-vite.config.js", "**/.storybook/mocks/apollo.js", "**/.storybook/mocks/gql-sse.js"]
50
+ }
51
+ ```
52
+
53
+ (домердж у наявний масив `ignore`, не заміна). Звірено з реальним `knip.json` пілотного консюмера.
54
+
42
55
  ## Vitest-конфіг і Stryker-ізоляція (Кластер 5)
43
56
 
44
57
  Канонічний `test.projects` (`unit`+`storybook`, browser-mode лише chromium) і ізольований `vitest.stryker.config` (той самий unit-набір, без browser-mode — `@stryker-mutator/vitest-runner` крашиться на browser-mode `projects`) — `vitest-config/main.mjs` (перевірка, AST через `oxc-parser`) і `vitest-config/fix-vitest-config.mjs` (точкові insert-only правки наявного конфіга, fixability: `config`). Деталі канону, чому саме chromium і межі автофіксу — `vitest-config/vitest-config.mdc`, не дублюється тут.
45
58
 
59
+ ### CI: Playwright-кеш і швидкий PR-прогін (Кластер 5, CI-частина)
60
+
61
+ `ci/main.mjs` + `ci/fix-ci.mjs` (fixability: `config`, `requires.capability: ci:github` — спить у репозиторіях без плагіна `@7n/rules-ci-github`): для кожного репозиторію з бодай одним пакетом у скоупі — канонічний composite action `.github/actions/setup-playwright-chromium/action.yml` (кеш `~/.cache/ms-playwright`/`~/Library/Caches/ms-playwright` за версією playwright з `bun.lock`, install **лише** `chromium --with-deps` при cache miss) і `.github/workflows/lint-storybook.yml` (матриця `strategy.matrix.package` — фактичні пакети у скоупі, `checkout` → `setup-bun-deps` → `setup-playwright-chromium` → `vitest run --project=storybook`). Композитний action і workflow — репо-рівневі файли (не per-package), перевірка й автофікс не per-package.
62
+
63
+ `workflow.on.push.paths` — **`**/.storybook/**`** (не `.storybook/**` без префіксу): пакет у скоупі майже завжди лежить не в корені репозиторію (`npm/.storybook/**` тощо), а GitHub Actions `paths`-глоб без `**/`-префіксу анкорить збіг до кореня репо й мовчки не спрацьовує на push у вкладений пакет. Той самий анкоринг-баг був і в усіх `concern.json#lint.glob`/`main.json#auto.glob`-патернах цього правила (`package.json`, `.storybook/**`, `vitest.config.*` без префіксу) — виправлено разом, звірено емпірично на пілотному консюмері (`components/.github/workflows/lint-storybook.yml`).
64
+
65
+ Nightly-only `@7n/test coverage` (mutation testing) — свідомо поза цим concern-ом: ADR розділяє швидкий PR-шлях (`--project=storybook`, цей concern) і nightly mutation-прогін, який лишається окремою інфраструктурою (`test/stryker_config`) і не дублюється тут.
66
+
46
67
  ## Гігієна сторонніх залежностей (Кластер 6)
47
68
 
48
69
  `hygiene/main.mjs`: (1) undeclared third-party imports у `.vue`-файлах пакета — import стороннього пакета, якого немає в `dependencies`/`peerDependencies` (реальний кейс ADR — зламаний default-export `@vuepic/vue-datepicker` v14, silent breakage без цієї перевірки); (2) наявність `src/css/quasar.variables.{scss,sass}` без відповідного `quasar({ sassVariables: true })` у `.storybook/main.js` — глобальні Quasar SCSS-змінні пакета інакше не резолвляться в ізольованому Storybook-рендері. Docs-only детальний виклад не потрібен — концерн самодостатній, без окремого `.mdc`.
@@ -73,6 +73,30 @@ export const Realtime = {
73
73
  }
74
74
  ```
75
75
 
76
+ ### Точковий alias-мок одного модуля
77
+
78
+ **Скоуп цього рецепта — компонентні бібліотеки, НЕ сторінки.** Рішення «виключно MSW» вище (розділ «Apollo/GraphQL — виключно MSW») стосується сторінок (розширення ADR, `2026-07-20`) — там повний data-flow (query+mutation+subscription, кілька викликів на сторінку), і мок-хендлери мають бути переносними в майбутні vitest/playwright-сценарії. Реальний кейс компонентної бібліотеки (пілот `components/npm`) — інший: один компонент статично імпортує модуль із side-effect на рівні build-у (напр. `@nitra/vite-boot/apollo` — конструює Apollo-клієнт із `VITE_HASURA_HOST`/WS-link env-змінних, яких немає в Storybook), і компонент має 1-2 точкові виклики цього модуля, не повний GraphQL data-flow. Розгортати MSW-стек (`msw-storybook-addon`, worker, handlers) заради одного статичного імпорту — зайві накладні витрати, надто коли `msw` навіть не встановлений у пакеті.
79
+
80
+ Рецепт — build-time підміна імпорту на рівні Vite-resolve у `viteFinal` `.storybook/main.js` (не мокає мережу, підміняє сам модуль):
81
+
82
+ ```js
83
+ async viteFinal(config) {
84
+ const { mergeConfig } = await import('vite')
85
+ return mergeConfig(config, {
86
+ resolve: {
87
+ alias: {
88
+ // NImages.vue статично імпортує apolloClient/gql (не provide/inject) — реальний
89
+ // модуль конструює Apollo-клієнт з env-змінних, яких немає в Storybook.
90
+ // Підміна на рівні Vite-resolve замість зміни коду компонента.
91
+ '@nitra/vite-boot/apollo': join(dirName, 'mocks/apollo.js')
92
+ }
93
+ }
94
+ })
95
+ }
96
+ ```
97
+
98
+ **Критерій вибору:** компонент з 1-2 точковими викликами стороннього модуля (side-effect boot/client-конструктор, не повний query/mutation/subscription-flow) → alias-мок одного модуля. Сторінки, підписки чи повний GraphQL data-flow (кілька query/mutation, стан, error/loading-сценарії) → MSW (розділ вище) — не супереч рішенню розширення ADR, а розмежування скоупу: alias — для компонентних бібліотек із мінімальним мережевим слідом, MSW — для сторінок і повнішого data-flow.
99
+
76
100
  ### Pinia
77
101
 
78
102
  Справжня `createPinia()` у page-декораторі — **без** `pinia-plugin-persistedstate` (`persist: true` у сторі стає no-op, читання/запис localStorage не потрібні в ізольованому рендері). Наповнення початкового стану — через `parameters.pinia.initialState`, не хардкод у декораторі:
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
3
  "fixability": "config",
4
- "lint": { "scope": "full", "glob": ["package.json", "**/*.vue", ".storybook/**"] }
4
+ "lint": { "scope": "full", "glob": ["**/package.json", "**/*.vue", "**/.storybook/**"] }
5
5
  }
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: fix-scaffold.mjs
4
4
  resource: plugins/lang-js/rules/storybook/scaffold/fix-scaffold.mjs
5
5
  docgen:
6
- crc: 31a867f5
6
+ crc: 10336699
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  score: 90
9
9
  issues: internal-name:detectStoriesGlob,judge:inaccurate:0.95
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: plugins/lang-js/rules/storybook/scaffold/main.mjs
5
5
  docgen:
6
- crc: c06a9e15
6
+ crc: 0505810e
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -49,6 +49,17 @@ export function renderMocksGqlSse(templateDir = TEMPLATE_DIR) {
49
49
  return readFileSync(join(templateDir, 'mocks/gql-sse.js'), 'utf8')
50
50
  }
51
51
 
52
+ /**
53
+ * Вміст канонічного `.storybook/empty-vite.config.js` — verbatim з template (порожній
54
+ * стенд-ін для `core.builder.options.viteConfigPath` у `main.js`, не залежить від пакета).
55
+ * Експортовано — переюз у `adopt/main.mjs`.
56
+ * @param {string} [templateDir] каталог template/ (за замовчуванням — цього concern-а)
57
+ * @returns {string} вміст `empty-vite.config.js`
58
+ */
59
+ export function renderEmptyViteConfig(templateDir = TEMPLATE_DIR) {
60
+ return readFileSync(join(templateDir, 'empty-vite.config.js'), 'utf8')
61
+ }
62
+
52
63
  /**
53
64
  * Записує файл, створюючи батьківські каталоги й реєструючи запис для rollback.
54
65
  * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').FixContext} ctx fix-контекст рунга
@@ -83,6 +94,7 @@ export const patterns = [
83
94
 
84
95
  const templateDir = join(ctx.concernDir, 'template')
85
96
  const mocksTemplate = renderMocksGqlSse(templateDir)
97
+ const emptyViteConfigTemplate = renderEmptyViteConfig(templateDir)
86
98
 
87
99
  const touchedFiles = []
88
100
  for (const v of targets) {
@@ -98,10 +110,48 @@ export const patterns = [
98
110
  writeScaffoldFile(ctx, mocksAbs, mocksTemplate)
99
111
  touchedFiles.push(mocksAbs)
100
112
  }
113
+
114
+ // empty-vite.config.js — main.js посилається на нього напряму (viteConfigPath),
115
+ // без нього щойно відтворений main.js неробочий; генерується разом (belt-and-suspenders
116
+ // з окремим 'storybook-scaffold-empty-vite-config' нижче, який покриває випадок, коли
117
+ // ЛИШЕ цей файл видалено, а main.js лишається канонічним).
118
+ const emptyViteConfigAbs = join(absPkgDir, '.storybook/empty-vite.config.js')
119
+ if (!existsSync(emptyViteConfigAbs)) {
120
+ writeScaffoldFile(ctx, emptyViteConfigAbs, emptyViteConfigTemplate)
121
+ touchedFiles.push(emptyViteConfigAbs)
122
+ }
101
123
  }
102
124
  return { touchedFiles, message: `.storybook/main.js: створено для ${targets.length} пакет(ів)` }
103
125
  }
104
126
  },
127
+ {
128
+ id: 'storybook-scaffold-empty-vite-config',
129
+ test: violations => violations.some(v => v.reason === 'missing-empty-vite-config'),
130
+ apply: (violations, ctx) => {
131
+ const targets = violations.filter(
132
+ v => v.reason === 'missing-empty-vite-config' && typeof v.data?.rootDir === 'string'
133
+ )
134
+ if (targets.length === 0 || !ctx.concernDir) return { touchedFiles: [] }
135
+
136
+ const emptyViteConfigTemplate = renderEmptyViteConfig(join(ctx.concernDir, 'template'))
137
+
138
+ const touchedFiles = []
139
+ for (const v of targets) {
140
+ const absPkgDir = resolvePkgDir(ctx.cwd, v.data.rootDir)
141
+ const abs = join(absPkgDir, '.storybook/empty-vite.config.js')
142
+ if (existsSync(abs)) continue
143
+ writeScaffoldFile(ctx, abs, emptyViteConfigTemplate)
144
+ touchedFiles.push(abs)
145
+ }
146
+ return {
147
+ touchedFiles,
148
+ message:
149
+ touchedFiles.length > 0
150
+ ? `.storybook/empty-vite.config.js: створено для ${touchedFiles.length} пакет(ів)`
151
+ : undefined
152
+ }
153
+ }
154
+ },
105
155
  {
106
156
  id: 'storybook-scaffold-preview-js',
107
157
  test: violations => violations.some(v => v.reason === 'missing-preview-js'),
@@ -1,5 +1,5 @@
1
1
  /** @see ./docs/main.md */
2
- import { existsSync } from 'node:fs'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
 
@@ -17,7 +17,20 @@ export const MAIN_JS_MARKERS = [
17
17
  { token: '@storybook/vue3-vite', hint: 'framework @storybook/vue3-vite' },
18
18
  { token: 'viteFinal', hint: 'viteFinal-override vite.config пакета' },
19
19
  { token: "'vite-plugin-pages'", hint: 'фільтр vite-plugin-pages у viteFinal' },
20
- { token: "'vite-plugin-vue-layouts'", hint: 'фільтр vite-plugin-vue-layouts у viteFinal' }
20
+ { token: "'vite-plugin-vue-layouts'", hint: 'фільтр vite-plugin-vue-layouts у viteFinal' },
21
+ { token: "'vite-plugin-vue-layouts-next'", hint: 'фільтр vite-plugin-vue-layouts-next у viteFinal' },
22
+ {
23
+ token: 'isVueTransformFamily',
24
+ hint: 'сімейний фільтр vue-трансформерів (vite:vue/vue-macros) — стійкість до VueMacros-стека'
25
+ },
26
+ {
27
+ token: 'resolvePluginEntry',
28
+ hint: 'resolve/flatten Promise/масиву плагінів перед фільтрацією (VueMacros повертає Promise)'
29
+ },
30
+ {
31
+ token: 'viteConfigPath',
32
+ hint: 'core.builder.options.viteConfigPath на empty-vite.config.js (блокує builder-vite autodiscovery vite.config пакета — інакше подвійна SFC-трансформація на storybook build)'
33
+ }
21
34
  ]
22
35
 
23
36
  /** Маркери канону `.storybook/preview.js`. Експортовано — переюз у `adopt/main.mjs`. */
@@ -26,17 +39,50 @@ export const PREVIEW_JS_MARKERS = [
26
39
  { token: 'iconSet', hint: 'iconSet' },
27
40
  { token: 'iconMapFn', hint: 'iconMapFn (без нього внутрішні Quasar-іконки недоступні)' },
28
41
  { token: 'msw-storybook-addon', hint: 'msw-storybook-addon' },
29
- { token: 'onUnhandledRequest', hint: 'onUnhandledRequest-фільтр' }
42
+ { token: 'onUnhandledRequest', hint: 'onUnhandledRequest-фільтр' },
43
+ { token: 'mswLoader', hint: 'mswLoader (не mswDecorator — deprecated у msw-storybook-addon 2.x)' }
44
+ ]
45
+
46
+ /**
47
+ * Маркери канону `.storybook/empty-vite.config.js` (сусідній файл main.js — стенд-ін для
48
+ * `core.builder.options.viteConfigPath`, блокує autodiscovery `vite.config` пакета
49
+ * `@storybook/builder-vite`-ом). Експортовано — переюз у `adopt/main.mjs`.
50
+ */
51
+ export const EMPTY_VITE_CONFIG_MARKERS = [
52
+ { token: 'defineConfig', hint: 'порожній defineConfig({}) — стенд-ін для viteConfigPath' }
30
53
  ]
31
54
 
32
55
  /**
33
- * Layout-детекція для stories-glob (ADR Кластер 2): `src/components/` присутній glob
34
- * звужується до нього; пласка структура (`src/` без `components/`) — ширший glob по `src/`.
35
- * Шлях відносний до `.storybook/` (де лежить сам `main.js`), тому з префіксом `../`.
56
+ * Чи має корінь пакета плоскі `.vue`-файли (flat-root layout `NDialog.vue`,
57
+ * `NDialog.stories.js` лежать прямо в КОРЕНІ пакета, `src/` майже порожній чи
58
+ * відсутній). Реальний кейс пілотного консюмера (components/npm) component
59
+ * library без `src/components/`, детекція за самою наявністю `src/` дала б 0
60
+ * знайдених історій (тихий регрес adopt-діагностики). Перевірка нерекурсивна —
61
+ * дивиться лише файли безпосередньо в `absPkgDir`.
62
+ * @param {string} absPkgDir абсолютний шлях кореня пакета
63
+ * @returns {boolean} true — у корені пакета є хоча б один `.vue`-файл
64
+ */
65
+ function hasFlatRootVueFiles(absPkgDir) {
66
+ let entries
67
+ try {
68
+ entries = readdirSync(absPkgDir, { withFileTypes: true })
69
+ } catch {
70
+ return false
71
+ }
72
+ return entries.some(e => e.isFile() && e.name.endsWith('.vue'))
73
+ }
74
+
75
+ /**
76
+ * Layout-детекція для stories-glob (ADR Кластер 2, розширено пілотом на flat-root):
77
+ * `.vue`-файли прямо в корені пакета (без `src/`) → flat-root glob по корені;
78
+ * інакше `src/components/` присутній → glob звужується до нього; інакше — ширший
79
+ * glob по всьому `src/`. Шлях відносний до `.storybook/` (де лежить сам `main.js`),
80
+ * тому з префіксом `../`.
36
81
  * @param {string} absPkgDir абсолютний шлях кореня пакета
37
82
  * @returns {string} glob для `stories` у `.storybook/main.js`
38
83
  */
39
84
  export function detectStoriesGlob(absPkgDir) {
85
+ if (hasFlatRootVueFiles(absPkgDir)) return '../*.stories.@(js|ts)'
40
86
  return existsSync(join(absPkgDir, 'src/components'))
41
87
  ? '../src/components/**/*.stories.@(js|ts)'
42
88
  : '../src/**/*.stories.@(js|ts)'
@@ -130,6 +176,22 @@ async function checkPackageScaffold({ rootDir, absDir, pkg }, reporter) {
130
176
  reporter
131
177
  )
132
178
 
179
+ // empty-vite.config.js — сусідній файл, на який main.js посилається через
180
+ // core.builder.options.viteConfigPath; без нього посилання в main.js "розбите" навіть
181
+ // якщо сам main.js канонічний (маркер viteConfigPath присутній), тому перевіряється
182
+ // окремо, а не лише як частина MAIN_JS_MARKERS.
183
+ await checkCanonFile(
184
+ absDir,
185
+ '.storybook/empty-vite.config.js',
186
+ EMPTY_VITE_CONFIG_MARKERS,
187
+ 'missing-empty-vite-config',
188
+ 'empty-vite-config-marker-missing',
189
+ label,
190
+ rootDir,
191
+ `${relPrefix}.storybook/empty-vite.config.js`,
192
+ reporter
193
+ )
194
+
133
195
  const scriptValue = pkg?.scripts?.storybook
134
196
  if (scriptValue !== STORYBOOK_SCRIPT) {
135
197
  const pkgJsonRel = `${relPrefix}package.json`
@@ -0,0 +1,28 @@
1
+ ---
2
+ type: JS Module
3
+ title: empty-vite.config.js
4
+ resource: plugins/lang-js/rules/storybook/scaffold/template/empty-vite.config.js
5
+ docgen:
6
+ crc: 5c9f8904
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Канонічний порожній `Vite`-конфіг як стенд-ін для `core.builder.options.viteConfigPath` у `.storybook/main.js`. Його відсутність або поломка змушує `@storybook/builder-vite` через `loadConfigFromFile` знайти пакетний `vite.config.js` і додатково змішати нефільтровані плагіни ще до `viteFinal`; `mergeConfig` конкатенує масиви `plugins`, тож фільтр у `viteFinal` не прибирає вже доданий дублікат `@vitejs/plugin-vue`, а лише додає ще один. Без цього файлу `storybook build` падає на кожному `.vue` з `At least one <template> or <script> is required` через подвійну SFC-трансформацію; `dev --smoke-test` цього не виявляє, це підтверджує лише повний `build`. `npx @7n/rules fix storybook` відтворює цей файл, якщо він видалений або зламаний канон.
17
+
18
+ ## Поведінка
19
+
20
+ 1. Файл виступає канонічним порожнім `Vite`-конфігом для `storybook` і використовується як явна точка підключення в `core.builder.options.viteConfigPath`.
21
+ 2. Якщо канон пошкоджено або файл зник, `npx @7n/rules fix storybook` відтворює його, щоб зберегти узгоджений режим збірки.
22
+ 3. Файл не додає власної поведінки до `Vite`-збірки; його роль — заблокувати неявне підхоплення пакетного `vite.config.js` з боку `@storybook/builder-vite`.
23
+ 4. Це прибирає ризик домішування сторонніх `Vite`-плагінів у `storybook build`, які інакше можуть бути обʼєднані до `viteFinal` і спричинити подвійну обробку `Vue`-компонентів.
24
+ 5. Завдяки цьому `storybook build` поводиться стабільно на `.vue`-файлах у бібліотеках компонентів; без такого стенд-іну повний build може падати, тоді як `dev --smoke-test` цього не виявляє.
25
+
26
+ ## Гарантії поведінки
27
+
28
+ - Read-only: не виконує операцій запису (ФС/БД).
@@ -4,7 +4,8 @@ title: plugins/lang-js/rules/storybook/scaffold/template
4
4
  resource: plugins/lang-js/rules/storybook/scaffold/template/
5
5
  ---
6
6
 
7
- | Файл | Тип |
8
- | ------------------------ | --------- |
9
- | [main.js](main.md) | JS Module |
10
- | [preview.js](preview.md) | JS Module |
7
+ | Файл | Тип |
8
+ | -------------------------------------------- | --------- |
9
+ | [empty-vite.config.js](empty-vite.config.md) | JS Module |
10
+ | [main.js](main.md) | JS Module |
11
+ | [preview.js](preview.md) | JS Module |
@@ -3,9 +3,8 @@ type: JS Module
3
3
  title: main.js
4
4
  resource: plugins/lang-js/rules/storybook/scaffold/template/main.js
5
5
  docgen:
6
- crc: 5b21e6e2
6
+ crc: 18fbef4b
7
7
  model: openai-codex/gpt-5.4-mini
8
- tier: cloud-min
9
8
  score: 100
10
9
  issues: judge:inaccurate:0.96
11
10
  judgeModel: openai-codex/gpt-5.4-mini
@@ -3,9 +3,8 @@ type: JS Module
3
3
  title: preview.js
4
4
  resource: plugins/lang-js/rules/storybook/scaffold/template/preview.js
5
5
  docgen:
6
- crc: 5590ef55
6
+ crc: 445c773b
7
7
  model: openai-codex/gpt-5.5
8
- tier: cloud-avg
9
8
  score: 100
10
9
  issues: judge:inaccurate:0.96
11
10
  judgeModel: openai-codex/gpt-5.4-mini
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Канонічний ПОРОЖНІЙ Vite-конфіг — стенд-ін для `core.builder.options.viteConfigPath` у
3
+ * `.storybook/main.js` (storybook.mdc, ADR канон-storybook-для-vue-компонентних-бібліотек).
4
+ * `npx @7n/rules fix storybook` відтворює цей файл, якщо його видалено чи зламано канон.
5
+ *
6
+ * Навіщо: `@storybook/builder-vite` без явного `viteConfigPath` сам через
7
+ * `loadConfigFromFile` знаходить `vite.config.js` ПАКЕТА і домерджує його НЕФІЛЬТРОВАНІ
8
+ * плагіни (той самий `vue()`, обгорнутий VueMacros/unplugin-auto-import) ще ДО виклику
9
+ * `viteFinal` у `main.js` — `mergeConfig` конкатенує масиви `plugins`, тож фільтр у
10
+ * `viteFinal` лише ДОДАЄ ще один `@vitejs/plugin-vue`, а не прибирає вже змерджений
11
+ * дублікат від builder-vite. Наслідок без цього файлу: `storybook build` (саме `build`,
12
+ * не `dev`!) падає на кожному `.vue` з "At least one <template> or <script> is required" —
13
+ * подвійна SFC-трансформація. `dev --smoke-test` цю різницю НЕ ловить (лінива
14
+ * трансформація), лише повний `build`. Перевірено емпірично на пілотному консюмері.
15
+ */
16
+ import { defineConfig } from 'vite'
17
+
18
+ export default defineConfig({})
@@ -3,17 +3,69 @@
3
3
  * канон-storybook-для-vue-компонентних-бібліотек). Згенеровано правилом `storybook` —
4
4
  * `npx @7n/rules fix storybook` відтворює цей файл, якщо його видалено чи зламано канон.
5
5
  */
6
+ import process from 'node:process'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname, join } from 'node:path'
6
9
  import { loadConfigFromFile, mergeConfig } from 'vite'
7
10
  import vue from '@vitejs/plugin-vue'
8
11
  import { quasar, transformAssetUrls } from '@quasar/vite-plugin'
9
12
 
13
+ // Абсолютний каталог `.storybook/` (де лежить сам main.js) — потрібен нижче для
14
+ // `core.builder.options.viteConfigPath` (порожній стенд-ін лежить поруч).
15
+ const dirName = dirname(fileURLToPath(import.meta.url))
16
+
10
17
  // Плагіни власного Vite-збирача Storybook — порядок ФІКСОВАНИЙ: @vitejs/plugin-vue
11
18
  // ПЕРЕД quasar() (інакше Quasar-плагін не бачить SFC, уже скомпільований plugin-vue).
12
19
  const OWN_PLUGINS = [vue({ template: { transformAssetUrls } }), quasar({ sassVariables: true })]
13
20
 
14
21
  // Плагіни файлової маршрутизації додатка-споживача — не мають сенсу в ізольованому
15
- // рендері одного компонента, знімаються при обʼєднанні з vite.config пакета.
16
- const INCOMPATIBLE_PLUGIN_NAMES = new Set(['vite-plugin-pages', 'vite-plugin-vue-layouts'])
22
+ // рендері одного компонента, знімаються при обʼєднанні з vite.config пакета. Обидві
23
+ // версії layouts-пакета (старий і `-next`-форк) та pages-роутери — реальні консюмери
24
+ // мігрують поступово, старий і новий пакет можуть співіснувати в різних vite.config.
25
+ const INCOMPATIBLE_PLUGIN_NAMES = new Set([
26
+ 'vite-plugin-pages',
27
+ 'unplugin-vue-router',
28
+ 'vite-plugin-vue-layouts',
29
+ 'vite-plugin-vue-layouts-next'
30
+ ])
31
+
32
+ /**
33
+ * Плагін належить до сімейства Vue SFC-трансформерів, які ДУБЛЮЮТЬ `OWN_PLUGINS`'
34
+ * `vue()`. Реальний стек консюмерів (components/npm/vite.config.js) обгортає
35
+ * `@vitejs/plugin-vue` через `VueMacros({ plugins: { vue: Vue() } })` — після
36
+ * резолву цей виклик повертає МАСИВ плагінів: сам `vite:vue` (той самий transform,
37
+ * що й `OWN_PLUGINS`' `vue()` — дублювання дає ПОДВІЙНУ SFC-трансформацію) плюс
38
+ * службові `vue-macros-*` (devtools/exclude-dep-optimize). Плагіни macro-синтаксису
39
+ * (`unplugin-vue-define-props`/`define-emit`/`reactivity-transform` тощо) — НЕ
40
+ * фільтруються: вони не дублюються `OWN_PLUGINS` і потрібні для macro-фіч пакета;
41
+ * Vite впорядковує їх через власний `enforce: 'pre'|'post'` незалежно від позиції
42
+ * в підсумковому масиві `plugins`, тож порядок відносно `OWN_PLUGINS` тут не важливий.
43
+ * @param {string | undefined} name ім'я плагіна
44
+ * @returns {boolean} true — плагін дублює `OWN_PLUGINS`' `vue()`
45
+ */
46
+ function isVueTransformFamily(name) {
47
+ return typeof name === 'string' && (name.startsWith('vite:vue') || name.includes('vue-macros'))
48
+ }
49
+
50
+ /**
51
+ * Резолвить один запис `config.plugins` у плаский масив реальних плагінів. Vite
52
+ * офіційно підтримує `Plugin | Promise<Plugin> | (Plugin | Promise<Plugin>)[]`
53
+ * (довільна вкладеність) — `VueMacros(...)` сам повертає `Promise`, що резолвиться
54
+ * в масив плагінів. `loadConfigFromFile` читає файл конфіга як є й НЕ виконує це
55
+ * resolve/flatten (це робить лише повний `resolveConfig` пізніше у власному циклі
56
+ * Vite) — без ручного resolve/flatten тут фільтр порівнював би ім'я з
57
+ * `Promise`-об'єктом, що ще не резолвився (`undefined`), і пропускав би дублікат далі.
58
+ * @param {unknown} entry один елемент/Promise/масив із `config.plugins`
59
+ * @returns {Promise<object[]>} плаский масив плагінів після resolve
60
+ */
61
+ async function resolvePluginEntry(entry) {
62
+ const resolved = await entry
63
+ if (Array.isArray(resolved)) {
64
+ const nested = await Promise.all(resolved.map(resolvePluginEntry))
65
+ return nested.flat(Infinity)
66
+ }
67
+ return resolved ? [resolved] : []
68
+ }
17
69
 
18
70
  /** @type {import('@storybook/vue3-vite').StorybookConfig} */
19
71
  const config = {
@@ -24,15 +76,33 @@ const config = {
24
76
  },
25
77
  // Публічний asset для msw service worker, який ініціалізує preview.js.
26
78
  staticDirs: ['./public'],
79
+ core: {
80
+ builder: {
81
+ name: '@storybook/builder-vite',
82
+ // ОБОВ'ЯЗКОВИЙ обхід (емпірично підтверджено — лише через `storybook build`, dev
83
+ // smoke-test не ловить): без цього `@storybook/builder-vite` сам через
84
+ // `loadConfigFromFile` знаходить `../vite.config.js` пакета ще ДО виклику
85
+ // `viteFinal` нижче й домерджує його НЕФІЛЬТРОВАНІ плагіни (VueMacros-обгорнутий
86
+ // `vue()`, layout-роутинг тощо) у storybookConfig. `mergeConfig` конкатенує масиви
87
+ // `plugins`, тож фільтр у `viteFinal` лише ДОДАЄ ще один `@vitejs/plugin-vue`, а НЕ
88
+ // прибирає вже змерджений builder-vite дублікат — подвійна SFC-трансформація,
89
+ // `storybook build` падає на кожному `.vue` з "At least one <template> or <script>
90
+ // is required". `viteConfigPath` на порожній стенд-ін (`empty-vite.config.js`,
91
+ // канонічний сусідній файл цього скафолда) блокує це autodiscovery повністю.
92
+ options: { viteConfigPath: join(dirName, 'empty-vite.config.js') }
93
+ }
94
+ },
27
95
  async viteFinal(storybookConfig) {
28
96
  const loaded = await loadConfigFromFile({ command: 'serve', mode: 'development' }, undefined, process.cwd())
29
- const userPlugins = (loaded?.config?.plugins ?? [])
30
- .flat()
97
+ const rawPlugins = loaded?.config?.plugins ?? []
98
+ const resolvedNested = await Promise.all(rawPlugins.map(resolvePluginEntry))
99
+ const userPlugins = resolvedNested
100
+ .flat(Infinity)
31
101
  .filter(Boolean)
32
102
  .filter(p => !INCOMPATIBLE_PLUGIN_NAMES.has(p.name))
33
103
  // vue()/quasar() пакета замінюємо власними екземплярами у фіксованому порядку вище —
34
- // не дублюємо; решта плагінів пакета (auto-import, svg-loader тощо) лишається.
35
- .filter(p => p.name !== 'vite:vue' && p.name !== 'quasar')
104
+ // не дублюємо; решта плагінів пакета (auto-import, VueMacros macro-sugar тощо) лишається.
105
+ .filter(p => !isVueTransformFamily(p.name) && p.name !== 'quasar')
36
106
 
37
107
  return mergeConfig(storybookConfig, {
38
108
  resolve: loaded?.config?.resolve,