@lorrylurui/code-intelligence-mcp 1.1.15 → 1.2.0

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
@@ -1,4 +1,4 @@
1
- # Code Intelligence MCP (Minimal)
1
+ # Code Intelligence MCP
2
2
 
3
3
  - MCP Server(stdio)
4
4
  - Tool: `search_symbols`
@@ -8,7 +8,6 @@
8
8
  - Tool: `recommend_component`
9
9
  - Tool: `incUsage`
10
10
  - Prompt: `reusable-code-advisor`
11
- - MySQL Repository(可选启用)
12
11
  - Cursor Skill:`reusable-code-advisor`(`.cursor/skills/reusable-code-advisor/`,
13
12
 
14
13
  ## 1) 配置mcp servers
@@ -32,5 +31,77 @@
32
31
 
33
32
  ## 3) 项目根目录环境变量
34
33
 
34
+ <!-- 最小配置 1.表名 2.需要检索的文件路径和类型 -->
35
+
35
36
  MYSQL\*SYMBOLS_TABLE=frontend_collections_symbols
36
- INDEX_GLOB=interview-code-collection/\*\*/\_.{js,jsx,ts,tsx}
37
+ INDEX_GLOB=xxx/\*\*/\_.{js,jsx,ts,tsx}
38
+
39
+ # 待优化项
40
+
41
+ 修复优先级:
42
+ ✅8
43
+ ✅5
44
+ ✅134 done,但是第二层embedding需要优化,llm fallback太慢+漂移,也需要调整模型
45
+ ✅6
46
+ ✅27
47
+
48
+ 1. meta里面有多个信息,哪些做结构化过滤,哪些做向量检索?
49
+ 结论:ast normalizers后拼一个语义模板,用这个模板内容生成向量
50
+ ❓做法见qa-doc/semantic-phrase.md
51
+ 2. 对于 class类型,content字段保留关键方法或摘要,而不是完全为空
52
+ 最新修改:content赋值为语义模板
53
+ 3. category过于模糊,
54
+ 这三层怎么做:category 优先使用规则和 embedding 分类,
55
+ LLM 只作为 fallback,避免不稳定和成本问题
56
+ ❓做法见qa-doc/category.md
57
+ 4. type category meta.kind 字段是否多余了?type只有5个值,
58
+ type表达代码结构、category表达语义结构,kind?
59
+ type: function / component / hook / class / type / interface
60
+ category:最新的三层结构(还没实现,只有文档)
61
+ kind: 现在跟type重叠较多,建议弱化meta.kind → 只保留特殊情况:
62
+ ❓改造方法qa-doc/type-category-kind.md
63
+ 5. 在ci做增量索引时,把changed files,如果是1000+文件,性能爆炸,考虑用file hash 判断?embedding也没有优化缓存?
64
+ ❓见qa-doc/ci-hash-solution 方案:🥈 file hash + ast normalizer hash,新增semantic_hash
65
+ - CI 增量(git changed files 触发)
66
+ 只需要 semantic_hash
67
+ file_hash 可省,因为文件必然变了
68
+ - 每日全量扫描
69
+ file_hash 用来跳过 AST 解析(CPU 优化)
70
+ semantic_hash 用来跳过 embedding(费用优化)
71
+ content_hash 删掉,职责完全被 semantic_hash 覆盖
72
+ 6. 大仓问题:
73
+ ❓big-repo.md
74
+ - ci embedding解耦,新增embedding_status, ci时,全量写入status='pending'-> ci finish
75
+ - ci如果检测到文件删除,则对被删除的代码块标记delete(这里需要新增字段)
76
+ - node+redis 消费写embedding job
77
+ - 对语义模板semantic_hash做向量缓存,semantic_hash相同即功能未变
78
+ - 大仓分片并行
79
+ 7. content暂时用不到,但也不用删除,目前暴利截取4000字符需要优化:
80
+ content(降级为辅助字段):✔ 不参与 embedding✔ 不参与排序✔ 不参与过滤✔ 用于:1. LLM改造建议 2.debug 3.future rerank
81
+ 最简单:只存 signature
82
+ 最优:content = {
83
+ signature: "function fetchData(url, options)",
84
+ snippet: "核心逻辑代码(<=300行)",
85
+ keyCalls: ["fetch", "cache"]
86
+ }
87
+ 8. TopK???,首先去掉usage过滤,再做两次topk,1.根据余弦相似度选topk 2.对1的结果用现有的usage,updated_at等加权排序
88
+ ❓topK.md
89
+ 现在:SQL过滤(type) → ORDER BY usage_count DESC LIMIT 3000→ embedding 相似度排序→ 取 top20
90
+ 这个逻辑不对,导致query: "debounce function",debounce 使用少 ❌ fetch 很热门 ✅,结果Top3000里全是 fetch / request, debounce 被过滤掉 ❌
91
+
92
+ 👉 优点:
93
+ • 不阻塞 CI
94
+ • 可扩展
95
+
96
+ 6. 大仓问题呢?
97
+
98
+ # 简历里还没做的优化
99
+
100
+ 1. embedding基石 - 语义模板模板,使用ast数据拼装语义模板
101
+ 2. class的content为null
102
+ 3. category分层 1.规则 2.预设所有种类,使用embedding召回 3.llm兜底
103
+ 4. type meta.kind逻辑优化,现在太重叠了
104
+ 5. ci-hash-solution
105
+ 6. 大仓问题
106
+ 7. content优化
107
+ 8. ✅topk优化
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI增量索引CLI:处理changed files和deleted files
4
+ *
5
+ * 用法:
6
+ * node src/cli/ci-index-cli.js --changed src/file1.ts,src/file2.ts --deleted src/old.ts --renamed src/old.ts:src/new.ts
7
+ */
8
+ import { resolve } from 'node:path';
9
+ import { loadProjectDotenv } from '../config/env.js';
10
+ import { runIncrementalIndex } from './ci-index.js';
11
+ async function main() {
12
+ const args = process.argv.slice(2);
13
+ const projectRoot = resolve(process.env.INDEX_ROOT ?? process.cwd());
14
+ loadProjectDotenv(projectRoot);
15
+ let changedFiles = [];
16
+ let deletedFiles = [];
17
+ let renamedFiles = [];
18
+ for (let i = 0; i < args.length; i++) {
19
+ const arg = args[i];
20
+ if (arg === '--changed' && i + 1 < args.length) {
21
+ changedFiles = args[i + 1]
22
+ .split(',')
23
+ .map((s) => s.trim())
24
+ .filter(Boolean);
25
+ i++;
26
+ }
27
+ else if (arg === '--deleted' && i + 1 < args.length) {
28
+ deletedFiles = args[i + 1]
29
+ .split(',')
30
+ .map((s) => s.trim())
31
+ .filter(Boolean);
32
+ i++;
33
+ }
34
+ else if (arg === '--renamed' && i + 1 < args.length) {
35
+ renamedFiles = args[i + 1]
36
+ .split(',')
37
+ .map((s) => {
38
+ const [from, to] = s.split(':');
39
+ return { from: from.trim(), to: to.trim() };
40
+ })
41
+ .filter((r) => r.from && r.to);
42
+ i++;
43
+ }
44
+ }
45
+ if (changedFiles.length === 0 &&
46
+ deletedFiles.length === 0 &&
47
+ renamedFiles.length === 0) {
48
+ console.error('Usage: node ci-index-cli.js --changed file1,file2 --deleted file3 --renamed old:new');
49
+ process.exit(1);
50
+ }
51
+ console.error(`[ci-index-cli] projectRoot=${projectRoot}`);
52
+ console.error(`[ci-index-cli] changed: ${changedFiles.join(', ')}`);
53
+ console.error(`[ci-index-cli] deleted: ${deletedFiles.join(', ')}`);
54
+ console.error(`[ci-index-cli] renamed: ${renamedFiles.map((r) => `${r.from}->${r.to}`).join(', ')}`);
55
+ await runIncrementalIndex({
56
+ projectRoot,
57
+ changedFiles,
58
+ deletedFiles,
59
+ renamedFiles,
60
+ });
61
+ console.error('[ci-index-cli] completed successfully');
62
+ }
63
+ main().catch((err) => {
64
+ console.error('[ci-index-cli] failed:', err);
65
+ process.exit(1);
66
+ });
@@ -0,0 +1,80 @@
1
+ // CI增量索引:处理changed files和deleted files
2
+ import { env, loadProjectDotenv } from '../config/env.js';
3
+ import { getMySqlPool } from '../db/mysql.js';
4
+ import { indexProject } from '../indexer/indexProject.js';
5
+ import { DEFAULT_STATUS_ON_UPSERT, SYMBOL_STATUS, } from '../config/symbolStatus.js';
6
+ import { enqueueEmbeddingBatch, closeEmbeddingQueue, } from '../services/embeddingQueue.js';
7
+ export async function runIncrementalIndex(opts) {
8
+ const { projectRoot, changedFiles, deletedFiles, renamedFiles = [] } = opts;
9
+ loadProjectDotenv(projectRoot);
10
+ const pool = getMySqlPool();
11
+ if (!pool) {
12
+ throw new Error('Failed to get MySQL pool');
13
+ }
14
+ const tableName = env.mysqlSymbolsTable;
15
+ // 1. 删除文件:标记 offline
16
+ for (const file of deletedFiles) {
17
+ await pool.query(`UPDATE ${tableName} SET status = ? WHERE path = ?`, [
18
+ SYMBOL_STATUS.OFFLINE,
19
+ file,
20
+ ]);
21
+ console.error(`[ci-index] marked offline: ${file}`);
22
+ }
23
+ // 2. 重命名文件:更新path
24
+ for (const { from, to } of renamedFiles) {
25
+ await pool.query(`UPDATE ${tableName} SET path = ? WHERE path = ?`, [
26
+ to,
27
+ from,
28
+ ]);
29
+ console.error(`[ci-index] renamed: ${from} -> ${to}`);
30
+ }
31
+ // 3. 变更/新增文件:重新索引并标记 pending
32
+ if (changedFiles.length > 0) {
33
+ const rows = await indexProject({
34
+ projectRoot,
35
+ globPatterns: changedFiles,
36
+ });
37
+ for (const row of rows) {
38
+ // 写入结构化数据,标记pending
39
+ await pool.query(`INSERT INTO ${tableName}
40
+ (name, type, category, path, description, content, meta,
41
+ file_hash, semantic_hash, status,
42
+ usage_count, created_at, updated_at)
43
+ VALUES (?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, 0, NOW(), NOW())
44
+ ON DUPLICATE KEY UPDATE
45
+ type = VALUES(type),
46
+ category = VALUES(category),
47
+ description = VALUES(description),
48
+ content = VALUES(content),
49
+ meta = VALUES(meta),
50
+ file_hash = VALUES(file_hash),
51
+ semantic_hash = VALUES(semantic_hash),
52
+ status = ?,
53
+ updated_at = NOW()`, [
54
+ row.name,
55
+ row.type,
56
+ row.category ?? null,
57
+ row.path,
58
+ row.description ?? null,
59
+ row.content ?? null,
60
+ JSON.stringify(row.meta),
61
+ row.file_hash,
62
+ row.semantic_hash,
63
+ DEFAULT_STATUS_ON_UPSERT,
64
+ DEFAULT_STATUS_ON_UPSERT,
65
+ ]);
66
+ console.error(`[ci-index] indexed (pending): ${row.path}:${row.name}`);
67
+ }
68
+ // 批量入队:jobId = semanticHash,相同 hash 自动去重,1000 个符号可能只产生 N 个唯一 job
69
+ const hashes = [
70
+ ...new Set(rows.map((r) => r.semantic_hash).filter(Boolean)),
71
+ ];
72
+ if (hashes.length > 0) {
73
+ await enqueueEmbeddingBatch(hashes);
74
+ console.error(`[ci-index] enqueued ${hashes.length} unique semantic hashes for embedding`);
75
+ }
76
+ }
77
+ await closeEmbeddingQueue();
78
+ await pool.end();
79
+ console.error(`[ci-index] processed ${deletedFiles.length} deletions, ${renamedFiles.length} renames, ${changedFiles.length} changes`);
80
+ }
@@ -109,8 +109,7 @@ async function main() {
109
109
  }
110
110
  }
