@7n/test 0.14.5 → 0.16.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.
@@ -0,0 +1,38 @@
1
+ ---
2
+ type: JS Module
3
+ title: gen-stories.mjs
4
+ resource: npm/src/gen-stories.mjs
5
+ docgen:
6
+ crc: 25f5d4a8
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ tier: local-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Файл автоматично генерує файли CSF3 Storybook для Vue-компонентів, які не мають документації. Для кожного компонента використовується одноразова генерація (single-shot LLM per component) для створення відповідного файлу історії. Після генерації код валідується командою `vitest run --project=storybook <story>` проти локального тестового середовища Storybook vitest. Якщо локальне тестове середовище недоступне, файл історії все одно створюється з попередженням.
17
+
18
+ ## Поведінка
19
+
20
+ 1. Викликається функція `generateStories` для початку генерації файлів Storybook CSF3 для переліку нерозкритих Vue-компонентів.
21
+ 2. Для кожного компонента у списку виконується наступне:
22
+ a. Створюється ланцюжок для трасування та бюджетування.
23
+ b. Генерується базовий промпт для LLM, що містить детальні інструкції щодо створення story-файлу для поточного Vue-компонента, включаючи конвенції проєкту.
24
+ c. Викликається LLM для генерації вмісту story-файлу, з можливістю повторних спроб у разі помилок від LLM.
25
+ d. Згенерований код фіксується у відповідному story-файлі.
26
+ e. Згенерований файл проходить валідацію через локальний проєкт Storybook vitest у браузерному режимі.
27
+ f. Якщо валідація успішна, файл вважається готовим.
28
+ g. Якщо валідація невдала, файл видаляється, і система збирає помилки для наступної спроби.
29
+ h. Якщо валідація неможлива (локальний vitest не знайдено), файл залишається без валідації з попередженням.
30
+ 3. Після завершення обробки кожного компонента ланцюжок закривається з відповідним статусом.
31
+
32
+ ## Публічний API
33
+
34
+ generateStories — Створює файли історій (`.stories.*`) для Vue-компонентів, які не були охоплені тестами, генеруючи кожен файл як окремий ланцюжок для відстеження та бюджетування (аналогічно функціоналу `generateTests`).
35
+
36
+ ## Гарантії поведінки
37
+
38
+ - (специфічних машинно-виведених гарантій немає)
package/src/docs/index.md CHANGED
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: index.js
4
4
  resource: npm/src/index.js
5
5
  docgen:
6
- crc: b4a27890
6
+ crc: 42fb5ee5
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
package/src/docs/run.md CHANGED
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: run.mjs
4
4
  resource: npm/src/run.mjs
5
5
  docgen:
