ai-unit-test-generator 1.3.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 +264 -0
- package/LICENSE +22 -0
- package/README.md +432 -0
- package/bin/cli.js +217 -0
- package/lib/ai/client.mjs +79 -0
- package/lib/ai/extractor.mjs +199 -0
- package/lib/ai/index.mjs +11 -0
- package/lib/ai/prompt-builder.mjs +298 -0
- package/lib/core/git-analyzer.mjs +151 -0
- package/lib/core/index.mjs +11 -0
- package/lib/core/scanner.mjs +233 -0
- package/lib/core/scorer.mjs +633 -0
- package/lib/index.js +18 -0
- package/lib/index.mjs +25 -0
- package/lib/testing/analyzer.mjs +43 -0
- package/lib/testing/index.mjs +10 -0
- package/lib/testing/runner.mjs +32 -0
- package/lib/utils/index.mjs +11 -0
- package/lib/utils/marker.mjs +182 -0
- package/lib/workflows/all.mjs +51 -0
- package/lib/workflows/batch.mjs +187 -0
- package/lib/workflows/index.mjs +10 -0
- package/package.json +69 -0
- package/templates/default.config.json +199 -0
@@ -0,0 +1,151 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
import { execSync } from 'node:child_process'
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
5
|
+
import { join } from 'node:path'
|
6
|
+
|
7
|
+
function parseArgs(argv) {
|
8
|
+
const args = {}
|
9
|
+
for (let i = 2; i < argv.length; i++) {
|
10
|
+
const a = argv[i]
|
11
|
+
if (a.startsWith('--')) {
|
12
|
+
const [k, v] = a.includes('=') ? a.split('=') : [a, argv[i + 1]]
|
13
|
+
args[k.replace(/^--/, '')] = v === undefined || v.startsWith('--') ? true : v
|
14
|
+
if (v !== undefined && !v.startsWith('--') && !a.includes('=')) i++
|
15
|
+
}
|
16
|
+
}
|
17
|
+
return args
|
18
|
+
}
|
19
|
+
|
20
|
+
function run(cmd) {
|
21
|
+
const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'] })
|
22
|
+
.toString()
|
23
|
+
.trim()
|
24
|
+
return out
|
25
|
+
}
|
26
|
+
|
27
|
+
function loadJson(p) { if (!p || !existsSync(p)) return null; try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null } }
|
28
|
+
|
29
|
+
function assertGitRepo() {
|
30
|
+
try {
|
31
|
+
const inside = run('git rev-parse --is-inside-work-tree')
|
32
|
+
if (inside !== 'true') throw new Error('not a git work tree')
|
33
|
+
} catch (e) {
|
34
|
+
throw new Error('Not inside a git repository. Ensure git is initialized and files are tracked.')
|
35
|
+
}
|
36
|
+
}
|
37
|
+
|
38
|
+
function gitFiles(pattern = 'src') {
|
39
|
+
const out = run(`git ls-files "${pattern}"`)
|
40
|
+
const files = out ? out.split('\n').filter(Boolean) : []
|
41
|
+
if (!files.length) throw new Error(`No tracked files under '${pattern}'. Ensure files are added to git.`)
|
42
|
+
return files
|
43
|
+
}
|
44
|
+
|
45
|
+
function detectMultiPlatform(filePath, content) {
|
46
|
+
if (filePath.includes('.h5.') || filePath.includes('.crn.')) return true
|
47
|
+
if (!content) return false
|
48
|
+
return content.includes('xEnv') || content.includes('xUa')
|
49
|
+
}
|
50
|
+
|
51
|
+
function topCategory(path) {
|
52
|
+
const parts = path.replace(/\\/g, '/').split('/')
|
53
|
+
const idx = parts.indexOf('src')
|
54
|
+
return idx >= 0 && idx + 1 < parts.length ? parts[idx + 1] : parts[0]
|
55
|
+
}
|
56
|
+
|
57
|
+
// P1-1: 重命名为 inCategory(文件是否属于核心类别白名单)
|
58
|
+
function computeInCategory(path, categories = []) {
|
59
|
+
const cat = topCategory(path)
|
60
|
+
return categories.includes(cat)
|
61
|
+
}
|
62
|
+
|
63
|
+
// P2-2: 批量获取 Git 数据优化
|
64
|
+
function collectCommitsBatch(files) {
|
65
|
+
const byFile = {}
|
66
|
+
files.forEach(f => { byFile[f] = { c30: 0, c90: 0, c180: 0, authors: new Set() } })
|
67
|
+
|
68
|
+
// 一次性获取所有 commits(180天内)
|
69
|
+
const log180 = run('git log --since="180 days ago" --pretty=format:"%H|%an|%ar" --name-only').split('\n\n')
|
70
|
+
const now = Date.now()
|
71
|
+
const day30 = 30 * 24 * 60 * 60 * 1000
|
72
|
+
const day90 = 90 * 24 * 60 * 60 * 1000
|
73
|
+
|
74
|
+
for (const block of log180) {
|
75
|
+
if (!block.trim()) continue
|
76
|
+
const lines = block.split('\n')
|
77
|
+
const [hash, author, relTime] = (lines[0] || '').split('|')
|
78
|
+
if (!hash) continue
|
79
|
+
|
80
|
+
const daysAgo = parseRelativeTime(relTime)
|
81
|
+
const isIn30 = daysAgo <= 30
|
82
|
+
const isIn90 = daysAgo <= 90
|
83
|
+
|
84
|
+
for (let i = 1; i < lines.length; i++) {
|
85
|
+
const file = lines[i].trim()
|
86
|
+
if (!file || !byFile[file]) continue
|
87
|
+
if (isIn30) {
|
88
|
+
byFile[file].c30++
|
89
|
+
byFile[file].authors.add(author)
|
90
|
+
}
|
91
|
+
if (isIn90) byFile[file].c90++
|
92
|
+
byFile[file].c180++
|
93
|
+
}
|
94
|
+
}
|
95
|
+
|
96
|
+
return byFile
|
97
|
+
}
|
98
|
+
|
99
|
+
function parseRelativeTime(rel) {
|
100
|
+
if (!rel) return 999
|
101
|
+
const match = rel.match(/(\d+)\s+(day|week|month|year)/)
|
102
|
+
if (!match) return 999
|
103
|
+
const [, num, unit] = match
|
104
|
+
const n = Number(num)
|
105
|
+
if (unit.startsWith('day')) return n
|
106
|
+
if (unit.startsWith('week')) return n * 7
|
107
|
+
if (unit.startsWith('month')) return n * 30
|
108
|
+
if (unit.startsWith('year')) return n * 365
|
109
|
+
return 999
|
110
|
+
}
|
111
|
+
|
112
|
+
function collectWithExec(files, config) {
|
113
|
+
const results = {}
|
114
|
+
const crossCats = config?.crossModuleCategories || []
|
115
|
+
|
116
|
+
// P2-2: 批量获取所有 commits
|
117
|
+
const batchData = collectCommitsBatch(files)
|
118
|
+
|
119
|
+
for (const file of files) {
|
120
|
+
const data = batchData[file] || { c30: 0, c90: 0, c180: 0, authors: new Set() }
|
121
|
+
const commits30d = data.c30
|
122
|
+
const commits90d = data.c90
|
123
|
+
const commits180d = data.c180
|
124
|
+
const authors30d = data.authors.size
|
125
|
+
|
126
|
+
let content = ''
|
127
|
+
try { content = readFileSync(join(process.cwd(), file), 'utf8') } catch {}
|
128
|
+
const multiPlatform = detectMultiPlatform(file, content)
|
129
|
+
|
130
|
+
// P1-1: 改名为 inCategory
|
131
|
+
const inCategory = computeInCategory(file, crossCats)
|
132
|
+
results[file] = { commits30d, commits90d, commits180d, authors30d, inCategory, multiPlatform }
|
133
|
+
}
|
134
|
+
return results
|
135
|
+
}
|
136
|
+
|
137
|
+
function main() {
|
138
|
+
const args = parseArgs(process.argv)
|
139
|
+
const outPath = args.out
|
140
|
+
const config = loadJson('ut_scoring_config.json') || {}
|
141
|
+
|
142
|
+
assertGitRepo()
|
143
|
+
const files = gitFiles('src')
|
144
|
+
const results = collectWithExec(files, config)
|
145
|
+
|
146
|
+
const json = JSON.stringify(results, null, 2)
|
147
|
+
if (outPath) writeFileSync(outPath, json)
|
148
|
+
else process.stdout.write(json)
|
149
|
+
}
|
150
|
+
|
151
|
+
main()
|
@@ -0,0 +1,233 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
4
|
+
|
5
|
+
async function req(mod, installHint) {
|
6
|
+
try { return await import(mod) } catch {
|
7
|
+
const hint = installHint || mod
|
8
|
+
throw new Error(`${mod} not installed. Run: npm i -D ${hint}`)
|
9
|
+
}
|
10
|
+
}
|
11
|
+
|
12
|
+
function parseArgs(argv) {
|
13
|
+
const args = {}
|
14
|
+
for (let i = 2; i < argv.length; i++) {
|
15
|
+
const a = argv[i]
|
16
|
+
if (a.startsWith('--')) {
|
17
|
+
const [k, v] = a.includes('=') ? a.split('=') : [a, argv[i + 1]]
|
18
|
+
args[k.replace(/^--/, '')] = v === undefined || v.startsWith('--') ? true : v
|
19
|
+
if (v !== undefined && !v.startsWith('--') && !a.includes('=')) i++
|
20
|
+
}
|
21
|
+
}
|
22
|
+
return args
|
23
|
+
}
|
24
|
+
|
25
|
+
function loadJson(p) { try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null } }
|
26
|
+
|
27
|
+
async function listFiles(excludeDirs = []) {
|
28
|
+
const fg = (await req('fast-glob', 'fast-glob')).default
|
29
|
+
|
30
|
+
// 基础排除规则
|
31
|
+
const baseExcludes = ['!**/*.d.ts', '!**/node_modules/**']
|
32
|
+
|
33
|
+
// 添加用户指定的排除目录
|
34
|
+
const userExcludes = excludeDirs.map(dir => {
|
35
|
+
// 标准化路径:移除前后斜杠,确保以 src/ 开头
|
36
|
+
const normalized = dir.replace(/^\/+|\/+$/g, '')
|
37
|
+
const prefixed = normalized.startsWith('src/') ? normalized : `src/${normalized}`
|
38
|
+
return `!${prefixed}/**`
|
39
|
+
})
|
40
|
+
|
41
|
+
const patterns = ['src/**/*.{ts,tsx}', ...baseExcludes, ...userExcludes]
|
42
|
+
const files = await fg(patterns, { dot: false })
|
43
|
+
|
44
|
+
if (!files?.length) throw new Error('No source files found under src (check exclude patterns)')
|
45
|
+
return files
|
46
|
+
}
|
47
|
+
|
48
|
+
function decideTypeByPathAndName(filePath, exportName) {
|
49
|
+
const p = filePath.toLowerCase()
|
50
|
+
if (p.includes('/hooks/') || /^use[A-Z]/.test(exportName)) return 'hook'
|
51
|
+
if (p.includes('/components/') || /^[A-Z]/.test(exportName)) return 'component'
|
52
|
+
return 'function'
|
53
|
+
}
|
54
|
+
|
55
|
+
function decideLayer(filePath, cfg) {
|
56
|
+
const layers = cfg?.layers
|
57
|
+
if (!layers) return 'unknown'
|
58
|
+
|
59
|
+
// 标准化路径:移除反斜杠,去掉 src/ 前缀
|
60
|
+
let normalizedPath = filePath.replace(/\\/g, '/')
|
61
|
+
if (normalizedPath.startsWith('src/')) {
|
62
|
+
normalizedPath = normalizedPath.substring(4)
|
63
|
+
}
|
64
|
+
|
65
|
+
// 按优先级匹配层级(从最具体到最通用)
|
66
|
+
for (const [layerKey, layerDef] of Object.entries(layers)) {
|
67
|
+
const patterns = layerDef.patterns || []
|
68
|
+
for (const pattern of patterns) {
|
69
|
+
// 简单的 glob 匹配:支持 ** 和 *
|
70
|
+
const regexPattern = pattern
|
71
|
+
.replace(/\*\*/g, '.*')
|
72
|
+
.replace(/\*/g, '[^/]*')
|
73
|
+
const regex = new RegExp(regexPattern)
|
74
|
+
if (regex.test(normalizedPath)) {
|
75
|
+
return layerKey
|
76
|
+
}
|
77
|
+
}
|
78
|
+
}
|
79
|
+
|
80
|
+
return 'unknown'
|
81
|
+
}
|
82
|
+
|
83
|
+
function buildImpactHint(path) {
|
84
|
+
const lower = path.toLowerCase()
|
85
|
+
if (/(price|booking|soldout)/.test(lower)) return 'price/booking/soldout'
|
86
|
+
if (/(roomlist|filter)/.test(lower)) return 'roomlist/filter'
|
87
|
+
if (/login|order/.test(lower)) return 'login/order'
|
88
|
+
return 'display'
|
89
|
+
}
|
90
|
+
|
91
|
+
function buildRoiHint(path, content) {
|
92
|
+
const isPure = !/fetch\(|xRouter|xStorage|xUbt|xEnv|xUa/.test(content)
|
93
|
+
const needsUI = /<\w+/.test(content)
|
94
|
+
return {
|
95
|
+
isPure: isPure && !needsUI,
|
96
|
+
dependenciesInjectable: /\(.*:.*\)/.test(content) && !needsUI,
|
97
|
+
needsUI,
|
98
|
+
multiPlatformStrong: /xEnv|xUa|\.h5\.|\.crn\./.test(path)
|
99
|
+
}
|
100
|
+
}
|
101
|
+
|
102
|
+
function relativize(path) {
|
103
|
+
const cwd = process.cwd().replace(/\\/g, '/')
|
104
|
+
const norm = String(path).replace(/\\/g, '/')
|
105
|
+
return norm.startsWith(cwd) ? norm.slice(cwd.length + 1) : norm
|
106
|
+
}
|
107
|
+
|
108
|
+
function getLoc(text, start, end) {
|
109
|
+
const slice = text.slice(start, end)
|
110
|
+
return slice.split(/\r?\n/).length
|
111
|
+
}
|
112
|
+
|
113
|
+
async function extractTargets(files) {
|
114
|
+
const { Project, SyntaxKind } = await req('ts-morph', 'ts-morph')
|
115
|
+
const cfg = loadJson('ut_scoring_config.json') || {}
|
116
|
+
const internalInclude = cfg.internalInclude === true
|
117
|
+
const minLoc = cfg?.internalThresholds?.minLoc ?? 15
|
118
|
+
|
119
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true })
|
120
|
+
files.forEach(f => project.addSourceFileAtPathIfExists(f))
|
121
|
+
|
122
|
+
const targets = []
|
123
|
+
|
124
|
+
// 辅助函数:判断变量是否为可测试的函数/组件
|
125
|
+
function isTestableVariable(v) {
|
126
|
+
const init = v?.getInitializer()
|
127
|
+
if (!init) return false
|
128
|
+
const kind = init.getKind()
|
129
|
+
// 箭头函数、函数表达式、React组件(JSX)、HOC包装
|
130
|
+
if (kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression) return true
|
131
|
+
if (kind === SyntaxKind.CallExpression) {
|
132
|
+
const text = init.getText()
|
133
|
+
return /(React\.memo|forwardRef|memo|observer)\(/.test(text)
|
134
|
+
}
|
135
|
+
return false
|
136
|
+
}
|
137
|
+
|
138
|
+
for (const sf of project.getSourceFiles()) {
|
139
|
+
const absPath = sf.getFilePath()
|
140
|
+
const relPath = relativize(absPath)
|
141
|
+
const content = sf.getFullText()
|
142
|
+
|
143
|
+
// 导出符号 - 仅包含函数和组件
|
144
|
+
const exported = Array.from(new Set(sf.getExportSymbols().map(s => s.getName()).filter(Boolean)))
|
145
|
+
const fileLoc = content.split('\n').length
|
146
|
+
for (const name of exported) {
|
147
|
+
// 检查是否为函数声明
|
148
|
+
const fn = sf.getFunction(name)
|
149
|
+
if (fn) {
|
150
|
+
const type = decideTypeByPathAndName(relPath, name)
|
151
|
+
const layer = decideLayer(relPath, cfg)
|
152
|
+
targets.push({
|
153
|
+
name,
|
154
|
+
path: relPath,
|
155
|
+
type,
|
156
|
+
layer,
|
157
|
+
internal: false,
|
158
|
+
loc: fileLoc,
|
159
|
+
impactHint: buildImpactHint(relPath),
|
160
|
+
roiHint: buildRoiHint(relPath, content)
|
161
|
+
})
|
162
|
+
continue
|
163
|
+
}
|
164
|
+
|
165
|
+
// 检查是否为变量(可能是箭头函数或组件)
|
166
|
+
const v = sf.getVariableDeclaration(name)
|
167
|
+
if (v && isTestableVariable(v)) {
|
168
|
+
const type = decideTypeByPathAndName(relPath, name)
|
169
|
+
const layer = decideLayer(relPath, cfg)
|
170
|
+
targets.push({
|
171
|
+
name,
|
172
|
+
path: relPath,
|
173
|
+
type,
|
174
|
+
layer,
|
175
|
+
internal: false,
|
176
|
+
loc: fileLoc,
|
177
|
+
impactHint: buildImpactHint(relPath),
|
178
|
+
roiHint: buildRoiHint(relPath, content)
|
179
|
+
})
|
180
|
+
}
|
181
|
+
// 其他(interface/type/const/enum)直接跳过
|
182
|
+
}
|
183
|
+
|
184
|
+
// 内部顶层命名函数(非导出)
|
185
|
+
if (internalInclude) {
|
186
|
+
const fnDecls = sf.getFunctions().filter(fn => !fn.isExported() && !!fn.getName())
|
187
|
+
for (const fn of fnDecls) {
|
188
|
+
const name = fn.getName()
|
189
|
+
const start = fn.getStart()
|
190
|
+
const end = fn.getEnd()
|
191
|
+
const loc = getLoc(content, start, end)
|
192
|
+
if (loc < minLoc) continue
|
193
|
+
const type = decideTypeByPathAndName(relPath, name)
|
194
|
+
const layer = decideLayer(relPath, cfg)
|
195
|
+
targets.push({
|
196
|
+
name,
|
197
|
+
path: relPath,
|
198
|
+
type,
|
199
|
+
layer,
|
200
|
+
internal: true,
|
201
|
+
loc,
|
202
|
+
impactHint: buildImpactHint(relPath),
|
203
|
+
roiHint: buildRoiHint(relPath, content)
|
204
|
+
})
|
205
|
+
}
|
206
|
+
}
|
207
|
+
}
|
208
|
+
if (!targets.length) throw new Error('No targets (exported or internal) found. Check config thresholds and sources.')
|
209
|
+
return targets
|
210
|
+
}
|
211
|
+
|
212
|
+
async function main() {
|
213
|
+
const args = parseArgs(process.argv)
|
214
|
+
const cfg = loadJson('ut_scoring_config.json') || {}
|
215
|
+
|
216
|
+
// 从配置文件和命令行参数获取排除目录
|
217
|
+
const configExcludes = cfg?.targetGeneration?.excludeDirs || []
|
218
|
+
const cliExcludes = args.exclude ? args.exclude.split(',').map(s => s.trim()) : []
|
219
|
+
const allExcludes = [...configExcludes, ...cliExcludes]
|
220
|
+
|
221
|
+
if (allExcludes.length > 0) {
|
222
|
+
process.stdout.write(`Excluding directories: ${allExcludes.join(', ')}\n`)
|
223
|
+
}
|
224
|
+
|
225
|
+
const files = await listFiles(allExcludes)
|
226
|
+
const targets = await extractTargets(files)
|
227
|
+
|
228
|
+
const outPath = args.out || 'reports/targets.json'
|
229
|
+
writeFileSync(outPath, JSON.stringify(targets, null, 2))
|
230
|
+
process.stdout.write(`Generated ${targets.length} targets -> ${outPath}\n`)
|
231
|
+
}
|
232
|
+
|
233
|
+
main()
|