@7n/test 0.14.4 → 0.15.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.
- package/CHANGELOG.md +19 -0
- package/package.json +2 -2
- package/src/coverage/js-collector.mjs +340 -40
- package/src/coverage/storybook-mutation-llm.mjs +180 -0
- package/src/coverage/storybook-mutation.mjs +361 -0
- package/src/coverage/storybook.mjs +75 -0
- package/src/coverage-per-file.mjs +1 -1
- package/src/docs/coverage-fix.md +1 -1
- package/src/docs/coverage-per-file.md +1 -1
- package/src/docs/gen-stories.md +38 -0
- package/src/docs/index.md +1 -1
- package/src/docs/run.md +1 -1
- package/src/gen-stories.mjs +355 -0
- package/src/lib/docs/index.md +7 -8
- package/src/lib/docs/llm.md +1 -1
- package/src/lib/docs/resolve-js-root.md +31 -0
- package/src/run.mjs +105 -52
|
@@ -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
package/src/docs/run.md
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/docs/index.md
CHANGED
|
@@ -4,11 +4,10 @@ title: npm/src/lib
|
|
|
4
4
|
resource: npm/src/lib/
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
|
10
|
-
|
|
|
11
|
-
| [
|
|
12
|
-
| [
|
|
13
|
-
| [
|
|
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 |
|
package/src/lib/docs/llm.md
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|