@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,132 @@
1
+ ## Мокання зовнішніх залежностей у Storybook
2
+
3
+ Джерело рішень: `docs/adr/канон-storybook-для-vue-компонентних-бібліотек.md`, Кластер 3 і розділ «Розширення (2026-07-20): сторінки — route.params + Apollo subscription + Pinia» (прототип-верифікація на реальному кейсі `gt`). Це docs-only концерн — рецепти для агента, що впроваджує/підтримує Storybook у консюмер-репо; механічної перевірки немає (кожен пакет мокає свій набір залежностей по-своєму, детермінований чек дав би або хибні спрацювання, або нульове покриття).
4
+
5
+ ### Router
6
+
7
+ Детекція: компонент використовує `useRoute`/`useRouter` (composition API з `vue-router`). Базовий рецепт — `createMemoryHistory()` з одним catch-all-маршрутом, без реальних params:
8
+
9
+ ```js
10
+ import { createMemoryHistory, createRouter } from 'vue-router'
11
+
12
+ const router = createRouter({
13
+ history: createMemoryHistory(),
14
+ routes: [{ path: '/:pathMatch(.*)*', component: { render: () => null } }]
15
+ })
16
+ ```
17
+
18
+ Для сторінок (хвиля 2, app-проєкти з `src/pages/`) — реальний параметризований маршрут замість catch-all, `router.push` перед mount і `await router.isReady()` у Storybook `loaders` (не в декораторі — `loaders` виконується per-story до рендеру, прибирає перший рендер без `route.params`):
19
+
20
+ ```js
21
+ const router = createRouter({
22
+ history: createMemoryHistory(),
23
+ routes: [{ path: '/task/:id', component: TaskPage }]
24
+ })
25
+
26
+ export const pageLoader = ({ args }) => async () => {
27
+ await router.push(`/task/${args.taskId}`)
28
+ await router.isReady()
29
+ return { router }
30
+ }
31
+ ```
32
+
33
+ ### `@nitra/tfm`
34
+
35
+ No-op. `@nitra/tfm` (`tf`/`lang`/`getTr`) не потребує мокання — компонент рендериться з реальним модулем, переклади вже статичні дані в самому файлі (`vue/tfm-translations.mdc`). Задокументуй це явно в story-файлі коментарем (`// @nitra/tfm — no-op, реальний модуль`), щоб наступний агент не витрачав час на пошук неіснуючого моку.
36
+
37
+ ### Apollo/GraphQL — виключно MSW
38
+
39
+ **Рішення ADR-розширення:** мокання GraphQL (query/mutation/subscription) — тільки на мережевому рівні через `msw-storybook-addon`, **без** `resolve.alias`-підмін app-коду (`boot/apollo.js` чи еквівалент лишається повністю справжнім). Facilitator у сесії розширення рекомендував лінк-рівневий alias-мок (справжні хуки `@vue3-apollo/core` + фейковий termination `ApolloLink`), а після вибору MSW — гібрид MSW+alias для side-effect boot-модулів; обрано чистий MSW, бо app-код (включно з link-split http/sse) не розходиться з production-поведінкою, а мок-хендлери переносні в майбутні vitest/playwright.
40
+
41
+ Query/mutation — стандартний `graphql.query`/`graphql.mutation` handler, сценарії через `parameters.msw`:
42
+
43
+ ```js
44
+ import { graphql, HttpResponse } from 'msw'
45
+
46
+ export const Default = {
47
+ parameters: {
48
+ msw: {
49
+ handlers: [graphql.query('GetTask', () => HttpResponse.json({ data: { task: taskFixture } }))]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Підписки — `msw-storybook-addon` не має готового `graphql.subscription()`-хендлера; wire-протокол `graphql-sse` (`event: next\ndata: …`, distinct-connection mode) мокається вручну через канонічний хелпер `sseSubscription` з `.storybook/mocks/gql-sse.js` (генерується скафолдом, `scaffold/template/mocks/gql-sse.js` — не копіювати логіку в кожен пакет, одне джерело істини для протоколу):
56
+
57
+ ```js
58
+ import { http, HttpResponse } from 'msw'
59
+ import { sseSubscription } from '../../.storybook/mocks/gql-sse.js'
60
+
61
+ export const Realtime = {
62
+ parameters: {
63
+ msw: {
64
+ handlers: [
65
+ http.post('/graphql/stream', () =>
66
+ new HttpResponse(sseSubscription([{ data: { taskUpdated: frame1 } }, { data: { taskUpdated: frame2 } }]), {
67
+ headers: { 'Content-Type': 'text/event-stream' }
68
+ })
69
+ )
70
+ ]
71
+ }
72
+ }
73
+ }
74
+ ```
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
+
100
+ ### Pinia
101
+
102
+ Справжня `createPinia()` у page-декораторі — **без** `pinia-plugin-persistedstate` (`persist: true` у сторі стає no-op, читання/запис localStorage не потрібні в ізольованому рендері). Наповнення початкового стану — через `parameters.pinia.initialState`, не хардкод у декораторі:
103
+
104
+ ```js
105
+ import { createPinia } from 'pinia'
106
+
107
+ export function pageDecorator({ route, pinia } = {}) {
108
+ return (story, ctx) => {
109
+ const app = createPinia()
110
+ if (ctx.parameters.pinia?.initialState) {
111
+ for (const [id, state] of Object.entries(ctx.parameters.pinia.initialState)) {
112
+ app.state.value[id] = state
113
+ }
114
+ }
115
+ // ...install router/pinia у app instance, повернути wrapped story
116
+ }
117
+ }
118
+ ```
119
+
120
+ **Не** `@pinia/testing` (`createTestingPinia`) — вона за замовчуванням підміняє actions заглушками, а сторінка в Storybook має жити зі справжньою бізнес-логікою стору, не з заглушкою.
121
+
122
+ ### Патерн story для сторінки
123
+
124
+ - Одна фабрика-декоратор `pageDecorator({ route, pinia })` на весь повторюваний код (router + pinia + Quasar layout) — не дублювати в кожному story-файлі.
125
+ - **`QLayout`/`QPageContainer`-wrapper обов'язковий**: `q-page` кидає виняток без layout-предка. Quasar SFC-transform (auto-реєстрація компонентів через build-time трансформ) **не працює** в runtime-шаблонах декораторів — `QLayout`/`QPageContainer`/`QPage` реєструються явно (`app.component('QLayout', QLayout)` тощо), інакше рендер падає з незрозумілою помилкою про невідомий тег.
126
+ - Фікстури — окремим модулем `.storybook/fixtures/<page>.js`, не inline в story-файлі (переюз між `Default`/`Loading`/`Error`/`Realtime`).
127
+ - **Smoke-мінімум**: одна story «рендериться без помилок» на кожну сторінку — обов'язковий нижній рівень покриття, навіть без окремих stories для кожного стану.
128
+ - Окремі stories для `Loading`/`Error`/`Realtime` (сценарій з кількома SSE-кадрами через `sseSubscription`) — де застосовно, поверх smoke-мінімуму.
129
+
130
+ ### Мережеві side-effect компоненти без моку
131
+
132
+ Компонент робить мережеві виклики (analytics-трекер, session-boot тощо), для яких мокання поки не покрите канонічним рецептом — постав у story коментар-маркер `// n-storybook: лише display-стан, мережеві side-effects без моку`, щоб було видно свідоме рішення, а не забутий пропуск.
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
+ "fixability": "config",
4
+ "lint": { "scope": "full", "glob": ["package.json", "**/*.vue", ".storybook/**"] }
5
+ }
@@ -0,0 +1,29 @@
1
+ ---
2
+ type: JS Module
3
+ title: fix-scaffold.mjs
4
+ resource: plugins/lang-js/rules/storybook/scaffold/fix-scaffold.mjs
5
+ docgen:
6
+ crc: 31a867f5
7
+ model: openai-codex/gpt-5.4-mini
8
+ score: 90
9
+ issues: internal-name:detectStoriesGlob,judge:inaccurate:0.95
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Файл відновлює канонічні Storybook-артефакти для concern-а `storybook/scaffold`: створює відсутні `.storybook/main.js`, `.storybook/preview.js`, `.storybook/mocks/gql-sse.js` і синхронізує `package.json#scripts.storybook` за шаблоном concern-а. `main.js` відновлюється з однією заміною для конкретного пакета — stories-glob за layout-детекцією (`detectStoriesGlob`, `main.mjs`); `preview.js` і `.storybook/mocks/gql-sse.js` відновлюються як verbatim-копії, однакові для всіх пакетів. Це потрібно, щоб привести пакет до канонічного Storybook-стану concern-а. Код працює fail-safe і не кидає винятків назовні; конфіги, на які спирається код: package.json
16
+
17
+ ## Поведінка
18
+
19
+ 1. `patterns` запускає два окремі відновлювальні сценарії для Storybook: один для відсутніх `.storybook/main.js` і `.storybook/preview.js`, інший — для відсутнього `scripts.storybook` у `package.json`.
20
+ 2. Для кожного пакета з проблемою `missing-main-js` створює `.storybook/main.js` за шаблоном concern-а, підставляючи пакетний stories glob відповідно до layout-перевірки.
21
+ 3. Для того ж пакета за потреби створює `.storybook/mocks/gql-sse.js` як канонічну копію з шаблону; якщо файл уже є, не перезаписує його.
22
+ 4. Для кожного пакета з проблемою `missing-preview-js` створює `.storybook/preview.js` як канонічну копію з шаблону concern-а.
23
+ 5. Для кожного `package.json` з проблемою `missing-storybook-script` додає або оновлює `scripts.storybook` до канонічного значення; якщо JSON не читається, запис пропускається без падіння.
24
+ 6. Усі зміни застосовуються лише там, де доступний корінь concern-а або шлях до файлу, тож autofix працює fail-safe і не зупиняє весь прогін через одну некоректну ціль.
25
+ 7. Орієнтується на `package.json` як на конфігураційне джерело для скрипта Storybook.
26
+
27
+ ## Гарантії поведінки
28
+
29
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
@@ -0,0 +1,10 @@
1
+ ---
2
+ type: Directory Index
3
+ title: plugins/lang-js/rules/storybook/scaffold
4
+ resource: plugins/lang-js/rules/storybook/scaffold/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ----------------------------------- | --------- |
9
+ | [fix-scaffold.mjs](fix-scaffold.md) | JS Module |
10
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,36 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.mjs
4
+ resource: plugins/lang-js/rules/storybook/scaffold/main.mjs
5
+ docgen:
6
+ crc: 2cad0405
7
+ model: openai-codex/gpt-5.4-mini
8
+ score: 100
9
+ issues: judge:inaccurate:0.98
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Файл підтримує Storybook-скафолд для Vue-пакетів і орієнтується на `package.json`, щоб узгодити сценарій запуску з проєктним конфігом. Експортована константа-рядок `STORYBOOK_SCRIPT="storybook dev -p 6006 --no-open"` задає стандартний Storybook-запуск без відкриття браузера. `detectStoriesGlob` визначає межі пошуку stories для пакета. `lint` перевіряє наявність очікуваних файлів і канонічних маркерів та підказує `npx @7n/rules fix storybook` у разі проблем.
16
+
17
+ ## Поведінка
18
+
19
+ - STORYBOOK_SCRIPT — канонічне значення `package.json#scripts.storybook`: `storybook dev -p 6006 --no-open`.
20
+ - detectStoriesGlob — визначає glob для Storybook stories залежно від структури пакета: для `src/components/` звужує пошук до цієї теки, інакше бере ширший glob по `src/`; шлях формується відносно `.storybook/`.
21
+ - lint — перевіряє для всіх Vue-пакетів у скоупі канонічний Storybook-скафолд: `.storybook/main.js`, `.storybook/preview.js` і `package.json#scripts.storybook`; якщо файлу або потрібних маркерів бракує, повідомляє порушення з підказкою на `npx @7n/rules fix storybook`.
22
+
23
+ Changelog: pending
24
+
25
+ ## Публічний API
26
+
27
+ - STORYBOOK_SCRIPT — Канонічне значення `package.json#scripts.storybook` (storybook.mdc).
28
+ - detectStoriesGlob — Layout-детекція для stories-glob (ADR Кластер 2): `src/components/` присутній → glob
29
+ звужується до нього; пласка структура (`src/` без `components/`) — ширший glob по `src/`.
30
+ Шлях відносний до `.storybook/` (де лежить сам `main.js`), тому з префіксом `../`.
31
+ - lint — Перевіряє канонічний Storybook-скафолд (`.storybook/main.js`, `.storybook/preview.js`,
32
+ `package.json#scripts.storybook`) для всіх пакетів у скоупі (`scope/main.mjs`).
33
+
34
+ ## Гарантії поведінки
35
+
36
+ - Read-only: не виконує операцій запису (ФС/БД).
@@ -0,0 +1,150 @@
1
+ /**
2
+ * T0-autofix для concern-а `storybook/scaffold`: відтворює канонічні `.storybook/main.js`,
3
+ * `.storybook/preview.js`, `.storybook/mocks/gql-sse.js` і `package.json#scripts.storybook`
4
+ * з `template/` цього concern-а. `main.js` має одну заміну на пакет — stories-glob за
5
+ * layout-детекцією (`detectStoriesGlob`, `main.mjs`); `preview.js`/`mocks/gql-sse.js` —
6
+ * verbatim-копія (Quasar/msw-налаштування не залежать від конкретного пакета).
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ import { detectStoriesGlob, STORYBOOK_SCRIPT } from './main.mjs'
13
+
14
+ const STORIES_GLOB_TOKEN = '__STORYBOOK_STORIES_GLOB__'
15
+
16
+ /** Каталог `template/` цього concern-а. Експортовано — переюз у `adopt/main.mjs`. */
17
+ export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'template')
18
+
19
+ /**
20
+ * Рендерить канонічний `.storybook/main.js` для конкретного пакета (єдина заміна —
21
+ * stories-glob за layout-детекцією). Експортовано — той самий рендер переюзає
22
+ * `adopt/main.mjs` для генерації повністю відсутнього файлу (не дублювати шаблонування).
23
+ * @param {string} absPkgDir абсолютний шлях кореня пакета
24
+ * @param {string} [templateDir] каталог template/ (за замовчуванням — цього concern-а)
25
+ * @returns {string} готовий вміст `main.js`
26
+ */
27
+ export function renderMainJs(absPkgDir, templateDir = TEMPLATE_DIR) {
28
+ const mainTemplate = readFileSync(join(templateDir, 'main.js'), 'utf8')
29
+ return mainTemplate.split(STORIES_GLOB_TOKEN).join(detectStoriesGlob(absPkgDir))
30
+ }
31
+
32
+ /**
33
+ * Вміст канонічного `.storybook/preview.js` — verbatim з template (не залежить від пакета).
34
+ * Експортовано — переюз у `adopt/main.mjs`.
35
+ * @param {string} [templateDir] каталог template/ (за замовчуванням — цього concern-а)
36
+ * @returns {string} вміст `preview.js`
37
+ */
38
+ export function renderPreviewJs(templateDir = TEMPLATE_DIR) {
39
+ return readFileSync(join(templateDir, 'preview.js'), 'utf8')
40
+ }
41
+
42
+ /**
43
+ * Вміст канонічного `.storybook/mocks/gql-sse.js` — verbatim з template. Експортовано —
44
+ * переюз у `adopt/main.mjs`.
45
+ * @param {string} [templateDir] каталог template/ (за замовчуванням — цього concern-а)
46
+ * @returns {string} вміст `mocks/gql-sse.js`
47
+ */
48
+ export function renderMocksGqlSse(templateDir = TEMPLATE_DIR) {
49
+ return readFileSync(join(templateDir, 'mocks/gql-sse.js'), 'utf8')
50
+ }
51
+
52
+ /**
53
+ * Записує файл, створюючи батьківські каталоги й реєструючи запис для rollback.
54
+ * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').FixContext} ctx fix-контекст рунга
55
+ * @param {string} absPath абсолютний шлях цільового файлу
56
+ * @param {string} content вміст файлу
57
+ * @returns {void}
58
+ */
59
+ function writeScaffoldFile(ctx, absPath, content) {
60
+ mkdirSync(dirname(absPath), { recursive: true })
61
+ ctx.recordWrite?.(absPath)
62
+ writeFileSync(absPath, content, 'utf8')
63
+ }
64
+
65
+ /**
66
+ * Резолвить абсолютний корінь пакета з `rootDir` violation.data (`.` — корінь репозиторію).
67
+ * @param {string} cwd абсолютний корінь репозиторію
68
+ * @param {string} rootDir posix-relative корінь пакета
69
+ * @returns {string} абсолютний шлях кореня пакета
70
+ */
71
+ function resolvePkgDir(cwd, rootDir) {
72
+ return rootDir === '.' ? cwd : join(cwd, rootDir)
73
+ }
74
+
75
+ /** @type {import('@7n/rules/scripts/lib/lint-surface/types.mjs').T0Pattern[]} */
76
+ export const patterns = [
77
+ {
78
+ id: 'storybook-scaffold-main-js',
79
+ test: violations => violations.some(v => v.reason === 'missing-main-js'),
80
+ apply: (violations, ctx) => {
81
+ const targets = violations.filter(v => v.reason === 'missing-main-js' && typeof v.data?.rootDir === 'string')
82
+ if (targets.length === 0 || !ctx.concernDir) return { touchedFiles: [] }
83
+
84
+ const templateDir = join(ctx.concernDir, 'template')
85
+ const mocksTemplate = renderMocksGqlSse(templateDir)
86
+
87
+ const touchedFiles = []
88
+ for (const v of targets) {
89
+ const absPkgDir = resolvePkgDir(ctx.cwd, v.data.rootDir)
90
+ const rendered = renderMainJs(absPkgDir, templateDir)
91
+
92
+ const mainAbs = join(absPkgDir, '.storybook/main.js')
93
+ writeScaffoldFile(ctx, mainAbs, rendered)
94
+ touchedFiles.push(mainAbs)
95
+
96
+ const mocksAbs = join(absPkgDir, '.storybook/mocks/gql-sse.js')
97
+ if (!existsSync(mocksAbs)) {
98
+ writeScaffoldFile(ctx, mocksAbs, mocksTemplate)
99
+ touchedFiles.push(mocksAbs)
100
+ }
101
+ }
102
+ return { touchedFiles, message: `.storybook/main.js: створено для ${targets.length} пакет(ів)` }
103
+ }
104
+ },
105
+ {
106
+ id: 'storybook-scaffold-preview-js',
107
+ test: violations => violations.some(v => v.reason === 'missing-preview-js'),
108
+ apply: (violations, ctx) => {
109
+ const targets = violations.filter(v => v.reason === 'missing-preview-js' && typeof v.data?.rootDir === 'string')
110
+ if (targets.length === 0 || !ctx.concernDir) return { touchedFiles: [] }
111
+
112
+ const previewTemplate = renderPreviewJs(join(ctx.concernDir, 'template'))
113
+
114
+ const touchedFiles = []
115
+ for (const v of targets) {
116
+ const absPkgDir = resolvePkgDir(ctx.cwd, v.data.rootDir)
117
+ const previewAbs = join(absPkgDir, '.storybook/preview.js')
118
+ writeScaffoldFile(ctx, previewAbs, previewTemplate)
119
+ touchedFiles.push(previewAbs)
120
+ }
121
+ return { touchedFiles, message: `.storybook/preview.js: створено для ${targets.length} пакет(ів)` }
122
+ }
123
+ },
124
+ {
125
+ id: 'storybook-scaffold-package-script',
126
+ test: violations => violations.some(v => v.reason === 'missing-storybook-script'),
127
+ apply: (violations, ctx) => {
128
+ const targets = violations.filter(v => v.reason === 'missing-storybook-script' && v.file)
129
+ const touchedFiles = []
130
+ for (const v of targets) {
131
+ const abs = join(ctx.cwd, v.file)
132
+ let pkg
133
+ try {
134
+ pkg = JSON.parse(readFileSync(abs, 'utf8'))
135
+ } catch {
136
+ continue
137
+ }
138
+ pkg.scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {}
139
+ if (pkg.scripts.storybook === STORYBOOK_SCRIPT) continue
140
+ pkg.scripts.storybook = STORYBOOK_SCRIPT
141
+ ctx.recordWrite?.(abs)
142
+ writeFileSync(abs, `${JSON.stringify(pkg, null, 2)}\n`)
143
+ touchedFiles.push(abs)
144
+ }
145
+ return touchedFiles.length > 0
146
+ ? { touchedFiles, message: `scripts.storybook: встановлено у ${touchedFiles.length} package.json` }
147
+ : { touchedFiles: [] }
148
+ }
149
+ }
150
+ ]
@@ -0,0 +1,196 @@
1
+ /** @see ./docs/main.md */
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+
6
+ import { createViolationReporter } from '@7n/rules/scripts/lib/lint-surface/violation-reporter.mjs'
7
+ import { collectInScopeVuePackages } from '../scope/main.mjs'
8
+
9
+ /** Канонічне значення `package.json#scripts.storybook` (storybook.mdc). */
10
+ export const STORYBOOK_SCRIPT = 'storybook dev -p 6006 --no-open'
11
+
12
+ /**
13
+ * Маркери канону `.storybook/main.js`, перевірені текстовим пошуком (без AST — рядки стабільні).
14
+ * Експортовано — той самий список переюзає `adopt/main.mjs` для diff-діагностики (не дублювати).
15
+ */
16
+ export const MAIN_JS_MARKERS = [
17
+ { token: '@storybook/vue3-vite', hint: 'framework @storybook/vue3-vite' },
18
+ { token: 'viteFinal', hint: 'viteFinal-override vite.config пакета' },
19
+ { token: "'vite-plugin-pages'", hint: 'фільтр vite-plugin-pages у 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
+
32
+ /** Маркери канону `.storybook/preview.js`. Експортовано — переюз у `adopt/main.mjs`. */
33
+ export const PREVIEW_JS_MARKERS = [
34
+ { token: 'Quasar', hint: 'повний install Quasar' },
35
+ { token: 'iconSet', hint: 'iconSet' },
36
+ { token: 'iconMapFn', hint: 'iconMapFn (без нього внутрішні Quasar-іконки недоступні)' },
37
+ { token: 'msw-storybook-addon', hint: 'msw-storybook-addon' },
38
+ { token: 'onUnhandledRequest', hint: 'onUnhandledRequest-фільтр' }
39
+ ]
40
+
41
+ /**
42
+ * Чи має корінь пакета плоскі `.vue`-файли (flat-root layout — `NDialog.vue`,
43
+ * `NDialog.stories.js` лежать прямо в КОРЕНІ пакета, `src/` майже порожній чи
44
+ * відсутній). Реальний кейс пілотного консюмера (components/npm) — component
45
+ * library без `src/components/`, детекція за самою наявністю `src/` дала б 0
46
+ * знайдених історій (тихий регрес adopt-діагностики). Перевірка нерекурсивна —
47
+ * дивиться лише файли безпосередньо в `absPkgDir`.
48
+ * @param {string} absPkgDir абсолютний шлях кореня пакета
49
+ * @returns {boolean} true — у корені пакета є хоча б один `.vue`-файл
50
+ */
51
+ function hasFlatRootVueFiles(absPkgDir) {
52
+ let entries
53
+ try {
54
+ entries = readdirSync(absPkgDir, { withFileTypes: true })
55
+ } catch {
56
+ return false
57
+ }
58
+ return entries.some(e => e.isFile() && e.name.endsWith('.vue'))
59
+ }
60
+
61
+ /**
62
+ * Layout-детекція для stories-glob (ADR Кластер 2, розширено пілотом на flat-root):
63
+ * `.vue`-файли прямо в корені пакета (без `src/`) → flat-root glob по корені;
64
+ * інакше `src/components/` присутній → glob звужується до нього; інакше — ширший
65
+ * glob по всьому `src/`. Шлях відносний до `.storybook/` (де лежить сам `main.js`),
66
+ * тому з префіксом `../`.
67
+ * @param {string} absPkgDir абсолютний шлях кореня пакета
68
+ * @returns {string} glob для `stories` у `.storybook/main.js`
69
+ */
70
+ export function detectStoriesGlob(absPkgDir) {
71
+ if (hasFlatRootVueFiles(absPkgDir)) return '../*.stories.@(js|ts)'
72
+ return existsSync(join(absPkgDir, 'src/components'))
73
+ ? '../src/components/**/*.stories.@(js|ts)'
74
+ : '../src/**/*.stories.@(js|ts)'
75
+ }
76
+
77
+ /**
78
+ * @param {string} content вміст файлу
79
+ * @param {{ token: string, hint: string }[]} markers очікувані канонічні маркери
80
+ * @returns {{ token: string, hint: string }[]} маркери, яких бракує
81
+ */
82
+ export function missingMarkers(content, markers) {
83
+ return markers.filter(m => !content.includes(m.token))
84
+ }
85
+
86
+ /**
87
+ * Перевіряє один канонічний файл скафолду (`.storybook/main.js` або `.storybook/preview.js`):
88
+ * відсутність → `missingReason`-порушення з посиланням на `npx \@7n/rules fix storybook`;
89
+ * присутність без якогось канонічного маркера → `markerReason`-порушення на маркер.
90
+ * @param {string} absDir абсолютний корінь пакета
91
+ * @param {string} relFile posix-relative шлях файлу від кореня пакета (`.storybook/main.js`)
92
+ * @param {{ token: string, hint: string }[]} markers канонічні маркери файлу
93
+ * @param {string} missingReason reason для порушення "файл відсутній"
94
+ * @param {string} markerReason reason для порушення "маркер відсутній"
95
+ * @param {string} label людський підпис пакета для повідомлень
96
+ * @param {string} rootDir root dir пакета (для violation.data)
97
+ * @param {string} fileRel posix-relative шлях файлу від кореня репозиторію (для violation.file)
98
+ * @param {ReturnType<typeof createViolationReporter>} reporter reporter поточного лінту
99
+ * @returns {Promise<void>}
100
+ */
101
+ async function checkCanonFile(
102
+ absDir,
103
+ relFile,
104
+ markers,
105
+ missingReason,
106
+ markerReason,
107
+ label,
108
+ rootDir,
109
+ fileRel,
110
+ reporter
111
+ ) {
112
+ const abs = join(absDir, relFile)
113
+ if (existsSync(abs)) {
114
+ const content = await readFile(abs, 'utf8')
115
+ for (const m of missingMarkers(content, markers)) {
116
+ reporter.fail(`[${label}] ${relFile} не відповідає канону — бракує: ${m.hint} (storybook.mdc)`, {
117
+ reason: markerReason,
118
+ file: fileRel
119
+ })
120
+ }
121
+ return
122
+ }
123
+ reporter.fail(`[${label}] відсутній ${relFile} — канонічний скафолд: npx @7n/rules fix storybook (storybook.mdc)`, {
124
+ reason: missingReason,
125
+ file: fileRel,
126
+ data: { rootDir }
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Перевіряє один в-скоупі пакет: `.storybook/main.js`, `.storybook/preview.js`,
132
+ * `package.json#scripts.storybook`.
133
+ * @param {import('../scope/main.mjs').InScopePackage} pkgEntry пакет у скоупі
134
+ * @param {ReturnType<typeof createViolationReporter>} reporter reporter поточного лінту
135
+ * @returns {Promise<void>}
136
+ */
137
+ async function checkPackageScaffold({ rootDir, absDir, pkg }, reporter) {
138
+ const label = rootDir === '.' ? 'корінь' : rootDir
139
+ const relPrefix = rootDir === '.' ? '' : `${rootDir}/`
140
+
141
+ await checkCanonFile(
142
+ absDir,
143
+ '.storybook/main.js',
144
+ MAIN_JS_MARKERS,
145
+ 'missing-main-js',
146
+ 'main-js-marker-missing',
147
+ label,
148
+ rootDir,
149
+ `${relPrefix}.storybook/main.js`,
150
+ reporter
151
+ )
152
+
153
+ await checkCanonFile(
154
+ absDir,
155
+ '.storybook/preview.js',
156
+ PREVIEW_JS_MARKERS,
157
+ 'missing-preview-js',
158
+ 'preview-js-marker-missing',
159
+ label,
160
+ rootDir,
161
+ `${relPrefix}.storybook/preview.js`,
162
+ reporter
163
+ )
164
+
165
+ const scriptValue = pkg?.scripts?.storybook
166
+ if (scriptValue !== STORYBOOK_SCRIPT) {
167
+ const pkgJsonRel = `${relPrefix}package.json`
168
+ const current = scriptValue ? `'${scriptValue}'` : 'відсутній'
169
+ reporter.fail(
170
+ `[${label}] package.json#scripts.storybook має бути '${STORYBOOK_SCRIPT}' (зараз: ${current}) — storybook.mdc`,
171
+ { reason: 'missing-storybook-script', file: pkgJsonRel, data: { rootDir } }
172
+ )
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Перевіряє канонічний Storybook-скафолд (`.storybook/main.js`, `.storybook/preview.js`,
178
+ * `package.json#scripts.storybook`) для всіх пакетів у скоупі (`scope/main.mjs`).
179
+ * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст лінту
180
+ * @returns {Promise<import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintResult>} результат лінту
181
+ */
182
+ export async function lint(ctx) {
183
+ const reporter = createViolationReporter(ctx)
184
+ const pkgs = await collectInScopeVuePackages(ctx.cwd)
185
+
186
+ if (pkgs.length === 0) {
187
+ reporter.pass('storybook: немає Vue component library пакетів у скоупі (storybook.mdc)')
188
+ return reporter.result()
189
+ }
190
+
191
+ for (const entry of pkgs) {
192
+ await checkPackageScaffold(entry, reporter)
193
+ }
194
+
195
+ return reporter.result()
196
+ }
@@ -0,0 +1,10 @@
1
+ ---
2
+ type: Directory Index
3
+ title: plugins/lang-js/rules/storybook/scaffold/template
4
+ resource: plugins/lang-js/rules/storybook/scaffold/template/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ------------------------ | --------- |
9
+ | [main.js](main.md) | JS Module |
10
+ | [preview.js](preview.md) | JS Module |
@@ -0,0 +1,29 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.js
4
+ resource: plugins/lang-js/rules/storybook/scaffold/template/main.js
5
+ docgen:
6
+ crc: 74cf3397
7
+ model: openai-codex/gpt-5.4-mini
8
+ score: 100
9
+ issues: judge:inaccurate:0.96
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Файл задає канонічний Storybook-конфіг для Vue-компонентних бібліотек, які дотримуються правила `storybook` і ADR `канон-storybook-для-vue-компонентних-бібліотек`, щоб спільний формат прев’ю був відтворюваним у всіх таких пакетах. Він фіксує налаштування, потрібні для Storybook-перегляду Vue-компонентів, і зберігає лише ті ресурси та плагіни, без яких прев’ю не працює як очікується.
16
+
17
+ ## Поведінка
18
+
19
+ 1. Формує канонічний Storybook-конфіг для Vue-компонентної бібліотеки, щоб ізольований перегляд компонентів працював однаково в усіх пакетах, які дотримуються цього правила.
20
+ 2. Підтягує базові налаштування застосунку-споживача й зливає їх із Storybook-налаштуваннями, щоб зберегти спільні параметри рендерингу, шляхів і CSS.
21
+ 3. Вилучає плагіни, які залежать від повної маршрутизації або layout-інфраструктури застосунку, бо в Storybook вони не дають користі для одного компонента.
22
+ 4. Замінює Vue- і Quasar-інтеграцію на власні екземпляри у фіксованому порядку, щоб шаблони компонентів і Quasar-стилі оброблялися передбачувано.
23
+ 5. Залишає інші корисні плагіни пакета, щоб у Storybook зберігалися допоміжні можливості на кшталт auto-import чи SVG-підтримки.
24
+ 6. Підключає публічні assets із теки `public`, щоб preview міг ініціалізувати service worker для `msw`.
25
+ 7. Якщо канонічний файл пошкоджено або видалено, відтворюється правилом `storybook` через `npx @7n/rules fix storybook`.
26
+
27
+ ## Гарантії поведінки
28
+
29
+ - Read-only: не виконує операцій запису (ФС/БД).