首页
/ ClickHouse diff-review:零依赖的本地浏览器 Diff 评审技能,从 server.mjs 实现到退出码语义全解析

ClickHouse diff-review:零依赖的本地浏览器 Diff 评审技能,从 server.mjs 实现到退出码语义全解析

2026-09-05 20:33:52作者:范垣楠Rhoda

本篇围绕 ClickHouse 仓库中 .claude/skills/diff-review/ 目录下的 diff-review 技能展开:它把未提交或已提交的本地改动通过一个绑定 loopback 的 Node 服务渲染成浏览器内可逐行批注的 diff 页面,并把用户的行评论以结构化 JSON 回传给 Agent 逐条处理。读完本文,你能完整掌握该技能的启动命令、全部命令行参数与 JSON 输出格式,理解 server.mjs 中 diff 收集、远程环境探测、会话 nonce 鉴权与安全边界的实现细节,以及退出码 0/1/3/4 各自的语义与故障排查方法。

技能定位:提交前让"人"在浏览器里评审 Agent 的改动

diff-review 是 ClickHouse 仓库 .claude/skills/ 目录下的一组 Claude Code 开发工作流技能之一(同目录还有 reviewbisectinvestigate-ci 等技能)。它解决的具体场景是:在 Agent 执行 git commit 或打开 PR 之前,让用户(人类开发者)在自己的浏览器里阅读全部待提交改动,逐行留下评论,并把这批评论作为一次正式的代码评审交还给 Agent——Agent 必须处理每一条评论(要么改代码,要么回答问题)。

SKILL.md 的定义,工作闭环是:

  1. 后台任务方式启动本地服务,渲染 diff;
  2. Agent 告知用户评审地址后进入等待,不得自己抓取该 URL(来自 Agent 的请求会被误判为"浏览器已打开");
  3. 服务在用户点击 UI 中的 Finish review 后退出,把行评论写入 --out 指定的 JSON 文件;
  4. Agent 读取 JSON,逐条引用并处理每条评论;修复完成后建议再跑一轮评审,然后才提交。

何时不要使用这个技能

SKILL.md 中专门有一节 "When not to use it",核心约束来自一个事实:服务只绑定 loopback(127.0.0.1)。因此 UI 只有两种可达方式——

  • 服务器所在机器本机的浏览器;
  • 用户自己的机器通过 SSH 端口转发(当机器"看起来是远程的"时,服务会打印出可直接复制的 ssh -L 命令)。

它无法存活的场景是无人值守的隔离 VM:既然永远不会有浏览器打开页面,就没有必要一直挂着后台任务——服务会在 120 秒后以退出码 4 自行退出。此时应放弃该技能,退回到终端里 git diff / git show 展示 diff,或干脆直接开 PR 让评审在 PR 页面上进行。

启动评审服务器:完整命令与全部参数

技能的标准启动方式(在 Agent 会话中应以后台任务方式运行,如 Claude Code 的 run_in_background: true):

node .claude/skills/diff-review/server.mjs \
  --repo <repo-root> \
  --base HEAD \
  --port 3000 \
  --out /tmp/diff-review-comments.json

各参数含义(与 server.mjs 顶部的 Usage 注释一致):

参数 默认值 说明
--repo <path> process.cwd() 仓库根目录。服务启动时先执行 git -C <path> rev-parse --show-toplevel 校验并解析真实根目录(见 server.mjsgit() / gitOrDie() 封装)
--base <ref> HEAD diff 的基线。默认 HEAD 时评审的是未提交的工作:staged + unstaged + untracked 全部纳入
--committed 关闭 评审已提交的分支工作(开 PR 前的典型用法)。此时 diff 的是 <base>..HEAD,且完全忽略工作树——未提交或未跟踪的本地编辑不会泄漏进评审
--port <n> 3000 监听端口。端口被占用时可换一个端口并同步告知用户新 URL
--out <file> tmpdir()/diff-review-<时间戳>.json 评审结果 JSON 的落盘位置
--no-open 关闭 不自动打开浏览器(Linux 无图形会话时服务本身也会跳过自动打开)
--force(或环境变量 DIFF_REVIEW_FORCE=1 关闭 忽略"远程机器"探测,无限期等待用户打开页面——适用于用户明确说需要更多时间去配置端口转发的情况

两种 diff 模式的取舍(源自 SKILL.md):

  • 默认模式--base HEAD):评审工作树相对 HEAD 的全部改动,包括未跟踪文件,适合"写完一段代码、提交前让人过目";
  • --committed 模式:配合仓库默认分支的 merge base 使用,例如 ClickHouse 的 PR 惯例是直接指向 master(见 .claude/CLAUDE.md 中"Every pull request must target master directly"的规定),推荐组合是:
