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,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
+