ai-unit-test-generator 1.4.6 → 2.0.2

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.
@@ -140,6 +140,111 @@ async function extractTargets(files) {
140
140
  return false
141
141
  }
142
142
 
143
+ // ✅ 文件级缓存:避免重复扫描每个文件的导入
144
+ const fileImportsCache = new Map()
145
+
146
+ function getCachedFileImports(sf) {
147
+ const filePath = sf.getFilePath()
148
+ if (fileImportsCache.has(filePath)) {
149
+ return fileImportsCache.get(filePath)
150
+ }
151
+
152
+ const imports = sf.getImportDeclarations()
153
+ const criticalKeywords = ['stripe', 'payment', 'auth', 'axios', 'fetch', 'prisma', 'db', 'api', 'jotai', 'zustand']
154
+ const criticalImports = imports
155
+ .map(imp => imp.getModuleSpecifierValue())
156
+ .filter(mod => criticalKeywords.some(kw => mod.toLowerCase().includes(kw)))
157
+ .slice(0, 5) // 限制数量
158
+
159
+ fileImportsCache.set(filePath, criticalImports)
160
+ return criticalImports
161
+ }
162
+
163
+ // ✅ 新增:提取函数的 AI 分析元数据
164
+ function extractMetadata(node, sf) {
165
+ const metadata = {
166
+ criticalImports: [],
167
+ businessEntities: [],
168
+ hasDocumentation: false,
169
+ documentation: '',
170
+ errorHandling: 0,
171
+ externalCalls: 0,
172
+ paramCount: 0,
173
+ returnType: ''
174
+ }
175
+
176
+ if (!node) return metadata
177
+
178
+ try {
179
+ // 1. 提取关键导入(使用缓存)
180
+ metadata.criticalImports = getCachedFileImports(sf)
181
+
182
+ // 2. 提取参数类型(识别业务实体)
183
+ if (node.getParameters) {
184
+ const params = node.getParameters()
185
+ metadata.paramCount = params.length
186
+
187
+ // ✅ 从配置读取业务实体关键词(支持项目定制)
188
+ const entityKeywords = cfg?.aiEnhancement?.entityKeywords ||
189
+ ['Payment', 'Order', 'Booking', 'User', 'Hotel', 'Room', 'Cart', 'Price', 'Guest', 'Request', 'Response']
190
+
191
+ metadata.businessEntities = params
192
+ .map(p => {
193
+ try {
194
+ return p.getType().getText()
195
+ } catch {
196
+ return ''
197
+ }
198
+ })
199
+ .filter(type => entityKeywords.some(kw => type.includes(kw)))
200
+ .slice(0, 3) // 限制数量
201
+ }
202
+
203
+ // 3. 提取返回类型
204
+ if (node.getReturnType) {
205
+ try {
206
+ const returnType = node.getReturnType().getText()
207
+ // 简化类型(避免过长)
208
+ metadata.returnType = returnType.length > 100 ? returnType.slice(0, 100) + '...' : returnType
209
+ } catch {}
210
+ }
211
+
212
+ // 4. 提取 JSDoc 注释
213
+ if (node.getJsDocs) {
214
+ const jsDocs = node.getJsDocs()
215
+ if (jsDocs.length > 0) {
216
+ metadata.hasDocumentation = true
217
+ metadata.documentation = jsDocs
218
+ .map(doc => {
219
+ const comment = doc.getComment()
220
+ return typeof comment === 'string' ? comment : ''
221
+ })
222
+ .filter(Boolean)
223
+ .join(' ')
224
+ .slice(0, 200) // 限制长度
225
+ }
226
+ }
227
+
228
+ // 5. 统计异常处理(try-catch)
229
+ if (node.getDescendantsOfKind) {
230
+ metadata.errorHandling = node.getDescendantsOfKind(SyntaxKind.TryStatement).length
231
+ }
232
+
233
+ // 6. 统计外部 API 调用
234
+ if (node.getDescendantsOfKind) {
235
+ const callExpressions = node.getDescendantsOfKind(SyntaxKind.CallExpression)
236
+ metadata.externalCalls = callExpressions.filter(call => {
237
+ const expr = call.getExpression().getText()
238
+ return /fetch|axios|\.get\(|\.post\(|\.put\(|\.delete\(/.test(expr)
239
+ }).length
240
+ }
241
+ } catch (err) {
242
+ // 提取失败不影响主流程
243
+ }
244
+
245
+ return metadata
246
+ }
247
+
143
248
  for (const sf of project.getSourceFiles()) {
144
249
  const absPath = sf.getFilePath()
145
250
  const relPath = relativize(absPath)
@@ -154,6 +259,7 @@ async function extractTargets(files) {
154
259
  if (fn) {
155
260
  const type = decideTypeByPathAndName(relPath, name)
156
261
  const layer = decideLayer(relPath, cfg)
262
+ const metadata = extractMetadata(fn, sf) // ✅ 提取元数据
157
263
  targets.push({
158
264
  name,
159
265
  path: relPath,
@@ -162,7 +268,8 @@ async function extractTargets(files) {
162
268
  internal: false,
163
269
  loc: fileLoc,
164
270
  impactHint: buildImpactHint(relPath),
165
- roiHint: buildRoiHint(relPath, content)
271
+ roiHint: buildRoiHint(relPath, content),
272
+ metadata // ✅ 添加元数据
166
273
  })
167
274
  continue
168
275
  }
@@ -172,6 +279,8 @@ async function extractTargets(files) {
172
279
  if (v && isTestableVariable(v)) {
173
280
  const type = decideTypeByPathAndName(relPath, name)
174
281
  const layer = decideLayer(relPath, cfg)
282
+ const init = v.getInitializer()
283
+ const metadata = extractMetadata(init, sf) // ✅ 提取元数据
175
284
  targets.push({
176
285
  name,
177
286
  path: relPath,
@@ -180,7 +289,8 @@ async function extractTargets(files) {
180
289
  internal: false,
181
290
  loc: fileLoc,
182
291
  impactHint: buildImpactHint(relPath),
183
- roiHint: buildRoiHint(relPath, content)
292
+ roiHint: buildRoiHint(relPath, content),
293
+ metadata // ✅ 添加元数据
184
294
  })
185
295
  }
186
296
  // 其他(interface/type/const/enum)直接跳过
@@ -25,11 +25,136 @@ function loadJson(p) {
25
25
  function toFixedDown(num, digits = 2) { const m = Math.pow(10, digits); return Math.floor(num * m) / m }
26
26
  function clamp(n, min, max) { return Math.max(min, Math.min(max, n)) }
27
27
 
28
+ // 匹配 AI 建议的模式(支持 glob 语法)
29
+ function matchPattern(filePath, pattern) {
30
+ if (!pattern || !filePath) return false
31
+
32
+ // 移除开头的 src/ 如果存在(标准化路径)
33
+ const normalizedPath = filePath.replace(/^src\//, '')
34
+
35
+ // 简单 glob 匹配:支持 ** 和 *
36
+ const regexPattern = pattern
37
+ .replace(/\./g, '\\.') // . 转义
38
+ .replace(/\*\*/g, '__DSTAR__') // ** 临时占位
39
+ .replace(/\*/g, '[^/]*') // * 匹配非斜杠字符
40
+ .replace(/__DSTAR__/g, '.*') // ** 匹配任意字符
41
+
42
+ const regex = new RegExp(`^${regexPattern}$`)
43
+ return regex.test(normalizedPath) || regex.test(filePath)
44
+ }
45
+
28
46
  function stripJsonComments(s) {
29
47
  return String(s)
30
48
  .replace(/\/\*[\s\S]*?\*\//g, '')
31
49
  .replace(/(^|\s)\/\/.*$/gm, '')
32
50
  }
51
+
52
+ // 根据路径匹配层级(作为 fallback)
53
+ function matchLayerByPath(filePath, cfg) {
54
+ const layers = cfg?.layers || {}
55
+
56
+ // 按配置文件中定义的顺序遍历层级(foundation → business → state → ui)
57
+ const layerOrder = ['foundation', 'business', 'state', 'ui']
58
+
59
+ for (const layerKey of layerOrder) {
60
+ const layerDef = layers[layerKey]
61
+ if (!layerDef) continue
62
+
63
+ const patterns = layerDef.patterns || []
64
+ for (const pattern of patterns) {
65
+ // 简单的 glob 匹配:支持 ** 和 *
66
+ const regexPattern = pattern
67
+ .replace(/\./g, '\\.') // . 转义
68
+ .replace(/\*\*/g, '__DSTAR__') // ** 临时占位
69
+ .replace(/\*/g, '[^/]*') // * 匹配单层目录
70
+ .replace(/__DSTAR__/g, '.*') // ** 匹配任意层级目录
71
+ const regex = new RegExp(`^${regexPattern}$`)
72
+ if (regex.test(filePath)) {
73
+ return layerKey
74
+ }
75
+ }
76
+ }
77
+
78
+ // 如果都不匹配,尝试配置中的其他层级
79
+ for (const [layerKey, layerDef] of Object.entries(layers)) {
80
+ if (layerOrder.includes(layerKey)) continue // 已经检查过了
81
+ const patterns = layerDef.patterns || []
82
+ for (const pattern of patterns) {
83
+ const regexPattern = pattern
84
+ .replace(/\./g, '\\.')
85
+ .replace(/\*\*/g, '__DSTAR__')
86
+ .replace(/\*/g, '[^/]*')
87
+ .replace(/__DSTAR__/g, '.*')
88
+ const regex = new RegExp(`^${regexPattern}$`)
89
+ if (regex.test(filePath)) {
90
+ return layerKey
91
+ }
92
+ }
93
+ }
94
+
95
+ return 'unknown'
96
+ }
97
+
98
+ // 综合判断层级:基于代码特征(roiHint)+ 路径约定
99
+ function matchLayer(target, cfg) {
100
+ const { path, type, roiHint = {} } = target
101
+
102
+ // ===== 第一优先级:明确的状态管理路径约定 =====
103
+ // atoms/stores 是明确的架构约定,优先识别
104
+ if (path.match(/\/(atoms|stores)\//)) {
105
+ return 'state'
106
+ }
107
+
108
+ // ===== 第二优先级:基于代码特征(roiHint)=====
109
+
110
+ // UI 层:needsUI = true(代码中包含 JSX 或 React Hooks)
111
+ if (roiHint.needsUI === true) {
112
+ // 排除 context(虽然有 JSX 但本质是状态管理)
113
+ if (path.match(/\/context\//)) {
114
+ return 'state'
115
+ }
116
+ return 'ui'
117
+ }
118
+
119
+ // Foundation 层:纯函数 + 依赖可注入(真正的工具函数)
120
+ if (roiHint.isPure === true && roiHint.dependenciesInjectable === true) {
121
+ return 'foundation'
122
+ }
123
+
124
+ // Foundation 层:纯函数(即使依赖不可注入,如某些 utils)
125
+ // 但排除状态管理相关的纯函数(如 createStore)
126
+ if (roiHint.isPure === true && !path.match(/\/(atoms|stores|context)\//)) {
127
+ return 'foundation'
128
+ }
129
+
130
+ // Business 层:非纯函数 + 不需要 UI(业务逻辑、API 调用)
131
+ if (roiHint.isPure === false && roiHint.needsUI === false) {
132
+ // 排除状态管理
133
+ if (!path.match(/\/(atoms|stores|context)\//)) {
134
+ return 'business'
135
+ }
136
+ }
137
+
138
+ // ===== 第三优先级:基于类型 =====
139
+
140
+ // component 类型一定是 UI 层
141
+ if (type === 'component') {
142
+ return 'ui'
143
+ }
144
+
145
+ // hook 类型:根据位置判断
146
+ if (type === 'hook') {
147
+ // 在 components/pages 下的 hook 是 UI 层
148
+ if (path.match(/\/(components|pages)\//)) {
149
+ return 'ui'
150
+ }
151
+ // 独立的 hook 更可能是业务逻辑
152
+ return 'business'
153
+ }
154
+
155
+ // ===== 第四优先级:路径 fallback =====
156
+ return matchLayerByPath(path, cfg)
157
+ }
33
158
  function loadConfig(pathFromArg) {
34
159
  const paths = [pathFromArg, 'ai-test.config.jsonc', 'ai-test.config.json']
35
160
  for (const p of paths) {
@@ -577,7 +702,62 @@ async function main() {
577
702
  coverageScore = mapCoverageScore(linesPct, cfg)
578
703
  }
579
704
 
580
- let { score, priority, layer, layerName } = computeScore({ BC, CC: CCFinal, ER, ROI, testability, dependencyCount, coverageScore }, t, cfg)
705
+ // 根据代码特征和路径匹配层级(如果还没有匹配)
706
+ if (!t.layer || t.layer === 'unknown') {
707
+ t.layer = matchLayer(t, cfg) // 传入整个 target,而不是路径
708
+ }
709
+
710
+ // ✅ AI 增强:应用 AI 分析建议(如果启用且已分析)
711
+ let enhancedBC = BC
712
+ let enhancedER = ER
713
+ let enhancedTestability = testability
714
+
715
+ if (cfg?.aiEnhancement?.enabled && cfg?.aiEnhancement?.analyzed && cfg?.aiEnhancement?.suggestions) {
716
+ const suggestions = cfg.aiEnhancement.suggestions
717
+
718
+ // 匹配 businessCriticalPaths(提升 BC)
719
+ if (suggestions.businessCriticalPaths && Array.isArray(suggestions.businessCriticalPaths)) {
720
+ for (const item of suggestions.businessCriticalPaths) {
721
+ if (matchPattern(path, item.pattern)) {
722
+ enhancedBC = Math.max(enhancedBC, item.suggestedBC)
723
+ break // 只应用第一个匹配
724
+ }
725
+ }
726
+ }
727
+
728
+ // 匹配 highRiskModules(提升 ER)
729
+ if (suggestions.highRiskModules && Array.isArray(suggestions.highRiskModules)) {
730
+ for (const item of suggestions.highRiskModules) {
731
+ if (matchPattern(path, item.pattern)) {
732
+ enhancedER = Math.max(enhancedER, item.suggestedER)
733
+ break
734
+ }
735
+ }
736
+ }
737
+
738
+ // 匹配 testabilityAdjustments(调整 testability)
739
+ if (suggestions.testabilityAdjustments && Array.isArray(suggestions.testabilityAdjustments)) {
740
+ for (const item of suggestions.testabilityAdjustments) {
741
+ if (matchPattern(path, item.pattern)) {
742
+ const adj = parseInt(item.adjustment)
743
+ if (!isNaN(adj)) {
744
+ enhancedTestability = clamp(enhancedTestability + adj, 0, 10)
745
+ }
746
+ break
747
+ }
748
+ }
749
+ }
750
+ }
751
+
752
+ let { score, priority, layer, layerName } = computeScore({
753
+ BC: enhancedBC,
754
+ CC: CCFinal,
755
+ ER: enhancedER,
756
+ ROI,
757
+ testability: enhancedTestability,
758
+ dependencyCount,
759
+ coverageScore
760
+ }, t, cfg)
581
761
 
582
762
  // 覆盖率加权(可选):对低覆盖文件小幅加分
583
763
  if (coverageSummary) {
@@ -605,11 +785,11 @@ async function main() {
605
785
  type,
606
786
  layer: layer || 'N/A',
607
787
  layerName: layerName || 'N/A',
608
- BC,
788
+ BC: enhancedBC, // 使用 AI 增强后的值
609
789
  CC: CCFinal,
610
- ER,
790
+ ER: enhancedER, // 使用 AI 增强后的值
611
791
  ROI,
612
- testability,
792
+ testability: enhancedTestability, // 使用 AI 增强后的值
613
793
  dependencyCount,
614
794
  coveragePct: coveragePct ?? 'N/A',
615
795
  coverageScore: coverageScore ?? 'N/A',
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 配置文件管理工具
3
+ */
4
+
5
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname } from 'node:path'
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const __dirname = dirname(__filename)
12
+ const PKG_ROOT = join(__dirname, '../..')
13
+
14
+ /**
15
+ * 移除 JSON 注释
16
+ */
17
+ function stripJsonComments(str) {
18
+ return String(str)
19
+ .replace(/\/\*[\s\S]*?\*\//g, '') // 块注释
20
+ .replace(/(^|\s)\/\/.*$/gm, '') // 行注释
21
+ }
22
+
23
+ /**
24
+ * 自动探测配置文件
25
+ */
26
+ export function detectConfig(providedPath) {
27
+ const detectOrder = [
28
+ providedPath,
29
+ 'ai-test.config.jsonc',
30
+ 'ai-test.config.json',
31
+ 'ut_scoring_config.json' // 向后兼容
32
+ ].filter(Boolean)
33
+
34
+ return detectOrder.find(p => existsSync(p)) || null
35
+ }
36
+
37
+ /**
38
+ * 确保配置文件存在(不存在则创建)
39
+ */
40
+ export function ensureConfig(configPath = 'ai-test.config.jsonc') {
41
+ const detected = detectConfig(configPath)
42
+
43
+ if (detected) {
44
+ return detected
45
+ }
46
+
47
+ // 创建默认配置
48
+ console.log('⚙️ Config not found, creating default config...')
49
+ const templatePath = join(PKG_ROOT, 'templates', 'default.config.jsonc')
50
+ copyFileSync(templatePath, configPath)
51
+ console.log(`✅ Config created: ${configPath}\n`)
52
+
53
+ return configPath
54
+ }
55
+
56
+ /**
57
+ * 读取配置文件(支持 JSONC)
58
+ */
59
+ export function readConfig(configPath) {
60
+ if (!existsSync(configPath)) {
61
+ throw new Error(`Config file not found: ${configPath}`)
62
+ }
63
+
64
+ const content = readFileSync(configPath, 'utf-8')
65
+ const cleaned = stripJsonComments(content)
66
+
67
+ try {
68
+ return JSON.parse(cleaned)
69
+ } catch (err) {
70
+ throw new Error(`Failed to parse config file: ${configPath}\n${err.message}`)
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 写入配置文件(保留 JSONC 格式)
76
+ */
77
+ export function writeConfig(configPath, config) {
78
+ const content = JSON.stringify(config, null, 2)
79
+ writeFileSync(configPath, content, 'utf-8')
80
+ }
81
+
82
+ /**
83
+ * 检查配置是否已经 AI 分析过
84
+ */
85
+ export function isAnalyzed(config) {
86
+ return config?.aiEnhancement?.analyzed === true
87
+ }
88
+
89
+ /**
90
+ * 验证配置结构
91
+ */
92
+ export function validateConfig(config) {
93
+ const errors = []
94
+
95
+ // 检查必需字段
96
+ if (!config.scoringMode) {
97
+ errors.push('Missing required field: scoringMode')
98
+ }
99
+
100
+ if (config.scoringMode === 'layered' && !config.layers) {
101
+ errors.push('Layered mode requires "layers" field')
102
+ }
103
+
104
+ if (config.scoringMode === 'legacy' && (!config.weights || !config.thresholds)) {
105
+ errors.push('Legacy mode requires "weights" and "thresholds" fields')
106
+ }
107
+
108
+ return errors
109
+ }
110
+
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  export { markDone } from './marker.mjs'
8
+ export * from './config-manager.mjs'
9
+ export * from './scan-manager.mjs'
8
10
 
9
11
  // 可以在未来添加更多工具函数
10
12
  // export { readJson, writeJson } from './file-io.mjs'
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 扫描结果管理工具
3
+ */
4
+
5
+ import { existsSync, statSync, readdirSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ /**
9
+ * 检查源代码是否有变化
10
+ */
11
+ export function hasSourceCodeChanged(targetsPath, srcDir = 'src') {
12
+ if (!existsSync(targetsPath)) {
13
+ return true
14
+ }
15
+
16
+ const targetsTime = statSync(targetsPath).mtimeMs
17
+ return checkDirectoryRecursive(srcDir, targetsTime)
18
+ }
19
+
20
+ /**
21
+ * 递归检查目录中的文件修改时间
22
+ */
23
+ function checkDirectoryRecursive(dir, compareTime) {
24
+ if (!existsSync(dir)) {
25
+ return false
26
+ }
27
+
28
+ try {
29
+ const entries = readdirSync(dir, { withFileTypes: true })
30
+
31
+ for (const entry of entries) {
32
+ const fullPath = join(dir, entry.name)
33
+
34
+ if (entry.isDirectory()) {
35
+ // 跳过不需要检查的目录
36
+ if (['node_modules', 'dist', '.git', 'reports', 'coverage'].includes(entry.name)) {
37
+ continue
38
+ }
39
+
40
+ if (checkDirectoryRecursive(fullPath, compareTime)) {
41
+ return true
42
+ }
43
+ } else if (entry.isFile()) {
44
+ // 只检查代码文件
45
+ if (!/\.(ts|tsx|js|jsx)$/.test(entry.name)) {
46
+ continue
47
+ }
48
+
49
+ const fileTime = statSync(fullPath).mtimeMs
50
+ if (fileTime > compareTime) {
51
+ console.log(` Detected change: ${fullPath}`)
52
+ return true
53
+ }
54
+ }
55
+ }
56
+ } catch (err) {
57
+ // 忽略权限错误等
58
+ console.warn(` Warning: Could not access ${dir}`)
59
+ }
60
+
61
+ return false
62
+ }
63
+
64
+ /**
65
+ * 检查配置文件是否有变化
66
+ */
67
+ export function hasConfigChanged(configPath, scoresPath) {
68
+ if (!existsSync(configPath) || !existsSync(scoresPath)) {
69
+ return false
70
+ }
71
+
72
+ return statSync(configPath).mtimeMs > statSync(scoresPath).mtimeMs
73
+ }
74
+
75
+ /**
76
+ * 检查是否需要重新扫描
77
+ * @returns {boolean}
78
+ */
79
+ export function needsRescan(options) {
80
+ const { force, targetsPath, scoresPath, configPath } = options
81
+
82
+ // 1. 强制重扫
83
+ if (force) {
84
+ return true
85
+ }
86
+
87
+ // 2. 目标文件不存在
88
+ if (!existsSync(targetsPath)) {
89
+ return true
90
+ }
91
+
92
+ // 3. 打分结果不存在
93
+ if (scoresPath && !existsSync(scoresPath)) {
94
+ return true
95
+ }
96
+
97
+ // 4. 源代码有变化
98
+ if (hasSourceCodeChanged(targetsPath)) {
99
+ return true
100
+ }
101
+
102
+ // 5. 配置文件有变化
103
+ if (configPath && scoresPath && hasConfigChanged(configPath, scoresPath)) {
104
+ return true
105
+ }
106
+
107
+ return false
108
+ }
109
+
110
+ /**
111
+ * 获取文件的年龄(小时)
112
+ */
113
+ export function getFileAgeHours(filePath) {
114
+ if (!existsSync(filePath)) {
115
+ return Infinity
116
+ }
117
+
118
+ const stats = statSync(filePath)
119
+ return (Date.now() - stats.mtimeMs) / (1000 * 60 * 60)
120
+ }
121
+