6
- crc: f71ad579
6
+ crc: 000afe1f
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.95
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Storybook CSF3 `.stories.*` generation for uncovered Vue components (single-shot
3
+ * LLM per component, validated via the target's own Storybook vitest project).
4
+ *
5
+ * Simpler than gen-tests.mjs's per-export tiered pipeline: a Vue SFC is one unit
6
+ * (one component), not many independently-testable exports — one story file is
7
+ * the natural generation grain, not per-prop blocks.
8
+ *
9
+ * Validation runs `vitest run --project=storybook <story>` (browser mode, Playwright)
10
+ * against the TARGET project's own local vitest — never the bundled shim (no
11
+ * Storybook/Playwright setup there). When no local vitest is found the story is
12
+ * still written, best-effort, with a warning (mirrors gen-tests.mjs's graceful
13
+ * degradation for other missing-tooling cases).
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
16
+ import { spawnSync } from 'node:child_process'
17
+ import { dirname, join, relative } from 'node:path'
18
+ import { callText, MEMORY_ERROR_RE } from './lib/llm.mjs'
19
+ import { budgetFor } from '@7n/llm-lib/prompt-budget'
20
+ import { startChain } from '@7n/llm-lib/chain'
21
+ import { resolveVitestRun } from './lib/vitest-shim.mjs'
22
+
23
+ const MAX_SRC_BYTES = 6000
24
+ const STORY_MAX_ATTEMPTS = 5
25
+ const CODE_BLOCK_RE = /```(?:vue|js|javascript|mjs|ts|typescript)?\n([\s\S]*?)```/
26
+ const SCRIPT_LANG_TS_RE = /<script[^>]*\blang\s*=\s*["']ts["'][^>]*>/
27
+ const VUE_EXT_RE = /\.vue$/
28
+ const FRONTMATTER_RE = /^---[\s\S]*?---\n/
29
+
30
+ /**
31
+ * Finds the project's n-test.mdc rules by walking up from dir (max 4 levels).
32
+ * Duplicated from gen-tests.mjs (not imported) — importing it would drag in its
33
+ * unrelated per-export pipeline deps (classify-exports, ast-analyze, runtime-probe)
34
+ * just for this ~12-line helper.
35
+ * @param {string} dir project root
36
+ * @returns {string|null} rules text or null
37
+ */
38
+ function findTestRules(dir) {
39
+ let current = dir
40
+ for (let i = 0; i < 4; i++) {
41
+ const candidate = join(current, '.cursor/rules/n-test.mdc')
42
+ if (existsSync(candidate)) {
43
+ return readFileSync(candidate, 'utf8').replace(FRONTMATTER_RE, '').trim()
44
+ }
45
+ const parent = dirname(current)
46
+ if (parent === current) break
47
+ current = parent
48
+ }
49
+ return null
50
+ }
51
+
52
+ /**
53
+ * Reads a source file and trims it to the prompt budget.
54
+ * @param {string} absPath absolute source path
55
+ * @returns {string} source snippet or empty string when unavailable
56
+ */
57
+ function readSourceSnippet(absPath) {
58
+ if (!existsSync(absPath)) return ''
59
+ const content = readFileSync(absPath, 'utf8')
60
+ return content.length > MAX_SRC_BYTES ? `${content.slice(0, MAX_SRC_BYTES)}\n...(truncated)` : content
61
+ }
62
+
63
+ /**
64
+ * Extracts the first fenced code block from LLM text output.
65
+ * @param {string} text LLM output
66
+ * @returns {string} extracted code or empty string
67
+ */
68
+ function extractCode(text) {
69
+ const m = CODE_BLOCK_RE.exec(text)
70
+ return m ? m[1].trim() : ''
71
+ }
72
+
73
+ /**
74
+ * Detects `<script lang="ts">`/`<script setup lang="ts">` — decides story file extension.
75
+ * @param {string} content SFC source
76
+ * @returns {'ts'|'js'} script language
77
+ */
78
+ function detectScriptLang(content) {
79
+ return SCRIPT_LANG_TS_RE.test(content) ? 'ts' : 'js'
80
+ }
81
+
82
+ /**
83
+ * Component name from a `.vue` file path (basename without extension).
84
+ * @param {string} file relative source path
85
+ * @returns {string} component name
86
+ */
87
+ function componentBaseName(file) {
88
+ const base = file.split('/').pop() ?? file
89
+ return base.replace(VUE_EXT_RE, '')
90
+ }
91
+
92
+ /**
93
+ * Story file path next to the component (Storybook convention — not `tests/`).
94
+ * @param {string} file relative `.vue` source path
95
+ * @param {'ts'|'js'} lang script language
96
+ * @returns {string} relative `.stories.<ext>` path
97
+ */
98
+ function storyFilePath(file, lang) {
99
+ const lastSlash = file.lastIndexOf('/')
100
+ const dir = lastSlash === -1 ? '' : file.slice(0, lastSlash)
101
+ const name = componentBaseName(file)
102
+ return dir ? `${dir}/${name}.stories.${lang}` : `${name}.stories.${lang}`
103
+ }
104
+
105
+ /**
106
+ * Builds the story-generation prompt for one Vue component.
107
+ * @param {{file: string, pct: number}} fileInfo uncovered `.vue` file info
108
+ * @param {string} dir project root
109
+ * @returns {{prompt: string, storyRelPath: string, lang: 'ts'|'js'}} prompt and resolved paths
110
+ */
111
+ function buildStoryPrompt(fileInfo, dir) {
112
+ const { file } = fileInfo
113
+ const absPath = join(dir, file)
114
+ const content = readSourceSnippet(absPath)
115
+ const lang = detectScriptLang(content)
116
+ const name = componentBaseName(file)
117
+ const storyRelPath = storyFilePath(file, lang)
118
+ const componentFileName = file.split('/').pop() ?? file
119
+ const testRules = findTestRules(dir)
120
+
121
+ const prompt = [
122
+ `Напиши Storybook CSF3 story-файл \`${storyRelPath}\` для Vue-компонента \`${file}\`.`,
123
+ `Компонент: \`${name}\`, файл поряд: \`./${componentFileName}\``,
124
+ '',
125
+ 'Правила (СУВОРО):',
126
+ `- Перший рядок: import ${name} from './${componentFileName}'`,
127
+ `- default export: { title: "<логічний шлях за структурою тек>", component: ${name} }`,
128
+ '- Щонайменше один named export-story (наприклад Default) з `args`, що відповідають РЕАЛЬНИМ props компонента (defineProps/props option) — не вигадуй неіснуючі props',
129
+ '- Формат CSF3 (Component Story Format 3) — БЕЗ legacy `storiesOf`, БЕЗ default function-based формату',
130
+ `- Файл ${lang === 'ts' ? 'TypeScript — типізуй Meta/StoryObj через @storybook/vue3' : 'чистий JavaScript — без TS-синтаксису (as Type, generics)'}`,
131
+ `- Поверни лише код у \`\`\`${lang} … \`\`\``,
132
+ ...(testRules ? ['', '## Конвенції проєкту:', testRules] : []),
133
+ '',
134
+ `Source (${file}):`,
135
+ '```vue',
136
+ content || '(недоступно)',
137
+ '```'
138
+ ]
139
+ .filter(Boolean)
140
+ .join('\n')
141
+
142
+ return { prompt, storyRelPath, lang }
143
+ }
144
+
145
+ /**
146
+ * Wraps the original prompt with Storybook-vitest error feedback for a retry.
147
+ * @param {string} basePrompt initial story prompt
148
+ * @param {string} prevCode previous story code
149
+ * @param {string} errors vitest error output
150
+ * @param {number} attempt current attempt number
151
+ * @returns {string} retry prompt text
152
+ */
153
+ function buildStoryRetryPrompt(basePrompt, prevCode, errors, attempt) {
154
+ return [
155
+ basePrompt,
156
+ '',
157
+ '---',
158
+ `## Спроба ${attempt}: попередній story-файл не пройшов Storybook vitest (browser mode)`,
159
+ '',
160
+ 'Твій попередній варіант:',
161
+ '```',
162
+ prevCode,
163
+ '```',
164
+ '',
165
+ 'Помилки:',
166
+ '```',
167
+ errors,
168
+ '```',
169
+ '',
170
+ 'Поверни виправлений story-файл у ```… ```'
171
+ ].join('\n')
172
+ }
173
+
174
+ /**
175
+ * Validates a written story file against the target's own Storybook vitest project
176
+ * (browser mode, Playwright). Only runs when the target has a local vitest install —
177
+ * the bundled shim has no Storybook/Playwright setup, so browser mode can't work there.
178
+ * @param {string} dir project root
179
+ * @param {string} storyRelPath relative path to the story file (from dir)
180
+ * @returns {{validated: boolean, passed: boolean, errors: string}} validation result
181
+ */
182
+ function runStoryValidate(dir, storyRelPath) {
183
+ const { bin, configArgs } = resolveVitestRun(dir)
184
+ if (configArgs.length > 0) {
185
+ // bundled/shim vitest — немає Playwright/Storybook у shim-конфізі, валідація неможлива
186
+ return { validated: false, passed: true, errors: '' }
187
+ }
188
+ const result = spawnSync(
189
+ process.execPath,
190
+ [bin, 'run', '--project=storybook', '--root', dir, '--reporter=verbose', storyRelPath],
191
+ { cwd: dir, encoding: 'utf8', timeout: 60_000, env: process.env }
192
+ )
193
+ if (result.status === 0) return { validated: true, passed: true, errors: '' }
194
+ const out = (result.stdout ?? '') + (result.stderr ?? '')
195
+ return { validated: true, passed: false, errors: out.slice(0, 3000) }
196
+ }
197
+
198
+ /**
199
+ * Converts an LLM exception into loop-control state (mirrors gen-tests.mjs's
200
+ * resolveLoopCallFailure — memory-guard errors propagate, other errors retry/stop).
201
+ * @param {Error} error caught LLM error
202
+ * @param {number} attempt current attempt number
203
+ * @returns {{stop: boolean, lastErrors: string|null}} loop-control state
204
+ */
205
+ function resolveStoryCallFailure(error, attempt) {
206
+ if (MEMORY_ERROR_RE.test(error.message ?? '')) throw error
207
+ console.error(` ✗ pi помилка (спроба ${attempt}): ${error.message}`)
208
+ if (attempt >= STORY_MAX_ATTEMPTS) return { stop: true, lastErrors: null }
209
+ return { stop: false, lastErrors: `LLM error: ${error.message}` }
210
+ }
211
+
212
+ /**
213
+ * Writes generated code to disk and validates it; removes the file on failed validation
214
+ * so a failed attempt never leaves a broken story behind.
215
+ * @param {string} dir project root
216
+ * @param {string} storyRelPath relative story path
217
+ * @param {string} code generated story code
218
+ * @param {typeof runStoryValidate} validateFn validate function
219
+ * @returns {{absStoryPath: string, validated: boolean, passed: boolean, errors: string}} write+validate result
220
+ */
221
+ function writeAndValidateStory(dir, storyRelPath, code, validateFn) {
222
+ const absStoryPath = join(dir, storyRelPath)
223
+ mkdirSync(dirname(absStoryPath), { recursive: true })
224
+ writeFileSync(absStoryPath, code + '\n', 'utf8')
225
+ const result = validateFn(dir, storyRelPath)
226
+ if (result.validated && !result.passed) rmSync(absStoryPath, { force: true })
227
+ return { absStoryPath, ...result }
228
+ }
229
+
230
+ /**
231
+ * Formats the "(спроба N)" suffix for the success log line (avoids a nested template).
232
+ * @param {number} attempt attempt number
233
+ * @returns {string} formatted suffix
234
+ */
235
+ function formatAttemptSuffix(attempt) {
236
+ return ` (спроба ${attempt})`
237
+ }
238
+
239
+ /**
240
+ * Runs one LLM attempt: calls the model, extracts code, validates shape. Returns
241
+ * `{code}` on success or `{retry: {lastCode, lastErrors}}` to continue the loop,
242
+ * or `{stop: true}` to give up on this file.
243
+ * @param {string} prompt prompt for this attempt
244
+ * @param {import('./gen-tests.mjs').PiCallFn} callTextFn cloud LLM caller
245
+ * @param {string} dir project root
246
+ * @param {number} attempt current attempt number
247
+ * @returns {Promise<{code: string}|{retry: {lastCode: string|null, lastErrors: string}}|{stop: true}>} attempt outcome
248
+ */
249
+ async function runStoryAttempt(prompt, callTextFn, dir, attempt) {
250
+ let resp
251
+ try {
252
+ resp = await callTextFn(prompt, { cwd: dir, maxTokens: budgetFor('single-file').maxTokens })
253
+ } catch (error) {
254
+ const failure = resolveStoryCallFailure(error, attempt)
255
+ if (failure.stop) return { stop: true }
256
+ return { retry: { lastCode: null, lastErrors: failure.lastErrors } }
257
+ }
258
+
259
+ const code = extractCode(resp)
260
+ if (!code || !code.includes('export default')) {
261
+ return {
262
+ retry: {
263
+ lastCode: code || null,
264
+ lastErrors: 'Story-файл має містити default export з `component`. Поверни лише код у ```… ```'
265
+ }
266
+ }
267
+ }
268
+ return { code }
269
+ }
270
+
271
+ /**
272
+ * Generates, validates (run → feedback retry) and writes one story file.
273
+ * @param {{file: string, pct: number}} fileInfo uncovered `.vue` file info
274
+ * @param {string} dir project root
275
+ * @param {import('./gen-tests.mjs').PiCallFn} callTextFn cloud LLM caller
276
+ * @param {typeof runStoryValidate} [validateFn] validate injection (для тестів)
277
+ * @returns {Promise<string|null>} written story path or null
278
+ */
279
+ async function generateOneStory(fileInfo, dir, callTextFn, validateFn = runStoryValidate) {
280
+ const { file } = fileInfo
281
+ const { prompt: basePrompt, storyRelPath } = buildStoryPrompt(fileInfo, dir)
282
+
283
+ let lastCode = null
284
+ let lastErrors = null
285
+
286
+ for (let attempt = 1; attempt <= STORY_MAX_ATTEMPTS; attempt++) {
287
+ const prompt =
288
+ lastErrors && lastCode ? buildStoryRetryPrompt(basePrompt, lastCode, lastErrors, attempt) : basePrompt
289
+
290
+ const outcome = await runStoryAttempt(prompt, callTextFn, dir, attempt)
291
+ if (outcome.stop) return null
292
+ if (outcome.retry) {
293
+ console.log(` ${file} ✗ спроба ${attempt} невдала → retry`)
294
+ lastCode = outcome.retry.lastCode ?? lastCode
295
+ lastErrors = outcome.retry.lastErrors
296
+ continue
297
+ }
298
+
299
+ const { absStoryPath, validated, passed, errors } = writeAndValidateStory(
300
+ dir,
301
+ storyRelPath,
302
+ outcome.code,
303
+ validateFn
304
+ )
305
+ if (!validated) {
306
+ console.log(` ⚠ ${relative(dir, absStoryPath)}: локальний vitest не знайдено — записано без валідації`)
307
+ return absStoryPath
308
+ }
309
+ if (passed) {
310
+ const suffix = attempt > 1 ? formatAttemptSuffix(attempt) : ''
311
+ console.log(` ✓ Записано: ${relative(dir, absStoryPath)}${suffix}`)
312
+ return absStoryPath
313
+ }
314
+
315
+ console.log(` ${file} ✗ storybook vitest fail (спроба ${attempt}/${STORY_MAX_ATTEMPTS})`)
316
+ lastCode = outcome.code
317
+ lastErrors = errors
318
+ }
319
+
320
+ console.log(` ⚠ ${file}: ${STORY_MAX_ATTEMPTS} спроб вичерпано — story не записано`)
321
+ return null
322
+ }
323
+
324
+ /**
325
+ * Generates `.stories.*` files for a list of uncovered Vue components.
326
+ * Each file is one chain unit (ланцюжок для tracing/бюджету, паритет з generateTests).
327
+ * @param {Array<{file: string, pct: number}>} files uncovered `.vue` files
328
+ * @param {string} dir project root
329
+ * @param {{callText?: import('./gen-tests.mjs').PiCallFn, validateStory?: typeof runStoryValidate, makeChain?: typeof startChain}} [opts] generation options
330
+ * @returns {Promise<void>} resolves after all requested files are processed
331
+ */
332
+ export async function generateStories(files, dir, opts = {}) {
333
+ if (files.length === 0) return
334
+
335
+ const callTextFn = opts.callText ?? callText
336
+ const validateFn = opts.validateStory ?? runStoryValidate
337
+ const makeChain = opts.makeChain ?? startChain
338
+
339
+ console.log(`\n🎨 Генерую Storybook stories для ${files.length} Vue-компонентів...\n`)
340
+
341
+ for (const fileInfo of files) {
342
+ console.log(` → ${fileInfo.file} (${fileInfo.pct.toFixed(1)}%)`)
343
+ const chain = makeChain({ kind: 'story-generate', unit: fileInfo.file, cwd: dir })
344
+ const chainedCloud = (prompt, callOpts = {}) => callTextFn(prompt, { ...callOpts, chain })
345
+ let failed = null
346
+ try {
347
+ await generateOneStory(fileInfo, dir, chainedCloud, validateFn)
348
+ } catch (error) {
349
+ failed = String(error.message ?? error).slice(0, 200)
350
+ throw error
351
+ } finally {
352
+ chain.end({ outcome: failed ? 'fail' : 'success', extra: failed ? { error: failed } : {} })
353
+ }
354
+ }
355
+ }
@@ -4,11 +4,10 @@ title: npm/src/lib
4
4
  resource: npm/src/lib/
5
5
  ---
6
6
 
7
- # npm/src/lib
8
-
9
- | Файл | Тип |
10
- | ------------------------------------- | --------- |
11
- | [ast-analyze.mjs](ast-analyze.md) | JS Module |
12
- | [llm.mjs](llm.md) | JS Module |
13
- | [runtime-probe.mjs](runtime-probe.md) | JS Module |
14
- | [vitest-shim.mjs](vitest-shim.md) | JS Module |
7
+ | Файл | Тип |
8
+ | ----------------------------------------- | --------- |
9
+ | [ast-analyze.mjs](ast-analyze.md) | JS Module |
10
+ | [llm.mjs](llm.md) | JS Module |
11
+ | [resolve-js-root.mjs](resolve-js-root.md) | JS Module |
12
+ | [runtime-probe.mjs](runtime-probe.md) | JS Module |
13
+ | [vitest-shim.mjs](vitest-shim.md) | JS Module |
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: llm.mjs
4
4
  resource: npm/src/lib/llm.mjs
5
5
  docgen:
6
- crc: b126a1aa
6
+ crc: ed7d0b9f
7
7
  ---
8
8
 
9
9
  ## Огляд
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: JS Module
3
+ title: resolve-js-root.mjs
4
+ resource: npm/src/lib/resolve-js-root.mjs
5
+ docgen:
6
+ crc: 270edc8d
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ tier: local-min-retry
9
+ score: 100
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Файл визначає корені JavaScript-коду в проєкті, використовуючи конфігурацію `package.json`. Він знаходить єдиний корінь для однопакетного проєкту або всі корені для проєктів з багатопакетною структурою, підтримуючи glob-патерни (наприклад, `cf/*`) для workspace-проєктів.
16
+
17
+ ## Поведінка
18
+
19
+ Поведінка:
20
+ resolveJsRoot повертає абсолютний шлях до кореня JavaScript-коду, якщо він визначений, інакше повертає null.
21
+ resolveAllJsRoots повертає список абсолютних шляхів до всіх коренів JavaScript-коду у проєкті, враховуючи конфігурацію `workspaces` з `package.json`.
22
+
23
+ ## Публічний API
24
+
25
+ resolveJsRoot — знаходить основний корінь проєкту, де розташований `package.json`.
26
+ resolveAllJsRoots — надає повний список усіх JS-коренів у проєкті. Для багатопакетних (workspace) структур повертає кожен окремий workspace з його `package.json`; для одного пакета — поточний робочий каталог; повертає порожній масив, якщо `package.json` відсутній у корені.
27
+
28
+ ## Гарантії поведінки
29
+
30
+ - Read-only: не виконує операцій запису (ФС/БД).
31
+ - Свідомо пропускає шляхи: `.git`, `node_modules`.
package/src/run.mjs CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  } from './coverage-per-file.mjs'
21
21
  import { quickClassify } from './assess-need.mjs'
