我为什么认真试了一个「给 diff 换单位」的工具
我每天让 agent 干的第一件事不是写代码,是看代码变了什么。以前的做法是把 git diff 丢给它,然后看它开始犯两种毛病:一是把格式化当逻辑改动,一个只加了注释的 PR 它能给你写三百字风险分析;二是跨文件的影响它看不见,你改了一个函数签名,它盯着那一个文件看,压根想不到调用方在另一个目录里。
这两个毛病有个共同根源:diff 的单位是「行」。行没有身份,谁是谁它对不上。sem 的整个卖点就是把单位从「行」换成「实体」——函数、方法、类。它不问你「改了哪些行」,它问「哪个函数被改了、被重命名了、被移动了,谁还在调它」。
这跟工坊之前写过的那些代码索引工具(GitNexus、codebase-memory-mcp、gortex)是两条路。那些工具是「先建一张图,再让 agent 来查」;sem 是「不建新东西,直接在 git 上长出一层实体,连你原有的 git diff 都换掉」。所以我想看的不是它功能多不多,而是它换单位这件事,到底换得干不干净。
先划赛道:给 agent「看懂代码改动」的东西,现在有三条线
不先分类很容易拿不同物种互比。我按「它把什么东西喂给模型」划三条线:
| 路线 | 代表 | 怎么做 | 它不做什么 |
|---|---|---|---|
| ① 结构化 / AST diff | difftastic、GumTree | 解析出语法树,按结构比对,给人看更像「移动/重排」的 diff | 没有实体身份、没有调用图、不能回答「谁调用了它」「要跑哪些测试」 |
| ② 代码索引 / 图谱 MCP | GitNexus、codebase-memory-mcp、gortex、code-review-graph、serena | 先把仓库建成图/符号库,agent 用 MCP 查询「该读哪几段」 | 它自己不动 git:不告诉你「这次提交里哪个函数被重命名」、不区分逻辑改动和纯格式改动 |
| ③ 语义版本控制 | sem(Ataraxy-Labs,3,405) | 在 git 之上加一层实体:实体级 diff/blame/影响面/评审证书,还能直接接管 git diff | 不替你改代码、不做自动修复;跨文件移动识别有缺口(下面实测) |
真正跟它正面撞的是路线②和 difftastic。sem 的取舍很明确:它不要求你先索引一遍再问它,装完在任何 git 仓库里直接就能用,连仓库都不需要配置——代价是它只能给你 git 里已有的东西(历史、diff、工作区),不像图谱工具能回答「这个模块的圈复杂度」这类需要全局建模的问题。
装上:16.9 MB 的压缩包,解出来一个 91.9 MB 的二进制
README 给的第一条安装命令是 curl ... | sh,我不太爱直接喂 shell,就手动下了 GitHub Release 的二进制,顺手对了一下明明写在仓库里的 checksums.txt:
$ curl -fsSL -o sem-linux-x86_64.tar.gz <v0.27.0 release> $ sha256sum sem-linux-x86_64.tar.gz 3f588a3c62e2d34ba427d51e754f31977922227e5f99a9c69e05977f16900713 sem-linux-x86_64.tar.gz $ grep linux-x86_64 checksums.txt 3f588a3c62e2d34ba427d51e754f31977922227e5f99a9c69e05977f16900713 sem-linux-x86_64.tar.gz $ tar xzf sem-linux-x86_64.tar.gz && ls -l sem -rwxr-xr-x 1 ubuntu ubuntu 91928760 Oct 4 19:16 sem
16.9 MB 的包,解出来 91.9 MB(91,928,760 字节)。这体积我特意记了一下 —— 因为它里面装的是 tree-sitter 的语法树 + git2 + 一整套解析器,不是「一个轻量 CLI」。顺手也试了 npm 那条路:
$ npm install --save-dev @ataraxy-labs/sem added 1 package, and audited 2 packages in 3s $ du -sh node_modules 88M node_modules $ ./node_modules/@ataraxy-labs/sem/vendor/sem --version sem 0.27.0
npm 包装出来 88 MB,包里有个 scripts/verify-checksum.mjs,postinstall 会校验平台二进制的校验和——这一点比很多「下载个二进制就跑」的包规矩。版本也能对上:npm 上 latest 是 0.27.0,二进制也是 0.27.0。
第一个坑:名字撞车。GNU Parallel 也提供一个叫 sem 的可执行文件(/usr/bin/sem,是 parallel 的软链)。我这台机器上恰好没装 parallel,所以没事;但只要你的 PATH 里 parallel 排在前面,sem 就会变成 GNU parallel,报一堆看不懂的参数错误。README 自己也在显眼位置写了这条,还开着一个 issue(#77)。修法就三个:别名、把 cargo/brew 的 bin 提前、或者干脆用 npx sem。
实测一:它到底认不认得「同一个函数换了样子」
我在一个仓库里放了 src/config.py、tests/test_config.py、api/handler.go、src/server.ts,提交后做了一次「什么花样都来一点」的改动:
parse_config:改逻辑(真的动了行为)load_config→read_config:改名,函数体一字未动ConfigError:只加了一行 docstringformatUser/Session:只加注释、加空行,逻辑没动Handler:在文件里挪了个位置
然后 sem diff:
┌─ api/handler.go ──────────────────────────────────── │ ↕ function Handler [reordered] └─────────────────────────────────────────────────────── ┌─ src/config.py ───────────────────────────────────── │ ∆ function parse_config [modified] │ ↻ function load_config -> read_config [renamed+cosmetic] │ ∆ class ConfigError [modified] └─────────────────────────────────────────────────────── ┌─ src/server.ts ───────────────────────────────────── │ ~ function formatUser [cosmetic] │ ~ class Session [cosmetic] └───────────────────────────────────────────────────────
这一段确实是我试下来最爽的地方。同样一次改动,git diff 给你的是一堆 +/- 行,agent 得自己猜「这是不是只改了格式」;sem 直接把标签贴好了:纯格式改动标 [cosmetic]、改名标 [renamed+cosmetic]、换了位置标 [reordered]。要让 agent 别在「只加了个注释」的 PR 上写风险分析,这个标签比什么都管用。
它做这件事靠三步匹配:先按实体 ID 精确配对;配不上就比结构哈希(忽略空白和注释的语法树指纹),所以「函数体没变、只是改了名」能被认成 rename;再配不上才退到模糊相似度。这个思路不新鲜,但落在「每个 commit 都跑一遍」的 CLI 上,工程味道是对的。
实测二:跨文件搬一个函数,它认输了
rename、reorder 都成了,那我自然要试被 README 反复强调的「移动」。我把 Validate 函数从 api/handler.go 整个搬到新文件 api/validate.go——函数体一个字符都没改:
$ sem diff ┌─ api/handler.go ──────────────────────────────────── │ ⊖ function Validate [deleted] └─────────────────────────────────────────────────────── ┌─ api/validate.go ─────────────────────────────────── │ ⊕ function Validate [added] └─────────────────────────────────────────────────────── Summary: 3 added, 3 deleted across 2 files
同一份代码、同一个名字、同一个结构,它给出的答案是「删了一个、加了一个」,中间那个「这是同一个函数搬了家」它没连上。我怕是工作区状态的问题,又 git add 之后重试、再用提交区间 sem diff --from HEAD~1 --to HEAD 重试——结果一样,JSON 里的 moved 字段始终是 0。
这就有点尴尬了:它的 JSON schema 里明明留了 moved 桶和 oldFilePath 字段(说明设计上是想支持跨文件移动的),但在这个版本里,我把一个函数从 A 文件搬到 B 文件,它读到的是「A 少一个、B 多一个」。对 agent 来说,这恰好是它要帮我们消掉的那类噪音——你以为它在帮你找「搬家」,它还是在报增删。只能说:跨文件的匹配大概率还没打通,或者条件比 README 描述的苛刻得多。
顺手把 token 账算清楚:它不是来给你省字节的
「实体级 diff 更省 token」是这类工具最容易被默认的期待。我把同一次改动的几种输出都量了一遍:
| 输出 | 字节数 | 说明 |
|---|---|---|
git diff(原始) | 1,385 | 行级 |
sem diff --format plain | 424 | git status 风格,最省 |
sem diff(默认) | 1,441 | 带框线的实体清单 |
sem diff -v | 2,792 | 每个实体带行内差分 |
sem diff --format markdown | 616 | 适合贴 PR |
sem diff --format json | 4,724 | 带每个实体的前后完整源码 |
看到没——给机器读的 JSON 反而是最大的,因为它把这几个实体的「改前完整源码」和「改后完整源码」都带上了。它省的不是字节,是判断:model 不用自己去推断「哪段是纯格式、哪段是逻辑」,标签直接写好了。真要给 agent 省 token,得用 plain(424 字节,比 git diff 小 69%)或者 sem diff --file-exts .py 这种收窄口径的办法。把 JSON 直接喂模型,那是走向另一个方向。
它的 MCP 面也一样克制。我把它当 MCP server 起起来,列出工具表:
$ sem mcp # 一个 8 工具、整张表 10,168 字节的 stdio server
tools: sem_find, sem_grep, sem_impact, sem_check,
sem_certify, sem_diff, sem_graph, sem_history
8 个工具、schema 一共 10,168 字节(约 2.5K token)。对比一下:_agent-browser_ 默认档 29 个工具的 schema 自己要吃 15,873 token、Playwright MCP 也有 5,209。sem 这张表在同类里算小的——因为它只暴露「四问四动词」,不铺功能。顺带记一个坑:README 里工具叫 sem_find,我以为参数是 name,结果它要 query,传错立刻就报 unknown field `name`, expected one of `query`, `queries`, `mode`, `in`, `file`, `limit`, `token_budget`...。名字对不上参数名,第一次接的 agent 大概率会先撞一下。
四个动词里,有两个是真的能干活
它把「agent 需要问的代码问题」压成四个动作:在哪(find/grep)、改了会影响谁(impact)、对不对(check)、该让人类看什么(certify)。我挨个过了一遍,成色差别挺大。
sem impact:这一条最有价值
改了一个函数,想知道要跑哪些测试——这是我日常最想要的一个答案。它给得挺干净:
$ sem impact parse_config --tests
⊕ function parse_config (src/config.py:4–9)
⚡ 2 tests affected:
tests/test_config.py
function test_parse_config (L4–5)
function test_parse_config_empty (L8–9)
它最后总跟一句 complete: no other textual path to this definition ...(动态派发、反射、字符串键查找、运行时注册的回调都不建模)。这句话我挺欣赏——它主动告诉你它的答案什么时候会漏,而不是假装全知。因为 Python 里 getattr(obj, name) 这种调用,静态图是真看不见。
不过在「整个未提交改动」这个用法上它翻了个小车:
$ sem impact --diff HEAD~1 --tests error: Entity ID 'api/handler.go::orphan::added@L1-3' not found error: Entity ID 'api/handler.go::orphan::added@L9-9' not found ...
原因是我那次改动里有「module 层面的碎片」(package、import 那几行)变动,sem 把它们算成一个叫 orphan 的实体塞进了 diff,可 impact 又没法按 ID 反查这种碎片,于是报错。它没崩、exit code 还是 0,但输出里那两行 error 会直接进到 agent 的上下文里。改动的文件稍微多一点,这种 orphan 就会跟着多起来。
sem check:默认状态下基本交白卷
它号称能跑「项目自己的编译器、类型检查、lint 和测试」,而且只重跑改动影响到的部分。听起来很棒,但我在一个纯 Python 仓库里跑:
$ sem check note: no checker applies to this project (none detected and none requested) sem check: UNDECIDED (exit 2)
我这个仓库里有 tests/test_config.py、有 pytest 用例,但它一个 checker 都没识别出来——因为它要靠项目里的配置(pyproject 等)去判断该跑什么。它的设计是「没有可跑的就返回 2(无法判定),永远不返回 pass」,这一点很诚实,但也意味着:你不把项目的检查配置补全,sem check 就是个只会说「我不知道」的摆设。已知的 checker 只有 ts、lint、tests、go、cargo、cmd 六个,你得自己用 .sem/check.json 把命令接进去。
sem certify:给人类看的评审证书
这个反而超出预期。给它一个提交区间,它吐一份 markdown,里面有「改了哪些实体」「签名变化」「这个改动没碰到的调用方」「静态引用锥」「哪些测试够得着」:
# sem certificate 78d7d7e..e068afe 3 files, 9 entities changed (2 added, 5 modified, 1 deleted, 1 renamed, 0 moved). - modified function `parse_config` (src/config.py:4-9) - renamed function `read_config` (src/config.py:12-14) ## Signature changes - `read_config` (src/config.py:12) before: `def load_config(path)` after: `def read_config(path)` ## Tests that statically reach the change 1 file(s): tests/test_config.py
「你改了 parse_config,还有 2 个调用方在 tests/ 里、这次改动没碰它们」——这句话对 reviewer 的价值比整份 diff 都高。而且它连「我只看静态引用,动态派发看不到」都写在证书里。
最有野心也最危险的一步:把全局 git diff 劫走
sem config setup 一条命令,我没在真机上跑,而是把它导到一个隔离的 HOME 里,看它到底写什么:
⊕ setting up sem ◆ git diff → sem diff aliased globally ◆ Claude Code hooks prompt-submit hook installed ◆ pre-commit hook blast radius on staged changes
它改了四个地方:
| 写在哪 | 写了什么 |
|---|---|
~/.gitconfig | [diff] external = ~/.local/bin/sem-diff-wrapper(全局,不是这个仓库) |
~/.local/bin/sem-diff-wrapper | 一个把 git 的 7 参数 diff 调用转成 sem diff 的 shell 包装 |
~/.claude/settings.json | 加一个 UserPromptSubmit hook,prompt 提交前注入上下文 |
仓库 .git/hooks/pre-commit | 放一段跑 sem diff --staged 的钩子 |
我很喜欢它「一个动词接管工作流」的果断,也确认了 sem config unsetup 能把这四处干净地全部撤回(我实测了,unsetup 输出四行 ✓,settings.json 回到 {},pre-commit 也没了)。但风险摆在明面上:它把 diff.external 设成全局的,意味着你机器上每一个仓库的 git diff 都会先经过它;而那个 wrapper 里写的是 exec sem diff——靠 PATH 找 sem。哪天你 PATH 变了、或者 parallel 的 sem 抢到了前面、或者某个脚本在只有基础环境的容器里跑 git diff,坏掉的不是 sem,是你全部的 git diff。想体验它,我建议先在一两个仓库里用,别一上手就全局接管。
我踩的坑,和仓库里明摆着的坑
先说我自己的,最实的一个:它的 JSON 摘要自己数不对。那次「重命名 + 重排 + 纯格式」的改动,sem diff --format json 的 summary 是这样的:
{"added":2,"modified":5,"deleted":1,"moved":0,"renamed":1,"reordered":1,"total":9}
把命名的几个桶加起来:2+5+1+0+1+1 = 10,可它自己的 total 写的是 9。我把它 changes 数组拆开数了一遍,实际是 modified 只有 4 个——也就是说 summary 里的 modified 比真实数组多算了一个(大概率是把那个 rename 的实体重复计了一次)。而 README 里白纸黑字说「命名的桶永远等于 total」。这种错不影响正确性,但如果你拿 summary 去做判断(比如「这次有没有逻辑改动」),它就给你递了个假数。
再补几个用之前该知道的:
- 跨文件移动识别不出来,实测两次都是
added + deleted,moved恒为 0(上面详细写了)。 sem check默认交白卷:不配 checker 就是 UNDECIDED。(exit 2)sem impact --diff撞上 orphan 会刷 error:module 层面的碎片改动会让它报「Entity ID not found」。- 文档口径三处不一致:README 徽章写「32 种语言」,GitHub 仓库简介写「28 languages」,而正文那张「What it parses」表我数下来列了 34 种编程语言(外加 JSON/YAML/TOML/Markdown 等结构化格式)。三个数没一个是重的。
- 官方 GitHub Action 示例钉的版本过旧:README 里 action 写的是
@v0.23.1,而最新已经到v0.27.0(相差一个多月、好几个版本)。照着抄会用一个旧版去拦新 PR。 - issue 很少,但都没关:3.4K Star 只挂 5 个 open issue,说明维护得干净;反过来也意味着它在真实复杂仓库里的坑,社区还没替你踩完。里面有一条 #34
WASM Support从早期挂到现在——它的 tree-sitter 是原生 Rust 编译进去的(README 明说不走 WASM),所以浏览器 / WASM 环境这条路基本是断的。 - 体积:91.9 MB 的二进制、npm 装上 88 MB。它把 32+ 种语言的语法全部吃进了肚子里,轻量这件事它没打算争。
它的立场,和我会怎么用它
sem 出自 Ataraxy Labs,这是他们那套「给 agent 用的开发基础设施」里的一块——旁边还有 weave(实体级 git merge driver)、inspect(语义化 review)、opensessions(给 agent 看的 tmux 侧栏),外加一篇叫 Why sem? 的宣言(他们的说法是「代码不是文本」)。这个出身决定了它的取向:不是再做一个 agent,而是去改 agent 脚下的地板——git 本身。
我会怎么用它,很具体:
- 留在「读 diff / 找影响面」这一步用,别一上来就全局接管。
sem diff --format plain(424 字节)和sem impact <函数> --tests这两条,是我会直接塞进自己 cron 流程里的——前者让 agent 一眼看出「这次是不是纯格式」,后者省掉「改完跑全量测试」。 sem certify适合当 PR 的第一屏。它给的「改了哪些实体 + 没被碰到的调用方 + 哪些测试够得着」,比让 reviewer 自己读 diff 强;配官方那个 action 还能自动给 PR 贴一条会原地更新的评论。- 想要「跨文件搬家也能认出来」的,现在别指望它。这条缺口没补上之前,重命名/重排能信、跨文件移动得自己再核一遍。
- 已经有 codebase-memory-mcp / gortex 那类图谱工具的,两者不冲突:图谱工具回答「我该读哪几段」,sem 回答「这次改动变了哪些实体、谁还依赖它」。一个管静态全貌,一个管增量历史。
- 纯 Python 小项目、又懒得配 checker 的,
sem check先别开,它只会回你 UNDECIDED。
最后一句判断。这一波「给 agent 看代码」的工具,绝大多数在往「建更大的索引」方向卷;sem 反着走,去动了最老、最没人愿意碰的那块地基——git diff 本身。这个方向我认为是对的:agent 犯的很多错,源头不是模型不够强,是它拿到的「改动」根本就是以错误的单位组织的。sem 把单位从行换成函数,这一步它是真做到了;它没做到的,是把「搬家」也算进同一个单位里,以及在给别人改地板之前,先把自己 JSON 里的那笔账算平。
项目地址:github.com/Ataraxy-Labs/sem(文档:ataraxy-labs.github.io/sem)