@7n/rules-lang-js 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +13 -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 +30 -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 +46 -0
  9. package/rules/storybook/adopt/main.mjs +379 -0
  10. package/rules/storybook/hygiene/concern.json +4 -0
  11. package/rules/storybook/hygiene/docs/index.md +9 -0
  12. package/rules/storybook/hygiene/docs/main.md +45 -0
  13. package/rules/storybook/hygiene/main.mjs +254 -0
  14. package/rules/storybook/main.json +1 -0
  15. package/rules/storybook/main.mdc +60 -0
  16. package/rules/storybook/mocking/concern.json +3 -0
  17. package/rules/storybook/mocking/mocking.mdc +108 -0
  18. package/rules/storybook/scaffold/concern.json +5 -0
  19. package/rules/storybook/scaffold/docs/fix-scaffold.md +29 -0
  20. package/rules/storybook/scaffold/docs/index.md +10 -0
  21. package/rules/storybook/scaffold/docs/main.md +36 -0
  22. package/rules/storybook/scaffold/fix-scaffold.mjs +150 -0
  23. package/rules/storybook/scaffold/main.mjs +164 -0
  24. package/rules/storybook/scaffold/template/docs/index.md +10 -0
  25. package/rules/storybook/scaffold/template/docs/main.md +30 -0
  26. package/rules/storybook/scaffold/template/docs/preview.md +46 -0
  27. package/rules/storybook/scaffold/template/main.js +45 -0
  28. package/rules/storybook/scaffold/template/mocks/docs/gql-sse.md +36 -0
  29. package/rules/storybook/scaffold/template/mocks/docs/index.md +9 -0
  30. package/rules/storybook/scaffold/template/mocks/gql-sse.js +25 -0
  31. package/rules/storybook/scaffold/template/preview.js +47 -0
  32. package/rules/storybook/scope/concern.json +4 -0
  33. package/rules/storybook/scope/docs/index.md +9 -0
  34. package/rules/storybook/scope/docs/main.md +62 -0
  35. package/rules/storybook/scope/main.mjs +202 -0
  36. package/rules/storybook/vitest-config/concern.json +8 -0
  37. package/rules/storybook/vitest-config/docs/fix-vitest-config.md +38 -0
  38. package/rules/storybook/vitest-config/docs/index.md +10 -0
  39. package/rules/storybook/vitest-config/docs/main.md +72 -0
  40. package/rules/storybook/vitest-config/fix-vitest-config.mjs +340 -0
  41. package/rules/storybook/vitest-config/main.mjs +358 -0
  42. package/rules/storybook/vitest-config/template/docs/index.md +12 -0
  43. package/rules/storybook/vitest-config/template/docs/storybook-project-entry.md +34 -0
  44. package/rules/storybook/vitest-config/template/docs/unit-project-entry.md +30 -0
  45. package/rules/storybook/vitest-config/template/docs/vitest.config.baseline.md +29 -0
  46. package/rules/storybook/vitest-config/template/docs/vitest.stryker.config.baseline.md +31 -0
  47. package/rules/storybook/vitest-config/template/storybook-project-entry.js +22 -0
  48. package/rules/storybook/vitest-config/template/unit-project-entry.js +5 -0
  49. package/rules/storybook/vitest-config/template/vitest.config.baseline.mjs +37 -0
  50. package/rules/storybook/vitest-config/template/vitest.stryker.config.baseline.mjs +19 -0
  51. package/rules/storybook/vitest-config/vitest-config.mdc +33 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0] - 2026-07-21
4
+
5
+ ### Added
6
+
7
+ - storybook: канон Storybook хвилі 1 для Vue-компонентних бібліотек — детекція скоупу (isVueComponentLibraryPkg, поріг ≥3 .vue, opt-out), канонічний скафолд .storybook/main.js+preview.js+mocks/gql-sse.js, package.json#scripts.storybook (ADR канон-storybook-для-vue-компонентних-бібліотек)
8
+ - npm-module/bun: governance-виняток канону Storybook (кластер 7 ADR канон-storybook-для-vue-компонентних-бібліотек) — npm_package_json.rego дозволяє канонічні Storybook-devDeps (storybook, @storybook/vue3-vite, @storybook/vue3, msw, msw-storybook-addon) у npm/package.json із зафіксованою точною версією (deny на неканонічний пакет або неканонічну версію); bun/package_json.rego розширює root-only test peers на @vitest/browser + playwright (browser-mode provider для named vitest project "storybook", лише chromium) та @storybook/addon-vitest (storybookTest-плагін того самого vitest-конфіга) — Storybook-identity-пакети у корінь свідомо не додаються
9
+ - storybook: vitest-config-концерн хвилі 1 (ADR Кластер 5) — canonical test.projects unit+storybook (browser-mode, лише chromium, stories-glob) дописується поверх наявного vitest-конфіга, ізольований vitest.stryker.config генерується поруч (Stryker крашиться на browser-mode projects)
10
+ - storybook: концерни mocking (docs-only рецепти router/tfm/Apollo-MSW/Pinia/page-story) і hygiene (undeclared third-party imports у .vue, auto-detect sassVariables) — ADR Кластер 3/6
11
+
12
+ ### Fixed
13
+
14
+ - storybook: підключено concern-и scope/scaffold/vitest-config до unified lint-рушія (lint-блок у concern.json — check:true без lint мовчки ігнорувався run-detectors.mjs), додано --adopt-режим (adopt/main.mjs) і скіл n-storybook
15
+
3
16
  ## [0.9.0] - 2026-07-20
4
17
 
