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.
@@ -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,11 @@
1
+ /**
2
+ * Core 模块:代码分析与评分引擎
3
+ *
4
+ * 提供核心的代码扫描、Git 历史分析和优先级评分功能
5
+ * 该模块不依赖 AI,可独立使用
6
+ */
7
+
8
+ export { main as scanCode } from './scanner.mjs'
9
+ export { main as analyzeGit } from './git-analyzer.mjs'
10
+ export { main as scoreTargets } from './scorer.mjs'
11
+
@@ -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()