111
111
  loadProjectDotenv(projectRoot);
112
- console.error(`[duplicate-check] projectRoot=${projectRoot}, ` +
113
- `MYSQL_ENABLED=${process.env.MYSQL_ENABLED}`);
112
+ console.error(`[duplicate-check] projectRoot=${projectRoot}, `);
114
113
  // 3️ 解析命令行参数
115
114
  const args = parseArgs(process.argv.slice(2));
116
115
  const changedFilesPath = args.get('changed-files') ?? 'changed_files.txt';
@@ -131,10 +130,6 @@ async function main() {
131
130
  }
132
131
  else {
133
132
  validateEnv();
134
- const pool = getMySqlPool();
135
- if (!pool || !env.mysqlEnabled) {
136
- throw new Error('duplicate-check 需要 MYSQL_ENABLED=true 并可连接 MySQL。');
137
- }
138
133
  if (!env.embeddingServiceUrl) {
139
134
  throw new Error('duplicate-check 需要 EMBEDDING_SERVICE_URL(embedding service)。');
140
135
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * embedding worker 进程入口。
3
+ *
4
+ * 本地启动:
5
+ * npm run worker:embedding
6
+ *
7
+ * 大仓分片(多进程并行):
8
+ * WORKER_CONCURRENCY=10 npm run worker:embedding &
9
+ * WORKER_CONCURRENCY=10 npm run worker:embedding &
10
+ * # 启动 N 个进程,BullMQ 自动分配任务,无需手动分片
11
+ *
12
+ * 环境变量:
13
+ * REDIS_URL Redis 连接 URL(默认 redis://127.0.0.1:6379)
14
+ * MYSQL_HOST / ... MySQL 连接配置
15
+ * EMBEDDING_SERVICE_URL Python embedding 服务地址
16
+ * WORKER_CONCURRENCY 单进程并发 job 数(默认 5)
17
+ * WORKER_RPM_LIMIT 全局 RPM 上限(默认 100,跨所有 worker 进程)
18
+ * PROJECT_ROOT 项目根目录,用于加载 .env(默认 cwd)
19
+ */
20
+ import { loadProjectDotenv } from '../config/env.js';
21
+ import { startEmbeddingWorker } from '../workers/embeddingWorker.js';
22
+ const projectRoot = process.env.PROJECT_ROOT ?? process.cwd();
23
+ loadProjectDotenv(projectRoot);
24
+ const concurrency = Number(process.env.WORKER_CONCURRENCY ?? '5');
25
+ const rpmLimit = Number(process.env.WORKER_RPM_LIMIT ?? '100');
26
+ const worker = startEmbeddingWorker({ concurrency, rpmLimit });
27
+ console.error(`[embedding-worker] started concurrency=${concurrency} rpm_limit=${rpmLimit}`);
28
+ // 优雅关闭:等当前 job 执行完再退出
29
+ for (const sig of ['SIGINT', 'SIGTERM']) {
30
+ process.on(sig, async () => {
31
+ console.error('[embedding-worker] shutting down…');
32
+ await worker.close();
33
+ process.exit(0);
34
+ });
35
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Phase 2 CLI:扫描代码库并写入 MySQL `symbols`(需 `MYSQL_ENABLED=true`)。
3
+ * Phase 2 CLI:扫描代码库并写入 MySQL `symbols`。
4
4
  *
5
5
  * 环境变量加载顺序:
6
6
  * 1. 命令行参数(最高优先级)
@@ -16,10 +16,11 @@ import { runReindex } from '../services/reindex.js';
16
16
  * 进程退出码:成功 `0`,无 MySQL 或异常 `1`。
17
17
  */
18
18
  async function main() {
19
+ // const projectRoot = resolve(process.env.INDEX_ROOT ?? process.cwd());
20
+ loadProjectDotenv(resolve(process.env.INDEX_ROOT ?? process.cwd()));
19
21
  const projectRoot = resolve(process.env.INDEX_ROOT ?? process.cwd());
20
- loadProjectDotenv(projectRoot);
21
- console.error(`[index] MYSQL_ENABLED=${process.env.MYSQL_ENABLED}, ` +
22
- `MYSQL_HOST=${process.env.MYSQL_HOST}` +
22
+ console.error(projectRoot, process.env.INDEX_ROOT);
23
+ console.error(`MYSQL_HOST=${process.env.MYSQL_HOST}` +
23
24
  `[index] projectRoot=${projectRoot}`);
24
25
  const globPatterns = process.env.INDEX_GLOB
25
26
  ? process.env.INDEX_GLOB.split(/\s+/)
@@ -72,15 +72,13 @@ const requiredWhenEnabled = [
72
72
  'MYSQL_USER',
73
73
  'MYSQL_DATABASE',
74
74
  ];
75
- console.error(`[Config] MYSQL_ENABLED: ${process.env.MYSQL_ENABLED},
76
- MYSQL_HOST: ${process.env.MYSQL_HOST},
75
+ console.error(`[Config] MYSQL_HOST: ${process.env.MYSQL_HOST},
77
76
  MYSQL_USER: ${process.env.MYSQL_USER},
78
77
  MYSQL_DATABASE: ${process.env.MYSQL_DATABASE},
79
78
  EMBEDDING_SERVICE_URL: ${process.env.EMBEDDING_SERVICE_URL},
80
79
  MYSQL_SYMBOLS_TABLE: ${process.env.MYSQL_SYMBOLS_TABLE}
81
80
  `);
82
81
  export const env = {
83
- mysqlEnabled: process.env.MYSQL_ENABLED === 'true',
84
82
  mysqlHost: process.env.MYSQL_HOST ?? '127.0.0.1',
85
83
  mysqlPort: Number(process.env.MYSQL_PORT ?? '3306'),
86
84
  mysqlUser: process.env.MYSQL_USER ?? 'root',
@@ -90,11 +88,10 @@ export const env = {
90
88
  mysqlSymbolsTable: process.env.MYSQL_SYMBOLS_TABLE ?? 'symbols',
91
89
  /** Phase 5:指向 Python FastAPI 嵌入服务根 URL,如 http://127.0.0.1:8765 */
92
90
  embeddingServiceUrl: (process.env.EMBEDDING_SERVICE_URL ?? '').trim(),
91
+ /** Redis 连接 URL,供 BullMQ embedding worker 使用 */
92
+ redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
93
93
  };
94
94
  export function validateEnv() {
95
- if (!env.mysqlEnabled) {
96
- return;
97
- }
98
95
  for (const key of requiredWhenEnabled) {
99
96
  if (!process.env[key]) {
100
97
  throw new Error(`Missing environment variable: ${key}`);
@@ -0,0 +1,8 @@
1
+ export const SYMBOL_STATUS = {
2
+ OFFLINE: 0,
3
+ PENDING: 1,
4
+ ONLINE: 2,
5
+ ERROR: 3,
6
+ };
7
+ export const DEFAULT_STATUS_ON_UPSERT = SYMBOL_STATUS.PENDING;
8
+ export const SEARCHABLE_STATUS = SYMBOL_STATUS.ONLINE;
package/dist/db/mysql.js CHANGED
@@ -1,10 +1,7 @@
1
- import mysql from "mysql2/promise";
2
- import { env } from "../config/env.js";
1
+ import mysql from 'mysql2/promise';
2
+ import { env } from '../config/env.js';
3
3
  let pool = null;
4
4
  export function getMySqlPool() {
5
- if (!env.mysqlEnabled) {
6
- return null;
7
- }
8
5
  if (!pool) {
9
6
  pool = mysql.createPool({
10
7
  host: env.mysqlHost,
@@ -13,7 +10,7 @@ export function getMySqlPool() {
13
10
  password: env.mysqlPassword,
14
11
  database: env.mysqlDatabase,
15
12
  waitForConnections: true,
16
- connectionLimit: 10
13
+ connectionLimit: 10,
17
14
  });
18
15
  }
19
16
  return pool;
package/dist/db/schema.js CHANGED
@@ -2,13 +2,14 @@
2
2
  * 动态生成数据库表结构 SQL,表名可通过环境变量配置
3
3
  */
4
4
  import { env } from '../config/env.js';
5
+ import { DEFAULT_STATUS_ON_UPSERT } from '../config/symbolStatus.js';
5
6
  /** 获取 symbols 表的建表 SQL */
6
7
  export function getSymbolsTableSQL() {
7
8
  const tableName = env.mysqlSymbolsTable;
8
9
  return `CREATE TABLE IF NOT EXISTS ${tableName} (
9
10
  id INT PRIMARY KEY AUTO_INCREMENT,
10
11
  name VARCHAR(255) NOT NULL,
11
- type ENUM('component', 'util', 'selector', 'type') NOT NULL,
12
+ type ENUM('component', 'function', 'type', 'class', 'interface', 'hook') NOT NULL,
12
13
  category VARCHAR(255) NULL,
13
14
  path TEXT NOT NULL,
14
15
  description TEXT NULL,
@@ -20,7 +21,13 @@ export function getSymbolsTableSQL() {
20
21
  updated_user VARCHAR(255) NOT NULL DEFAULT 'LorryIsLuRui',
21
22
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
22
23
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
23
- UNIQUE KEY uk_symbols_path_name (path(512), name(255))
24
+ file_hash VARCHAR(64) NULL COMMENT '文件内容 SHA256',
25
+ semantic_hash VARCHAR(64) NULL COMMENT 'normalized AST 语义模板 SHA256',
26
+ status TINYINT NOT NULL DEFAULT ${DEFAULT_STATUS_ON_UPSERT} COMMENT '状态: 0-offline(删除), 1-pending(待处理), 2-online(可用), 3-error(错误)',
27
+ UNIQUE KEY uk_symbols_path_name (path(512), name(255)),
28
+ INDEX idx_file_hash (file_hash),
29
+ INDEX idx_semantic_hash (semantic_hash),
30
+ INDEX idx_status (status)
24
31
  )`;
25
32
  }
26
33
  /** 获取所有建表 SQL(可一次性执行) */
@@ -0,0 +1,201 @@
1
+ /**
2
+ * astNormalizer.ts
3
+ * 对 ts-morph Node 做语义级标准化,生成 semantic_hash。
4
+ *
5
+ * 去掉:参数名、泛型参数名、函数体实现、空白格式、字面量值
6
+ * 保留:参数类型结构、返回类型、sideEffects、hooks
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { Node, SyntaxKind } from 'ts-morph';
10
+ // ─────────────────────────────────────────────
11
+ // 内置类型白名单:不替换为 $T
12
+ // ─────────────────────────────────────────────
13
+ const BUILTIN_TYPES = new Set([
14
+ 'string',
15
+ 'number',
16
+ 'boolean',
17
+ 'void',
18
+ 'null',
19
+ 'undefined',
20
+ 'never',
21
+ 'unknown',
22
+ 'any',
23
+ 'object',
24
+ 'symbol',
25
+ 'bigint',
26
+ 'Promise',
27
+ 'Array',
28
+ 'Record',
29
+ 'Map',
30
+ 'Set',
31
+ 'WeakMap',
32
+ 'WeakSet',
33
+ 'Partial',
34
+ 'Required',
35
+ 'Readonly',
36
+ 'Pick',
37
+ 'Omit',
38
+ 'Exclude',
39
+ 'Extract',
40
+ 'NonNullable',
41
+ 'ReturnType',
42
+ 'InstanceType',
43
+ 'React',
44
+ 'ReactNode',
45
+ 'ReactElement',
46
+ 'FC',
47
+ 'MouseEvent',
48
+ 'KeyboardEvent',
49
+ 'ChangeEvent',
50
+ 'HTMLElement',
51
+ 'HTMLDivElement',
52
+ 'HTMLInputElement',
53
+ 'CSSProperties',
54
+ 'RefObject',
55
+ 'MutableRefObject',
56
+ ]);
57
+ function normalizeTypeName(name) {
58
+ if (BUILTIN_TYPES.has(name))
59
+ return name;
60
+ if (/^T[A-Z]/.test(name) || (name.length === 1 && /[A-Z]/.test(name)))
61
+ return '$T';
62
+ return name;
63
+ }
64
+ function normalizeTypeString(typeStr) {
65
+ return typeStr
66
+ .replace(/\b([A-Z][A-Za-z0-9]*)\b/g, (match) => normalizeTypeName(match))
67
+ .replace(/\s+/g, ' ')
68
+ .trim();
69
+ }
70
+ // ─────────────────────────────────────────────
71
+ // normalizeNode:递归遍历 AST,输出标准化字符串
72
+ // ─────────────────────────────────────────────
73
+ export function normalizeNode(node) {
74
+ const paramNames = new Map();
75
+ let paramIdx = 0;
76
+ function allocParam(name) {
77
+ if (!paramNames.has(name))
78
+ paramNames.set(name, `$p${paramIdx++}`);
79
+ return paramNames.get(name);
80
+ }
81
+ function visit(n) {
82
+ const kind = n.getKind();
83
+ // 函数体 → {}(不关心实现)
84
+ if (kind === SyntaxKind.Block)
85
+ return '{}';
86
+ // 参数:只保留类型,去参数名
87
+ if (Node.isParameterDeclaration(n)) {
88
+ const nameNode = n.getNameNode();
89
+ const typeNode = n.getTypeNode();
90
+ const typeStr = typeNode
91
+ ? normalizeTypeString(typeNode.getText())
92
+ : '$unknown';
93
+ const prefix = n.isRestParameter() ? '...' : '';
94
+ const suffix = n.hasInitializer() ? '=$default' : '';
95
+ // 解构参数:{ userId, options }: Config → {}:Config
96
+ if (Node.isObjectBindingPattern(nameNode) ||
97
+ Node.isArrayBindingPattern(nameNode)) {
98
+ return `${prefix}{}:${typeStr}${suffix}`;
99
+ }
100
+ allocParam(n.getName());
101
+ return `${prefix}$p:${typeStr}${suffix}`;
102
+ }
103
+ // 泛型参数:T / TData → $T
104
+ if (Node.isTypeParameterDeclaration(n)) {
105
+ const constraint = n.getConstraint();
106
+ return `$T${constraint ? ` extends ${normalizeTypeString(constraint.getText())}` : ''}`;
107
+ }
108
+ // 类型引用:标准化名称
109
+ if (Node.isTypeReference(n))
110
+ return normalizeTypeString(n.getText());
111
+ // JSX:只记录存在
112
+ if (Node.isJsxElement(n) || Node.isJsxSelfClosingElement(n))
113
+ return '<JSX/>';
114
+ // 字面量 → 占位符
115
+ if (kind === SyntaxKind.StringLiteral)
116
+ return '"$s"';
117
+ if (kind === SyntaxKind.NumericLiteral ||
118
+ kind === SyntaxKind.BigIntLiteral)
119
+ return '$n';
120
+ if (kind === SyntaxKind.TrueKeyword || kind === SyntaxKind.FalseKeyword)
121
+ return '$b';
122
+ const children = n.getChildren();
123
+ if (children.length === 0)
124
+ return n.getText();
125
+ return children.map(visit).join('');
126
+ }
127
+ return visit(node).replace(/\s+/g, ' ').trim();
128
+ }
129
+ // ─────────────────────────────────────────────
130
+ // extractNormalizedSignature:从声明节点提取标准化签名
131
+ // ─────────────────────────────────────────────
132
+ export function extractNormalizedSignature(node) {
133
+ // 函数声明 / 箭头函数 / 函数表达式
134
+ if (Node.isFunctionDeclaration(node) ||
135
+ Node.isArrowFunction(node) ||
136
+ Node.isFunctionExpression(node)) {
137
+ const typeParams = Node.isFunctionDeclaration(node)
138
+ ? node.getTypeParameters().map((tp) => normalizeNode(tp))
139
+ : [];
140
+ const params = node.getParameters().map((p) => normalizeNode(p));
141
+ const retNode = node.getReturnTypeNode?.();
142
+ const returnType = retNode
143
+ ? normalizeTypeString(retNode.getText())
144
+ : '$inferred';
145
+ const tpStr = typeParams.length ? `<${typeParams.join(',')}>` : '';
146
+ return `fn${tpStr}(${params.join(',')})=>${returnType}`;
147
+ }
148
+ // 变量声明(const foo = () => {})
149
+ if (Node.isVariableDeclaration(node)) {
150
+ const init = node.getInitializer();
151
+ if (init &&
152
+ (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
153
+ return extractNormalizedSignature(init);
154
+ }
155
+ return normalizeNode(node);
156
+ }
157
+ // interface / type alias
158
+ if (Node.isInterfaceDeclaration(node) ||
159
+ Node.isTypeAliasDeclaration(node)) {
160
+ return normalizeNode(node);
161
+ }
162
+ // class:只取方法签名列表
163
+ if (Node.isClassDeclaration(node)) {
164
+ const methods = node.getMethods().map((m) => {
165
+ const params = m.getParameters().map((p) => normalizeNode(p));
166
+ const retNode = m.getReturnTypeNode();
167
+ const ret = retNode
168
+ ? normalizeTypeString(retNode.getText())
169
+ : '$inferred';
170
+ return `${m.getName()}(${params.join(',')})=>${ret}`;
171
+ });
172
+ return `class{${methods.join(';')}}`;
173
+ }
174
+ return normalizeNode(node);
175
+ }
176
+ // ─────────────────────────────────────────────
177
+ // computeSemanticHash
178
+ // 纳入:标准化签名 + name + type + description + sideEffects + hooks
179
+ // 排除:参数名、实现、格式、callers/callees
180
+ // ─────────────────────────────────────────────
181
+ export function computeSemanticHash(row) {
182
+ const node = row.node || null;
183
+ const meta = row.meta || {};
184
+ const stable = {
185
+ name: row.name,
186
+ type: row.type,
187
+ description: row.description ?? null,
188
+ signature: node ? extractNormalizedSignature(node) : '',
189
+ sideEffects: [
190
+ ...(meta.sideEffects ?? []),
191
+ ].sort(),
192
+ hooks: [...(meta.hooks ?? [])].sort(),
193
+ };
194
+ return createHash('sha256').update(JSON.stringify(stable)).digest('hex');
195
+ }
196
+ // ─────────────────────────────────────────────
197
+ // computeFileHash:对文件原始内容
198
+ // ─────────────────────────────────────────────
199
+ export function computeFileHash(fileContent) {
200
+ return createHash('sha256').update(fileContent).digest('hex');
201
+ }