5
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules-lang-js",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Плагін @7n/rules: JS/npm/bun-екосистема — lint-правила (js/bun/vue/js-run/npm-module/db), taze-провайдер (package.json, bunx taze) і doc-files-екстрактори (oxc AST)",
5
5
  "keywords": [
6
6
  "javascript",
@@ -9,6 +9,8 @@ Rego-пакет: `bun.package_json`
9
9
  Gate виносить два класи deny:
10
10
 
11
11
  1. **Заборонені top-level поля** — будь-яке поле з `package.json.deny.json` присутнє у файлі (навіть із порожнім значенням `{}`).
12
- 2. **devDependencies не з білого списку** — дозволені лише `@nitra/*`/`@7n/*` та root-only тестові peer/tools (`vitest`, `@vitest/coverage-v8`, `@stryker-mutator/vitest-runner`, `@playwright/test`). Будь-який інший пакет → deny. CLI-тули, які `n-rules lint` спавнить через `bunx` (oxlint, jscpd, v8r, github-actionlint тощо), у root не пінуються — вони приїжджають як `dependencies` пакета `@7n/rules` (npm-module.mdc).
12
+ 2. **devDependencies не з білого списку** — дозволені лише `@nitra/*`/`@7n/*` та root-only тестові peer/tools (`vitest`, `@vitest/coverage-v8`, `@vitest/browser`, `@stryker-mutator/vitest-runner`, `@stryker-mutator/core`, `@playwright/test`, `playwright`, `@storybook/addon-vitest`, `@7n/test`). Будь-який інший пакет → deny. CLI-тули, які `n-rules lint` спавнить через `bunx` (oxlint, jscpd, v8r, github-actionlint тощо), у root не пінуються — вони приїжджають як `dependencies` пакета `@7n/rules` (npm-module.mdc).
13
+
14
+ `@vitest/browser`/`playwright`/`@storybook/addon-vitest` — додані для named vitest project "storybook" (канон Storybook, кластер 5: browser-mode, лише chromium) — той самий root vitest.config, що й `unit`-проект; `@storybook/addon-vitest` постачає `storybookTest`-плагін для цього vitest-конфіга (канонічний template правила `storybook`). **Межа з `npm-module.mdc`**: Storybook-специфічні identity-пакети (`storybook`, `@storybook/vue3-vite`, `@storybook/vue3`, `msw`, `msw-storybook-addon`) у цей allowlist **не** додаються — вони живуть у `npm/package.json` консюмер-пакета (канон Storybook, кластер 7 Governance), бо `isStorybookRoot()` у `@7n/test` детектує Storybook-скоуп саме за тим файлом, не кореневим. `@storybook/addon-vitest` — виняток із цієї межі: він test-tooling (vitest-плагін), а не identity-маркер, тож root, як і решта vitest-peer'ів.
13
15
 
14
16
  Перевірки, що потребують FS або cross-file контексту (наприклад наявність `yarn.lock`), лишаються у JS-шарі.
@@ -8,6 +8,15 @@
8
8
  # - `devDependencies` лише `@nitra/*` + root-only тестові peer/tools для `@7n/test coverage`
9
9
  # (правило `test` enabled завжди — див. `test/auto.md`; published workspace-и не мають
10
10
  # `devDependencies` за `npm-module.mdc`)
11
+ # - `@vitest/browser`/`playwright`/`@storybook/addon-vitest` (browser-mode provider +
12
+ # `storybookTest`-плагін для named vitest project "storybook", лише chromium — канон
13
+ # Storybook кластер 5) теж root-only test peers: той самий vitest.config, що й
14
+ # `unit`-проект, живе в корені монорепо-споживача. Storybook-специфічні
15
+ # identity-пакети (`storybook`, `@storybook/vue3*`, `msw*`) НЕ сюди — вони живуть у
16
+ # `npm/package.json` (канон Storybook кластер 7, `npm-module.mdc`), бо
17
+ # `isStorybookRoot()` @7n/test читає саме той файл, не кореневий package.json.
18
+ # `@storybook/addon-vitest` — виняток із цього правила: це test-tooling (плагін
19
+ # vitest-конфіга), а не Storybook-identity-маркер, тож root, а не npm/package.json.
11
20
  #
12
21
  # Перевірки, які потребують FS / cross-file контексту, лишаються у JS.
13
22
  package bun.package_json
@@ -50,7 +59,27 @@ deny contains msg if {
50
59
  # @stryker-mutator/core — обов'язковий exact-pin peer vitest-runner@9+ (раніше тягнувся транзитивно)
51
60
  # @7n/test — оркестратор `coverage` (npx @7n/test coverage); devDependency, щоб npx резолвив
52
61
  # локально без мережевого fetch щоразу.
53
- allowed_root_test_deps := {"vitest", "@vitest/coverage-v8", "@stryker-mutator/vitest-runner", "@stryker-mutator/core", "@playwright/test", "@7n/test"}
62
+ # @vitest/browser + playwright — провайдер browser-mode для named vitest project
63
+ # "storybook" (канон Storybook кластер 5: лише chromium, PR — швидкий
64
+ # --project=storybook). `playwright` (не `@playwright/test`) — сирий driver, який
65
+ # @vitest/browser використовує як provider; `@playwright/test` лишається окремо для
66
+ # змістовних E2E-сценаріїв (n-vue.mdc).
67
+ # @storybook/addon-vitest — постачає `storybookTest` для vitest-плагіна в канонічному
68
+ # vitest.config named-проекту "storybook" (той самий канон Storybook кластер 5); версія
69
+ # з лінійки Storybook 9.x (узгоджена з `storybook`@9.1.10, запіненим у
70
+ # npm_package_json.rego) — allowlist тут за іменем, точний пінінг версії root-tooling
71
+ # не робимо (на відміну від Storybook-identity-пакетів у npm/package.json).
72
+ allowed_root_test_deps := {
73
+ "vitest",
74
+ "@vitest/coverage-v8",
75
+ "@vitest/browser",
76
+ "@stryker-mutator/vitest-runner",
77
+ "@stryker-mutator/core",
78
+ "@playwright/test",
79
+ "playwright",
80
+ "@storybook/addon-vitest",
81
+ "@7n/test",
82
+ }
54
83
 
55
84
  allowed_root_dev_dependency(name) if {
56
85
  startswith(name, "@nitra/")
@@ -16,10 +16,13 @@ Rego-пакет: `npm-module.npm_package_json`
16
16
  - Обовʼязкове, має бути непорожнім масивом.
17
17
  - Subset-of перевірка: кожне значення з канонічного сніпету має бути присутнє у `files`. За замовчуванням — `"types"` обовʼязковий.
18
18
 
19
- **Поле `devDependencies`** (inverse-pattern, логіка в rego):
19
+ **Поле `devDependencies`** (inverse-pattern + Storybook-виняток, логіка в rego):
20
20
 
21
21
  - Не публікуються користувачам пакета — має бути відсутнє або порожнє `{}`.
22
- - Наявність будь-яких devDeps deny з переліком залежностей. Dev-інструментарій переноситься у кореневий `package.json`; CLI-тули, які пакет спавнить через `bunx` у репозиторіях-споживачах (пінінг версій),у `dependencies` (кореневе bun-правило `package_json` такі пакети в root devDeps не пускає).
22
+ - **Виняток канонічні Storybook-пакети** (канон Storybook, кластер 7 Governance: `docs/adr/канон-storybook-для-vue-компонентних-бібліотек.md`): `storybook`, `@storybook/vue3-vite`, `@storybook/vue3`, `msw`, `msw-storybook-addon` дозволені як devDeps саме тут, у `npm/package.json` консюмер-пакета **не** в кореневому `package.json`. Обґрунтування: майбутній `isStorybookRoot()` у `@7n/test` читає саме цей файл, щоб визначити Storybook-скоуп workspace-пакета, тож маркер-пакети мають бути видимі тут, а не в кореневих tooling-deps. Версія кожного канонічного пакета зафіксована точно (map `storybook_canon_dev_deps` у rego) — присутність пакета з іншою версією теж deny (окреме повідомлення, не плутати з allowlist-забороною).
23
+ - Будь-який інший devDep (не з канонічного Storybook-списку) → deny з переліком імен. Dev-інструментарій переноситься у кореневий `package.json`; CLI-тули, які пакет спавнить через `bunx` у репозиторіях-споживачах (пінінг версій), — у `dependencies` (кореневе bun-правило `package_json` такі пакети в root devDeps не пускає).
24
+
25
+ Канон Storybook-devDeps та їхні версії — static map у `npm_package_json.rego` (не template-driven: це опційний allowlist, а не mandatory-presence дані, тож генеричний T0-fix-writer цього concern-а їх у кожен `package.json` не мерджить — див. коментар на початку rego-файлу).
23
26
 
24
27
  Канонічний сніпет `files`: [package.json.snippet.json](./template/package.json.snippet.json)
25
28
 
@@ -50,8 +53,20 @@ FS-перевірки (наявність файлу зі шляху `types`, с
50
53
  { "files": ["bin", "mdc"] }
51
54
  ```
52
55
 
53
- ✗ Неправильно — наявні `devDependencies`:
56
+ ✗ Неправильно — наявні `devDependencies`, не з канонічного Storybook-списку:
54
57
 
55
58
  ```json
56
59
  { "devDependencies": { "@7n/rules": "^1.0.0" } }
57
60
  ```
61
+
62
+ ✓ Правильно — канонічний Storybook-devDep із зафіксованою версією (канон Storybook):
63
+
64
+ ```json
65
+ { "devDependencies": { "storybook": "9.1.10" } }
66
+ ```
67
+
68
+ ✗ Неправильно — Storybook-devDep присутній, але версія не збігається з каноном:
69
+
70
+ ```json
71
+ { "devDependencies": { "storybook": "8.0.0" } }
72
+ ```
@@ -6,7 +6,17 @@
6
6
  #
7
7
  # Логіка, що ЛИШАЄТЬСЯ у rego (inverse-patterns, не виносяться у template):
8
8
  # - форма поля `types` (regex pattern: `./types/index.d.ts` або `./types/<…>.d.ts|.d.mts`);
9
- # - `devDependencies` мають бути відсутні або порожні (inverse-pattern — заборона будь-яких).
9
+ # - `devDependencies` мають бути відсутні/порожні АБО належати канонічному
10
+ # Storybook-allowlist із зафіксованою точною версією (канон Storybook, кластер 7
11
+ # Governance: `docs/adr/канон-storybook-для-vue-компонентних-бібліотек.md`).
12
+ # Storybook-devDeps живуть саме у `npm/package.json` консюмер-пакета
13
+ # (а не в кореневому package.json), бо майбутній `isStorybookRoot()` у
14
+ # `@7n/test` читає саме цей файл, щоб визначити Storybook-скоуп пакета.
15
+ # Канон — static map (той самий підхід, що й `allowed_root_test_deps` у
16
+ # `bun.package_json`), НЕ template: це не mandatory-presence дані (більшість
17
+ # npm-пакетів Storybook не має), тож генеричний T0-fix-writer цього
18
+ # concern-а (`createTemplateFixPattern`, deep-merge усього `template.snippet`
19
+ # у target) канонічні devDeps у кожен package.json не домерджує.
10
20
  #
11
21
  # FS-перевірки (наявність файлу зі шляху `types`, скан tarball на тест-патерни) — у JS.
12
22
  package npm_module.npm_package_json
@@ -20,10 +30,27 @@ types_field_template := concat(" ", [
20
30
 
21
31
  dev_deps_template := concat(" ", [
22
32
  "npm/package.json: \"devDependencies\" не публікуються користувачам пакета —",
33
+ "дозволені лише канонічні Storybook-пакети (isStorybookRoot(), канон Storybook);",
23
34
  "dev-інструментарій перенеси у кореневий package.json, а CLI-тули, які пакет",
24
35
  "спавнить через bunx у споживачів, — у \"dependencies\": %v (npm-module.mdc)",
25
36
  ])
26
37
 
38
+ storybook_version_template := concat(" ", [
39
+ "npm/package.json: devDependencies.%v = %q не відповідає зафіксованій версії",
40
+ "Storybook-канону %q — вирівняй версію пакета до канону (npm-module.mdc, канон Storybook)",
41
+ ])
42
+
43
+ # Канонічні Storybook-devDeps (isStorybookRoot()-маркери, канон Storybook кластер 7):
44
+ # зафіксована точна версія — єдина дозволена версія для кожного пакета. Оновлення —
45
+ # ручна правка цієї map.
46
+ storybook_canon_dev_deps := {
47
+ "storybook": "9.1.10",
48
+ "@storybook/vue3-vite": "9.1.10",
49
+ "@storybook/vue3": "9.1.10",
50
+ "msw": "2.11.3",
51
+ "msw-storybook-addon": "2.0.5",
52
+ }
53
+
27
54
  # ── deny: types (regex — лишається в rego) ───────────────────────────────
28
55
 
29
56
  deny contains msg if {
@@ -57,13 +84,23 @@ deny contains msg if {
57
84
  msg := sprintf("npm/package.json: масив \"%s\" має містити %q (npm-module.mdc)", [field, required])
58
85
  }
59
86
 
60
- # ── deny: devDependencies (inverse-pattern, лишається в rego) ────────────
87
+ # ── deny: devDependencies (inverse-pattern + Storybook-allowlist виняток)
88
+
89
+ deny contains msg if {
90
+ dev := object.get(input, "devDependencies", {})
91
+ forbidden_names := [n | some n, _ in dev; not n in object.keys(storybook_canon_dev_deps)]
92
+ count(forbidden_names) > 0
93
+ msg := sprintf(dev_deps_template, [concat(", ", sort(forbidden_names))])
94
+ }
95
+
96
+ # ── deny: Storybook-devDep присутній, але версія розходиться з каноном ──
61
97
 
62
98
  deny contains msg if {
63
99
  dev := object.get(input, "devDependencies", {})
64
- count(dev) > 0
65
- names := concat(", ", sort([n | some n, _ in dev]))
66
- msg := sprintf(dev_deps_template, [names])
100
+ some name, version in dev
101
+ canonical := storybook_canon_dev_deps[name]
102
+ version != canonical
103
+ msg := sprintf(storybook_version_template, [name, version, canonical])
67
104
  }
68
105
 
69
106
  # ── helpers ────────────────────────────────────────────────────────────────
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: plugins/lang-js/rules/storybook/adopt
4
+ resource: plugins/lang-js/rules/storybook/adopt/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ------------------- | --------- |
9
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,46 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.mjs
4
+ resource: plugins/lang-js/rules/storybook/adopt/main.mjs
5
+ docgen:
6
+ crc: 640409c7
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 90
10
+ issues: anchor-miss:(mocking.mdc),anchor-miss:(vitest-config.mdc),judge:inaccurate:0.99
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Режим adopt для канону Storybook у Vue-бібліотеках: звіряє пакети з канонічними секціями `main.js`, `preview.js`, `mocks/gql-sse.js`, `package.json#scripts.storybook`, `vitest test.projects` і `vitest.stryker.config`, а для вже наявних ручних `.storybook/` без сліпого перезапису показує діагностику diff по секціях. Автофікс (`--fix-missing`) додає лише повністю відсутні секції, з тим самим рендером, що й T0-фікс concern-ів `scaffold`/`vitest-config`, без втручання в наявні розбіжні файли. Дає CLI-звіт по кожному пакету зі станом `status` і секціями `SECTION`, окремо фіксуючи `diagnosePackage`, `fixMissingSections`, `runAdopt` і `formatReport`; помилки перехоплює fail-safe, тому збій або діагностики, або фіксу для одного пакета переводить лише його в `status: 'broken'`, а решта пакетів продовжують оброблятися.
17
+ ## Поведінка
18
+
19
+ - `STATUS` — задає фіксовані стани діагностики секцій Storybook: збіг, розбіжність або відсутність.
20
+ - `SECTION` — задає стабільні назви секцій звіту, щоб adopt-перевірка й `--fix-missing` узгоджено посилалися на ті самі частини конфігурації.
21
+ - `diagnosePackage` — перевіряє пакет по секціях і повертає підсумок: канонічний, з відсутніми файлами або з відмінностями; помилки окремих секцій не валять увесь пакет.
22
+ - `fixMissingSections` — створює лише ті канонічні секції, яких у пакеті немає взагалі, і не перезаписує наявні розбіжні файли.
23
+ - `runAdopt` — проганяє adopt-перевірку по всіх або вибраних пакетах у скоупі, із fail-safe поведінкою: збій одного пакета переходить у `broken`, але інші обробляються далі.
24
+ - `formatReport` — перетворює результати adopt-прогону на лаконічний український звіт для CLI, включно з переліком згенерованого або повідомленням про відсутність пакетів у скоупі.
25
+
26
+ ## Публічний API
27
+
28
+ - STATUS — Статуси однієї секції діагностики (діагностика ≠ lint-violation — тут завжди 4 значення).
29
+ - SECTION — Канонічні назви секцій (стабільні — на них зав'язаний `--fix-missing`-switch).
30
+ - diagnosePackage — Діагностика одного пакета в скоупі по секціях. Ніколи не кидає — збій окремої
31
+ секції (парсинг/IO) відображається як секція `differ`, а не виняток, що впав
32
+ би на весь пакет; лишається на розсуд `diagnosePackage` (circuit breaker рівня
33
+ пакета — тут не потрібен, бо секції вже ізольовані одна від одної).
34
+ - fixMissingSections — Генерує канонічний вміст лише для секцій зі статусом `missing` одного пакета
35
+ (adopt-автофікс НІКОЛИ не чіпає секції зі статусом `differ` — інструкція
36
+ для агента/людини, не сліпий перезапис). Кожна секція фіксується незалежно;
37
+ збій однієї не блокує решту (той самий circuit-breaker принцип, лише на дрібнішому рівні).
38
+ - runAdopt — Adopt-прогін усіх (чи обраних) пакетів у скоупі. Circuit breaker (ADR Кластер 8):
39
+ збій діагностики/фіксу ОДНОГО пакета деградує до `status: 'broken'` для нього —
40
+ решта пакетів обробляються далі, весь прогін ніколи не падає через один зламаний.
41
+ відсутні секції; `rootDirs` — звузити прогін до цих коренів пакетів (порожньо/відсутнє → усі в скоупі)
42
+ - formatReport — Форматує людський звіт по результатах `runAdopt` (українською, для виводу скіла в CLI).
43
+
44
+ ## Гарантії поведінки
45
+
46
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Adopt-режим канону Storybook (ADR канон-storybook-для-vue-компонентних-бібліотек,
3
+ * Кластер 8; main.mdc). Для пакетів, де ВЖЕ є ручний `.storybook/`, що не збігається з
4
+ * каноном, — діагностика diff по секціях проти канонічних `template/` (main.js,
5
+ * preview.js, mocks/gql-sse.js, package.json#scripts.storybook, vitest test.projects,
6
+ * vitest.stryker.config), БЕЗ сліпого перезапису розбіжних файлів. Автофікс
7
+ * (`--fix-missing`) — лише для секцій, яких немає ВЗАГАЛІ (той самий рендер, що й
8
+ * T0-фікс concern-ів `scaffold`/`vitest-config` — переюз, не дублювання шаблонування).
9
+ *
10
+ * Circuit breaker (ADR): збій діагностики чи фіксу одного пакета деградує до
11
+ * `status: 'broken'` для ЦЬОГО пакета — решта пакетів прогону обробляються далі,
12
+ * увесь прогін ніколи не падає через один зламаний пакет.
13
+ *
14
+ * Викликається зі скіла (`npm/skills/storybook/SKILL.md`, `--adopt`):
15
+ * bun node_modules/@7n/rules-lang-js/rules/storybook/adopt/main.mjs [--fix-missing] [--cwd <path>] [rootDir...]
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
19
+
20
+ import { isRunAsCli } from '@7n/rules/scripts/cli-entry.mjs'
21
+
22
+ import { collectInScopeVuePackages } from '../scope/main.mjs'
23
+ import { MAIN_JS_MARKERS, missingMarkers, PREVIEW_JS_MARKERS, STORYBOOK_SCRIPT } from '../scaffold/main.mjs'
24
+ import { renderMainJs, renderMocksGqlSse, renderPreviewJs } from '../scaffold/fix-scaffold.mjs'
25
+ import { buildFreshVitestConfig, buildStrykerConfig } from '../vitest-config/fix-vitest-config.mjs'
26
+ import {
27
+ BROWSER_KEY_RE,
28
+ CHROMIUM_RE,
29
+ classifyProjects,
30
+ findProperty,
31
+ findTestObject,
32
+ parseModule,
33
+ resolveVitestConfigPath,
34
+ STORIES_RE,
35
+ strykerConfigPathFor
36
+ } from '../vitest-config/main.mjs'
37
+
38
+ /** Статуси однієї секції діагностики (діагностика ≠ lint-violation — тут завжди 4 значення). */
39
+ export const STATUS = Object.freeze({ MATCH: 'match', DIFFER: 'differ', MISSING: 'missing' })
40
+
41
+ /** Канонічні назви секцій (стабільні — на них зав'язаний `--fix-missing`-switch). */
42
+ export const SECTION = Object.freeze({
43
+ MAIN_JS: 'main.js',
44
+ PREVIEW_JS: 'preview.js',
45
+ MOCKS_GQL_SSE: 'mocks/gql-sse.js',
46
+ PACKAGE_SCRIPT: 'package.json#scripts.storybook',
47
+ VITEST_PROJECTS: 'vitest test.projects (unit+storybook)',
48
+ STRYKER_CONFIG: 'vitest.stryker.config'
49
+ })
50
+
51
+ /**
52
+ * posix-relative шлях файлу пакета від кореня репозиторію (для звіту/сообщений).
53
+ * @param {{ rootDir: string }} entry запис пакета
54
+ * @param {string} suffix шлях файлу відносно кореня пакета
55
+ * @returns {string} відносний шлях від кореня репозиторію
56
+ */
57
+ function relFileFor(entry, suffix) {
58
+ return entry.rootDir === '.' ? suffix : `${entry.rootDir}/${suffix}`
59
+ }
60
+
61
+ /**
62
+ * Діагностика файлу, канон якого перевіряється текстовими маркерами (main.js/preview.js).
63
+ * @param {{ absDir: string, rootDir: string }} entry запис пакета
64
+ * @param {string} relPath шлях файлу відносно кореня пакета (`.storybook/main.js`)
65
+ * @param {string} sectionName ім'я секції звіту
66
+ * @param {{ token: string, hint: string }[]} markers канонічні маркери файлу
67
+ * @returns {{ name: string, file: string, status: string, detail?: string }} одна секція діагностики
68
+ */
69
+ function diagnoseMarkerFile(entry, relPath, sectionName, markers) {
70
+ const file = relFileFor(entry, relPath)
71
+ const abs = join(entry.absDir, relPath)
72
+ if (!existsSync(abs)) return { name: sectionName, file, status: STATUS.MISSING }
73
+ const content = readFileSync(abs, 'utf8')
74
+ const missing = missingMarkers(content, markers)
75
+ if (missing.length === 0) return { name: sectionName, file, status: STATUS.MATCH }
76
+ return {
77
+ name: sectionName,
78
+ file,
79
+ status: STATUS.DIFFER,
80
+ detail: `бракує: ${missing.map(m => m.hint).join(', ')}`
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Діагностика `.storybook/mocks/gql-sse.js` — verbatim-порівняння з канонічним helper-ом
86
+ * (одне джерело істини протоколу graphql-sse, mocking.mdc — не переносити копію в пакет).
87
+ * @param {{ absDir: string, rootDir: string }} entry запис пакета
88
+ * @returns {{ name: string, file: string, status: string, detail?: string }} секція діагностики
89
+ */
90
+ function diagnoseMocksSection(entry) {
91
+ const relPath = '.storybook/mocks/gql-sse.js'
92
+ const file = relFileFor(entry, relPath)
93
+ const abs = join(entry.absDir, relPath)
94
+ if (!existsSync(abs)) return { name: SECTION.MOCKS_GQL_SSE, file, status: STATUS.MISSING }
95
+ const actual = readFileSync(abs, 'utf8')
96
+ const canonical = renderMocksGqlSse()
97
+ if (actual === canonical) return { name: SECTION.MOCKS_GQL_SSE, file, status: STATUS.MATCH }
98
+ return {
99
+ name: SECTION.MOCKS_GQL_SSE,
100
+ file,
101
+ status: STATUS.DIFFER,
102
+ detail: 'вміст відрізняється від канонічного helper-а (mocking.mdc) — одне джерело істини, не дублюй логіку вручну'
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Діагностика `package.json#scripts.storybook`.
108
+ * @param {{ pkg: Record<string, unknown>, rootDir: string }} entry запис пакета
109
+ * @returns {{ name: string, file: string, status: string, detail?: string }} секція діагностики
110
+ */
111
+ function diagnoseScriptSection(entry) {
112
+ const file = relFileFor(entry, 'package.json')
113
+ const current = /** @type {{ scripts?: Record<string, unknown> }} */ (entry.pkg)?.scripts?.storybook
114
+ if (current === undefined) return { name: SECTION.PACKAGE_SCRIPT, file, status: STATUS.MISSING }
115
+ if (current === STORYBOOK_SCRIPT) return { name: SECTION.PACKAGE_SCRIPT, file, status: STATUS.MATCH }
116
+ return {
117
+ name: SECTION.PACKAGE_SCRIPT,
118
+ file,
119
+ status: STATUS.DIFFER,
120
+ detail: `зараз '${current}', канон '${STORYBOOK_SCRIPT}'`
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Діагностика `test.projects` наявного vitest-конфіга (unit+storybook, browser-mode маркери).
126
+ * @param {{ absDir: string, rootDir: string }} entry запис пакета
127
+ * @returns {{ name: string, file: string, status: string, detail?: string }} секція діагностики
128
+ */
129
+ function diagnoseVitestProjectsSection(entry) {
130
+ const name = SECTION.VITEST_PROJECTS
131
+ const vitestConfigPath = resolveVitestConfigPath(entry.absDir)
132
+ if (!vitestConfigPath) {
133
+ return { name, file: relFileFor(entry, 'vitest.config.mjs'), status: STATUS.MISSING }
134
+ }
135
+ const file = relFileFor(entry, vitestConfigPath.slice(entry.absDir.length + 1))
136
+ const src = readFileSync(vitestConfigPath, 'utf8')
137
+
138
+ let parsed
139
+ try {
140
+ parsed = parseModule(vitestConfigPath, src)
141
+ } catch (error) {
142
+ return { name, file, status: STATUS.DIFFER, detail: `не парситься (${error.message}) — перевір вручну` }
143
+ }
144
+ if (parsed.errors?.length) {
145
+ return { name, file, status: STATUS.DIFFER, detail: 'syntax error — перевір вручну' }
146
+ }
147
+
148
+ const testObj = findTestObject(parsed.program)
149
+ if (!testObj) {
150
+ return { name, file, status: STATUS.DIFFER, detail: 'немає test-блоку (defineConfig({ test: {...} }))' }
151
+ }
152
+
153
+ const projectsProp = findProperty(testObj, 'projects')
154
+ if (!projectsProp) {
155
+ return { name, file, status: STATUS.MISSING, detail: 'test.projects відсутній' }
156
+ }
157
+ if (projectsProp.value?.type !== 'ArrayExpression') {
158
+ return { name, file, status: STATUS.DIFFER, detail: 'test.projects не статичний масив (spread/змінна)' }
159
+ }
160
+
161
+ const { hasUnit, storybookSlice } = classifyProjects(src, projectsProp.value)
162
+ if (!hasUnit || !storybookSlice) {
163
+ const missingParts = [hasUnit ? null : "'unit'", storybookSlice ? null : "'storybook'"].filter(Boolean)
164
+ return { name, file, status: STATUS.DIFFER, detail: `бракує ${missingParts.join(' і ')} у test.projects` }
165
+ }
166
+
167
+ const missingHints = []
168
+ if (!CHROMIUM_RE.test(storybookSlice)) missingHints.push('chromium-інстанс')
169
+ if (!BROWSER_KEY_RE.test(storybookSlice)) missingHints.push('browser-mode')
170
+ if (!STORIES_RE.test(storybookSlice)) missingHints.push('stories-glob')
171
+ if (missingHints.length > 0) {
172
+ return { name, file, status: STATUS.DIFFER, detail: `storybook-project без: ${missingHints.join(', ')}` }
173
+ }
174
+
175
+ return { name, file, status: STATUS.MATCH }
176
+ }
177
+
178
+ /**
179
+ * Діагностика ізольованого `vitest.stryker.config.*` — байтове порівняння з канонічним
180
+ * baseline-рендером (fix-vitest-config.mjs, той самий генератор).
181
+ * @param {{ absDir: string, rootDir: string }} entry запис пакета
182
+ * @returns {Promise<{ name: string, file: string, status: string, detail?: string }>} секція діагностики
183
+ */
184
+ async function diagnoseStrykerSection(entry) {
185
+ const name = SECTION.STRYKER_CONFIG
186
+ const vitestConfigPath = resolveVitestConfigPath(entry.absDir)
187
+ if (!vitestConfigPath) {
188
+ return { name, file: relFileFor(entry, 'vitest.stryker.config.mjs'), status: STATUS.MISSING }
189
+ }
190
+ const strykerPath = strykerConfigPathFor(vitestConfigPath)
191
+ const file = relFileFor(entry, strykerPath.slice(entry.absDir.length + 1))
192
+ if (!existsSync(strykerPath)) {
193
+ return { name, file, status: STATUS.MISSING }
194
+ }
195
+ const actual = readFileSync(strykerPath, 'utf8')
196
+ const canonical = await buildStrykerConfig(entry.absDir)
197
+ if (actual === canonical) return { name, file, status: STATUS.MATCH }
198
+ return {
199
+ name,
200
+ file,
201
+ status: STATUS.DIFFER,
202
+ detail:
203
+ 'вміст відрізняється від канонічного baseline (vitest-config.mdc) — @stryker-mutator/vitest-runner крашиться на browser-mode projects, перевір вручну'
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Діагностика одного пакета в скоупі по секціях. Ніколи не кидає — збій окремої
209
+ * секції (парсинг/IO) відображається як секція `differ`, а не виняток, що впав
210
+ * би на весь пакет; лишається на розсуд `diagnosePackage` (circuit breaker рівня
211
+ * пакета — тут не потрібен, бо секції вже ізольовані одна від одної).
212
+ * @param {import('../scope/main.mjs').InScopePackage} entry пакет у скоупі
213
+ * @returns {Promise<{ rootDir: string, status: 'canonical'|'missing-files'|'differs', sections: object[] }>} діагностика пакета
214
+ */
215
+ export async function diagnosePackage(entry) {
216
+ const sections = [
217
+ diagnoseMarkerFile(entry, '.storybook/main.js', SECTION.MAIN_JS, MAIN_JS_MARKERS),
218
+ diagnoseMarkerFile(entry, '.storybook/preview.js', SECTION.PREVIEW_JS, PREVIEW_JS_MARKERS),
219
+ diagnoseMocksSection(entry),
220
+ diagnoseScriptSection(entry),
221
+ diagnoseVitestProjectsSection(entry),
222
+ await diagnoseStrykerSection(entry)
223
+ ]
224
+ let status = 'canonical'
225
+ if (sections.some(s => s.status === STATUS.DIFFER)) status = 'differs'
226
+ else if (sections.some(s => s.status === STATUS.MISSING)) status = 'missing-files'
227
+ return { rootDir: entry.rootDir, status, sections }
228
+ }
229
+
230
+ /**
231
+ * Генерує канонічний вміст лише для секцій зі статусом `missing` одного пакета
232
+ * (adopt-автофікс НІКОЛИ не чіпає секції зі статусом `differ` — інструкція
233
+ * для агента/людини, не сліпий перезапис). Кожна секція фіксується незалежно;
234
+ * збій однієї не блокує решту (той самий circuit-breaker принцип, лише на дрібнішому рівні).
235
+ * @param {import('../scope/main.mjs').InScopePackage} entry пакет у скоупі
236
+ * @param {object[]} sections секції діагностики пакета (`diagnosePackage().sections`)
237
+ * @returns {Promise<string[]>} абсолютні шляхи записаних файлів
238
+ */
239
+ export async function fixMissingSections(entry, sections) {
240
+ const written = []
241
+ const writeFileEnsureDir = (absPath, content) => {
242
+ mkdirSync(dirname(absPath), { recursive: true })
243
+ writeFileSync(absPath, content, 'utf8')
244
+ written.push(absPath)
245
+ }
246
+
247
+ const byName = new Map(sections.map(s => [s.name, s]))
248
+
249
+ if (byName.get(SECTION.MAIN_JS)?.status === STATUS.MISSING) {
250
+ writeFileEnsureDir(join(entry.absDir, '.storybook/main.js'), renderMainJs(entry.absDir))
251
+ }
252
+ if (byName.get(SECTION.PREVIEW_JS)?.status === STATUS.MISSING) {
253
+ writeFileEnsureDir(join(entry.absDir, '.storybook/preview.js'), renderPreviewJs())
254
+ }
255
+ if (byName.get(SECTION.MOCKS_GQL_SSE)?.status === STATUS.MISSING) {
256
+ writeFileEnsureDir(join(entry.absDir, '.storybook/mocks/gql-sse.js'), renderMocksGqlSse())
257
+ }
258
+ if (byName.get(SECTION.PACKAGE_SCRIPT)?.status === STATUS.MISSING) {
259
+ const pkgPath = join(entry.absDir, 'package.json')
260
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
261
+ pkg.scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {}
262
+ pkg.scripts.storybook = STORYBOOK_SCRIPT
263
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
264
+ written.push(pkgPath)
265
+ }
266
+ if (byName.get(SECTION.VITEST_PROJECTS)?.status === STATUS.MISSING && !resolveVitestConfigPath(entry.absDir)) {
267
+ // test.projects відсутній ЛИШЕ через відсутність усього vitest-конфіга — augment
268
+ // наявного (без відсутнього test-блоку) свідомо поза adopt-автофіксом: секція вже
269
+ // позначена `differ` (не `missing`) для такого випадку, сюди він не потрапляє.
270
+ const fresh = await buildFreshVitestConfig(entry.absDir)
271
+ writeFileEnsureDir(fresh.path, fresh.content)
272
+ }
273
+ if (byName.get(SECTION.STRYKER_CONFIG)?.status === STATUS.MISSING) {
274
+ const vitestConfigPath = resolveVitestConfigPath(entry.absDir) ?? join(entry.absDir, 'vitest.config.mjs')
275
+ const strykerPath = strykerConfigPathFor(vitestConfigPath)
276
+ writeFileEnsureDir(strykerPath, await buildStrykerConfig(entry.absDir))
277
+ }
278
+
279
+ return written
280
+ }
281
+
282
+ /**
283
+ * Adopt-прогін усіх (чи обраних) пакетів у скоупі. Circuit breaker (ADR Кластер 8):
284
+ * збій діагностики/фіксу ОДНОГО пакета деградує до `status: 'broken'` для нього —
285
+ * решта пакетів обробляються далі, весь прогін ніколи не падає через один зламаний.
286
+ * @param {string} cwd абсолютний корінь консюмер-репо
287
+ * @param {{ fixMissing?: boolean, rootDirs?: string[] }} [opts] `fixMissing` — генерувати
288
+ * відсутні секції; `rootDirs` — звузити прогін до цих коренів пакетів (порожньо/відсутнє → усі в скоупі)
289
+ * @returns {Promise<{ rootDir: string, status: string, sections?: object[], written?: string[], error?: string }[]>} результати за пакетами
290
+ */
291
+ export async function runAdopt(cwd, opts = {}) {
292
+ const fixMissing = opts.fixMissing === true
293
+ const wantedRoots = Array.isArray(opts.rootDirs) && opts.rootDirs.length > 0 ? new Set(opts.rootDirs) : null
294
+
295
+ const allPkgs = await collectInScopeVuePackages(cwd)
296
+ const pkgs = wantedRoots ? allPkgs.filter(p => wantedRoots.has(p.rootDir)) : allPkgs
297
+
298
+ const results = []
299
+ for (const entry of pkgs) {
300
+ try {
301
+ const diagnosis = await diagnosePackage(entry)
302
+ const written = fixMissing ? await fixMissingSections(entry, diagnosis.sections) : []
303
+ results.push({ ...diagnosis, written })
304
+ } catch (error) {
305
+ results.push({
306
+ rootDir: entry.rootDir,
307
+ status: 'broken',
308
+ error: error instanceof Error ? error.message : String(error)
309
+ })
310
+ }
311
+ }
312
+ return results
313
+ }
314
+
315
+ /** Іконка статусу пакета (`package.status`) для рядка звіту. */
316
+ const PACKAGE_STATUS_ICONS = Object.freeze({ canonical: '✅', 'missing-files': '➕' })
317
+ /** Іконка статусу секції (`STATUS.*`) для рядка звіту. */
318
+ const SECTION_STATUS_ICONS = Object.freeze({ [STATUS.MATCH]: ' ✓', [STATUS.MISSING]: ' +' })
319
+
320
+ /**
321
+ * Рядки звіту одного НЕ зламаного пакета (статус + секції + перелік згенерованого).
322
+ * @param {{ rootDir: string, status: string, sections: object[], written?: string[] }} r результат одного пакета
323
+ * @returns {string[]} рядки звіту
324
+ */
325
+ function formatPackageLines(r) {
326
+ const label = r.rootDir === '.' ? 'корінь' : r.rootDir
327
+ const lines = [`${PACKAGE_STATUS_ICONS[r.status] ?? '⚠️ '} [${label}] ${r.status}`]
328
+ for (const s of r.sections) {
329
+ const sIcon = SECTION_STATUS_ICONS[s.status] ?? ' ✗'
330
+ const suffix = s.detail ? ` — ${s.detail}` : ''
331
+ lines.push(`${sIcon} ${s.name} (${s.file}): ${s.status}${suffix}`)
332
+ }
333
+ if (r.written && r.written.length > 0) {
334
+ lines.push(` згенеровано: ${r.written.join(', ')}`)
335
+ }
336
+ return lines
337
+ }
338
+
339
+ /**
340
+ * Форматує людський звіт по результатах `runAdopt` (українською, для виводу скіла в CLI).
341
+ * @param {Awaited<ReturnType<typeof runAdopt>>} results результати `runAdopt`
342
+ * @returns {string} багаторядковий текстовий звіт
343
+ */
344
+ export function formatReport(results) {
345
+ if (results.length === 0) {
346
+ return 'storybook adopt: немає Vue component library пакетів у скоупі (storybook.mdc → scope/main.mjs)'
347
+ }
348
+ const lines = []
349
+ for (const r of results) {
350
+ if (r.status === 'broken') {
351
+ const label = r.rootDir === '.' ? 'корінь' : r.rootDir
352
+ lines.push(`⚠️ [${label}] діагностика впала (circuit breaker, пропущено): ${r.error}`)
353
+ continue
354
+ }
355
+ lines.push(...formatPackageLines(r))
356
+ }
357
+ return lines.join('\n')
358
+ }
359
+
360
+ /**
361
+ * CLI-вхід: `bun .../storybook/adopt/main.mjs [--fix-missing] [--cwd <path>] [rootDir...]`.
362
+ * @returns {Promise<void>} завершення прогону (друкує звіт, exit-код — кількість `differs`/`broken`)
363
+ */
364
+ async function runCli() {
365
+ const args = process.argv.slice(2)
366
+ const fixMissing = args.includes('--fix-missing')
367
+ const cwdIdx = args.indexOf('--cwd')
368
+ const cwd = cwdIdx !== -1 && args[cwdIdx + 1] ? args[cwdIdx + 1] : process.cwd()
369
+ const rootDirs = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--cwd')
370
+
371
+ const results = await runAdopt(cwd, { fixMissing, rootDirs })
372
+ console.log(formatReport(results))
373
+ const hasIssues = results.some(r => r.status === 'differs' || r.status === 'broken')
374
+ process.exitCode = hasIssues ? 1 : 0
375
+ }
376
+
377
+ if (isRunAsCli(import.meta.url)) {
378
+ await runCli()
379
+ }