22
22
  import { generateTests } from './gen-tests.mjs'
23
+ import { generateStories } from './gen-stories.mjs'
24
+ import { isStorybookRoot } from './coverage/storybook.mjs'
23
25
  import { fixFailingTests } from './fix-tests.mjs'
24
26
  import { runCoverageSteps } from './coverage/coverage.mjs'
25
27
  import { withLock } from './scripts/utils/with-lock.mjs'
@@ -27,9 +29,108 @@ import { readFileSync } from 'node:fs'
27
29
  import { writeFile } from 'node:fs/promises'
28
30
  import { join } from 'node:path'
29
31
 
32
+ const VUE_FILE_RE = /\.vue$/
33
+
30
34
  const MAX_ITERATIONS = 5
31
35
  const COVERAGE_THRESHOLD = 80
32
36
 
37
+ /**
38
+ * Routes uncovered files to the appropriate generator: `.vue` components in a
39
+ * Storybook-configured root (`.storybook/` + `@storybook/addon-vitest`) get CSF3
40
+ * stories (fills the `Vue (Storybook)` coverage dimension); everything else — and
41
+ * `.vue` files when the project has no Storybook set up — gets a regular vitest
42
+ * unit test, as before.
43
+ * @param {Array<{file: string, pct: number}>} uncovered files below the coverage threshold
44
+ * @param {string} dir project root
45
+ * @returns {Promise<void>} resolves after generation for both buckets completes
46
+ */
47
+ async function generateForUncovered(uncovered, dir) {
48
+ const vueFiles = uncovered.filter(f => VUE_FILE_RE.test(f.file))
49
+ let testFiles = uncovered
50
+
51
+ if (vueFiles.length > 0 && (await isStorybookRoot(dir))) {
52
+ console.log(`\n→ Генерую Storybook stories для ${vueFiles.length} Vue-компонентів:`)
53
+ for (const f of vueFiles) console.log(` • ${f.file} (${f.pct.toFixed(1)}%)`)
54
+ await generateStories(vueFiles, dir)
55
+ testFiles = uncovered.filter(f => !VUE_FILE_RE.test(f.file))
56
+ }
57
+
58
+ if (testFiles.length === 0) return
59
+
60
+ console.log(`\n→ Генерую тести для ${testFiles.length} файлів:`)
61
+ for (const f of testFiles) {
62
+ console.log(` • ${f.file} (${f.pct.toFixed(1)}%)`)
63
+ }
64
+ await generateTests(testFiles, dir)
65
+ }
66
+
67
+ /**
68
+ * Bootstrap: no tests exist at all — scans source files and locally classifies
69
+ * which ones warrant an initial test, then generates them.
70
+ * @param {string} dir project root
71
+ * @returns {Promise<'stop'|'continue'>} 'stop' — nothing to do or nothing needs tests;
72
+ * 'continue' — bootstrap tests were generated, loop should re-measure
73
+ */
74
+ async function runBootstrap(dir) {
75
+ const sourceFiles = await findSourceFiles(dir)
76
+ if (sourceFiles.length === 0) {
77
+ console.log('⚠ Vitest coverage не повернула даних — перевір налаштування vitest.')
78
+ return 'stop'
79
+ }
80
+ console.log(`\n── Bootstrap: ${sourceFiles.length} джерел — класифікую локально ──\n`)
81
+
82
+ const bootstrapFiles = sourceFiles
83
+ .map(f => {
84
+ try {
85
+ const q = quickClassify(readFileSync(join(dir, f), 'utf8'))
86
+ if (q?.needsTests === false) return null
87
+ } catch {
88
+ /* include on read error */
89
+ }
90
+ return { file: f, pct: 0 }
91
+ })
92
+ .filter(Boolean)
93
+
94
+ if (bootstrapFiles.length === 0) {
95
+ console.log('✓ Жоден файл не потребує unit-тестів (реекспорти/типи).')
96
+ return 'stop'
97
+ }
98
+
99
+ console.log(`\n→ Bootstrap-тести для (${bootstrapFiles.length}):`)
100
+ for (const f of bootstrapFiles) console.log(` • ${f.file}`)
101
+ await generateTests(bootstrapFiles, dir)
102
+ return 'continue'
103
+ }
104
+
105
+ /**
106
+ * Phase 2: mutation testing + auto-fix (skipped with `--no-mutation`, which instead
107
+ * writes COVERAGE.md from the last Phase 1 per-file measurement).
108
+ * @param {string} dir project root
109
+ * @param {{ noMutation?: boolean }} opts опції запуску
110
+ * @param {Array<{file: string, pct: number, linesFound: number, linesCovered: number}>} lastFiles останнє per-file вимірювання Phase 1
111
+ * @returns {Promise<number>} exit code
112
+ */
113
+ async function runMutationPhase(dir, opts, lastFiles) {
114
+ if (opts.noMutation) {
115
+ console.log('\n── Мутаційне тестування пропущено (--no-mutation) ──\n')
116
+ if (lastFiles.length > 0) {
117
+ await writeFile(join(dir, 'COVERAGE.md'), renderPerFileMarkdown(lastFiles), 'utf8')
118
+ console.log('✓ COVERAGE.md (тільки per-file покриття, без мутантів)')
119
+ } else {
120
+ console.log('⚠ Немає даних покриття — COVERAGE.md не записано.')
121
+ }
122
+ return 0
123
+ }
124
+
125
+ console.log('\n── Мутаційне тестування + автофікс ──\n')
126
+ const code = await withLock('coverage', () => runCoverageSteps({ fix: true, cwd: dir }))
127
+ if (code === 0) {
128
+ console.log('\n♻️ Повторний coverage після агента...\n')
129
+ return withLock('coverage', () => runCoverageSteps({ fix: false, cwd: dir }))
130
+ }
131
+ return code
132
+ }
133
+
33
134
  /**
34
135
  * @param {string} dir absolute path to project root
35
136
  * @param {{ noMutation?: boolean }} [opts] опції запуску
@@ -58,34 +159,8 @@ export async function runAutoTest(dir, opts = {}) {
58
159
  console.log('⚠ Тести падають, але coverage все одно порожня — зупиняю цикл.')
59
160
  break
60
161
  }
61
- // Bootstrap: no tests at all — scan source files and generate initial tests
62
- const sourceFiles = await findSourceFiles(dir)
63
- if (sourceFiles.length === 0) {
64
- console.log('⚠ Vitest coverage не повернула даних — перевір налаштування vitest.')
65
- break
66
- }
67
- console.log(`\n── Bootstrap: ${sourceFiles.length} джерел — класифікую локально ──\n`)
68
-
69
- const bootstrapFiles = sourceFiles
70
- .map(f => {
71
- try {
72
- const q = quickClassify(readFileSync(join(dir, f), 'utf8'))
73
- if (q?.needsTests === false) return null
74
- } catch {
75
- /* include on read error */
76
- }
77
- return { file: f, pct: 0 }
78
- })
79
- .filter(Boolean)
80
-
81
- if (bootstrapFiles.length === 0) {
82
- console.log('✓ Жоден файл не потребує unit-тестів (реекспорти/типи).')
83
- break
84
- }
85
-
86
- console.log(`\n→ Bootstrap-тести для (${bootstrapFiles.length}):`)
87
- for (const f of bootstrapFiles) console.log(` • ${f.file}`)
88
- await generateTests(bootstrapFiles, dir)
162
+ const outcome = await runBootstrap(dir)
163
+ if (outcome === 'stop') break
89
164
  continue