--base $(git merge-base origin/master HEAD) --committed

服务启动后的行为契约(SKILL.md "How to run" 与源码一致):

  • 打印评审 URL(http://localhost:3000),自动打开用户浏览器,保持存活直到用户点击 Finish review,然后以退出码 0 结束
  • 在"看起来是远程"的机器上(SSH 会话、云实例、无图形会话的 Linux),照常启动,但会额外打印一条 ssh -L 3000:localhost:3000 <user>@<host> 转发命令,且 120 秒内没有任何浏览器打开页面时以退出码 4 退出;
  • UI 完全自包含:渲染用的 @pierre/diffs 库已随技能 vendored(见 vendor/README.md),运行不需要任何网络访问,任何第三方 CDN 都看不到正在评审的 diff——这一点不是性能考量,而是安全边界:diff 里包含尚未提交的本地改动。

Agent 侧协作流程与评审结果 JSON

SKILL.md 规定的 Agent 行为有两条"禁令"和一条"义务":

  • 等待,不抢跑:把 URL(以及服务打印的端口转发命令,如有)转告用户后等待。不要自己 fetch 该 URL——对服务器而言,任何到达的请求都是"用户浏览器已打开"的证明,来自 Agent 的请求会破坏这个判断。也不要继续提交/开 PR——后台命令退出时(用户提交评审,或超时无人访问)你会被通知;
  • 必须处理每一条评论:它们是用户的正式代码评审。对每条评论简要引用原文,然后修改代码或回答问题。修复后在提交前主动提出再跑一轮评审;
  • verdict 决定能否提交verdict: "approve" 且无评论 → 继续 commit / PR;verdict: "request_changes" → 在完成修复且用户满意之前不得提交。

服务把评审结果写入 --out 文件,JSON 结构如下(SKILL.md 原文示例):

{
  "verdict": "approve | "request_changes"",
  "overall": "free-text overall comment (may be empty)",
  "comments": [
    { "file": "src/app.ts", "side": "new", "startLine": 12, "endLine": 15,
      "comment": "the user's comment text" }
  ]
}

其中 side: "new" 表示行号指向新版本文件;side: "old" 指向基线版本(即对删除行的评论)。从 server.mjs/submit 处理逻辑看,实际落盘的 JSON 还会额外带上三个服务端字段:submittedAt(ISO 时间戳)、repo(解析后的仓库根目录)、base(评审基线),方便事后追溯这份评审对应哪次改动。

服务端实现深读:diff 是怎么收集的

server.mjscollectFiles()L132-L205)是整个技能的数据核心,实现细节比 SKILL.md 的描述更完整:

1. 变更文件清单来自 git 的机器可读输出。

  • --committed 模式执行 git diff --name-status -z <base> HEAD
  • 默认模式执行 git diff --name-status -z <base>,再追加 git ls-files --others --exclude-standard -z 收集未跟踪文件(状态标记为 ?,后续统一按新增文件 A 处理);
  • 对重命名/复制(状态以 RC 开头)的记录会按 -z 的三段式格式解析出 oldPathnewPath,保证重命名文件的 diff 正确。

2. 新旧内容分别从两个来源读取。

  • 旧版本:git show <base>:<path>A/? 状态没有旧版本,置空);
  • 新版本:--committed 模式取 git show HEAD:<path>;默认模式直接读磁盘文件。这里有一个容易忽视的细节——符号链接不跟随:Git 把符号链接的 blob 存为链接目标路径字符串,源码特意用 lstatSync + readlinkSync 读链接本身而非目标内容,注释说明跟随链接"会给出错误的 diff,并且可能把无关的本地文件暴露进评审"(L174-L185);
  • 如果文件在 diff 与读取之间消失(比如评审期间又改了工作树),按已删除处理。

3. 两类文件会被跳过并在 UI 上提示。

  • 二进制:任一新旧内容的前 8192 字节中出现 NUL 字节即判定为二进制(looksBinary),跳过;
  • 过大:任一版本超过 MAX_FILE_BYTES = 2,000,000 字节,跳过(L41)。

被跳过的文件会连同原因(binarylarger than 2000000 bytes)一起放进 /data 响应的 skipped 列表,评审页面顶部的黄色横幅会列出 "Not shown: ...",用户知道哪些改动没有出现在评审中。若没有任何可评审文件(含被跳过列表也为空),服务打印提示并以退出码 3 结束。

