@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 CHANGED
@@ -4,6 +4,7 @@
4
4
  - Tool: `search_symbols`
5
5
  - Tool: `get_symbol_detail`
6
6
  - Tool: `search_by_structure`
7
+ - Tool: `recommend_component`
7
8
  - Tool: `reindex`
8
9
  - Tool: `incUsage`
9
10
  - Prompt: `reusable-code-advisor`
@@ -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) ? right : 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
- .filter((p) => bt.isIdentifier(p))
252
- .map((p) => p.name);
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: null,
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
- returnType: getReturnType(decl),
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 (!fn.returnType)
333
- return undefined;
334
- const rt = fn.returnType;
335
- if (bt.isTSTypeAnnotation(rt)) {
336
- const inner = rt.typeAnnotation;
337
- if (bt.isTSStringKeyword(inner))
338
- return 'string';
339
- if (bt.isTSNumberKeyword(inner))
340
- return 'number';
341
- if (bt.isTSBooleanKeyword(inner))
342
- return 'boolean';
343
- if (bt.isTSVoidKeyword(inner))
344
- return 'void';
345
- if (bt.isTSAnyKeyword(inner))
346
- return 'any';
347
- if (bt.isTSUnknownKeyword(inner))
348
- return 'unknown';
349
- if (bt.isTSNullKeyword(inner))
350
- return 'null';
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 undefined;
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
- if (params.length === 0)
25
- return [];
26
- const first = params[0];
27
- const nameNode = first.getNameNode();
28
- if (Node.isObjectBindingPattern(nameNode)) {
29
- return nameNode.getElements().map((el) => el.getName());
30
- }
31
- if (Node.isIdentifier(nameNode)) {
32
- return [nameNode.getText()];
33
- }
34
- return [];
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 (const m of markers) {
48
- if (filePath.toLowerCase().includes(`/${m}/`)) {
49
- return m;
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
- if (/use$/i.test(exportName))
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: props.length ? props : params };
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 name = param.name;
65
- const type = jsdoc?.params[name] ?? 'unknown';
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
- const props = param.properties.map((p) => {
70
- if (p.key?.name) {
71
- const name = p.key.name;
72
- const type = jsdoc?.params[name] ?? 'unknown';
73
- return `${name}:${type}`;
74
- }
75
- return 'unknown';
76
- });
77
- return `{${props.sort().join(';')}}`;
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, `$p${i}`, jsdoc));
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, `$p${i}`, jsdoc));
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) &&
@@ -0,0 +1,6 @@
1
+ export function makeParamPlaceholder(index, isRest = false) {
2
+ return `${isRest ? '...' : ''}$p${index}`;
3
+ }
4
+ export function isParamPlaceholder(name) {
5
+ return /^\.\.\.\$p\d+$|^\$p\d+$/.test(name);
6
+ }
@@ -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, `$p${paramIdx++}`);
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 `${prefix}$p${index}:${typeStr}${suffix}`;
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 `${typeName}<${normalizedArgs.join(',')}>`;
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 typeName;
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. 调用 search_symbols 检索候选,type 根据用户需求传(component/function/hook/class/type/interface);描述功能意图时设置 semantic=true
11
- 2. 如果用户指定了结构过滤条件(props/params/properties/hooks),额外调用 search_by_structure 做结构匹配
12
- 3. search_symbols(limit=20) 拉候选,再对 Top 3 调用 get_symbol_detail 做深度判断
13
- 4. 若仅凭签名/摘要无法判断,对最相关的若干候选调用 get_symbol_detail 获取详情
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
- 6. 给出**唯一首选**推荐,并说明理由,同时使用 **AskUserQuestion** 工具,提供两个选项:
18
+ 5. 给出**唯一首选**推荐,并说明理由,同时使用 **AskUserQuestion** 工具,提供两个选项:
20
19
  - 采纳推荐
21
20
  - 取消
22
- 7. 用户选择"采纳推荐"后,立即调用 inc_usage 工具记录该行为(symbolId 从搜索结果的 id 字段获取),不要遗漏此步骤。
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 "zod";
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
- props: z.array(z.string().min(1)).optional(),
6
- limit: z.number().int().min(1).max(20).optional().default(3)
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(repository) {
10
+ export function createRecommendComponentTool(recommendationService) {
9
11
  return {
10
- name: "recommend_component",
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.query, repository, {
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: "text",
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.2",
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/",