gitlab-ai-review 4.2.4 → 6.3.9
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/README.md +36 -2
- package/cli.js +118 -32
- package/index.js +741 -335
- package/lib/ai-client.js +145 -61
- package/lib/config.js +798 -11
- package/lib/diff-parser.js +143 -44
- package/lib/document-loader.js +329 -0
- package/lib/export-analyzer.js +384 -0
- package/lib/gitlab-client.js +588 -7
- package/lib/prompt-tools.js +241 -453
- package/package.json +52 -50
- package/lib/impact-analyzer.js +0 -700
- package/lib/incremental-callchain-analyzer.js +0 -811
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导出分析器 - 使用 TypeScript Compiler API 分析文件的 export
|
|
3
|
+
* 用于检测哪些导出函数/变量被修改,以便分析调用链影响
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import ts from 'typescript';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 检查节点是否有 export 修饰符
|
|
10
|
+
*/
|
|
11
|
+
function hasExportModifier(node) {
|
|
12
|
+
const modifiers = ts.getModifiers(node);
|
|
13
|
+
return modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) || false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 获取节点所在行号
|
|
18
|
+
*/
|
|
19
|
+
function getLineNumber(sourceFile, node) {
|
|
20
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 计算代码内容的简单哈希(用于对比是否修改)
|
|
25
|
+
*/
|
|
26
|
+
function simpleHash(str) {
|
|
27
|
+
if (!str) return '';
|
|
28
|
+
let hash = 0;
|
|
29
|
+
for (let i = 0; i < str.length; i++) {
|
|
30
|
+
const char = str.charCodeAt(i);
|
|
31
|
+
hash = ((hash << 5) - hash) + char;
|
|
32
|
+
hash = hash & hash;
|
|
33
|
+
}
|
|
34
|
+
return hash.toString(16);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 提取文件中所有的 export 符号
|
|
39
|
+
* @param {string} code - 文件内容
|
|
40
|
+
* @param {string} fileName - 文件名(用于确定语言类型)
|
|
41
|
+
* @returns {Array} 导出列表
|
|
42
|
+
*/
|
|
43
|
+
export function extractExports(code, fileName = 'temp.ts') {
|
|
44
|
+
if (!code) return [];
|
|
45
|
+
|
|
46
|
+
const exports = [];
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const sourceFile = ts.createSourceFile(
|
|
50
|
+
fileName,
|
|
51
|
+
code,
|
|
52
|
+
ts.ScriptTarget.Latest,
|
|
53
|
+
true
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
function visit(node) {
|
|
57
|
+
// 1. export function funcName() {}
|
|
58
|
+
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
59
|
+
if (hasExportModifier(node)) {
|
|
60
|
+
const bodyText = node.body ? node.body.getText(sourceFile) : '';
|
|
61
|
+
exports.push({
|
|
62
|
+
name: node.name.getText(sourceFile),
|
|
63
|
+
type: 'function',
|
|
64
|
+
bodyHash: simpleHash(bodyText),
|
|
65
|
+
startLine: getLineNumber(sourceFile, node),
|
|
66
|
+
// 提取函数签名(参数)
|
|
67
|
+
signature: node.parameters ?
|
|
68
|
+
node.parameters.map(p => p.getText(sourceFile)).join(', ') : '',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. export const/let/var varName = ...
|
|
74
|
+
if (ts.isVariableStatement(node)) {
|
|
75
|
+
if (hasExportModifier(node)) {
|
|
76
|
+
node.declarationList.declarations.forEach(decl => {
|
|
77
|
+
if (ts.isIdentifier(decl.name)) {
|
|
78
|
+
const initText = decl.initializer ? decl.initializer.getText(sourceFile) : '';
|
|
79
|
+
exports.push({
|
|
80
|
+
name: decl.name.getText(sourceFile),
|
|
81
|
+
type: 'variable',
|
|
82
|
+
bodyHash: simpleHash(initText),
|
|
83
|
+
startLine: getLineNumber(sourceFile, decl),
|
|
84
|
+
// 如果是箭头函数,也提取签名
|
|
85
|
+
signature: decl.initializer && ts.isArrowFunction(decl.initializer) ?
|
|
86
|
+
decl.initializer.parameters.map(p => p.getText(sourceFile)).join(', ') : '',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. export class ClassName {}
|
|
94
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
95
|
+
if (hasExportModifier(node)) {
|
|
96
|
+
const bodyText = node.members ?
|
|
97
|
+
node.members.map(m => m.getText(sourceFile)).join('\n') : '';
|
|
98
|
+
exports.push({
|
|
99
|
+
name: node.name.getText(sourceFile),
|
|
100
|
+
type: 'class',
|
|
101
|
+
bodyHash: simpleHash(bodyText),
|
|
102
|
+
startLine: getLineNumber(sourceFile, node),
|
|
103
|
+
signature: '',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. export interface InterfaceName {} (TypeScript)
|
|
109
|
+
if (ts.isInterfaceDeclaration(node) && node.name) {
|
|
110
|
+
if (hasExportModifier(node)) {
|
|
111
|
+
const bodyText = node.members ?
|
|
112
|
+
node.members.map(m => m.getText(sourceFile)).join('\n') : '';
|
|
113
|
+
exports.push({
|
|
114
|
+
name: node.name.getText(sourceFile),
|
|
115
|
+
type: 'interface',
|
|
116
|
+
bodyHash: simpleHash(bodyText),
|
|
117
|
+
startLine: getLineNumber(sourceFile, node),
|
|
118
|
+
signature: '',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 5. export type TypeName = ... (TypeScript)
|
|
124
|
+
if (ts.isTypeAliasDeclaration(node) && node.name) {
|
|
125
|
+
if (hasExportModifier(node)) {
|
|
126
|
+
const typeText = node.type ? node.type.getText(sourceFile) : '';
|
|
127
|
+
exports.push({
|
|
128
|
+
name: node.name.getText(sourceFile),
|
|
129
|
+
type: 'type',
|
|
130
|
+
bodyHash: simpleHash(typeText),
|
|
131
|
+
startLine: getLineNumber(sourceFile, node),
|
|
132
|
+
signature: '',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 6. export default function/class
|
|
138
|
+
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) {
|
|
139
|
+
const modifiers = ts.getModifiers(node);
|
|
140
|
+
const hasDefault = modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
141
|
+
const hasExport = modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
142
|
+
if (hasDefault && hasExport) {
|
|
143
|
+
const name = node.name ? node.name.getText(sourceFile) : 'default';
|
|
144
|
+
const bodyText = ts.isFunctionDeclaration(node) && node.body ?
|
|
145
|
+
node.body.getText(sourceFile) : '';
|
|
146
|
+
exports.push({
|
|
147
|
+
name,
|
|
148
|
+
type: 'default',
|
|
149
|
+
bodyHash: simpleHash(bodyText),
|
|
150
|
+
startLine: getLineNumber(sourceFile, node),
|
|
151
|
+
signature: '',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 7. export { name1, name2 } 命名导出
|
|
157
|
+
if (ts.isExportDeclaration(node) && node.exportClause) {
|
|
158
|
+
if (ts.isNamedExports(node.exportClause)) {
|
|
159
|
+
node.exportClause.elements.forEach(element => {
|
|
160
|
+
exports.push({
|
|
161
|
+
name: element.name.getText(sourceFile),
|
|
162
|
+
type: 'named-export',
|
|
163
|
+
bodyHash: '', // 命名导出没有 body
|
|
164
|
+
startLine: getLineNumber(sourceFile, element),
|
|
165
|
+
signature: '',
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ts.forEachChild(node, visit);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
visit(sourceFile);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.warn(`⚠️ 解析文件 ${fileName} 失败:`, error.message);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return exports;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 对比两个版本的 exports,找出变更的符号
|
|
184
|
+
* @param {Array} oldExports - 旧版本的导出
|
|
185
|
+
* @param {Array} newExports - 新版本的导出
|
|
186
|
+
* @returns {Object} { added, removed, modified }
|
|
187
|
+
*/
|
|
188
|
+
export function findChangedExports(oldExports, newExports) {
|
|
189
|
+
const changes = {
|
|
190
|
+
added: [], // 新增的导出
|
|
191
|
+
removed: [], // 删除的导出
|
|
192
|
+
modified: [], // 修改的导出(内容变了)
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const oldMap = new Map(oldExports.map(e => [e.name, e]));
|
|
196
|
+
const newMap = new Map(newExports.map(e => [e.name, e]));
|
|
197
|
+
|
|
198
|
+
// 找出新增和修改的
|
|
199
|
+
for (const [name, newExp] of newMap) {
|
|
200
|
+
const oldExp = oldMap.get(name);
|
|
201
|
+
if (!oldExp) {
|
|
202
|
+
// 新增
|
|
203
|
+
changes.added.push(newExp);
|
|
204
|
+
} else if (oldExp.bodyHash !== newExp.bodyHash || oldExp.signature !== newExp.signature) {
|
|
205
|
+
// 修改(内容或签名变了)
|
|
206
|
+
changes.modified.push({
|
|
207
|
+
...newExp,
|
|
208
|
+
oldSignature: oldExp.signature,
|
|
209
|
+
newSignature: newExp.signature,
|
|
210
|
+
changeType: oldExp.signature !== newExp.signature ? 'signature' : 'body',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 找出删除的
|
|
216
|
+
for (const [name, oldExp] of oldMap) {
|
|
217
|
+
if (!newMap.has(name)) {
|
|
218
|
+
changes.removed.push(oldExp);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return changes;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 提取文件中的 import 语句
|
|
227
|
+
* @param {string} code - 文件内容
|
|
228
|
+
* @param {string} fileName - 文件名
|
|
229
|
+
* @returns {Array} import 列表
|
|
230
|
+
*/
|
|
231
|
+
export function extractImports(code, fileName = 'temp.ts') {
|
|
232
|
+
if (!code) return [];
|
|
233
|
+
|
|
234
|
+
const imports = [];
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const sourceFile = ts.createSourceFile(
|
|
238
|
+
fileName,
|
|
239
|
+
code,
|
|
240
|
+
ts.ScriptTarget.Latest,
|
|
241
|
+
true
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
function visit(node) {
|
|
245
|
+
if (ts.isImportDeclaration(node)) {
|
|
246
|
+
const modulePath = node.moduleSpecifier.getText(sourceFile).replace(/['"]/g, '');
|
|
247
|
+
const importInfo = {
|
|
248
|
+
from: modulePath,
|
|
249
|
+
symbols: [],
|
|
250
|
+
defaultImport: null,
|
|
251
|
+
namespaceImport: null,
|
|
252
|
+
line: getLineNumber(sourceFile, node),
|
|
253
|
+
fullText: node.getText(sourceFile),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
if (node.importClause) {
|
|
257
|
+
// 默认导入
|
|
258
|
+
if (node.importClause.name) {
|
|
259
|
+
importInfo.defaultImport = node.importClause.name.getText(sourceFile);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (node.importClause.namedBindings) {
|
|
263
|
+
// 命名导入
|
|
264
|
+
if (ts.isNamedImports(node.importClause.namedBindings)) {
|
|
265
|
+
node.importClause.namedBindings.elements.forEach(element => {
|
|
266
|
+
importInfo.symbols.push(element.name.getText(sourceFile));
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
// 命名空间导入
|
|
270
|
+
if (ts.isNamespaceImport(node.importClause.namedBindings)) {
|
|
271
|
+
importInfo.namespaceImport = node.importClause.namedBindings.name.getText(sourceFile);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
imports.push(importInfo);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
ts.forEachChild(node, visit);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
visit(sourceFile);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.warn(`⚠️ 解析 imports 失败:`, error.message);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return imports;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 在代码中查找符号的使用位置
|
|
292
|
+
* @param {string} code - 文件内容
|
|
293
|
+
* @param {string} symbolName - 要查找的符号名
|
|
294
|
+
* @returns {Array} 使用位置列表
|
|
295
|
+
*/
|
|
296
|
+
export function findSymbolUsages(code, symbolName) {
|
|
297
|
+
if (!code || !symbolName) return [];
|
|
298
|
+
|
|
299
|
+
const usages = [];
|
|
300
|
+
const lines = code.split('\n');
|
|
301
|
+
|
|
302
|
+
// 正则匹配符号使用(不在注释中)
|
|
303
|
+
const symbolRegex = new RegExp(`\\b${symbolName}\\b`, 'g');
|
|
304
|
+
|
|
305
|
+
lines.forEach((line, index) => {
|
|
306
|
+
const trimmed = line.trim();
|
|
307
|
+
// 跳过注释行
|
|
308
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (symbolRegex.test(line)) {
|
|
313
|
+
usages.push({
|
|
314
|
+
line: index + 1,
|
|
315
|
+
content: line,
|
|
316
|
+
});
|
|
317
|
+
// 重置正则的 lastIndex
|
|
318
|
+
symbolRegex.lastIndex = 0;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return usages;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 提取符号使用的上下文代码
|
|
327
|
+
* @param {string} code - 文件内容
|
|
328
|
+
* @param {string} symbolName - 符号名
|
|
329
|
+
* @param {number} contextLines - 上下文行数
|
|
330
|
+
* @returns {string} 上下文代码
|
|
331
|
+
*/
|
|
332
|
+
export function extractUsageContext(code, symbolName, contextLines = 2) {
|
|
333
|
+
if (!code || !symbolName) return '';
|
|
334
|
+
|
|
335
|
+
const lines = code.split('\n');
|
|
336
|
+
const usages = findSymbolUsages(code, symbolName);
|
|
337
|
+
|
|
338
|
+
if (usages.length === 0) return '';
|
|
339
|
+
|
|
340
|
+
const contextParts = [];
|
|
341
|
+
|
|
342
|
+
// 首先找 import 语句
|
|
343
|
+
const imports = extractImports(code);
|
|
344
|
+
const relevantImport = imports.find(imp =>
|
|
345
|
+
imp.symbols.includes(symbolName) ||
|
|
346
|
+
imp.defaultImport === symbolName ||
|
|
347
|
+
imp.namespaceImport
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
if (relevantImport) {
|
|
351
|
+
contextParts.push(`${relevantImport.line}| ${relevantImport.fullText}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 添加使用处的上下文(最多3处)
|
|
355
|
+
const usagesToShow = usages.slice(0, 3);
|
|
356
|
+
usagesToShow.forEach((usage, i) => {
|
|
357
|
+
if (contextParts.length > 0) {
|
|
358
|
+
contextParts.push('\n// ...\n');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const start = Math.max(0, usage.line - 1 - contextLines);
|
|
362
|
+
const end = Math.min(lines.length, usage.line + contextLines);
|
|
363
|
+
|
|
364
|
+
for (let j = start; j < end; j++) {
|
|
365
|
+
const marker = j === usage.line - 1 ? '>' : ' ';
|
|
366
|
+
contextParts.push(`${marker} ${j + 1}| ${lines[j]}`);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (usages.length > 3) {
|
|
371
|
+
contextParts.push(`\n// ... 还有 ${usages.length - 3} 处使用`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return contextParts.join('\n');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export default {
|
|
378
|
+
extractExports,
|
|
379
|
+
findChangedExports,
|
|
380
|
+
extractImports,
|
|
381
|
+
findSymbolUsages,
|
|
382
|
+
extractUsageContext,
|
|
383
|
+
};
|
|
384
|
+
|