ai-unit-test-generator 1.4.6 → 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.
- package/CHANGELOG.md +131 -0
- package/README.md +43 -18
- package/bin/cli.js +100 -211
- package/lib/ai/analyzer-prompt.mjs +120 -0
- package/lib/ai/config-writer.mjs +99 -0
- package/lib/ai/context-builder.mjs +52 -0
- package/lib/ai/index.mjs +11 -0
- package/lib/ai/reviewer.mjs +283 -0
- package/lib/ai/sampler.mjs +97 -0
- package/lib/ai/validator.mjs +101 -0
- package/lib/core/scanner.mjs +112 -2
- package/lib/core/scorer.mjs +184 -4
- package/lib/utils/config-manager.mjs +110 -0
- package/lib/utils/index.mjs +2 -0
- package/lib/utils/scan-manager.mjs +121 -0
- package/lib/workflows/analyze.mjs +157 -0
- package/lib/workflows/generate.mjs +98 -0
- package/lib/workflows/index.mjs +7 -0
- package/lib/workflows/init.mjs +45 -0
- package/lib/workflows/scan.mjs +144 -0
- package/package.json +1 -1
- package/templates/default.config.jsonc +58 -0
package/lib/core/scanner.mjs
CHANGED
@@ -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)直接跳过
|
package/lib/core/scorer.mjs
CHANGED
@@ -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
|
-
|
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
|
+
|
package/lib/utils/index.mjs
CHANGED
@@ -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
|
+
|