90
165
  }
91
166
 
@@ -106,30 +181,8 @@ export async function runAutoTest(dir, opts = {}) {
106
181
  }
107
182
  prevUncoveredCount = uncovered.length
108
183
 
109
- console.log(`\n→ Генерую тести для ${uncovered.length} файлів:`)
110
- for (const f of uncovered) {
111
- console.log(` • ${f.file} (${f.pct.toFixed(1)}%)`)
112
- }
113
-
114
- await generateTests(uncovered, dir)
184
+ await generateForUncovered(uncovered, dir)
115
185
  }
116
186
 
117
- if (opts.noMutation) {
118
- console.log('\n── Мутаційне тестування пропущено (--no-mutation) ──\n')
119
- if (lastFiles.length > 0) {
120
- await writeFile(join(dir, 'COVERAGE.md'), renderPerFileMarkdown(lastFiles), 'utf8')
121
- console.log('✓ COVERAGE.md (тільки per-file покриття, без мутантів)')
122
- } else {
123
- console.log('⚠ Немає даних покриття — COVERAGE.md не записано.')
124
- }
125
- return 0
126
- }
127
-
128
- console.log('\n── Мутаційне тестування + автофікс ──\n')
129
- const code = await withLock('coverage', () => runCoverageSteps({ fix: true, cwd: dir }))
130
- if (code === 0) {
131
- console.log('\n♻️ Повторний coverage після агента...\n')
132
- return withLock('coverage', () => runCoverageSteps({ fix: false, cwd: dir }))
133
- }
134
- return code
187
+ return runMutationPhase(dir, opts, lastFiles)
135
188
  }