@lorrylurui/code-intelligence-mcp 2.0.2 → 2.0.3
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 +1 -0
- package/dist/indexer/babelParser.js +125 -36
- package/dist/indexer/extractMeta.js +16 -12
- package/dist/indexer/heuristics.js +7 -6
- package/dist/indexer/indexProject.js +3 -2
- package/dist/indexer/jsAstNormalizer.js +47 -14
- package/dist/indexer/paramPlaceholder.js +6 -0
- package/dist/indexer/tsAstNormalizer.js +8 -5
- package/dist/prompts/reusableCodeAdvisorPrompt.js +6 -7
- package/dist/server/createServer.js +5 -0
- package/dist/services/recommendationService.js +132 -0
- package/dist/tools/recommendComponent.js +15 -32
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -4,8 +4,61 @@
|
|
|
4
4
|
import * as babelParser from '@babel/parser';
|
|
5
5
|
import * as bt from '@babel/types';
|
|
6
6
|
import { getRelativePathForDisplay, inferCategoryFromPath, inferCategoryFromName, } from './heuristics.js';
|
|
7
|
+
import { makeParamPlaceholder } from './paramPlaceholder.js';
|
|
7
8
|
import { computeFileHash } from './tsAstNormalizer.js';
|
|
8
9
|
import { computeSemanticHashJs } from './jsAstNormalizer.js';
|
|
10
|
+
function inheritLeadingComments(target, source) {
|
|
11
|
+
const sourceComments = source
|
|
12
|
+
?.leadingComments;
|
|
13
|
+
const targetNode = target;
|
|
14
|
+
if (!targetNode.leadingComments && sourceComments) {
|
|
15
|
+
targetNode.leadingComments = sourceComments;
|
|
16
|
+
}
|
|
17
|
+
return target;
|
|
18
|
+
}
|
|
19
|
+
function normalizeJsDocType(type) {
|
|
20
|
+
const text = type.trim().toLowerCase();
|
|
21
|
+
if (text.includes('string'))
|
|
22
|
+
return 'string';
|
|
23
|
+
if (text.includes('number'))
|
|
24
|
+
return 'number';
|
|
25
|
+
if (text.includes('boolean'))
|
|
26
|
+
return 'boolean';
|
|
27
|
+
if (text.includes('array'))
|
|
28
|
+
return 'array';
|
|
29
|
+
if (text.includes('object'))
|
|
30
|
+
return 'object';
|
|
31
|
+
if (text.includes('window') || text.includes('htmlelement'))
|
|
32
|
+
return 'object';
|
|
33
|
+
if (text.includes('void'))
|
|
34
|
+
return 'void';
|
|
35
|
+
if (text.includes('null'))
|
|
36
|
+
return 'null';
|
|
37
|
+
return 'unknown';
|
|
38
|
+
}
|
|
39
|
+
function parseJsDocInfo(node) {
|
|
40
|
+
const comments = node.leadingComments;
|
|
41
|
+
if (!comments?.length)
|
|
42
|
+
return { description: null };
|
|
43
|
+
const block = comments.find((comment) => comment.type === 'CommentBlock');
|
|
44
|
+
if (!block?.value)
|
|
45
|
+
return { description: null };
|
|
46
|
+
const lines = block.value
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((line) => line.replace(/^\s*\*\s?/, '').trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
const descriptionLines = lines.filter((line) => !line.startsWith('@'));
|
|
51
|
+
const returnLine = lines.find((line) => line.startsWith('@returns') || line.startsWith('@return'));
|
|
52
|
+
const returnMatch = returnLine?.match(/@returns?\s+\{([^}]+)\}/);
|
|
53
|
+
return {
|
|
54
|
+
description: descriptionLines.length
|
|
55
|
+
? descriptionLines.join(' ')
|
|
56
|
+
: null,
|
|
57
|
+
...(returnMatch
|
|
58
|
+
? { returnType: normalizeJsDocType(returnMatch[1]) }
|
|
59
|
+
: {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
9
62
|
/** 从 JS 文件内容解析导出的代码块 */
|
|
10
63
|
export function parseJsFile(filePath, content, projectRoot) {
|
|
11
64
|
const out = [];
|
|
@@ -77,7 +130,7 @@ function processStatement(stmt, filePath, isJsx, projectRoot) {
|
|
|
77
130
|
if (bt.isFunctionDeclaration(decl)) {
|
|
78
131
|
const name = decl.id?.name;
|
|
79
132
|
if (name) {
|
|
80
|
-
out.push(createRowFromFunction(name, decl, filePath, projectRoot, isJsx));
|
|
133
|
+
out.push(createRowFromFunction(name, inheritLeadingComments(decl, stmt), filePath, projectRoot, isJsx));
|
|
81
134
|
}
|
|
82
135
|
}
|
|
83
136
|
else if (bt.isClassDeclaration(decl)) {
|
|
@@ -96,7 +149,7 @@ function processStatement(stmt, filePath, isJsx, projectRoot) {
|
|
|
96
149
|
if (name &&
|
|
97
150
|
(bt.isArrowFunctionExpression(init) ||
|
|
98
151
|
bt.isFunctionExpression(init))) {
|
|
99
|
-
const fnDecl = arrowToFunction(name, init);
|
|
152
|
+
const fnDecl = inheritLeadingComments(arrowToFunction(name, init), stmt);
|
|
100
153
|
out.push(createRowFromFunction(name, fnDecl, filePath, projectRoot, isJsx));
|
|
101
154
|
}
|
|
102
155
|
}
|
|
@@ -123,20 +176,20 @@ function processStatement(stmt, filePath, isJsx, projectRoot) {
|
|
|
123
176
|
const value = prop.value;
|
|
124
177
|
if (bt.isFunctionExpression(value) ||
|
|
125
178
|
bt.isArrowFunctionExpression(value)) {
|
|
126
|
-
const fnDecl = arrowToFunction(name, bt.isArrowFunctionExpression(value)
|
|
179
|
+
const fnDecl = inheritLeadingComments(arrowToFunction(name, bt.isArrowFunctionExpression(value)
|
|
127
180
|
? value
|
|
128
|
-
: value);
|
|
181
|
+
: value), stmt);
|
|
129
182
|
out.push(createRowFromFunction(name, fnDecl, filePath, projectRoot, isJsx));
|
|
130
183
|
}
|
|
131
184
|
}
|
|
132
185
|
}
|
|
133
186
|
}
|
|
134
187
|
else if (bt.isFunctionExpression(right)) {
|
|
135
|
-
const fnDecl = arrowToFunction('default', right);
|
|
188
|
+
const fnDecl = inheritLeadingComments(arrowToFunction('default', right), stmt);
|
|
136
189
|
out.push(createRowFromFunction('default', fnDecl, filePath, projectRoot, isJsx));
|
|
137
190
|
}
|
|
138
191
|
else if (bt.isArrowFunctionExpression(right)) {
|
|
139
|
-
const fnDecl = arrowToFunction('default', right);
|
|
192
|
+
const fnDecl = inheritLeadingComments(arrowToFunction('default', right), stmt);
|
|
140
193
|
out.push(createRowFromFunction('default', fnDecl, filePath, projectRoot, isJsx));
|
|
141
194
|
}
|
|
142
195
|
}
|
|
@@ -150,7 +203,9 @@ function processStatement(stmt, filePath, isJsx, projectRoot) {
|
|
|
150
203
|
const right = expr.right;
|
|
151
204
|
if (bt.isFunctionExpression(right) ||
|
|
152
205
|
bt.isArrowFunctionExpression(right)) {
|
|
153
|
-
const fnDecl = arrowToFunction(name, bt.isArrowFunctionExpression(right)
|
|
206
|
+
const fnDecl = inheritLeadingComments(arrowToFunction(name, bt.isArrowFunctionExpression(right)
|
|
207
|
+
? right
|
|
208
|
+
: right), stmt);
|
|
154
209
|
out.push(createRowFromFunction(name, fnDecl, filePath, projectRoot, isJsx));
|
|
155
210
|
}
|
|
156
211
|
}
|
|
@@ -162,18 +217,18 @@ function processStatement(stmt, filePath, isJsx, projectRoot) {
|
|
|
162
217
|
const decl = stmt.declaration;
|
|
163
218
|
if (bt.isFunctionDeclaration(decl)) {
|
|
164
219
|
const name = decl.id?.name || 'default';
|
|
165
|
-
out.push(createRowFromFunction(name, decl, filePath, projectRoot, isJsx));
|
|
220
|
+
out.push(createRowFromFunction(name, inheritLeadingComments(decl, stmt), filePath, projectRoot, isJsx));
|
|
166
221
|
}
|
|
167
222
|
else if (bt.isClassDeclaration(decl)) {
|
|
168
223
|
const name = decl.id?.name || 'default';
|
|
169
224
|
out.push(createRowFromClass(name, decl, filePath, projectRoot));
|
|
170
225
|
}
|
|
171
226
|
else if (bt.isArrowFunctionExpression(decl)) {
|
|
172
|
-
const fnDecl = arrowToFunction('default', decl);
|
|
227
|
+
const fnDecl = inheritLeadingComments(arrowToFunction('default', decl), stmt);
|
|
173
228
|
out.push(createRowFromFunction('default', fnDecl, filePath, projectRoot, isJsx));
|
|
174
229
|
}
|
|
175
230
|
else if (bt.isFunctionExpression(decl)) {
|
|
176
|
-
const fnDecl = arrowToFunction('default', decl);
|
|
231
|
+
const fnDecl = inheritLeadingComments(arrowToFunction('default', decl), stmt);
|
|
177
232
|
out.push(createRowFromFunction('default', fnDecl, filePath, projectRoot, isJsx));
|
|
178
233
|
}
|
|
179
234
|
}
|
|
@@ -235,6 +290,7 @@ function arrowToFunction(name, arrow) {
|
|
|
235
290
|
function createRowFromFunction(name, decl, filePath, projectRoot, isJsx) {
|
|
236
291
|
const relPath = getRelativePathForDisplay(projectRoot, filePath);
|
|
237
292
|
const category = inferCategoryFromPath(filePath) || inferCategoryFromName(name);
|
|
293
|
+
const jsdoc = parseJsDocInfo(decl);
|
|
238
294
|
// 检测是否有 JSX
|
|
239
295
|
const hasJsx = isJsx || containsJsx(decl);
|
|
240
296
|
// 判断类型:
|
|
@@ -247,9 +303,34 @@ function createRowFromFunction(name, decl, filePath, projectRoot, isJsx) {
|
|
|
247
303
|
: isJsx || /^[A-Z]/.test(name)
|
|
248
304
|
? 'component'
|
|
249
305
|
: 'function';
|
|
250
|
-
const params = decl.params
|
|
251
|
-
|
|
252
|
-
|
|
306
|
+
const params = decl.params.flatMap((param, index) => {
|
|
307
|
+
if (bt.isIdentifier(param)) {
|
|
308
|
+
return [makeParamPlaceholder(index)];
|
|
309
|
+
}
|
|
310
|
+
if (bt.isAssignmentPattern(param) && bt.isIdentifier(param.left)) {
|
|
311
|
+
return [makeParamPlaceholder(index)];
|
|
312
|
+
}
|
|
313
|
+
if (bt.isRestElement(param) && bt.isIdentifier(param.argument)) {
|
|
314
|
+
return [makeParamPlaceholder(index, true)];
|
|
315
|
+
}
|
|
316
|
+
if (bt.isObjectPattern(param)) {
|
|
317
|
+
return param.properties
|
|
318
|
+
.map((prop) => {
|
|
319
|
+
if (bt.isObjectProperty(prop) &&
|
|
320
|
+
bt.isIdentifier(prop.key)) {
|
|
321
|
+
return prop.key.name;
|
|
322
|
+
}
|
|
323
|
+
if (bt.isRestElement(prop) &&
|
|
324
|
+
bt.isIdentifier(prop.argument)) {
|
|
325
|
+
return `...${prop.argument.name}`;
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
})
|
|
329
|
+
.filter((value) => Boolean(value))
|
|
330
|
+
.sort();
|
|
331
|
+
}
|
|
332
|
+
return [makeParamPlaceholder(index)];
|
|
333
|
+
});
|
|
253
334
|
const hooks = extractHooksFromBody(decl);
|
|
254
335
|
const sideEffects = extractSideEffects(decl);
|
|
255
336
|
return {
|
|
@@ -257,12 +338,14 @@ function createRowFromFunction(name, decl, filePath, projectRoot, isJsx) {
|
|
|
257
338
|
type,
|
|
258
339
|
category,
|
|
259
340
|
path: relPath,
|
|
260
|
-
description:
|
|
341
|
+
description: jsdoc.description,
|
|
261
342
|
content: `function ${decl.id?.name || 'anonymous'}(${params.join(', ')}) { ... }`,
|
|
262
343
|
meta: {
|
|
263
344
|
kind: 'function',
|
|
264
345
|
params,
|
|
265
|
-
|
|
346
|
+
...(getReturnType(decl, jsdoc)
|
|
347
|
+
? { returnType: getReturnType(decl, jsdoc) }
|
|
348
|
+
: {}),
|
|
266
349
|
...(hooks.length ? { hooks } : {}),
|
|
267
350
|
...(sideEffects.length ? { sideEffects } : {}),
|
|
268
351
|
},
|
|
@@ -328,28 +411,28 @@ function containsJsx(node) {
|
|
|
328
411
|
visit(node);
|
|
329
412
|
return found;
|
|
330
413
|
}
|
|
331
|
-
function getReturnType(fn) {
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
414
|
+
function getReturnType(fn, jsdoc) {
|
|
415
|
+
if (fn.returnType) {
|
|
416
|
+
const rt = fn.returnType;
|
|
417
|
+
if (bt.isTSTypeAnnotation(rt)) {
|
|
418
|
+
const inner = rt.typeAnnotation;
|
|
419
|
+
if (bt.isTSStringKeyword(inner))
|
|
420
|
+
return 'string';
|
|
421
|
+
if (bt.isTSNumberKeyword(inner))
|
|
422
|
+
return 'number';
|
|
423
|
+
if (bt.isTSBooleanKeyword(inner))
|
|
424
|
+
return 'boolean';
|
|
425
|
+
if (bt.isTSVoidKeyword(inner))
|
|
426
|
+
return 'void';
|
|
427
|
+
if (bt.isTSAnyKeyword(inner))
|
|
428
|
+
return 'any';
|
|
429
|
+
if (bt.isTSUnknownKeyword(inner))
|
|
430
|
+
return 'unknown';
|
|
431
|
+
if (bt.isTSNullKeyword(inner))
|
|
432
|
+
return 'null';
|
|
433
|
+
}
|
|
351
434
|
}
|
|
352
|
-
return
|
|
435
|
+
return jsdoc?.returnType;
|
|
353
436
|
}
|
|
354
437
|
/**
|
|
355
438
|
* 遍历 Babel AST 节点,收集所有满足条件的回调
|
|
@@ -460,6 +543,12 @@ function extractSideEffects(fn) {
|
|
|
460
543
|
if (calleeTextLower.includes('xmlhttprequest')) {
|
|
461
544
|
effects.add('network');
|
|
462
545
|
}
|
|
546
|
+
if (bt.isMemberExpression(n.callee) &&
|
|
547
|
+
bt.isIdentifier(n.callee.property) &&
|
|
548
|
+
(n.callee.property.name === 'getBoundingClientRect' ||
|
|
549
|
+
n.callee.property.name === 'getComputedStyle')) {
|
|
550
|
+
effects.add('dom');
|
|
551
|
+
}
|
|
463
552
|
}
|
|
464
553
|
// 2. 计时器
|
|
465
554
|
if (bt.isCallExpression(n)) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* 从 AST 抽取写入 `symbols.meta` 的结构化字段(props/params、hooks、返回值、类型成员等)。
|
|
3
3
|
*/
|
|
4
4
|
import { Node, SyntaxKind, } from 'ts-morph';
|
|
5
|
+
import { makeParamPlaceholder } from './paramPlaceholder.js';
|
|
5
6
|
/**
|
|
6
7
|
* 将节点收窄为可调用的函数形态,便于统一抽取参数与函数体。
|
|
7
8
|
* @returns 若不是函数声明/表达式/箭头函数则为 `undefined`。
|
|
@@ -16,22 +17,25 @@ function asFn(node) {
|
|
|
16
17
|
return undefined;
|
|
17
18
|
}
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
+
* 提取稳定参数结构:普通位置参数统一为 `$pN`,对象解构保留字段名。
|
|
20
21
|
* @returns 用于后续在 `component` 中映射为 `props`,在 `util` 中保留为 `params`。
|
|
21
22
|
*/
|
|
22
23
|
function extractParamNames(fn) {
|
|
23
24
|
const params = fn.getParameters();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
return params.flatMap((param, index) => {
|
|
26
|
+
const nameNode = param.getNameNode();
|
|
27
|
+
if (Node.isObjectBindingPattern(nameNode)) {
|
|
28
|
+
return nameNode
|
|
29
|
+
.getElements()
|
|
30
|
+
.map((el) => el.getName())
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.sort();
|
|
33
|
+
}
|
|
34
|
+
if (Node.isIdentifier(nameNode)) {
|
|
35
|
+
return [makeParamPlaceholder(index, param.isRestParameter())];
|
|
36
|
+
}
|
|
37
|
+
return [makeParamPlaceholder(index, param.isRestParameter())];
|
|
38
|
+
});
|
|
35
39
|
}
|
|
36
40
|
/**
|
|
37
41
|
* 从第一个参数的类型信息提取字段名(例如 `props: DialogProps` -> `title/content/onClose`)。
|
|
@@ -31,6 +31,8 @@ export function hasJsxInNode(node) {
|
|
|
31
31
|
* @returns 命中 `components|features|...`
|
|
32
32
|
*/
|
|
33
33
|
export function inferCategoryFromPath(filePath) {
|
|
34
|
+
const normalized = filePath.toLowerCase().replace(/\\/g, '/');
|
|
35
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
34
36
|
const markers = [
|
|
35
37
|
'components',
|
|
36
38
|
'features',
|
|
@@ -44,9 +46,10 @@ export function inferCategoryFromPath(filePath) {
|
|
|
44
46
|
'types',
|
|
45
47
|
'apis',
|
|
46
48
|
];
|
|
47
|
-
for (
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
50
|
+
const segment = segments[i];
|
|
51
|
+
if (markers.includes(segment)) {
|
|
52
|
+
return segment;
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
55
|
return null;
|
|
@@ -69,9 +72,7 @@ export function inferCategoryFromName(originName) {
|
|
|
69
72
|
return null;
|
|
70
73
|
}
|
|
71
74
|
export function isHookLike(exportName) {
|
|
72
|
-
|
|
73
|
-
return true;
|
|
74
|
-
return false;
|
|
75
|
+
return /^use[A-Z0-9_]/.test(exportName);
|
|
75
76
|
}
|
|
76
77
|
/**
|
|
77
78
|
* 读取节点(或父节点)上第一段 JSDoc 的 **描述正文**(不含 `@tag`)。
|
|
@@ -9,6 +9,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
9
9
|
import { extractInterfaceOrTypeMeta, extractMetaFromCallable, } from './extractMeta.js';
|
|
10
10
|
import { getLeadingDocDescription, getRelativePathForDisplay, hasJsxInNode, isHookLike, isTsxFile, } from './heuristics.js';
|
|
11
11
|
import { parseJsFile } from './babelParser.js';
|
|
12
|
+
import { isParamPlaceholder } from './paramPlaceholder.js';
|
|
12
13
|
import { computeFileHash, computeSemanticHash } from './tsAstNormalizer.js';
|
|
13
14
|
const CALLERS_LIMIT = 20;
|
|
14
15
|
/** 判断文件类型 */
|
|
@@ -31,8 +32,8 @@ function mergeCallableMeta(symbolType, raw) {
|
|
|
31
32
|
if (symbolType === 'component' && params?.length) {
|
|
32
33
|
const props = [
|
|
33
34
|
...new Set([...(paramTypeFields ?? []), ...params]),
|
|
34
|
-
].filter((name) => name.toLowerCase() !== 'props');
|
|
35
|
-
return { ...rest, props
|
|
35
|
+
].filter((name) => name.toLowerCase() !== 'props' && !isParamPlaceholder(name)).sort();
|
|
36
|
+
return { ...rest, ...(props.length ? { props } : {}) };
|
|
36
37
|
}
|
|
37
38
|
return { ...rest, ...(params?.length ? { params } : {}) };
|
|
38
39
|
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
import * as t from '@babel/types';
|
|
18
|
+
import { makeParamPlaceholder } from './paramPlaceholder.js';
|
|
18
19
|
// ─────────────────────────────────────────────
|
|
19
20
|
// JSDoc 类型提取
|
|
20
21
|
// ─────────────────────────────────────────────
|
|
@@ -43,6 +44,9 @@ function parseJSDoc(jsdoc) {
|
|
|
43
44
|
}
|
|
44
45
|
function normalizeType(type) {
|
|
45
46
|
type = type.trim().toLowerCase();
|
|
47
|
+
if (type.includes('window') || type.includes('htmlelement')) {
|
|
48
|
+
return 'object';
|
|
49
|
+
}
|
|
46
50
|
if (type.includes('string'))
|
|
47
51
|
return 'string';
|
|
48
52
|
if (type.includes('number'))
|
|
@@ -59,22 +63,42 @@ function normalizeType(type) {
|
|
|
59
63
|
}
|
|
60
64
|
return 'unknown';
|
|
61
65
|
}
|
|
66
|
+
function normalizeObjectPattern(param, jsdoc) {
|
|
67
|
+
const props = param.properties
|
|
68
|
+
.map((p) => {
|
|
69
|
+
if (p.type === 'ObjectProperty' && p.key?.name) {
|
|
70
|
+
const name = p.key.name;
|
|
71
|
+
const type = jsdoc?.params[name] ?? 'unknown';
|
|
72
|
+
return `${name}:${type}`;
|
|
73
|
+
}
|
|
74
|
+
if (p.type === 'RestElement' && p.argument?.name) {
|
|
75
|
+
return `...${p.argument.name}:unknown`;
|
|
76
|
+
}
|
|
77
|
+
return 'unknown';
|
|
78
|
+
})
|
|
79
|
+
.sort();
|
|
80
|
+
return `{${props.join(';')}}`;
|
|
81
|
+
}
|
|
62
82
|
function normalizeParam(param, fallbackName, jsdoc) {
|
|
63
83
|
if (param.type === 'Identifier') {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
return `${name}:${type}`;
|
|
84
|
+
const type = jsdoc?.params[param.name] ?? 'unknown';
|
|
85
|
+
return `${fallbackName}:${type}`;
|
|
67
86
|
}
|
|
68
87
|
if (param.type === 'ObjectPattern') {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
return normalizeObjectPattern(param, jsdoc);
|
|
89
|
+
}
|
|
90
|
+
if (param.type === 'AssignmentPattern') {
|
|
91
|
+
const left = normalizeParam(param.left, fallbackName, jsdoc);
|
|
92
|
+
return `${left}=$default`;
|
|
93
|
+
}
|
|
94
|
+
if (param.type === 'RestElement') {
|
|
95
|
+
if (param.argument?.type === 'Identifier') {
|
|
96
|
+
const type = jsdoc?.params[param.argument.name] ?? 'unknown';
|
|
97
|
+
return `...${fallbackName}:${type}`;
|
|
98
|
+
}
|
|
99
|
+
if (param.argument?.type === 'ObjectPattern') {
|
|
100
|
+
return `...${normalizeObjectPattern(param.argument, jsdoc)}`;
|
|
101
|
+
}
|
|
78
102
|
}
|
|
79
103
|
return `${fallbackName}:unknown`;
|
|
80
104
|
}
|
|
@@ -108,14 +132,14 @@ function inferReturnType(node) {
|
|
|
108
132
|
function normalizeFunction(node) {
|
|
109
133
|
const jsdocText = getJSDoc(node);
|
|
110
134
|
const jsdoc = jsdocText ? parseJSDoc(jsdocText) : undefined;
|
|
111
|
-
const params = node.params.map((p, i) => normalizeParam(p,
|
|
135
|
+
const params = node.params.map((p, i) => normalizeParam(p, makeParamPlaceholder(i), jsdoc));
|
|
112
136
|
const returnType = jsdoc?.returnType ?? inferReturnType(node);
|
|
113
137
|
return `fn(${params.join(',')})=>${returnType}`;
|
|
114
138
|
}
|
|
115
139
|
function normalizeArrowFunction(node) {
|
|
116
140
|
const jsdocText = getJSDoc(node);
|
|
117
141
|
const jsdoc = jsdocText ? parseJSDoc(jsdocText) : undefined;
|
|
118
|
-
const params = node.params.map((p, i) => normalizeParam(p,
|
|
142
|
+
const params = node.params.map((p, i) => normalizeParam(p, makeParamPlaceholder(i), jsdoc));
|
|
119
143
|
// const params = node.params.map((p, i) => normalizeParam(p, `$p${i}`));
|
|
120
144
|
const returnType = jsdoc?.returnType ?? inferReturnType(node);
|
|
121
145
|
return `fn(${params.join(',')})=>${returnType}`;
|
|
@@ -160,6 +184,15 @@ export function inferBehaviorFromJS(node) {
|
|
|
160
184
|
behavior.push('uses timer');
|
|
161
185
|
}
|
|
162
186
|
}
|
|
187
|
+
if (t.isCallExpression(n) &&
|
|
188
|
+
t.isMemberExpression(n.callee) &&
|
|
189
|
+
t.isIdentifier(n.callee.property)) {
|
|
190
|
+
const prop = n.callee.property.name;
|
|
191
|
+
if (prop === 'getBoundingClientRect' ||
|
|
192
|
+
prop === 'getComputedStyle') {
|
|
193
|
+
behavior.push('reads dom layout');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
163
196
|
// localStorage
|
|
164
197
|
if (t.isMemberExpression(n) &&
|
|
165
198
|
t.isIdentifier(n.object) &&
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { Node, SyntaxKind } from 'ts-morph';
|
|
10
|
+
import { makeParamPlaceholder } from './paramPlaceholder.js';
|
|
10
11
|
// ─────────────────────────────────────────────
|
|
11
12
|
// 内置类型白名单:不替换为 $T
|
|
12
13
|
// ─────────────────────────────────────────────
|
|
@@ -101,8 +102,9 @@ export function normalizeNode(node) {
|
|
|
101
102
|
const paramNames = new Map();
|
|
102
103
|
let paramIdx = 0;
|
|
103
104
|
function allocParam(name) {
|
|
104
|
-
if (!paramNames.has(name))
|
|
105
|
-
paramNames.set(name,
|
|
105
|
+
if (!paramNames.has(name)) {
|
|
106
|
+
paramNames.set(name, makeParamPlaceholder(paramIdx++));
|
|
107
|
+
}
|
|
106
108
|
return paramNames.get(name);
|
|
107
109
|
}
|
|
108
110
|
function visit(n) {
|
|
@@ -191,7 +193,7 @@ function normalizeParameter(param, index) {
|
|
|
191
193
|
destrProps.sort();
|
|
192
194
|
return `${prefix}{${destrProps.join(',')}}:${typeStr}${suffix}`;
|
|
193
195
|
}
|
|
194
|
-
return `${
|
|
196
|
+
return `${makeParamPlaceholder(index, param.isRestParameter())}:${typeStr}${suffix}`;
|
|
195
197
|
}
|
|
196
198
|
function normalizeTypeWithStructure(typeNode) {
|
|
197
199
|
// 如果是类型字面量 { name: string, age: number }
|
|
@@ -211,11 +213,12 @@ function normalizeTypeWithStructure(typeNode) {
|
|
|
211
213
|
// 如果是类型引用(e.g. Param, Array<string>, Promise<User>)
|
|
212
214
|
if (Node.isTypeReference(typeNode)) {
|
|
213
215
|
const typeName = typeNode.getTypeName().getText();
|
|
216
|
+
const normalizedTypeName = normalizeTypeName(typeName);
|
|
214
217
|
// 优先处理泛型参数(不管是否在白名单中)
|
|
215
218
|
const typeArgs = typeNode.getTypeArguments();
|
|
216
219
|
if (typeArgs.length > 0) {
|
|
217
220
|
const normalizedArgs = typeArgs.map((arg) => normalizeTypeWithStructure(arg));
|
|
218
|
-
return `${
|
|
221
|
+
return `${normalizedTypeName}<${normalizedArgs.join(',')}>`;
|
|
219
222
|
}
|
|
220
223
|
// 没有泛型参数时,检查是否是基础类型
|
|
221
224
|
if (BUILTIN_TYPES.has(typeName)) {
|
|
@@ -253,7 +256,7 @@ function normalizeTypeWithStructure(typeNode) {
|
|
|
253
256
|
catch (e) {
|
|
254
257
|
// 解析失败,回退到类型名
|
|
255
258
|
}
|
|
256
|
-
return
|
|
259
|
+
return normalizedTypeName;
|
|
257
260
|
}
|
|
258
261
|
// 其他情况(联合类型、交叉类型、基础类型等)
|
|
259
262
|
return normalizeTypeString(typeNode.getText());
|
|
@@ -7,19 +7,18 @@ const REUSABLE_CODE_ADVISOR_MARKDOWN = `# 可复用代码推荐
|
|
|
7
7
|
|
|
8
8
|
当用户需要可复用代码或实现类需求时,按顺序执行:
|
|
9
9
|
|
|
10
|
-
1.
|
|
11
|
-
2.
|
|
12
|
-
3.
|
|
13
|
-
4.
|
|
14
|
-
5. 从以下维度对比候选:
|
|
10
|
+
1. 如果用户是在找可复用组件,优先调用 recommend_component,由它完成候选搜索、结构过滤和排序。
|
|
11
|
+
2. 只有当用户明确要求手动检索、或需要更细粒度控制时,才组合调用 search_symbols / search_by_structure / get_symbol_detail。
|
|
12
|
+
3. 若仅凭摘要无法判断,对最相关的若干候选调用 get_symbol_detail 获取详情。
|
|
13
|
+
4. 从以下维度对比候选:
|
|
15
14
|
- 与用户需求的**功能匹配度**
|
|
16
15
|
- **API 是否简单**、入参是否合适
|
|
17
16
|
- **依赖与副作用**风险
|
|
18
17
|
- **复用安全性**(稳定性、耦合度、是否便于扩展)
|
|
19
|
-
|
|
18
|
+
5. 给出**唯一首选**推荐,并说明理由,同时使用 **AskUserQuestion** 工具,提供两个选项:
|
|
20
19
|
- 采纳推荐
|
|
21
20
|
- 取消
|
|
22
|
-
|
|
21
|
+
6. 用户选择"采纳推荐"后,立即调用 inc_usage 工具记录该行为(symbolId 从搜索结果的 id 字段获取),不要遗漏此步骤。
|
|
23
22
|
|
|
24
23
|
## 不适用场景
|
|
25
24
|
|
|
@@ -6,12 +6,15 @@ import { createGetSymbolDetailTool } from "../tools/getSymbolDetail.js";
|
|
|
6
6
|
import { createReindexTool } from "../tools/reindex.js";
|
|
7
7
|
import { createSearchByStructureTool } from "../tools/searchByStructure.js";
|
|
8
8
|
import { createIncUsageTool } from "../tools/incUsage.js";
|
|
9
|
+
import { RecommendationService } from "../services/recommendationService.js";
|
|
10
|
+
import { createRecommendComponentTool } from "../tools/recommendComponent.js";
|
|
9
11
|
export function createServer() {
|
|
10
12
|
const server = new McpServer({
|
|
11
13
|
name: "code-intelligence-mcp",
|
|
12
14
|
version: "0.1.0"
|
|
13
15
|
});
|
|
14
16
|
const repository = new SymbolRepository();
|
|
17
|
+
const recommendationService = new RecommendationService(repository);
|
|
15
18
|
const searchTool = createSearchSymbolsTool(repository);
|
|
16
19
|
server.tool(searchTool.name, searchTool.description, searchTool.inputSchema, searchTool.handler);
|
|
17
20
|
const detailTool = createGetSymbolDetailTool(repository);
|
|
@@ -22,6 +25,8 @@ export function createServer() {
|
|
|
22
25
|
server.tool(reindexTool.name, reindexTool.description, reindexTool.inputSchema, reindexTool.handler);
|
|
23
26
|
const incUsageTool = createIncUsageTool(repository);
|
|
24
27
|
server.tool(incUsageTool.name, incUsageTool.description, incUsageTool.inputSchema, incUsageTool.handler);
|
|
28
|
+
const recommendComponentTool = createRecommendComponentTool(recommendationService);
|
|
29
|
+
server.tool(recommendComponentTool.name, recommendComponentTool.description, recommendComponentTool.inputSchema, recommendComponentTool.handler);
|
|
25
30
|
registerReusableCodeAdvisorPrompt(server);
|
|
26
31
|
return server;
|
|
27
32
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { rankSemanticHits, rankSymbols } from './ranking.js';
|
|
2
|
+
function uniqueStrings(values = []) {
|
|
3
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
4
|
+
}
|
|
5
|
+
function normalizeToken(value) {
|
|
6
|
+
return value.trim().toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
function getMetaStrings(symbol, key) {
|
|
9
|
+
const value = symbol.meta?.[key];
|
|
10
|
+
if (!Array.isArray(value))
|
|
11
|
+
return [];
|
|
12
|
+
return value.filter((item) => typeof item === 'string');
|
|
13
|
+
}
|
|
14
|
+
function getCallers(symbol) {
|
|
15
|
+
const value = symbol.meta?.callers;
|
|
16
|
+
if (!Array.isArray(value))
|
|
17
|
+
return [];
|
|
18
|
+
return value.filter((item) => typeof item === 'object' &&
|
|
19
|
+
item !== null &&
|
|
20
|
+
typeof item.name === 'string' &&
|
|
21
|
+
typeof item.path === 'string');
|
|
22
|
+
}
|
|
23
|
+
function toCandidate(symbol, score, reason, requiredProps, requiredHooks) {
|
|
24
|
+
const props = getMetaStrings(symbol, 'props');
|
|
25
|
+
const hooks = getMetaStrings(symbol, 'hooks');
|
|
26
|
+
const sideEffects = getMetaStrings(symbol, 'sideEffects');
|
|
27
|
+
return {
|
|
28
|
+
id: symbol.id,
|
|
29
|
+
name: symbol.name,
|
|
30
|
+
type: symbol.type,
|
|
31
|
+
path: symbol.path,
|
|
32
|
+
description: symbol.description,
|
|
33
|
+
usageCount: symbol.usageCount,
|
|
34
|
+
category: symbol.category,
|
|
35
|
+
score: Number(score.toFixed(3)),
|
|
36
|
+
reason,
|
|
37
|
+
matchedProps: requiredProps.filter((field) => props.map(normalizeToken).includes(normalizeToken(field))),
|
|
38
|
+
matchedHooks: requiredHooks.filter((field) => hooks.map(normalizeToken).includes(normalizeToken(field))),
|
|
39
|
+
callers: getCallers(symbol),
|
|
40
|
+
sideEffects,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function mergeCandidates(symbols) {
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const merged = [];
|
|
46
|
+
for (const symbol of symbols) {
|
|
47
|
+
if (seen.has(symbol.id))
|
|
48
|
+
continue;
|
|
49
|
+
seen.add(symbol.id);
|
|
50
|
+
merged.push(symbol);
|
|
51
|
+
}
|
|
52
|
+
return merged;
|
|
53
|
+
}
|
|
54
|
+
export class RecommendationService {
|
|
55
|
+
repository;
|
|
56
|
+
constructor(repository) {
|
|
57
|
+
this.repository = repository;
|
|
58
|
+
}
|
|
59
|
+
async recommendComponent(input) {
|
|
60
|
+
const requiredProps = uniqueStrings(input.requiredProps);
|
|
61
|
+
const requiredHooks = uniqueStrings(input.requiredHooks);
|
|
62
|
+
const structureFields = uniqueStrings([...requiredProps, ...requiredHooks]);
|
|
63
|
+
const preferSemantic = input.semantic ?? true;
|
|
64
|
+
const limit = input.limit ?? 5;
|
|
65
|
+
let queriedBy = preferSemantic
|
|
66
|
+
? 'semantic'
|
|
67
|
+
: 'keyword';
|
|
68
|
+
let searchResults;
|
|
69
|
+
if (preferSemantic) {
|
|
70
|
+
try {
|
|
71
|
+
searchResults = await this.repository.searchSemanticHits(input.query, {
|
|
72
|
+
type: 'component',
|
|
73
|
+
limit: Math.max(limit * 4, 12),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
queriedBy = 'keyword';
|
|
78
|
+
searchResults = (await this.repository.search(input.query, 'component')).map((symbol) => ({ symbol, similarity: 0 }));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
searchResults = (await this.repository.search(input.query, 'component')).map((symbol) => ({ symbol, similarity: 0 }));
|
|
83
|
+
}
|
|
84
|
+
const structureResults = structureFields.length
|
|
85
|
+
? await this.repository.searchByStructure(structureFields, {
|
|
86
|
+
type: 'component',
|
|
87
|
+
category: input.category,
|
|
88
|
+
limit: Math.max(limit * 4, 12),
|
|
89
|
+
})
|
|
90
|
+
: [];
|
|
91
|
+
const combined = mergeCandidates([
|
|
92
|
+
...structureResults,
|
|
93
|
+
...searchResults.map((item) => item.symbol),
|
|
94
|
+
]).filter((symbol) => input.category
|
|
95
|
+
? (symbol.category ?? '')
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.includes(input.category.toLowerCase())
|
|
98
|
+
: true);
|
|
99
|
+
if (combined.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
recommended: null,
|
|
102
|
+
alternatives: [],
|
|
103
|
+
queriedBy,
|
|
104
|
+
structureFilter: {
|
|
105
|
+
requiredProps,
|
|
106
|
+
requiredHooks,
|
|
107
|
+
},
|
|
108
|
+
message: '未找到符合条件的可复用组件。',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const ranked = queriedBy === 'semantic'
|
|
112
|
+
? rankSemanticHits(combined.map((symbol) => ({
|
|
113
|
+
symbol,
|
|
114
|
+
similarity: searchResults.find((item) => item.symbol.id === symbol.id)
|
|
115
|
+
?.similarity ?? 0.55,
|
|
116
|
+
})))
|
|
117
|
+
: rankSymbols(input.query, combined);
|
|
118
|
+
const candidates = ranked.map((item) => toCandidate(item.symbol, item.score, item.reason.summary, requiredProps, requiredHooks));
|
|
119
|
+
return {
|
|
120
|
+
recommended: candidates[0] ?? null,
|
|
121
|
+
alternatives: candidates.slice(1, limit),
|
|
122
|
+
queriedBy,
|
|
123
|
+
structureFilter: {
|
|
124
|
+
requiredProps,
|
|
125
|
+
requiredHooks,
|
|
126
|
+
},
|
|
127
|
+
message: candidates.length > 0
|
|
128
|
+
? '已找到可复用组件候选,首选已按综合匹配度排序。'
|
|
129
|
+
: '未找到符合条件的可复用组件。',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -1,44 +1,27 @@
|
|
|
1
|
-
import { z } from
|
|
2
|
-
import { recommendComponent } from "../skills/recommendComponent.js";
|
|
1
|
+
import { z } from 'zod';
|
|
3
2
|
export const recommendComponentInput = z.object({
|
|
4
3
|
query: z.string().min(1),
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
requiredProps: z.array(z.string().min(1)).optional(),
|
|
5
|
+
requiredHooks: z.array(z.string().min(1)).optional(),
|
|
6
|
+
category: z.string().optional(),
|
|
7
|
+
semantic: z.boolean().optional().default(true),
|
|
8
|
+
limit: z.number().int().min(1).max(10).optional().default(5),
|
|
7
9
|
});
|
|
8
|
-
export function createRecommendComponentTool(
|
|
10
|
+
export function createRecommendComponentTool(recommendationService) {
|
|
9
11
|
return {
|
|
10
|
-
name:
|
|
11
|
-
description:
|
|
12
|
-
|
|
13
|
-
工作流(自动完成,无需关心内部细节):
|
|
14
|
-
1. 关键词检索:根据 query 在 component 类型中搜索候选
|
|
15
|
-
2. 结构过滤:如果传了 props 参数,按结构匹配进一步过滤
|
|
16
|
-
3. 语义排序:对候选进行语义相似度排序
|
|
17
|
-
4. 详情补全:获取 Top 结果的完整代码详情
|
|
18
|
-
5. 返回推荐:输出 Top N 结果及评分、理由
|
|
19
|
-
|
|
20
|
-
输入参数:
|
|
21
|
-
- query:搜索关键词,如 "form"、"modal"、"table"
|
|
22
|
-
- props(可选):结构过滤所需的 props,如 ["onChange", "value"]
|
|
23
|
-
- limit(可选):返回数量,默认 3,最大 20
|
|
24
|
-
|
|
25
|
-
输出:
|
|
26
|
-
- results 数组,每项包含 name、path、score、reason、detail
|
|
27
|
-
- 如果没有合适结果,会返回最接近的候选并说明差距`,
|
|
12
|
+
name: 'recommend_component',
|
|
13
|
+
description: '高层推荐工具:当用户想找可复用 UI 组件时优先调用本工具。输入用户需求、必需 props/hooks 后,工具会自动完成候选搜索、结构过滤和首选推荐。',
|
|
28
14
|
inputSchema: recommendComponentInput.shape,
|
|
29
15
|
handler: async (input) => {
|
|
30
|
-
const result = await recommendComponent(input
|
|
31
|
-
props: input.props,
|
|
32
|
-
limit: input.limit
|
|
33
|
-
});
|
|
16
|
+
const result = await recommendationService.recommendComponent(input);
|
|
34
17
|
return {
|
|
35
18
|
content: [
|
|
36
19
|
{
|
|
37
|
-
type:
|
|
38
|
-
text: JSON.stringify(result, null, 2)
|
|
39
|
-
}
|
|
40
|
-
]
|
|
20
|
+
type: 'text',
|
|
21
|
+
text: JSON.stringify(result, null, 2),
|
|
22
|
+
},
|
|
23
|
+
],
|
|
41
24
|
};
|
|
42
|
-
}
|
|
25
|
+
},
|
|
43
26
|
};
|
|
44
27
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lorrylurui/code-intelligence-mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "MCP server 提供仓库内可复用代码块(ts/tsx/js/jsx/css/less)的索引和查询能力,支持基于代码上下文的智能推荐。",
|
|
6
6
|
"type": "module",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"dev": "tsx watch --clear-screen=false --exclude node_modules --exclude dist src/index.ts",
|
|
17
17
|
"dev:mcp": "node ./scripts/mcp-dev-watch.mjs",
|
|
18
18
|
"build": "tsc -p tsconfig.json",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
19
21
|
"start": "node dist/index.js",
|
|
20
22
|
"index": "tsx src/cli/index-codebase-cli.ts",
|
|
21
23
|
"ci-index": "tsx src/cli/ci-index-cli.ts",
|
|
@@ -44,7 +46,8 @@
|
|
|
44
46
|
"@types/node": "^22.10.1",
|
|
45
47
|
"@types/pg": "^8.20.0",
|
|
46
48
|
"tsx": "^4.19.2",
|
|
47
|
-
"typescript": "^5.6.3"
|
|
49
|
+
"typescript": "^5.6.3",
|
|
50
|
+
"vitest": "^3.2.4"
|
|
48
51
|
},
|
|
49
52
|
"publishConfig": {
|
|
50
53
|
"registry": "https://registry.npmjs.org/",
|