远程机器探测与 120 秒截止线

detectRemoteSignals()L54-L97)实现了 SKILL.md "Exit code 4" 一条提到的三类信号,源码注释还解释了每条信号的取舍:

  1. SSH 会话:存在 SSH_CONNECTION / SSH_CLIENT / SSH_TTY 任一环境变量。注释指出用户的浏览器通常在连接的另一端,只能通过转发端口到达 loopback;
  2. 云实例:优先检查 cloud-init 状态文件(/var/lib/cloud/instance/run/cloud-init/instance-data.json,最便宜的可靠标记);不存在时再读 DMI 的 sys_vendor / chassis_asset_tag / board_vendor,用正则匹配 Amazon EC2、Google、Microsoft、DigitalOcean、Hetzner、OpenStack、Alibaba Cloud、Oracle Cloud、Scaleway、Vultr 等厂商字符串。源码明确注释了故意不查 IMDS(169.254.169.254):那需要一次(IMDSv2 下两次)网络往返,在 link-local 路由被防火墙隔离时还会挂起,而 DMI 字符串零成本地给出同样的结论;
  3. 无图形会话:仅 Linux 且 DISPLAYWAYLAND_DISPLAY 均未设置时触发(macOS 与 Windows 恒有窗口服务器,不适用)。

关键在于这些信号不直接导致拒绝,只决定两件事:是否打印端口转发提示、是否"布防"一个 120 秒的截止线(NO_VISITOR_WAIT_SECS = 120L102)。真正"没人看"的证明只有一个可观测事实:120 秒内没有任何请求到达。源码注释举反例说明为什么信号本身不够:云桌面有自己的本地浏览器、ssh -X 会跨线打开浏览器、转发端口可以让任何机器触达 loopback——这些情况下页面其实是可达的。因此任何一个 HTTP 请求到达时,截止线定时器会被立即清除,之后无限期等待(L235-L241);而 Linux 上设置了 DISPLAYssh -X 会话甚至会被视为可以自动打开浏览器。

打印的转发命令也不是拍脑袋的模板。源码解析 SSH_CONNECTION(格式 <client_ip> <client_port> <server_ip> <server_port>)——这是用户自己的 ssh 实际连接的地址与端口,转发命令必须复用它;若非 22 端口则补上 -p;拿不到 SSH_CONNECTION 时宁可直接显示 <user>@<this-machine> 占位符也不装出"可复制即用"的样子,因为 hostname() 经常是用户机器无法解析的内网接口名(ip-172-31-…)(L361-L386)。

超时退出时(退出码 4) stderr 里同时给出两条退路建议:用 git diff / git show 在终端里看,或转发端口后加 --force 重跑以无限期等待。SKILL.md 强调这是无人值守隔离 VM 上的预期结果——应回退到终端 diff 继续工作,不要主动重试。

安全设计:loopback 绑定、会话 nonce 与零依赖 UI

这个技能面向的资产是"尚未提交的本地改动",源码里的安全决策都围绕这一点:

只监听 127.0.0.1。 server.listen(PORT, '127.0.0.1', ...)L335)把服务钉死在回环地址上,远程可达性只能由用户主动建立的端口转发提供。

每次会话的 nonce。 服务生成 NONCE = randomBytes(16) 的十六进制串,注入到下发的 ui.html 中(替换占位符 __DIFF_REVIEW_NONCE__,见 ui.html L140)。POST /submit 要求请求头 x-diff-review-nonce 与之匹配,同时校验 Origin 只允许 http://localhost:<port>http://127.0.0.1:<port>L274-L287)。效果是:只有本服务器发出去的那个页面能完成评审——同机的其他标签页、跨源页面或对着 localhost 的盲目 POST 都会被 403 拒绝,无法替用户提交评审。

HTTP 面极小。 全部路由只有五个:GET /(或 /index.html,下发替换过 nonce 的 UI)、GET /data(diff 数据 JSON)、GET /vendor/...(vendored 依赖,见下)、GET /slow(仅测试用,模拟延迟响应,上限 60 秒)、POST /submit。其余一律 404。静态资源服务采用固定白名单 VENDOR_FILESL224-L231):/vendor/pierre-diffs.mjs(gzip 存储,按请求的 Accept-Encoding 决定直接下发 gzip 还是运行时 gunzipSync 解压)加五个 node 内置模块的浏览器 polyfill(/node/process.mjsbuffereventsttyasync_hooks——后者是因为 esm.sh 构建的 bundle 按绝对路径 import 这些 polyfill)。白名单之外的 vendor 路径一律不服务。

