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,633 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
4
|
+
|
5
|
+
async function req(mod, hint) { try { return await import(mod) } catch { throw new Error(`${mod} not installed. Run: npm i -D ${hint || mod}`) } }
|
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 loadJson(p) {
|
21
|
+
if (!p || !existsSync(p)) return null
|
22
|
+
try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null }
|
23
|
+
}
|
24
|
+
|
25
|
+
function toFixedDown(num, digits = 2) { const m = Math.pow(10, digits); return Math.floor(num * m) / m }
|
26
|
+
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)) }
|
27
|
+
|
28
|
+
function loadConfig(pathFromArg) { return loadJson(pathFromArg) || loadJson('ut_scoring_config.json') || {} }
|
29
|
+
function pickWeight(cfg, key, def) { return cfg?.weights?.[key] ?? def }
|
30
|
+
function pickThreshold(cfg, key, def) { return cfg?.thresholds?.[key] ?? def }
|
31
|
+
|
32
|
+
function isMainChain(path, cfg) { const arr = (cfg?.mainChainPaths || []).map(s => String(s).toLowerCase()); const lower = (path || '').toLowerCase(); return arr.some(s => lower.includes(s)) }
|
33
|
+
|
34
|
+
function mapBCByConfig({ name, path, impactHint }, cfg, overrides) {
|
35
|
+
const key = `${path}#${name}`
|
36
|
+
if (overrides?.BC?.[key] !== undefined) return overrides.BC[key]
|
37
|
+
const lower = `${name} ${path} ${impactHint || ''}`.toLowerCase()
|
38
|
+
let bc = 3
|
39
|
+
const kw = cfg?.bcKeywords || {}
|
40
|
+
const ordered = Object.keys(kw).map(Number).sort((a,b)=>b-a)
|
41
|
+
for (const score of ordered) { const list = kw[String(score)] || []; if (list.some(token => lower.includes(String(token).toLowerCase()))) { bc = score; break } }
|
42
|
+
if (!isMainChain(path, cfg)) { const cap = cfg?.bcCapForNonMainChain ?? 8; if (bc > cap) bc = cap }
|
43
|
+
return bc
|
44
|
+
}
|
45
|
+
|
46
|
+
function mapCCFromMetrics(metrics, cfg, eslintCognitive, target, overrides) {
|
47
|
+
const key = `${target.path}#${target.name}`
|
48
|
+
if (overrides?.CC?.[key] !== undefined) return overrides.CC[key]
|
49
|
+
if (!metrics) throw new Error('Function-level metrics missing')
|
50
|
+
|
51
|
+
const cyclo = metrics?.cyclomatic ?? 0
|
52
|
+
const fusion = cfg?.ccFusion || {}
|
53
|
+
let cc = 3
|
54
|
+
|
55
|
+
// P0-1: CC融合认知复杂度(默认策略)
|
56
|
+
const cognitive = eslintCognitive?.[key]
|
57
|
+
if (cognitive !== undefined) {
|
58
|
+
// 融合 cyclomatic + cognitive
|
59
|
+
const wC = fusion.wC ?? 0.7
|
60
|
+
const wK = fusion.wK ?? 0.3
|
61
|
+
const fused = wC * cyclo + wK * cognitive
|
62
|
+
cc = clamp(Math.floor(fused / 5) + 3, 2, fusion.cap ?? 10)
|
63
|
+
} else {
|
64
|
+
// 该函数未被 ESLint 分析(可能太简单),仅用 cyclomatic
|
65
|
+
let base = 3
|
66
|
+
const ranges = cfg?.ccMapping?.cyclomatic || []
|
67
|
+
for (const r of ranges) {
|
68
|
+
const gte = r.gte ?? -Infinity, lte = r.lte ?? Infinity, gt = r.gt, eq = r.eq
|
69
|
+
let hit = false
|
70
|
+
if (typeof eq === 'number') hit = cyclo === eq
|
71
|
+
else if (typeof gt === 'number') hit = cyclo > gt
|
72
|
+
else hit = cyclo >= gte && cyclo <= lte
|
73
|
+
if (hit) { base = r.score; break }
|
74
|
+
}
|
75
|
+
let adj = 0
|
76
|
+
for (const r of (cfg?.ccMapping?.adjustments || [])) {
|
77
|
+
const val = metrics?.[r.field]
|
78
|
+
if (val === undefined || val === null) continue
|
79
|
+
const op = r.op || '>='
|
80
|
+
if ((op === '>=') && val >= r.value) adj += r.delta || 1
|
81
|
+
else if ((op === '>') && val > r.value) adj += r.delta || 1
|
82
|
+
else if ((op === '==') && val === r.value) adj += r.delta || 1
|
83
|
+
}
|
84
|
+
cc = clamp(base + Math.min(adj, 3), 2, cfg?.ccMapping?.cap ?? 10)
|
85
|
+
}
|
86
|
+
|
87
|
+
// P1-2: 修复内部函数 LOC 加成读取配置路径
|
88
|
+
const internal = target.internal === true
|
89
|
+
const loc = target.loc || 0
|
90
|
+
const ccAdjust = cfg?.ccAdjust || {}
|
91
|
+
const locBonusThreshold = ccAdjust.locBonusThreshold ?? 50
|
92
|
+
const locBonus = ccAdjust.locBonus ?? 1
|
93
|
+
if (internal && loc >= locBonusThreshold) {
|
94
|
+
cc = clamp(cc + locBonus, 2, cfg?.ccMapping?.cap ?? 10)
|
95
|
+
}
|
96
|
+
|
97
|
+
return cc
|
98
|
+
}
|
99
|
+
|
100
|
+
function mapLikelihoodFromGitByConfig(git, depGraphData, cfg) {
|
101
|
+
const rules = cfg?.likelihoodRules || []
|
102
|
+
const c30 = git?.commits30d ?? 0, c90 = git?.commits90d ?? 0, c180 = git?.commits180d ?? 0
|
103
|
+
let score = cfg?.fallbacks?.ERLikelihood ?? 3
|
104
|
+
for (const r of rules) {
|
105
|
+
if (r.field === 'commits30d') { if (r.op === '>=') { if (c30 >= r.value) { score = r.score; break } } if (r.op === 'between') { if (c30 >= r.min && c30 <= r.max) { score = r.score; break } } }
|
106
|
+
if (r.field === 'fallback90d' && r.op === 'gt') { if (c30 === 0 && c90 > r.value) { score = r.score; break } }
|
107
|
+
if (r.field === 'fallback180dZero' && r.op === 'eq') { if (c90 === 0 && c180 === 0 && r.value === true) { score = r.score; break } }
|
108
|
+
}
|
109
|
+
|
110
|
+
// P0-2: depGraph 提升(基于真实依赖图)
|
111
|
+
const depCfg = cfg?.depGraph || {}
|
112
|
+
if (depCfg.enable && depGraphData) {
|
113
|
+
const { crossModuleScore, fanOut, fanIn } = depGraphData
|
114
|
+
if (crossModuleScore >= (depCfg.neighborCategoryBoost ?? 2)) score = clamp(score + 1, 1, cfg?.boostRules?.cap ?? 5)
|
115
|
+
if ((fanOut + fanIn) >= (depCfg.degreeBoost ?? 8)) score = clamp(score + 1, 1, cfg?.boostRules?.cap ?? 5)
|
116
|
+
}
|
117
|
+
|
118
|
+
// Git 辅助信号提升
|
119
|
+
const boostCfg = cfg?.boostRules || {}
|
120
|
+
const boosted = ((git?.authors30d ?? 0) >= (boostCfg.authors30dGte ?? 999)) || git?.inCategory || git?.multiPlatform
|
121
|
+
if (boosted) score = clamp(score + 1, 1, boostCfg.cap ?? 5)
|
122
|
+
return score
|
123
|
+
}
|
124
|
+
|
125
|
+
function mapImpactFromHintByConfig(hint, cfg, localMap) {
|
126
|
+
if (localMap?.[hint] !== undefined) return localMap[hint]
|
127
|
+
const lower = (hint || '').toLowerCase()
|
128
|
+
const impactKw = cfg?.impactKeywords || {}
|
129
|
+
const ordered = Object.keys(impactKw).map(Number).sort((a,b)=>b-a)
|
130
|
+
for (const score of ordered) { const list = impactKw[String(score)] || []; if (list.some(token => lower.includes(String(token).toLowerCase()))) return score }
|
131
|
+
return 3
|
132
|
+
}
|
133
|
+
|
134
|
+
function mapERFromGitAndImpactConfig(git, impactHint, depGraphData, cfg, overrides, localImpact, target) {
|
135
|
+
const key = `${target.path}#${target.name}`
|
136
|
+
if (overrides?.ER?.[key] !== undefined) return overrides.ER[key]
|
137
|
+
const likelihood = mapLikelihoodFromGitByConfig(git, depGraphData, cfg)
|
138
|
+
const impact = mapImpactFromHintByConfig(impactHint, cfg, localImpact)
|
139
|
+
const matrix = cfg?.erMatrix || {}
|
140
|
+
return matrix?.[likelihood]?.[impact] ?? 6
|
141
|
+
}
|
142
|
+
|
143
|
+
function mapROIByConfig(hint, cfg, localMap, overrides, target) {
|
144
|
+
const key = `${target.path}#${target.name}`
|
145
|
+
if (overrides?.ROI?.[key] !== undefined) return overrides.ROI[key]
|
146
|
+
if (localMap?.[hint] !== undefined) return localMap[hint]
|
147
|
+
const r = cfg?.roiRules || {}
|
148
|
+
if (hint?.isPure) return r.pure ?? 10
|
149
|
+
if (hint?.dependenciesInjectable) return r.injectable ?? 9
|
150
|
+
if (hint?.multiPlatformStrong) return r.nativeOrNetwork ?? 5
|
151
|
+
if (hint?.needsUI) return r.needsUI ?? 3
|
152
|
+
return r.multiContext ?? 7
|
153
|
+
}
|
154
|
+
|
155
|
+
// 映射 testability(本质上就是 ROI 的重命名)
|
156
|
+
function mapTestabilityByConfig(hint, cfg, localMap, overrides, target) {
|
157
|
+
const key = `${target.path}#${target.name}`
|
158
|
+
if (overrides?.Testability?.[key] !== undefined) return overrides.Testability[key]
|
159
|
+
if (localMap?.[hint] !== undefined) return localMap[hint]
|
160
|
+
const r = cfg?.testabilityRules || cfg?.roiRules || {}
|
161
|
+
if (hint?.isPure) return r.pure ?? 10
|
162
|
+
if (hint?.dependenciesInjectable) return r.injectable ?? 9
|
163
|
+
if (hint?.multiPlatformStrong) return r.nativeOrNetwork ?? 5
|
164
|
+
if (hint?.needsUI) return r.needsUI ?? 3
|
165
|
+
return r.multiContext ?? 7
|
166
|
+
}
|
167
|
+
|
168
|
+
// 计算依赖计数分数(被多少个模块引用)
|
169
|
+
function mapDependencyCount(depGraphData, cfg) {
|
170
|
+
if (!depGraphData) return 2 // 默认分数
|
171
|
+
|
172
|
+
const fanIn = depGraphData.fanIn || 0
|
173
|
+
const mapping = cfg?.dependencyCountMapping || [
|
174
|
+
{ "gte": 10, "score": 10 },
|
175
|
+
{ "gte": 5, "lt": 10, "score": 8 },
|
176
|
+
{ "gte": 3, "lt": 5, "score": 6 },
|
177
|
+
{ "gte": 1, "lt": 3, "score": 4 },
|
178
|
+
{ "eq": 0, "score": 2 }
|
179
|
+
]
|
180
|
+
|
181
|
+
for (const rule of mapping) {
|
182
|
+
if (rule.eq !== undefined && fanIn === rule.eq) return rule.score
|
183
|
+
if (rule.gte !== undefined && rule.lt !== undefined) {
|
184
|
+
if (fanIn >= rule.gte && fanIn < rule.lt) return rule.score
|
185
|
+
}
|
186
|
+
if (rule.gte !== undefined && rule.lt === undefined) {
|
187
|
+
if (fanIn >= rule.gte) return rule.score
|
188
|
+
}
|
189
|
+
}
|
190
|
+
|
191
|
+
return 2
|
192
|
+
}
|
193
|
+
// 传统评分(兼容旧配置)
|
194
|
+
function computeScoreLegacy({ BC, CC, ER, ROI }, cfg) {
|
195
|
+
const s = BC * pickWeight(cfg,'BC',0.4) + CC * pickWeight(cfg,'CC',0.3) + ER * pickWeight(cfg,'ER',0.2) + ROI * pickWeight(cfg,'ROI',0.1)
|
196
|
+
const score = toFixedDown(s, (cfg?.round?.digits ?? 2))
|
197
|
+
const P0 = pickThreshold(cfg,'P0',8.5), P1 = pickThreshold(cfg,'P1',6.5), P2 = pickThreshold(cfg,'P2',4.5)
|
198
|
+
let priority = 'P3'
|
199
|
+
if (score >= P0) priority = 'P0'
|
200
|
+
else if (score >= P1) priority = 'P1'
|
201
|
+
else if (score >= P2) priority = 'P2'
|
202
|
+
return { score, priority, layer: 'N/A' }
|
203
|
+
}
|
204
|
+
|
205
|
+
// 分层评分(新方法)
|
206
|
+
function computeScoreLayered({ BC, CC, ER, testability, dependencyCount }, target, cfg) {
|
207
|
+
const layer = target.layer || 'unknown'
|
208
|
+
const layerDef = cfg?.layers?.[layer]
|
209
|
+
|
210
|
+
if (!layerDef) {
|
211
|
+
// 如果没有层级定义,回退到传统评分
|
212
|
+
return computeScoreLegacy({ BC, CC, ER, ROI: testability }, cfg)
|
213
|
+
}
|
214
|
+
|
215
|
+
const weights = layerDef.weights || {}
|
216
|
+
let score = 0
|
217
|
+
|
218
|
+
// 根据层级定义的权重计算分数
|
219
|
+
if (weights.businessCriticality !== undefined) score += BC * weights.businessCriticality
|
220
|
+
if (weights.complexity !== undefined) score += CC * weights.complexity
|
221
|
+
if (weights.errorRisk !== undefined) score += ER * weights.errorRisk
|
222
|
+
if (weights.testability !== undefined) score += testability * weights.testability
|
223
|
+
if (weights.dependencyCount !== undefined) score += dependencyCount * weights.dependencyCount
|
224
|
+
|
225
|
+
score = toFixedDown(score, cfg?.round?.digits ?? 2)
|
226
|
+
|
227
|
+
// 使用层级特定的阈值
|
228
|
+
const thresholds = layerDef.thresholds || { P0: 8.0, P1: 6.5, P2: 4.5 }
|
229
|
+
let priority = 'P3'
|
230
|
+
if (score >= thresholds.P0) priority = 'P0'
|
231
|
+
else if (score >= thresholds.P1) priority = 'P1'
|
232
|
+
else if (score >= thresholds.P2) priority = 'P2'
|
233
|
+
|
234
|
+
return { score, priority, layer, layerName: layerDef.name }
|
235
|
+
}
|
236
|
+
|
237
|
+
// 统一评分入口
|
238
|
+
function computeScore(metrics, target, cfg) {
|
239
|
+
const mode = cfg?.scoringMode || 'legacy'
|
240
|
+
|
241
|
+
if (mode === 'layered') {
|
242
|
+
return computeScoreLayered(metrics, target, cfg)
|
243
|
+
} else {
|
244
|
+
return computeScoreLegacy(metrics, cfg)
|
245
|
+
}
|
246
|
+
}
|
247
|
+
|
248
|
+
function defaultMd(rows, statusMap = new Map()) {
|
249
|
+
// 按总分降序排序(高分在前)
|
250
|
+
const sorted = [...rows].sort((a, b) => b.score - a.score)
|
251
|
+
|
252
|
+
let md = '<!-- UT Priority Scoring Report -->\n'
|
253
|
+
md += '<!-- Format: Status can be "TODO" | "DONE" | "SKIP" -->\n'
|
254
|
+
md += '<!-- You can mark status by replacing TODO with DONE or SKIP -->\n\n'
|
255
|
+
md += '| Status | Score | Priority | Name | Type | Layer | Path | BC | CC | ER | Testability | DepCount | Notes |\n'
|
256
|
+
md += '|--------|-------|----------|------|------|-------|------|----|----|----|-----------|---------| ------- |\n'
|
257
|
+
|
258
|
+
sorted.forEach(r => {
|
259
|
+
// 从状态映射中查找现有状态,否则默认为 TODO
|
260
|
+
const key = `${r.path}#${r.name}`
|
261
|
+
const status = statusMap.get(key) || 'TODO'
|
262
|
+
md += `| ${status} | ${r.score} | ${r.priority} | ${r.name} | ${r.type} | ${r.layerName || r.layer} | ${r.path} | ${r.BC} | ${r.CC} | ${r.ER} | ${r.testability || r.ROI} | ${r.dependencyCount || 'N/A'} | ${r.notes} |\n`
|
263
|
+
})
|
264
|
+
|
265
|
+
// 添加统计信息
|
266
|
+
md += '\n---\n\n'
|
267
|
+
md += '## 📊 Summary\n\n'
|
268
|
+
const p0 = sorted.filter(r => r.priority === 'P0').length
|
269
|
+
const p1 = sorted.filter(r => r.priority === 'P1').length
|
270
|
+
const p2 = sorted.filter(r => r.priority === 'P2').length
|
271
|
+
const p3 = sorted.filter(r => r.priority === 'P3').length
|
272
|
+
md += `- **Total Targets**: ${sorted.length}\n`
|
273
|
+
md += `- **P0 (Must Test)**: ${p0}\n`
|
274
|
+
md += `- **P1 (High Priority)**: ${p1}\n`
|
275
|
+
md += `- **P2 (Medium Priority)**: ${p2}\n`
|
276
|
+
md += `- **P3 (Low Priority)**: ${p3}\n\n`
|
277
|
+
md += '## 🎯 Quick Commands\n\n'
|
278
|
+
md += '```bash\n'
|
279
|
+
md += '# View P0 targets only\n'
|
280
|
+
md += 'grep "| TODO.*P0 |" reports/ut_scores.md\n\n'
|
281
|
+
md += '# Mark a target as DONE (example)\n'
|
282
|
+
md += 'sed -i "" "s/| TODO | 9.3 | P0 | findRecommendRoom/| DONE | 9.3 | P0 | findRecommendRoom/" reports/ut_scores.md\n\n'
|
283
|
+
md += '# Count remaining TODO items\n'
|
284
|
+
md += 'grep -c "| TODO |" reports/ut_scores.md\n'
|
285
|
+
md += '```\n'
|
286
|
+
|
287
|
+
return md
|
288
|
+
}
|
289
|
+
|
290
|
+
function defaultCsv(rows) {
|
291
|
+
// 按总分降序排序(高分在前)
|
292
|
+
const sorted = [...rows].sort((a, b) => b.score - a.score)
|
293
|
+
|
294
|
+
const head = ['status','score','priority','name','path','type','layer','layerName','BC','CC','ER','testability','dependencyCount','notes'].join(',')
|
295
|
+
const body = sorted.map(r => [
|
296
|
+
'TODO',
|
297
|
+
r.score,
|
298
|
+
r.priority,
|
299
|
+
r.name,
|
300
|
+
r.path,
|
301
|
+
r.type,
|
302
|
+
r.layer || 'N/A',
|
303
|
+
r.layerName || 'N/A',
|
304
|
+
r.BC,
|
305
|
+
r.CC,
|
306
|
+
r.ER,
|
307
|
+
r.testability || r.ROI,
|
308
|
+
r.dependencyCount || 'N/A',
|
309
|
+
`"${r.notes}"`
|
310
|
+
].join(',')).join('\n')
|
311
|
+
return head + '\n' + body + '\n'
|
312
|
+
}
|
313
|
+
|
314
|
+
async function buildFuncMetricsProvider(targets) {
|
315
|
+
const tsMorph = await req('ts-morph','ts-morph')
|
316
|
+
const escomplex = await req('escomplex','escomplex')
|
317
|
+
const { Project, SyntaxKind } = tsMorph
|
318
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true })
|
319
|
+
const byPath = new Map(); targets.forEach(t => { if (!byPath.has(t.path)) byPath.set(t.path, true) }); Array.from(byPath.keys()).forEach(p => project.addSourceFileAtPathIfExists(p))
|
320
|
+
const byFunc = {}
|
321
|
+
const skipped = []
|
322
|
+
for (const t of targets) {
|
323
|
+
const sf = project.getSourceFile(t.path); if (!sf) { process.stderr.write(`⚠️ Source not found: ${t.path}\n`); continue }
|
324
|
+
let node = sf.getFunction(t.name)
|
325
|
+
if (!node) { const v = sf.getVariableDeclaration(t.name); const init = v?.getInitializer(); if (init && (init.getKind() === SyntaxKind.FunctionExpression || init.getKind() === SyntaxKind.ArrowFunction)) node = init }
|
326
|
+
if (!node) { skipped.push(`${t.name} in ${t.path}`); continue }
|
327
|
+
const text = node.getText(); let cyclomatic = 0
|
328
|
+
try { const res = escomplex.analyzeModule ? escomplex.analyzeModule(text) : null; cyclomatic = res?.aggregate?.cyclomatic ?? 0 } catch { skipped.push(`${t.name} (escomplex failed)`); continue }
|
329
|
+
byFunc[`${t.path}#${t.name}`] = { cyclomatic }
|
330
|
+
}
|
331
|
+
if (skipped.length > 0) process.stdout.write(`⚠️ Skipped ${skipped.length} targets (not functions or parse failed)\n`)
|
332
|
+
return { project, byFunc }
|
333
|
+
}
|
334
|
+
|
335
|
+
function pickMetricsForTarget(provider, target) { const key = `${target.path}#${target.name}`; const func = provider?.byFunc?.[key]; if (!func) return { cyclomatic: 1 }; return func }
|
336
|
+
|
337
|
+
// P0-2: 构建文件依赖图
|
338
|
+
function buildDepGraph(project, cfg) {
|
339
|
+
const categories = cfg?.crossModuleCategories || []
|
340
|
+
const graph = new Map()
|
341
|
+
|
342
|
+
function topCategory(path) {
|
343
|
+
const parts = path.replace(/\\/g, '/').split('/')
|
344
|
+
const idx = parts.indexOf('src')
|
345
|
+
return idx >= 0 && idx + 1 < parts.length ? parts[idx + 1] : parts[0]
|
346
|
+
}
|
347
|
+
|
348
|
+
for (const sf of project.getSourceFiles()) {
|
349
|
+
const path = sf.getFilePath().replace(process.cwd() + '/', '')
|
350
|
+
const deps = new Set()
|
351
|
+
const cat = topCategory(path)
|
352
|
+
|
353
|
+
for (const imp of sf.getImportDeclarations()) {
|
354
|
+
const modSpec = imp.getModuleSpecifierValue()
|
355
|
+
if (!modSpec.startsWith('.')) continue
|
356
|
+
const impSf = imp.getModuleSpecifierSourceFile()
|
357
|
+
if (!impSf) continue
|
358
|
+
const resolved = sf.getDirectory().getRelativePathTo(impSf)
|
359
|
+
if (resolved) deps.add(resolved)
|
360
|
+
}
|
361
|
+
|
362
|
+
const depCats = new Set()
|
363
|
+
deps.forEach(d => {
|
364
|
+
const dCat = topCategory(d)
|
365
|
+
if (dCat !== cat && categories.includes(dCat)) depCats.add(dCat)
|
366
|
+
})
|
367
|
+
|
368
|
+
graph.set(path, {
|
369
|
+
category: cat,
|
370
|
+
deps: Array.from(deps),
|
371
|
+
crossModuleScore: depCats.size,
|
372
|
+
fanOut: deps.size,
|
373
|
+
fanIn: 0
|
374
|
+
})
|
375
|
+
}
|
376
|
+
|
377
|
+
// 计算 fanIn
|
378
|
+
for (const [path, data] of graph.entries()) {
|
379
|
+
data.deps.forEach(dep => {
|
380
|
+
if (graph.has(dep)) graph.get(dep).fanIn++
|
381
|
+
})
|
382
|
+
}
|
383
|
+
|
384
|
+
return graph
|
385
|
+
}
|
386
|
+
|
387
|
+
// P0-1: 加载 ESLint cognitive complexity
|
388
|
+
function loadESLintCognitive(eslintJsonPath) {
|
389
|
+
const eslint = loadJson(eslintJsonPath)
|
390
|
+
if (!eslint || !Array.isArray(eslint)) return null
|
391
|
+
const cognitive = {}
|
392
|
+
|
393
|
+
for (const file of eslint) {
|
394
|
+
const filePath = file.filePath?.replace(process.cwd() + '/', '')
|
395
|
+
if (!file.messages || !filePath) continue
|
396
|
+
|
397
|
+
// 读取源文件以匹配函数名
|
398
|
+
let sourceLines = null
|
399
|
+
try {
|
400
|
+
sourceLines = readFileSync(file.filePath, 'utf8').split('\n')
|
401
|
+
} catch {
|
402
|
+
continue
|
403
|
+
}
|
404
|
+
|
405
|
+
for (const msg of file.messages) {
|
406
|
+
if (msg.ruleId === 'sonarjs/cognitive-complexity' && msg.message) {
|
407
|
+
const complexityMatch = msg.message.match(/from\s+(\d+)\s+to/)
|
408
|
+
if (!complexityMatch) continue
|
409
|
+
const complexity = Number(complexityMatch[1])
|
410
|
+
|
411
|
+
// 从消息行号附近查找函数名
|
412
|
+
const line = msg.line - 1 // 0-based
|
413
|
+
const contextLines = sourceLines.slice(Math.max(0, line - 2), line + 3).join('\n')
|
414
|
+
|
415
|
+
// 匹配各种函数定义
|
416
|
+
const patterns = [
|
417
|
+
/export\s+(?:const|function)\s+(\w+)/, // export const/function name
|
418
|
+
/const\s+(\w+)\s*=\s*\(/, // const name = (
|
419
|
+
/function\s+(\w+)\s*\(/, // function name(
|
420
|
+
/(\w+)\s*:\s*\([^)]*\)\s*=>/, // name: () =>
|
421
|
+
/export\s+default\s+function\s+(\w+)/ // export default function name
|
422
|
+
]
|
423
|
+
|
424
|
+
let fnName = null
|
425
|
+
for (const pattern of patterns) {
|
426
|
+
const match = contextLines.match(pattern)
|
427
|
+
if (match) {
|
428
|
+
fnName = match[1]
|
429
|
+
break
|
430
|
+
}
|
431
|
+
}
|
432
|
+
|
433
|
+
if (fnName) {
|
434
|
+
cognitive[`${filePath}#${fnName}`] = complexity
|
435
|
+
}
|
436
|
+
}
|
437
|
+
}
|
438
|
+
}
|
439
|
+
return Object.keys(cognitive).length ? cognitive : null
|
440
|
+
}
|
441
|
+
|
442
|
+
async function main() {
|
443
|
+
const args = parseArgs(process.argv)
|
444
|
+
const gitPath = args.git
|
445
|
+
const targetsPath = args.targets
|
446
|
+
const outMd = args['out-md']
|
447
|
+
const outCsv = args['out-csv']
|
448
|
+
const configPath = args['config']
|
449
|
+
const coverageJsonPath = args['coverage'] || 'coverage/coverage-summary.json'
|
450
|
+
|
451
|
+
// P2-1: 基本输入校验
|
452
|
+
const cfg = loadConfig(configPath)
|
453
|
+
if (!cfg || typeof cfg !== 'object') throw new Error('Invalid config file')
|
454
|
+
|
455
|
+
// 根据评分模式验证配置
|
456
|
+
const mode = cfg.scoringMode || 'legacy'
|
457
|
+
if (mode === 'layered') {
|
458
|
+
if (!cfg.layers || typeof cfg.layers !== 'object') throw new Error('Layered mode requires cfg.layers')
|
459
|
+
} else {
|
460
|
+
if (!cfg.weights || !cfg.thresholds) throw new Error('Legacy mode requires cfg.weights and cfg.thresholds')
|
461
|
+
}
|
462
|
+
|
463
|
+
// Git signals 可选(--skip-git 时为空对象)
|
464
|
+
const gitSignals = loadJson(gitPath) || {}
|
465
|
+
|
466
|
+
const targets = loadJson(targetsPath)
|
467
|
+
if (!Array.isArray(targets) || targets.length === 0) throw new Error('Missing or empty targets array')
|
468
|
+
|
469
|
+
// P0-3: 加载项目内校准数据
|
470
|
+
const hintMaps = cfg?.hintMaps || {}
|
471
|
+
const localImpact = loadJson(hintMaps.impactLocal)
|
472
|
+
const localROI = loadJson(hintMaps.roiLocal)
|
473
|
+
const overrides = loadJson(cfg?.overrides)
|
474
|
+
|
475
|
+
// P0-1: 加载 ESLint cognitive
|
476
|
+
const eslintCognitive = loadESLintCognitive('reports/eslint.json') || null
|
477
|
+
if (!eslintCognitive) {
|
478
|
+
process.stdout.write('⚠️ ESLint cognitive complexity not available, using cyclomatic only\n')
|
479
|
+
}
|
480
|
+
|
481
|
+
const funcProvider = await buildFuncMetricsProvider(targets)
|
482
|
+
|
483
|
+
// P0-2: 构建依赖图
|
484
|
+
const depGraph = cfg?.depGraph?.enable ? buildDepGraph(funcProvider.project, cfg) : null
|
485
|
+
|
486
|
+
// 可选覆盖率引导:读取 coverage-summary.json
|
487
|
+
let coverageSummary = null
|
488
|
+
if (existsSync(coverageJsonPath)) {
|
489
|
+
try { coverageSummary = JSON.parse(readFileSync(coverageJsonPath, 'utf8')) } catch {}
|
490
|
+
}
|
491
|
+
|
492
|
+
const rows = []
|
493
|
+
for (const t of targets) {
|
494
|
+
const { name, path, type = 'function', impactHint = '', roiHint = {}, internal = false, loc = 0 } = t
|
495
|
+
const metrics = pickMetricsForTarget(funcProvider, t)
|
496
|
+
const git = gitSignals[path] || null
|
497
|
+
const depGraphData = depGraph?.get(path) || null
|
498
|
+
|
499
|
+
const BC = mapBCByConfig({ name, path, impactHint }, cfg, overrides)
|
500
|
+
const CC = mapCCFromMetrics(metrics, cfg, eslintCognitive, t, overrides)
|
501
|
+
|
502
|
+
// P0-4: 先计算 likelihood,再应用平台调整
|
503
|
+
let likelihood = 0
|
504
|
+
if (git) {
|
505
|
+
const rules = cfg?.likelihoodRules || []
|
506
|
+
const c30 = git?.commits30d ?? 0, c90 = git?.commits90d ?? 0, c180 = git?.commits180d ?? 0
|
507
|
+
likelihood = cfg?.fallbacks?.ERLikelihood ?? 3
|
508
|
+
for (const r of rules) {
|
509
|
+
if (r.field === 'commits30d') { if (r.op === '>=') { if (c30 >= r.value) { likelihood = r.score; break } } if (r.op === 'between') { if (c30 >= r.min && c30 <= r.max) { likelihood = r.score; break } } }
|
510
|
+
if (r.field === 'fallback90d' && r.op === 'gt') { if (c30 === 0 && c90 > r.value) { likelihood = r.score; break } }
|
511
|
+
if (r.field === 'fallback180dZero' && r.op === 'eq') { if (c90 === 0 && c180 === 0 && r.value === true) { likelihood = r.score; break } }
|
512
|
+
}
|
513
|
+
}
|
514
|
+
|
515
|
+
// 应用平台调整(仅当 likelihood < 4 时)
|
516
|
+
const plat = cfg?.ccMapping?.platformAdjust
|
517
|
+
let CCFinal = CC
|
518
|
+
if (git?.multiPlatform && likelihood < (plat?.skipIfLikelihoodGte ?? 4)) {
|
519
|
+
CCFinal = clamp(CC + (plat?.delta ?? 0), 2, plat?.cap ?? 10)
|
520
|
+
}
|
521
|
+
|
522
|
+
const ER = mapERFromGitAndImpactConfig(git, impactHint, depGraphData, cfg, overrides, localImpact, t)
|
523
|
+
const ROI = mapROIByConfig(roiHint, cfg, localROI, overrides, t)
|
524
|
+
const testability = mapTestabilityByConfig(roiHint, cfg, localROI, overrides, t)
|
525
|
+
const dependencyCount = mapDependencyCount(depGraphData, cfg)
|
526
|
+
|
527
|
+
let { score, priority, layer, layerName } = computeScore({ BC, CC: CCFinal, ER, ROI, testability, dependencyCount }, t, cfg)
|
528
|
+
|
529
|
+
// 覆盖率加权(可选):对低覆盖模块提升优先级或分数
|
530
|
+
if (coverageSummary) {
|
531
|
+
const fileKey = Object.keys(coverageSummary).find(k => k.endsWith(path))
|
532
|
+
const fileCov = fileKey ? coverageSummary[fileKey] : null
|
533
|
+
const linesPct = fileCov?.lines?.pct
|
534
|
+
const cfgBoost = cfg?.coverageBoost || { enable: false }
|
535
|
+
if (cfgBoost.enable && typeof linesPct === 'number') {
|
536
|
+
const threshold = cfgBoost.threshold ?? 60
|
537
|
+
const maxBoost = cfgBoost.maxBoost ?? 0.5
|
538
|
+
if (linesPct < threshold) {
|
539
|
+
const ratio = (threshold - linesPct) / Math.max(threshold, 1)
|
540
|
+
const delta = toFixedDown(Math.min(maxBoost, ratio * (cfgBoost.scale ?? 0.5)), 2)
|
541
|
+
score = toFixedDown(score + delta, cfg?.round?.digits ?? 2)
|
542
|
+
// 重新判定优先级
|
543
|
+
const layerDef = cfg?.layers?.[layer]
|
544
|
+
const thresholds = (layerDef?.thresholds) || { P0: pickThreshold(cfg,'P0',8.5), P1: pickThreshold(cfg,'P1',6.5), P2: pickThreshold(cfg,'P2',4.5) }
|
545
|
+
if (score >= thresholds.P0) priority = 'P0'
|
546
|
+
else if (score >= thresholds.P1) priority = 'P1'
|
547
|
+
else if (score >= thresholds.P2) priority = 'P2'
|
548
|
+
}
|
549
|
+
}
|
550
|
+
}
|
551
|
+
|
552
|
+
// 构建 notes
|
553
|
+
const notesParts = []
|
554
|
+
const cognitive = eslintCognitive?.[`${path}#${name}`]
|
555
|
+
if (cognitive !== undefined) notesParts.push('CC:fused')
|
556
|
+
else notesParts.push('CC:cyclo-only')
|
557
|
+
if (internal && loc >= (cfg?.ccAdjust?.locBonusThreshold ?? 50)) notesParts.push(`CC+${cfg?.ccAdjust?.locBonus ?? 1}loc`)
|
558
|
+
if (CCFinal > CC) notesParts.push('CC+platform')
|
559
|
+
if (depGraphData && depGraphData.crossModuleScore > 0) notesParts.push(`ER+depGraph(${depGraphData.crossModuleScore})`)
|
560
|
+
if (!git) notesParts.push('ER:fallback')
|
561
|
+
else notesParts.push('ER:git')
|
562
|
+
if (overrides) {
|
563
|
+
const key = `${path}#${name}`
|
564
|
+
if (overrides.BC?.[key]) notesParts.push('BC:override')
|
565
|
+
if (overrides.CC?.[key]) notesParts.push('CC:override')
|
566
|
+
if (overrides.ER?.[key]) notesParts.push('ER:override')
|
567
|
+
if (overrides.ROI?.[key]) notesParts.push('ROI:override')
|
568
|
+
}
|
569
|
+
if (localImpact) notesParts.push('Impact:local')
|
570
|
+
if (localROI) notesParts.push('ROI:local')
|
571
|
+
notesParts.push(internal ? 'Internal:Y' : 'Internal:N')
|
572
|
+
if (!isMainChain(path, cfg)) notesParts.push(`BC:cap≤${cfg?.bcCapForNonMainChain ?? 8}`)
|
573
|
+
|
574
|
+
rows.push({
|
575
|
+
name,
|
576
|
+
path,
|
577
|
+
type,
|
578
|
+
layer: layer || 'N/A',
|
579
|
+
layerName: layerName || 'N/A',
|
580
|
+
BC,
|
581
|
+
CC: CCFinal,
|
582
|
+
ER,
|
583
|
+
ROI,
|
584
|
+
testability,
|
585
|
+
dependencyCount,
|
586
|
+
score,
|
587
|
+
priority,
|
588
|
+
notes: notesParts.join('; ')
|
589
|
+
})
|
590
|
+
}
|
591
|
+
|
592
|
+
// 保留旧状态(DONE/SKIP)
|
593
|
+
const statusMap = readExistingStatus(outMd)
|
594
|
+
|
595
|
+
if (outMd) writeFileSync(outMd, defaultMd(rows, statusMap))
|
596
|
+
if (outCsv) writeFileSync(outCsv, defaultCsv(rows))
|
597
|
+
}
|
598
|
+
|
599
|
+
/**
|
600
|
+
* 读取现有报告的状态信息
|
601
|
+
*/
|
602
|
+
function readExistingStatus(mdPath) {
|
603
|
+
const statusMap = new Map()
|
604
|
+
|
605
|
+
if (!mdPath || !existsSync(mdPath)) {
|
606
|
+
return statusMap
|
607
|
+
}
|
608
|
+
|
609
|
+
try {
|
610
|
+
const content = readFileSync(mdPath, 'utf-8')
|
611
|
+
const lines = content.split('\n')
|
612
|
+
|
613
|
+
for (const line of lines) {
|
614
|
+
// 匹配表格行: | Status | ... | Name | ... | Path |
|
615
|
+
if (line.includes('| DONE |') || line.includes('| SKIP |')) {
|
616
|
+
const parts = line.split('|').map(p => p.trim()).filter(Boolean)
|
617
|
+
if (parts.length >= 4) {
|
618
|
+
const status = parts[0] // DONE 或 SKIP
|
619
|
+
const name = parts[3] // Name 列
|
620
|
+
const path = parts[6] // Path 列
|
621
|
+
const key = `${path}#${name}`
|
622
|
+
statusMap.set(key, status)
|
623
|
+
}
|
624
|
+
}
|
625
|
+
}
|
626
|
+
} catch (err) {
|
627
|
+
// 忽略读取错误,返回空 map
|
628
|
+
}
|
629
|
+
|
630
|
+
return statusMap
|
631
|
+
}
|
632
|
+
|
633
|
+
main()
|
package/lib/index.js
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
/**
|
2
|
+
* @ctrip/ut-priority-scorer
|
3
|
+
*
|
4
|
+
* AI-friendly Unit Test priority scoring system
|
5
|
+
* Based on layered architecture and testability metrics
|
6
|
+
*/
|
7
|
+
|
8
|
+
export { default as genTargets } from './gen-targets.mjs'
|
9
|
+
export { default as genGitSignals } from './gen-git-signals.mjs'
|
10
|
+
export { default as score } from './score-ut.mjs'
|
11
|
+
|
12
|
+
// 版本信息
|
13
|
+
export const version = '1.0.0'
|
14
|
+
|
15
|
+
// 默认配置路径
|
16
|
+
export const DEFAULT_CONFIG_PATH = 'ut_scoring_config.json'
|
17
|
+
export const DEFAULT_OUTPUT_DIR = 'reports'
|
18
|
+
|
package/lib/index.mjs
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
/**
|
2
|
+
* ai-test-generator - AI-powered unit test generator with smart priority scoring
|
3
|
+
*
|
4
|
+
* @example
|
5
|
+
* ```javascript
|
6
|
+
* // 使用 Core 模块进行代码分析
|
7
|
+
* import { scanCode, scoreTargets } from 'ai-test-generator'
|
8
|
+
*
|
9
|
+
* // 使用 AI 模块生成测试
|
10
|
+
* import { buildPrompt, callAI, extractTests } from 'ai-test-generator/ai'
|
11
|
+
*
|
12
|
+
* // 使用 Workflows 模块进行批量生成
|
13
|
+
* import { runBatch, runAll } from 'ai-test-generator/workflows'
|
14
|
+
* ```
|
15
|
+
*/
|
16
|
+
|
17
|
+
// 导出核心模块(最常用)
|
18
|
+
export * from './core/index.mjs'
|
19
|
+
|
20
|
+
// 导出其他模块(通过子路径导入)
|
21
|
+
// import * from 'ai-test-generator/ai'
|
22
|
+
// import * from 'ai-test-generator/testing'
|
23
|
+
// import * from 'ai-test-generator/workflows'
|
24
|
+
// import * from 'ai-test-generator/utils'
|
25
|
+
|