ai-unit-test-generator 1.4.5 → 2.0.1

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.
@@ -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
+
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Analyze 工作流:AI 分析代码库并生成配置建议
3
+ */
4
+
5
+ import { spawn } from 'node:child_process'
6
+ import { writeFileSync } from 'node:fs'
7
+ import { detectConfig } from '../utils/config-manager.mjs'
8
+ import { sampleCodeFiles, analyzeProjectStructure } from '../ai/sampler.mjs'
9
+ import { buildProjectContext } from '../ai/context-builder.mjs'
10
+ import { buildAnalysisPrompt } from '../ai/analyzer-prompt.mjs'
11
+ import { validateAndSanitize } from '../ai/validator.mjs'
12
+ import { interactiveReview } from '../ai/reviewer.mjs'
13
+ import { applyAISuggestions } from '../ai/config-writer.mjs'
14
+
15
+ /**
16
+ * AI 分析工作流
17
+ */
18
+ export async function analyze(options) {
19
+ const { config, output } = options
20
+
21
+ // 1. 检查配置是否存在
22
+ console.log('🔍 Step 1: Checking configuration...')
23
+ const configPath = detectConfig(config)
24
+
25
+ if (!configPath) {
26
+ console.error('❌ Config not found. Run `ai-test init` first.')
27
+ process.exit(1)
28
+ }
29
+
30
+ console.log(` Using config: ${configPath}\n`)
31
+
32
+ // 2. 分析项目结构
33
+ console.log('📊 Step 2: Analyzing project structure...')
34
+ const stats = await analyzeProjectStructure()
35
+ console.log(` Total files: ${stats.totalFiles}`)
36
+ console.log(` Total lines: ${stats.totalLines}`)
37
+ console.log(` Avg lines/file: ${stats.avgLinesPerFile}\n`)
38
+
39
+ // 3. 智能采样代码
40
+ console.log('🎯 Step 3: Sampling representative code...')
41
+ const samples = await sampleCodeFiles()
42
+ console.log(` Selected ${samples.length} files across layers\n`)
43
+
44
+ // 4. 构建项目上下文
45
+ console.log('📦 Step 4: Reading project context...')
46
+ const projectCtx = await buildProjectContext()
47
+ console.log(` Framework: ${projectCtx.framework}`)
48
+ console.log(` Critical deps: ${projectCtx.criticalDeps.length > 0 ? projectCtx.criticalDeps.join(', ') : 'None detected'}\n`)
49
+
50
+ // 5. 构建 AI Prompt
51
+ console.log('✍️ Step 5: Building AI analysis prompt...')
52
+ const prompt = buildAnalysisPrompt(samples, stats, projectCtx)
53
+
54
+ // 保存 prompt 到临时文件
55
+ const promptPath = 'prompt_analyze.txt'
56
+ writeFileSync(promptPath, prompt, 'utf-8')
57
+ console.log(` Prompt saved to: ${promptPath}\n`)
58
+
59
+ // 6. 调用 Cursor Agent
60
+ console.log('🤖 Step 6: Calling Cursor Agent...')
61
+ console.log(' Cursor will analyze the full codebase using its index...')
62
+ console.log(' This may take 1-2 minutes...\n')
63
+
64
+ const responseText = await callCursorAgent(promptPath)
65
+
66
+ if (!responseText) {
67
+ console.error('❌ AI analysis failed or returned empty response')
68
+ process.exit(1)
69
+ }
70
+
71
+ // 7. 解析并验证响应
72
+ console.log('✅ Step 7: Validating AI suggestions...')
73
+
74
+ let parsed
75
+ try {
76
+ // 尝试提取 JSON(AI 可能返回 markdown 包装)
77
+ const jsonMatch = responseText.match(/```json\s*([\s\S]*?)\s*```/) ||
78
+ responseText.match(/```\s*([\s\S]*?)\s*```/)
79
+
80
+ const jsonText = jsonMatch ? jsonMatch[1] : responseText
81
+ parsed = JSON.parse(jsonText)
82
+ } catch (err) {
83
+ console.error('❌ Failed to parse AI response as JSON')
84
+ console.error(' Response preview:', responseText.slice(0, 500))
85
+ process.exit(1)
86
+ }
87
+
88
+ const validated = validateAndSanitize(parsed)
89
+
90
+ const totalSuggestions = Object.values(validated).reduce((sum, arr) => sum + arr.length, 0)
91
+ console.log(` Validated ${totalSuggestions} suggestions\n`)
92
+
93
+ if (totalSuggestions === 0) {
94
+ console.log('⚠️ No valid suggestions from AI. Please check the response.')
95
+ process.exit(0)
96
+ }
97
+
98
+ // 8. 交互式审核
99
+ console.log('📝 Step 8: Interactive review...\n')
100
+ const approved = await interactiveReview(validated)
101
+
102
+ if (approved === null) {
103
+ console.log('\n❌ No changes saved.')
104
+ process.exit(0)
105
+ }
106
+
107
+ // 9. 写入配置
108
+ console.log('\n💾 Step 9: Updating configuration...')
109
+
110
+ try {
111
+ await applyAISuggestions(configPath, approved)
112
+ console.log('✅ Config updated!')
113
+ console.log('\n💡 Next: Run `ai-test scan` to recalculate scores with AI enhancements.')
114
+ } catch (err) {
115
+ console.error(`❌ Failed to update config: ${err.message}`)
116
+ process.exit(1)
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 调用 Cursor Agent
122
+ */
123
+ async function callCursorAgent(promptPath) {
124
+ return new Promise((resolve, reject) => {
125
+ const responsePath = 'reports/ai_analyze_response.txt'
126
+
127
+ const child = spawn('cursor-agent', [
128
+ '--prompt-file', promptPath,
129
+ '--output', responsePath
130
+ ], {
131
+ stdio: 'inherit',
132
+ shell: true,
133
+ cwd: process.cwd()
134
+ })
135
+
136
+ child.on('close', (code) => {
137
+ if (code !== 0) {
138
+ reject(new Error(`cursor-agent exited with code ${code}`))
139
+ return
140
+ }
141
+
142
+ // 读取响应
143
+ try {
144
+ const { readFileSync } = require('node:fs')
145
+ const response = readFileSync(responsePath, 'utf-8')
146
+ resolve(response)
147
+ } catch (err) {
148
+ reject(new Error(`Failed to read AI response: ${err.message}`))
149
+ }
150
+ })
151
+
152
+ child.on('error', (err) => {
153
+ reject(new Error(`Failed to spawn cursor-agent: ${err.message}`))
154
+ })
155
+ })
156
+ }
157
+