@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,382 @@
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
+ hasStoriesMarker,
33
+ parseModule,
34
+ PROVIDER_FACTORY_RE,
35
+ resolveVitestConfigPath,
36
+ strykerConfigPathFor
37
+ } from '../vitest-config/main.mjs'
38
+
39
+ /** Статуси однієї секції діагностики (діагностика ≠ lint-violation — тут завжди 4 значення). */
40
+ export const STATUS = Object.freeze({ MATCH: 'match', DIFFER: 'differ', MISSING: 'missing' })
41
+
42
+ /** Канонічні назви секцій (стабільні — на них зав'язаний `--fix-missing`-switch). */
43
+ export const SECTION = Object.freeze({
44
+ MAIN_JS: 'main.js',
45
+ PREVIEW_JS: 'preview.js',
46
+ MOCKS_GQL_SSE: 'mocks/gql-sse.js',
47
+ PACKAGE_SCRIPT: 'package.json#scripts.storybook',
48
+ VITEST_PROJECTS: 'vitest test.projects (unit+storybook)',
49
+ STRYKER_CONFIG: 'vitest.stryker.config'
50
+ })
51
+
52
+ /**
53
+ * posix-relative шлях файлу пакета від кореня репозиторію (для звіту/сообщений).
54
+ * @param {{ rootDir: string }} entry запис пакета
55
+ * @param {string} suffix шлях файлу відносно кореня пакета
56
+ * @returns {string} відносний шлях від кореня репозиторію
57
+ */
58
+ function relFileFor(entry, suffix) {
59
+ return entry.rootDir === '.' ? suffix : `${entry.rootDir}/${suffix}`
60
+ }
61
+
62
+ /**
63
+ * Діагностика файлу, канон якого перевіряється текстовими маркерами (main.js/preview.js).
64
+ * @param {{ absDir: string, rootDir: string }} entry запис пакета
65
+ * @param {string} relPath шлях файлу відносно кореня пакета (`.storybook/main.js`)
66
+ * @param {string} sectionName ім'я секції звіту
67
+ * @param {{ token: string, hint: string }[]} markers канонічні маркери файлу
68
+ * @returns {{ name: string, file: string, status: string, detail?: string }} одна секція діагностики
69
+ */
70
+ function diagnoseMarkerFile(entry, relPath, sectionName, markers) {
71
+ const file = relFileFor(entry, relPath)
72
+ const abs = join(entry.absDir, relPath)
73
+ if (!existsSync(abs)) return { name: sectionName, file, status: STATUS.MISSING }
74
+ const content = readFileSync(abs, 'utf8')
75
+ const missing = missingMarkers(content, markers)
76
+ if (missing.length === 0) return { name: sectionName, file, status: STATUS.MATCH }
77
+ return {
78
+ name: sectionName,
79
+ file,
80
+ status: STATUS.DIFFER,
81
+ detail: `бракує: ${missing.map(m => m.hint).join(', ')}`
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Діагностика `.storybook/mocks/gql-sse.js` — verbatim-порівняння з канонічним helper-ом
87
+ * (одне джерело істини протоколу graphql-sse, mocking.mdc — не переносити копію в пакет).
88
+ * @param {{ absDir: string, rootDir: string }} entry запис пакета
89
+ * @returns {{ name: string, file: string, status: string, detail?: string }} секція діагностики
90
+ */
91
+ function diagnoseMocksSection(entry) {
92
+ const relPath = '.storybook/mocks/gql-sse.js'
93
+ const file = relFileFor(entry, relPath)
94
+ const abs = join(entry.absDir, relPath)
95
+ if (!existsSync(abs)) return { name: SECTION.MOCKS_GQL_SSE, file, status: STATUS.MISSING }
96
+ const actual = readFileSync(abs, 'utf8')
97
+ const canonical = renderMocksGqlSse()
98
+ if (actual === canonical) return { name: SECTION.MOCKS_GQL_SSE, file, status: STATUS.MATCH }
99
+ return {
100
+ name: SECTION.MOCKS_GQL_SSE,
101
+ file,
102
+ status: STATUS.DIFFER,
103
+ detail: 'вміст відрізняється від канонічного helper-а (mocking.mdc) — одне джерело істини, не дублюй логіку вручну'
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Діагностика `package.json#scripts.storybook`.
109
+ * @param {{ pkg: Record<string, unknown>, rootDir: string }} entry запис пакета
110
+ * @returns {{ name: string, file: string, status: string, detail?: string }} секція діагностики
111
+ */
112
+ function diagnoseScriptSection(entry) {
113
+ const file = relFileFor(entry, 'package.json')
114
+ const current = /** @type {{ scripts?: Record<string, unknown> }} */ (entry.pkg)?.scripts?.storybook
115
+ if (current === undefined) return { name: SECTION.PACKAGE_SCRIPT, file, status: STATUS.MISSING }
116
+ if (current === STORYBOOK_SCRIPT) return { name: SECTION.PACKAGE_SCRIPT, file, status: STATUS.MATCH }
117
+ return {
118
+ name: SECTION.PACKAGE_SCRIPT,
119
+ file,
120
+ status: STATUS.DIFFER,
121
+ detail: `зараз '${current}', канон '${STORYBOOK_SCRIPT}'`
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Діагностика `test.projects` наявного vitest-конфіга (unit+storybook, browser-mode маркери).
127
+ * @param {{ absDir: string, rootDir: string }} entry запис пакета
128
+ * @returns {{ name: string, file: string, status: string, detail?: string }} секція діагностики
129
+ */
130
+ function diagnoseVitestProjectsSection(entry) {
131
+ const name = SECTION.VITEST_PROJECTS
132
+ const vitestConfigPath = resolveVitestConfigPath(entry.absDir)
133
+ if (!vitestConfigPath) {
134
+ return { name, file: relFileFor(entry, 'vitest.config.mjs'), status: STATUS.MISSING }
135
+ }
136
+ const file = relFileFor(entry, vitestConfigPath.slice(entry.absDir.length + 1))
137
+ const src = readFileSync(vitestConfigPath, 'utf8')
138
+
139
+ let parsed
140
+ try {
141
+ parsed = parseModule(vitestConfigPath, src)
142
+ } catch (error) {
143
+ return { name, file, status: STATUS.DIFFER, detail: `не парситься (${error.message}) — перевір вручну` }
144
+ }
145
+ if (parsed.errors?.length) {
146
+ return { name, file, status: STATUS.DIFFER, detail: 'syntax error — перевір вручну' }
147
+ }
148
+
149
+ const testObj = findTestObject(parsed.program)
150
+ if (!testObj) {
151
+ return { name, file, status: STATUS.DIFFER, detail: 'немає test-блоку (defineConfig({ test: {...} }))' }
152
+ }
153
+
154
+ const projectsProp = findProperty(testObj, 'projects')
155
+ if (!projectsProp) {
156
+ return { name, file, status: STATUS.MISSING, detail: 'test.projects відсутній' }
157
+ }
158
+ if (projectsProp.value?.type !== 'ArrayExpression') {
159
+ return { name, file, status: STATUS.DIFFER, detail: 'test.projects не статичний масив (spread/змінна)' }
160
+ }
161
+
162
+ const { hasUnit, storybookSlice } = classifyProjects(src, projectsProp.value)
163
+ if (!hasUnit || !storybookSlice) {
164
+ const missingParts = [hasUnit ? null : "'unit'", storybookSlice ? null : "'storybook'"].filter(Boolean)
165
+ return { name, file, status: STATUS.DIFFER, detail: `бракує ${missingParts.join(' і ')} у test.projects` }
166
+ }
167
+
168
+ const missingHints = []
169
+ if (!CHROMIUM_RE.test(storybookSlice)) missingHints.push('chromium-інстанс')
170
+ if (!BROWSER_KEY_RE.test(storybookSlice)) missingHints.push('browser-mode')
171
+ if (!hasStoriesMarker(storybookSlice)) missingHints.push('stories-джерело (include або storybookTest({ configDir }))')
172
+ if (!PROVIDER_FACTORY_RE.test(storybookSlice))
173
+ missingHints.push('provider-factory (playwright() з @vitest/browser-playwright)')
174
+ if (missingHints.length > 0) {
175
+ return { name, file, status: STATUS.DIFFER, detail: `storybook-project без: ${missingHints.join(', ')}` }
176
+ }
177
+
178
+ return { name, file, status: STATUS.MATCH }
179
+ }
180
+
181
+ /**
182
+ * Діагностика ізольованого `vitest.stryker.config.*` — байтове порівняння з канонічним
183
+ * baseline-рендером (fix-vitest-config.mjs, той самий генератор).
184
+ * @param {{ absDir: string, rootDir: string }} entry запис пакета
185
+ * @returns {Promise<{ name: string, file: string, status: string, detail?: string }>} секція діагностики
186
+ */
187
+ async function diagnoseStrykerSection(entry) {
188
+ const name = SECTION.STRYKER_CONFIG
189
+ const vitestConfigPath = resolveVitestConfigPath(entry.absDir)
190
+ if (!vitestConfigPath) {
191
+ return { name, file: relFileFor(entry, 'vitest.stryker.config.mjs'), status: STATUS.MISSING }
192
+ }
193
+ const strykerPath = strykerConfigPathFor(vitestConfigPath)
194
+ const file = relFileFor(entry, strykerPath.slice(entry.absDir.length + 1))
195
+ if (!existsSync(strykerPath)) {
196
+ return { name, file, status: STATUS.MISSING }
197
+ }
198
+ const actual = readFileSync(strykerPath, 'utf8')
199
+ const canonical = await buildStrykerConfig(entry.absDir)
200
+ if (actual === canonical) return { name, file, status: STATUS.MATCH }
201
+ return {
202
+ name,
203
+ file,
204
+ status: STATUS.DIFFER,
205
+ detail:
206
+ 'вміст відрізняється від канонічного baseline (vitest-config.mdc) — @stryker-mutator/vitest-runner крашиться на browser-mode projects, перевір вручну'
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Діагностика одного пакета в скоупі по секціях. Ніколи не кидає — збій окремої
212
+ * секції (парсинг/IO) відображається як секція `differ`, а не виняток, що впав
213
+ * би на весь пакет; лишається на розсуд `diagnosePackage` (circuit breaker рівня
214
+ * пакета — тут не потрібен, бо секції вже ізольовані одна від одної).
215
+ * @param {import('../scope/main.mjs').InScopePackage} entry пакет у скоупі
216
+ * @returns {Promise<{ rootDir: string, status: 'canonical'|'missing-files'|'differs', sections: object[] }>} діагностика пакета
217
+ */
218
+ export async function diagnosePackage(entry) {
219
+ const sections = [
220
+ diagnoseMarkerFile(entry, '.storybook/main.js', SECTION.MAIN_JS, MAIN_JS_MARKERS),
221
+ diagnoseMarkerFile(entry, '.storybook/preview.js', SECTION.PREVIEW_JS, PREVIEW_JS_MARKERS),
222
+ diagnoseMocksSection(entry),
223
+ diagnoseScriptSection(entry),
224
+ diagnoseVitestProjectsSection(entry),
225
+ await diagnoseStrykerSection(entry)
226
+ ]
227
+ let status = 'canonical'
228
+ if (sections.some(s => s.status === STATUS.DIFFER)) status = 'differs'
229
+ else if (sections.some(s => s.status === STATUS.MISSING)) status = 'missing-files'
230
+ return { rootDir: entry.rootDir, status, sections }
231
+ }
232
+
233
+ /**
234
+ * Генерує канонічний вміст лише для секцій зі статусом `missing` одного пакета
235
+ * (adopt-автофікс НІКОЛИ не чіпає секції зі статусом `differ` — інструкція
236
+ * для агента/людини, не сліпий перезапис). Кожна секція фіксується незалежно;
237
+ * збій однієї не блокує решту (той самий circuit-breaker принцип, лише на дрібнішому рівні).
238
+ * @param {import('../scope/main.mjs').InScopePackage} entry пакет у скоупі
239
+ * @param {object[]} sections секції діагностики пакета (`diagnosePackage().sections`)
240
+ * @returns {Promise<string[]>} абсолютні шляхи записаних файлів
241
+ */
242
+ export async function fixMissingSections(entry, sections) {
243
+ const written = []
244
+ const writeFileEnsureDir = (absPath, content) => {
245
+ mkdirSync(dirname(absPath), { recursive: true })
246
+ writeFileSync(absPath, content, 'utf8')
247
+ written.push(absPath)
248
+ }
249
+
250
+ const byName = new Map(sections.map(s => [s.name, s]))
251
+
252
+ if (byName.get(SECTION.MAIN_JS)?.status === STATUS.MISSING) {
253
+ writeFileEnsureDir(join(entry.absDir, '.storybook/main.js'), renderMainJs(entry.absDir))
254
+ }
255
+ if (byName.get(SECTION.PREVIEW_JS)?.status === STATUS.MISSING) {
256
+ writeFileEnsureDir(join(entry.absDir, '.storybook/preview.js'), renderPreviewJs())
257
+ }
258
+ if (byName.get(SECTION.MOCKS_GQL_SSE)?.status === STATUS.MISSING) {
259
+ writeFileEnsureDir(join(entry.absDir, '.storybook/mocks/gql-sse.js'), renderMocksGqlSse())
260
+ }
261
+ if (byName.get(SECTION.PACKAGE_SCRIPT)?.status === STATUS.MISSING) {
262
+ const pkgPath = join(entry.absDir, 'package.json')
263
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
264
+ pkg.scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {}
265
+ pkg.scripts.storybook = STORYBOOK_SCRIPT
266
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
267
+ written.push(pkgPath)
268
+ }
269
+ if (byName.get(SECTION.VITEST_PROJECTS)?.status === STATUS.MISSING && !resolveVitestConfigPath(entry.absDir)) {
270
+ // test.projects відсутній ЛИШЕ через відсутність усього vitest-конфіга — augment
271
+ // наявного (без відсутнього test-блоку) свідомо поза adopt-автофіксом: секція вже
272
+ // позначена `differ` (не `missing`) для такого випадку, сюди він не потрапляє.
273
+ const fresh = await buildFreshVitestConfig(entry.absDir)
274
+ writeFileEnsureDir(fresh.path, fresh.content)
275
+ }
276
+ if (byName.get(SECTION.STRYKER_CONFIG)?.status === STATUS.MISSING) {
277
+ const vitestConfigPath = resolveVitestConfigPath(entry.absDir) ?? join(entry.absDir, 'vitest.config.mjs')
278
+ const strykerPath = strykerConfigPathFor(vitestConfigPath)
279
+ writeFileEnsureDir(strykerPath, await buildStrykerConfig(entry.absDir))
280
+ }
281
+
282
+ return written
283
+ }
284
+
285
+ /**
286
+ * Adopt-прогін усіх (чи обраних) пакетів у скоупі. Circuit breaker (ADR Кластер 8):
287
+ * збій діагностики/фіксу ОДНОГО пакета деградує до `status: 'broken'` для нього —
288
+ * решта пакетів обробляються далі, весь прогін ніколи не падає через один зламаний.
289
+ * @param {string} cwd абсолютний корінь консюмер-репо
290
+ * @param {{ fixMissing?: boolean, rootDirs?: string[] }} [opts] `fixMissing` — генерувати
291
+ * відсутні секції; `rootDirs` — звузити прогін до цих коренів пакетів (порожньо/відсутнє → усі в скоупі)
292
+ * @returns {Promise<{ rootDir: string, status: string, sections?: object[], written?: string[], error?: string }[]>} результати за пакетами
293
+ */
294
+ export async function runAdopt(cwd, opts = {}) {
295
+ const fixMissing = opts.fixMissing === true
296
+ const wantedRoots = Array.isArray(opts.rootDirs) && opts.rootDirs.length > 0 ? new Set(opts.rootDirs) : null
297
+
298
+ const allPkgs = await collectInScopeVuePackages(cwd)
299
+ const pkgs = wantedRoots ? allPkgs.filter(p => wantedRoots.has(p.rootDir)) : allPkgs
300
+
301
+ const results = []
302
+ for (const entry of pkgs) {
303
+ try {
304
+ const diagnosis = await diagnosePackage(entry)
305
+ const written = fixMissing ? await fixMissingSections(entry, diagnosis.sections) : []
306
+ results.push({ ...diagnosis, written })
307
+ } catch (error) {
308
+ results.push({
309
+ rootDir: entry.rootDir,
310
+ status: 'broken',
311
+ error: error instanceof Error ? error.message : String(error)
312
+ })
313
+ }
314
+ }
315
+ return results
316
+ }
317
+
318
+ /** Іконка статусу пакета (`package.status`) для рядка звіту. */
319
+ const PACKAGE_STATUS_ICONS = Object.freeze({ canonical: '✅', 'missing-files': '➕' })
320
+ /** Іконка статусу секції (`STATUS.*`) для рядка звіту. */
321
+ const SECTION_STATUS_ICONS = Object.freeze({ [STATUS.MATCH]: ' ✓', [STATUS.MISSING]: ' +' })
322
+
323
+ /**
324
+ * Рядки звіту одного НЕ зламаного пакета (статус + секції + перелік згенерованого).
325
+ * @param {{ rootDir: string, status: string, sections: object[], written?: string[] }} r результат одного пакета
326
+ * @returns {string[]} рядки звіту
327
+ */
328
+ function formatPackageLines(r) {
329
+ const label = r.rootDir === '.' ? 'корінь' : r.rootDir
330
+ const lines = [`${PACKAGE_STATUS_ICONS[r.status] ?? '⚠️ '} [${label}] ${r.status}`]
331
+ for (const s of r.sections) {
332
+ const sIcon = SECTION_STATUS_ICONS[s.status] ?? ' ✗'
333
+ const suffix = s.detail ? ` — ${s.detail}` : ''
334
+ lines.push(`${sIcon} ${s.name} (${s.file}): ${s.status}${suffix}`)
335
+ }
336
+ if (r.written && r.written.length > 0) {
337
+ lines.push(` згенеровано: ${r.written.join(', ')}`)
338
+ }
339
+ return lines
340
+ }
341
+
342
+ /**
343
+ * Форматує людський звіт по результатах `runAdopt` (українською, для виводу скіла в CLI).
344
+ * @param {Awaited<ReturnType<typeof runAdopt>>} results результати `runAdopt`
345
+ * @returns {string} багаторядковий текстовий звіт
346
+ */
347
+ export function formatReport(results) {
348
+ if (results.length === 0) {
349
+ return 'storybook adopt: немає Vue component library пакетів у скоупі (storybook.mdc → scope/main.mjs)'
350
+ }
351
+ const lines = []
352
+ for (const r of results) {
353
+ if (r.status === 'broken') {
354
+ const label = r.rootDir === '.' ? 'корінь' : r.rootDir
355
+ lines.push(`⚠️ [${label}] діагностика впала (circuit breaker, пропущено): ${r.error}`)
356
+ continue
357
+ }
358
+ lines.push(...formatPackageLines(r))
359
+ }
360
+ return lines.join('\n')
361
+ }
362
+
363
+ /**
364
+ * CLI-вхід: `bun .../storybook/adopt/main.mjs [--fix-missing] [--cwd <path>] [rootDir...]`.
365
+ * @returns {Promise<void>} завершення прогону (друкує звіт, exit-код — кількість `differs`/`broken`)
366
+ */
367
+ async function runCli() {
368
+ const args = process.argv.slice(2)
369
+ const fixMissing = args.includes('--fix-missing')
370
+ const cwdIdx = args.indexOf('--cwd')
371
+ const cwd = cwdIdx !== -1 && args[cwdIdx + 1] ? args[cwdIdx + 1] : process.cwd()
372
+ const rootDirs = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--cwd')
373
+
374
+ const results = await runAdopt(cwd, { fixMissing, rootDirs })
375
+ console.log(formatReport(results))
376
+ const hasIssues = results.some(r => r.status === 'differs' || r.status === 'broken')
377
+ process.exitCode = hasIssues ? 1 : 0
378
+ }
379
+
380
+ if (isRunAsCli(import.meta.url)) {
381
+ await runCli()
382
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
+ "fixability": "config",
4
+ "requires": { "capability": "ci:github" },
5
+ "lint": {
6
+ "scope": "full",
7
+ "glob": [
8
+ "package.json",
9
+ "**/*.vue",
10
+ ".storybook/**",
11
+ ".github/actions/setup-playwright-chromium/action.yml",
12
+ ".github/workflows/lint-storybook.yml"
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,39 @@
1
+ ---
2
+ type: JS Module
3
+ title: fix-ci.mjs
4
+ resource: plugins/lang-js/rules/storybook/ci/fix-ci.mjs
5
+ docgen:
6
+ crc: 877b9feb
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 90
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Порожній шаблон `storybook/ci`, з якого відтворюються репо-рівневі CI-артефакти: `setup-playwright-chromium` як verbatim-копія composite action для Playwright Chromium і `.github/workflows/lint-storybook.yml` із матрицею `strategy.matrix.package`, згенерованою з фактичного списку пакетів через `collectInScopeVuePackages`. Публічні anchors: `TEMPLATE_DIR`, `renderPlaywrightAction`, `renderPackageDirsYaml`, `renderStorybookWorkflow`, `patterns`.
16
+
17
+ ## Поведінка
18
+
19
+ - **TEMPLATE_DIR** — вказує на каталог `template/` цього concern-а як джерело канонічних CI-шаблонів.
20
+ - **renderPlaywrightAction** — читає канонічний composite action `setup-playwright-chromium` із шаблону без змін.
21
+ - **renderPackageDirsYaml** — перетворює список коренів пакетів у YAML-рядки для матриці Storybook CI.
22
+ - **renderStorybookWorkflow** — збирає канонічний `.github/workflows/lint-storybook.yml`, підставляючи в нього матрицю пакетів зі скоупу репозиторію.
23
+ - **patterns** — описує T0-правила автофіксу для відновлення відсутніх CI-файлів цього concern-а.
24
+
25
+ ## Публічний API
26
+
27
+ - TEMPLATE_DIR — Каталог `template/` цього concern-а.
28
+ - renderPlaywrightAction — Вміст канонічного composite action `setup-playwright-chromium` — verbatim з template.
29
+ - renderPackageDirsYaml — Рендерить YAML-фрагмент `matrix.package` списку — по одному `- <rootDir>` на рядок, з тим
30
+ самим відступом, що й токен-рядок у шаблоні (10 пробілів — рівень елемента списку під
31
+ `strategy.matrix.package:`). `rootDir === '.'` (корінь монорепо) лишається `.` — валідний
32
+ `working-directory` для GitHub Actions.
33
+ - renderStorybookWorkflow — Вміст канонічного `.github/workflows/lint-storybook.yml` — template з підставленою
34
+ матрицею пакетів у скоупі.
35
+ - patterns — повертає набір шаблонів, за якими далі шукають або зіставляють потрібні значення.
36
+
37
+ ## Гарантії поведінки
38
+
39
+ - (специфічних машинно-виведених гарантій немає)
@@ -0,0 +1,10 @@
1
+ ---
2
+ type: Directory Index
3
+ title: plugins/lang-js/rules/storybook/ci
4
+ resource: plugins/lang-js/rules/storybook/ci/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ----------------------- | --------- |
9
+ | [fix-ci.mjs](fix-ci.md) | JS Module |
10
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,50 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.mjs
4
+ resource: plugins/lang-js/rules/storybook/ci/main.mjs
5
+ docgen:
6
+ crc: 65df5791
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 85
10
+ issues: internal-name:collectInScopeVuePackages,anchor-miss:(storybook.mdc),judge:inaccurate:0.96
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Файл фіксує канонічні репо-шляхи й маркери для storybook CI, щоб у Vue component library пакетах у scope швидко знаходити правильні `.github/actions/setup-playwright-chromium/action.yml` і `.github/workflows/lint-storybook.yml` та зіставляти їх із повідомленнями в `storybook.mdc`. Експортовані константи-рядки: PLAYWRIGHT_ACTION_REL=".github/actions/setup-playwright-chromium/action.yml" — канонічний шлях до Playwright action; STORYBOOK_WORKFLOW_REL=".github/workflows/lint-storybook.yml" — канонічний шлях до workflow linting. Маркери повідомлень: PLAYWRIGHT_ACTION_MARKERS і STORYBOOK_WORKFLOW_MARKERS, які прив’язують перевірки до повідомлень у `storybook.mdc`. Це потрібно, щоб підтримувати єдиний storybook-канон без зайвого обходу `.github` і `.git`, із кешуванням результату в межах одного прогону.
17
+
18
+ ## Поведінка
19
+
20
+ - PLAYWRIGHT_ACTION_REL — задає репо-рівневий шлях до канонічного composite action `.github/actions/setup-playwright-chromium/action.yml`, який перевіряється як частина storybook CI.
21
+ - STORYBOOK_WORKFLOW_REL — задає репо-рівневий шлях до канонічного workflow `.github/workflows/lint-storybook.yml`, що відповідає за швидкий storybook-лінт.
22
+ - PLAYWRIGHT_ACTION_MARKERS — описує канонічні ознаки composite action для storybook CI, щоб гарантувати кеш Playwright і встановлення лише chromium; у повідомленнях використовується прив’язка до `storybook.mdc`.
23
+ - STORYBOOK_WORKFLOW_MARKERS — описує канонічні ознаки workflow для storybook CI, щоб гарантувати запуск швидкого `vitest --project=storybook`; у повідомленнях використовується прив’язка до `storybook.mdc`.
24
+ - lint — перевіряє наявність storybook CI-канону лише для репозиторіїв із Vue component library пакетами в scope; якщо таких пакетів немає, завершується без зауважень. Свідомо не зачіпає `.github` і `.git` як окремі області обходу; кешує результат у межах одного прогону.
25
+
26
+ ## Публічний API
27
+
28
+ - PLAYWRIGHT_ACTION_REL — Repo-relative шлях канонічного composite action (не per-package — один на репозиторій).
29
+ - STORYBOOK_WORKFLOW_REL — Repo-relative шлях канонічного workflow, що запускає `vitest --project=storybook`.
30
+ - PLAYWRIGHT_ACTION_MARKERS — Маркери канону composite action `setup-playwright-chromium` (ADR Кластер 5): кеш
31
+ `ms-playwright` через `actions/cache`, ключ від версії playwright, install лише chromium.
32
+ Текстовий пошук — той самий підхід, що й `MAIN_JS_MARKERS`/`PREVIEW_JS_MARKERS` у `scaffold`.
33
+ - STORYBOOK_WORKFLOW_MARKERS — Маркери канону `.github/workflows/lint-storybook.yml`: композитний Playwright-кеш ПІСЛЯ
34
+ setup-bun-deps, і швидкий `vitest --project=storybook` (ADR Кластер 5 — nightly-only
35
+ `@7n/test coverage`/mutation-testing на PR не запускається, лише цей швидкий шлях).
36
+ - lint — Detector concern-а `storybook/ci` (ADR Кластер 5, CI-частина): для репозиторіїв з бодай
37
+ одним Vue component library пакетом у скоупі Storybook (`collectInScopeVuePackages`) —
38
+ канонічний composite action `setup-playwright-chromium` (кеш Playwright-браузерів, лише
39
+ chromium) і канонічний `.github/workflows/lint-storybook.yml`, що запускає швидкий
40
+ `vitest --project=storybook` на PR. Гейтований `requires.capability: ci:github` — спить
41
+ у репозиторіях без плагіна `@7n/rules-ci-github` (немає `.github/workflows`).
42
+
43
+ Nightly-only `@7n/test coverage` (mutation testing) — поза обсягом цього concern-а: ADR
44
+ Кластер 5 явно розділяє швидкий PR-шлях (цей concern) і nightly mutation-прогін, який
45
+ лишається окремою інфраструктурою `test/stryker_config`.
46
+
47
+ ## Гарантії поведінки
48
+
49
+ - Кешує результати в межах одного прогону.
50
+ - Свідомо пропускає шляхи: `.github`, `.git`.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * T0-autofix для concern-а `storybook/ci` (ADR Кластер 5, CI-частина): відтворює канонічний
3
+ * composite action `setup-playwright-chromium` (кеш Playwright-браузерів, лише chromium) і
4
+ * канонічний `.github/workflows/lint-storybook.yml` з `template/` цього concern-а.
5
+ *
6
+ * Обидва файли — репо-рівневі (не per-package): composite action — verbatim-копія; workflow —
7
+ * матриця `strategy.matrix.package` генерується з фактичного списку пакетів у скоупі
8
+ * (`collectInScopeVuePackages`), щоб CI реально покривав усі Storybook-пакети репозиторію.
9
+ */
10
+ import { mkdirSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { readFile } from 'node:fs/promises'
14
+
15
+ import { collectInScopeVuePackages } from '../scope/main.mjs'
16
+
17
+ /** Каталог `template/` цього concern-а. */
18
+ export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'template')
19
+
20
+ /** Токен у `lint-storybook.yml.snippet.yml`, що заміщається списком пакетів матриці. */
21
+ const PACKAGE_DIRS_TOKEN = '__STORYBOOK_CI_PACKAGE_DIRS__'
22
+
23
+ /**
24
+ * Вміст канонічного composite action `setup-playwright-chromium` — verbatim з template.
25
+ * @param {string} [templateDir] каталог template/ (за замовчуванням — цього concern-а)
26
+ * @returns {Promise<string>} вміст `action.yml`
27
+ */
28
+ export function renderPlaywrightAction(templateDir = TEMPLATE_DIR) {
29
+ return readFile(join(templateDir, 'setup-playwright-chromium.action.yml'), 'utf8')
30
+ }
31
+
32
+ /**
33
+ * Рендерить YAML-фрагмент `matrix.package` списку — по одному `- <rootDir>` на рядок, з тим
34
+ * самим відступом, що й токен-рядок у шаблоні (10 пробілів — рівень елемента списку під
35
+ * `strategy.matrix.package:`). `rootDir === '.'` (корінь монорепо) лишається `.` — валідний
36
+ * `working-directory` для GitHub Actions.
37
+ * @param {string[]} rootDirs відносні (posix) корені пакетів у скоупі
38
+ * @returns {string} YAML-рядки (без завершального переносу)
39
+ */
40
+ export function renderPackageDirsYaml(rootDirs) {
41
+ return rootDirs.map(dir => ` - ${dir}`).join('\n')
42
+ }
43
+
44
+ /**
45
+ * Вміст канонічного `.github/workflows/lint-storybook.yml` — template з підставленою
46
+ * матрицею пакетів у скоупі.
47
+ * @param {string[]} rootDirs відносні корені пакетів у скоупі (`collectInScopeVuePackages`)
48
+ * @param {string} [templateDir] каталог template/ (за замовчуванням — цього concern-а)
49
+ * @returns {Promise<string>} вміст `lint-storybook.yml`
50
+ */
51
+ export async function renderStorybookWorkflow(rootDirs, templateDir = TEMPLATE_DIR) {
52
+ const raw = await readFile(join(templateDir, 'lint-storybook.yml.snippet.yml'), 'utf8')
53
+ const tokenLineRe = new RegExp(`^[ \\t]*${PACKAGE_DIRS_TOKEN}[ \\t]*$`, 'mu')
54
+ // Replacer-функція (не рядок) — уникає unicorn/no-unsafe-string-replacement:
55
+ // рядок-заміна мав би трактувати `$`-послідовності як спецсимволи `String#replace`.
56
+ return raw.replace(tokenLineRe, () => renderPackageDirsYaml(rootDirs))
57
+ }
58
+
59
+ /**
60
+ * Записує файл, створюючи батьківські каталоги й реєструючи запис для rollback (той самий
61
+ * патерн, що й `writeScaffoldFile` у `scaffold/fix-scaffold.mjs`).
62
+ * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').FixContext} ctx fix-контекст рунга
63
+ * @param {string} absPath абсолютний шлях цільового файлу
64
+ * @param {string} content вміст файлу
65
+ * @returns {void}
66
+ */
67
+ function writeCiFile(ctx, absPath, content) {
68
+ mkdirSync(dirname(absPath), { recursive: true })
69
+ ctx.recordWrite?.(absPath)
70
+ writeFileSync(absPath, content, 'utf8')
71
+ }
72
+
73
+ /** @type {import('@7n/rules/scripts/lib/lint-surface/types.mjs').T0Pattern[]} */
74
+ export const patterns = [
75
+ {
76
+ id: 'storybook-ci-playwright-action',
77
+ test: violations => violations.some(v => v.reason === 'missing-playwright-action' && v.file),
78
+ apply: async (violations, ctx) => {
79
+ const target = violations.find(v => v.reason === 'missing-playwright-action' && v.file)
80
+ if (!target || !ctx.concernDir) return { touchedFiles: [] }
81
+
82
+ const content = await renderPlaywrightAction(join(ctx.concernDir, 'template'))
83
+ const abs = join(ctx.cwd, target.file)
84
+ writeCiFile(ctx, abs, content)
85
+ return { touchedFiles: [abs], message: `${target.file}: створено composite action setup-playwright-chromium` }
86
+ }
87
+ },
88
+ {
89
+ id: 'storybook-ci-workflow',
90
+ test: violations => violations.some(v => v.reason === 'missing-storybook-workflow' && v.file),
91
+ apply: async (violations, ctx) => {
92
+ const target = violations.find(v => v.reason === 'missing-storybook-workflow' && v.file)
93
+ if (!target || !ctx.concernDir) return { touchedFiles: [] }
94
+
95
+ const pkgs = await collectInScopeVuePackages(ctx.cwd)
96
+ const rootDirs = pkgs.map(p => p.rootDir)
97
+ if (rootDirs.length === 0) return { touchedFiles: [] }
98
+
99
+ const content = await renderStorybookWorkflow(rootDirs, join(ctx.concernDir, 'template'))
100
+ const abs = join(ctx.cwd, target.file)
101
+ writeCiFile(ctx, abs, content)
102
+ return { touchedFiles: [abs], message: `${target.file}: створено (${rootDirs.length} пакет(ів) у матриці)` }
103
+ }
104
+ }
105
+ ]