零依赖与 vendored 渲染库。 服务本身只用 Node 标准库(node:httpnode:child_processnode:cryptonode:zlibnode:fs 等),没有任何 npm 依赖。前端 diff 渲染库 @pierre/diffs 1.2.12 以自包含 ESM bundle 形式 vendored(含内联的 oniguruma wasm),vendor/README.md 记录了每个文件的来源与 sha256,并说明 vendored 的动机:"a compromised CDN could otherwise exfiltrate the diff under review, which includes uncommitted local changes"——被攻破的 CDN 可以借此把含未提交改动的 diff 偷出去。该文件还给出了升级时的复验步骤(重新拉取、记录哈希、审计 import 面、更新 server.mjsVENDOR_FILES 映射)。

评审 UI:逐行批注如何变成 JSON

ui.html 是单文件页面,核心逻辑:

  • 通过 fetch('/data') 拿到 {repo, base, files, skipped},对每个文件用 parseDiffFromFile(oldContents, newContents)浏览器内计算 diff,交给 vendored 的 CodeView 渲染。支持 split/unified 视图切换、sticky 文件头、按行号悬停高亮、顶部文件跳转下拉框;
  • 行评论:点击行号旁出现的 +(gutter utility)或跨多行拖拽即可创建草稿评论;草稿态显示文本框(Ctrl+Enter 保存、Esc 丢弃),已保存的评论可 Edit / Delete,页面头部实时显示已保存评论数;
  • 提交:点击 Finish review 弹出提交对话框,显示"N 条行评论将回传给会话",可填写整体评论(overall),然后二选一——Approveverdict: "approve")或 Send commentsverdict: "request_changes");存在未保存草稿时会先确认是否丢弃;
  • 提交时 UI 把内部状态映射为 JSON:行号区间取 start/end,侧别把 deletions 映射为 old、其余为 new,与上文 /submit 的鉴权逻辑配合落盘(ui.html L391-L419);
  • 页面还内置了 ?waitload=?testannotation= 两个测试钩子(延迟 load 事件、预置一条已保存评论和一条草稿),供无头截图工具验证渲染效果。

退出码速查与故障排查

汇总 SKILL.md "Troubleshooting" 一节与 server.mjs 中的 process.exit 调用点:

退出码 含义 处理建议
0 用户已提交评审,评论已写入 --out 读取 JSON,逐条处理评论
1 启动错误,如端口被占用(EADDRINUSE)或 git 命令失败 端口占用通常是上一个残留服务:pkill -f "diff-review/server.mjs",或改用 --port <other> 并给用户新 URL
3 相对所选 --base 没有可评审的改动 确认基线是否正确;若确实无改动则无需评审
4 机器"看起来是远程"(SSH 会话 / 云实例 / 无显示器的 Linux)且 120 秒内无浏览器访问 无人值守隔离 VM 上的预期结果:回退到终端 diff 继续,不要主动重试;用户确需更多时间时改用 --force(或 DIFF_REVIEW_FORCE=1)无限期等待
130 收到 SIGINT / SIGTERM(如用户想中止时 Agent 用 TaskStop 停掉后台任务) 中止即结束,不要无限等待

几个实操要点重申:

  • 端口 3000 被占:先 pkill -f "diff-review/server.mjs" 清掉残留服务,或换端口;服务自己也会在 stderr 里打印这两条建议(L323-L333);
  • 用户想中止:直接停止后台任务即可,服务会打印 SIGTERM received, exiting without a review 并以 130 退出;
  • --force 的正确用法:仅当用户主动提出"我需要更多时间转发端口"时才用,它会跳过全部远程信号探测,让服务无限期等待。

小结:这个技能在 ClickHouse 工作流中的位置

diff-review 与同目录下的 review 技能互补:后者让 Agent 按 ClickHouse 的评审标准(正确性、安全性、性能、changelog 模板合规等)审查 PR 或 diff;前者把人类的评审拉进同一工作流——Agent 的改动在离开本地之前先经过一次真实的人类逐行阅读。实现上它保持了与 ClickHouse 工程文化一致的克制:单一 .mjs 文件加一个 HTML 页面、零运行时依赖、静态资源白名单、会话级 nonce、可审计的 vendored 第三方 bundle(vendor/README.md 中逐文件记录 sha256),以及一套明确的退出码契约,使得"浏览器评审"这种依赖人机配合的流程在自动化环境里也能被可靠地启动、超时、回退和清理。

登录后查看全文
热门项目推荐
相关项目推荐