传统重复检测无法捕捉重命名后的复制。dry-mcp基于语义分析代码重复,按维护成本排序,让AI agent优先处理高价值重复。
我参与过的每一个 CI 流水线都有一份重复代码报告。我已经想不起来上一次认真看完一份报告是什么时候了。
数字从 4.2% 变到 4.5%,弹出一堆代码块,其中一半是构造函数、卫语句和属性映射——它们看起来相似是因为语言本身就是这样写的。而真正让我吃苦头的那段复制品从来没上过榜:有人(唉,就是我自己)复制了一个函数,改了三个变量名字,后来修 bug 时只改了其中一个,另一个完全漏掉了。
我们怪截止日期紧,但 StackOverflow 已经不能背锅了,所以也许是时候清理代码库了。
于是我写了 dry-mcp,一个用语义而非 token 匹配重复代码的 MCP 服务,按保留成本排序,把结果交给 AI agent 而不是扔给一个仪表盘。
SonarQube 在它的设计目标上做得很好,所以让我们精确描述一下它的设计目标是什么。根据文档,当至少 100 个连续重复的 token 分布在至少 10 行代码中时(Java 使用 10 个连续语句代替),这段代码块才算重复。缩进和字符串字面量的差异会被忽略。
由此产生两个后果:
重命名的副本漏掉了。 缩进和字面量被忽略,但标识符不会。每隔几行改一次变量名,100 个相同 token 的连续序列就断了。
每行重复代码权重相同。 标题指标是一个百分比,它告诉你有多少重复,而不是哪个重复最值得先修。
这是一个质量关卡("新代码重复率不得超过 X%")的正确设计,但它不是我实际问题的正确答案:我应该花一个下午修哪个?
匹配分两轮进行。
第一轮,所有代码块做归一化处理(去除格式和注释)后计算哈希。哈希相同的就是完全相同的副本。这步免费且绝不会错。
剩下的部分用 jina-embeddings-v2-base-code 的嵌入向量来比较,这是一个在代码上训练出来的模型。做同样事情的两段代码即使所有名字都不同也会靠得很近。用这个模型测量:
| 场景 | 相似度 |
|---|---|
| 完全相同的代码 | 0.98 |
| 改过名字的同一段逻辑 | 0.89 |
| 移植到另一门语言的对等代码 | 0.85 |
是的,第二行意味着一个被移植到另一门语言的辅助函数仍然被识别为同一段代码。我原本没打算做这个功能,但按语义匹配自然就有了。
一个 60 行的代码块被复制了 4 次,这是个真正的问题:每次改动都要改 4 处,其中一处必然会被忘掉。而一个 3 行的片段重复了 40 次则几乎总是一种惯用法。
所以排序时 block size 的权重不止考虑复制次数。想看复制次数最多的代码?用 orderBy: "frequency"。
对于那些仅仅是语言写法惯例导致的重复,系统会做降权处理而不是参与排序:
不会丢弃任何东西。includeSuppressed: true 会返回被降权的分组及其原因,这样规则可以被检查。
开发者都是懒人,包括我。我不想看重复代码报告。我想要 agent 找到最严重的复制并修复它。这就改变了输出的需求。
每个发现都带置信度。 近似匹配是故意包容的,因为漏掉一个大块重复比误报一个巧合更糟糕。所以不是静默过滤,而是给每个发现打上 certain、high、moderate 或 low 标签,并在回复中解释度量标准。agent 知道哪些可以直接处理,哪些需要先读一下。
坦诚告知完整性。 索引在后台运行(下面会详述)。任何从不完整的索引构建的回复都会说明这一点,并给出数字。局部答案不会被包装成完整的。
自助式范围控制。 不做配置的情况下,会分析根目录下的所有源文件。当范围看起来太宽时,回复会说清楚,列出最大的几个文件夹,并告诉 agent 在 duplication.config.json 里写什么。agent 编辑文件,下一次问题就使用新的范围。无需重启。
整个接口只有四个工具:
tools:
- create_index # 分析代码并构建索引
- list_duplications # 获取重复代码发现(排序后)
- explain_duplication # 获取某个发现的所有副本源码
- download_model # 下载嵌入模型
一份精简后的发现长这样:
duplications:
- id: 622069fe1577
occurrences:
- file: src/analysis/clusterer.ts
startLine: 58
endLine: 177
lines: 78
- file: src/analysis/duplication-service.ts
startLine: 322
endLine: 441
lines: 81
# ...three more
frequency: 5
medianLines: 81
removableLines: 324
severity: 1692.69
similarity: 0.763
confidence: high
summary:
clustersFound: 98
byConfidence:
certain: 4
high: 50
moderate: 44
agent 挑出排名第一的发现,用 explain_duplication 加上其 id 获取每个副本的源码,然后决定如何合并。
不需要安装语法解析器。代码块边界通过大括号和缩进来推断,因此 C#、TypeScript、Java、Go、Rust、Python、Ruby、PHP、SQL、shell、CSS 及其相关语言开箱即用。
权衡是:行号范围是近似值。返回的源码是权威的,周围的数字仅供参考。对于 agent 来说这不是问题,因为它会扫描所有内容然后决定修什么。
嵌入模型没有打包。即使是最小的权重也大约 160 MB,太大了没法通过 npm install 推送,而且在提问中途突然开始下载比一次性告诉用户运行一条命令糟糕得多:
node dist/index.js download-model # int8, ~160 MB
node dist/index.js download-model --fp32 # ~640 MB, 追求精度
int8 是默认值,因为这是 CPU 实际加速的精度。没有 AVX512-FP16 的 x86 核心没有原生 fp16 算力,所以 fp16 模型往往比 fp32 更慢,而 int8 直接使用 VNNI 指令。
没有模型服务也能启动。查询返回空结果并附带需要运行的命令,而不是报错。
所有计算都在本地 CPU 上运行。不需要 API key,没有代码离开你的机器。代价是给整个项目做嵌入需要分钟级而非秒级。
设计上它甚至更慢。我通常同时打开好几个项目,每个有自己的服务,运行在我正在编译代码的同一台机器上。如果不加限制,嵌入会抢走所有能用的核心。所以后台索引以 20% 占空比运行:工作一小段然后休息一段。所有服务加起来耗不到一个核心,一个项目在一个编辑会话期间也能完成。
索引进行期间,回复会带一个进度块:
progress:
filesInScope: 3510
filesEmbedded: 1204
pendingFiles: 2306
percentComplete: 34
对于超过几百个文件的项目,在一半项目被嵌入之前你只会看到这个块。从三分之一代码库得出的排序不是完整排序的早期版本。最严重的重复很可能在还没读到的部分,而回复看起来像是一个答案并会诱使人对其采取行动。
两件事让这变得可以忍受:
先定范围。 在首次运行前在 duplication.config.json 里放一个 include 列表。文件更少,同步更快,结果里也更少供应商代码。
只有首次同步慢。 向量缓存在 SQLite 中,以内容而非位置为 key。移动一个块、重新缩进或添加注释都会复用存储的向量,二十个文件中的相同块只嵌入一次,切换分支后只重新嵌入实际变化的部分。
git clone https://github.com/jgauffin/dry-mcp
cd duplication-mcp
npm install && npm run build
node dist/index.js download-model
添加到项目的 .mcp.json:
{
"mcpServers": {
"duplication": {
"command": "node",
"args": ["/path/to/duplication-mcp/dist/index.js", "."]
}
}
}
配置示例(duplication.config.json):
{
"include": ["src/**", "lib/**"],
"exclude": ["**/*.generated.*", "**/migrations/**"]
}
然后问你的 agent 最严重的重复在哪里。如果它告诉你还在索引,那是诚实的回答。过几分钟再问一次。
我很想听听它在你的代码库里发现了什么,尤其是哪里出错了。