Files
wuguanganandClaude Opus 5 d93a624b4e feat: 旁路 diff 复核 —— 拿真实改动核对它的自报
自查治「忘」,但自查仍然是它在说自己,治不了「骗」,也治不了
「它真心以为自己做了」。这一层读 git diff 里到底有没有对应改动。
结论只有 confirmed / disputed 两档:中间档会变成模型拿不准时的垃圾桶,
而用户看到"部分完成"也不知道该做什么。

新增 GitDiff(numstat/patch/balancedPatch)和 AcceptanceAuditor。
UI 上点 .diff 证据能看那个文件从基线到现在的真实 diff。

三次真机实验(一条真做了的 + 一条我故意谎报的),前两次都判错,
两个 bug 都只有在真机上才暴露得出来:

1. 整份 diff 截断 → git diff 按字母序输出,前几个文件吃光 24KB 预算,
   MainWindow.swift(真改了 79 行)一行都没进模型眼里,于是判"存疑,
   实际 diff 未展示"。任何真实规模的改动都会这样,不是调大上限能解决的。
   改成按文件均分预算(balancedPatch)。

2. 均分之后每文件只有 1200 字符,只露出第一个 hunk,对应那条要点的改动
   在后面 —— 同一类问题降了一层。提到 120k 总预算 / 6k 每文件。
   省这点 token 换来的是不可信的结论,而不可信的结论比没有结论更糟:
   用户会照着它去改本来没问题的东西。

3. 谎报的那条被判成 confirmed,理由是"Evidence.ran 真实执行命令"——
   模型把**类型声明**当成了功能存在。而 .ran 只是个 enum case,
   没有任何代码产生它。声明读起来非常像功能已经实现,这是 diff 复核
   最容易翻的一类车。提示词里单独加了一条。

第三次两条都判对,理由精确到代码位置("SessionsPane 会话行新增
Button(\"清单\") 调 onOpenLedger" / "仅见 Evidence.ran 声明,
未见实际执行验收命令并填充 exitCode/tail 的调用点")。

**但这仍然是一个弱信号,不该被当成结论。** 第 3 个 bug 恰好证明了
读 diff 判断不了"功能是否真的能用"——这正是阶段 5(Hub 自己跑验收命令)
存在的理由。

369 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:10:05 +08:00

128 lines
5.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// 读 git 的真实改动。
///
/// 验收清单里「治骗」的那一半靠它:旁路复核读的是这里产出的 diff,
/// 不是 Claude 说的话。
public enum GitDiff {
/// **不能靠 PATH 找 git。** Hub 是 GUI app,从 launchd 起,
/// PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`。
/// `/usr/bin/git` 是 macOS 自带的 xcode-select 转发器,一定在。
static let git = "/usr/bin/git"
/// 一个文件的改动量。
public struct FileChange: Sendable, Equatable {
public let path: String
public let added: Int
public let removed: Int
}
public static func isRepository(_ cwd: String) -> Bool {
run(cwd, ["rev-parse", "--git-dir"]) != nil
}
public static func head(_ cwd: String) -> String? {
run(cwd, ["rev-parse", "HEAD"])?.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// 从 `since` 到现在的每个文件改了多少行。
///
/// **必须带上未提交的改动**(`git diff <since>` 而不是
/// `git diff <since> HEAD`):Claude 刚干完活通常还没 commit,
/// 只看已提交的部分等于把这一轮的成果全漏掉 —— 那样每一条都会被判成
/// 「代码里找不到」,复核结论全是假阳性。
public static func numstat(_ cwd: String, since: String?) -> [FileChange] {
var args = ["diff", "--numstat"]
if let since, !since.isEmpty { args.append(since) }
guard let output = run(cwd, args) else { return [] }
return output.split(separator: "\n").compactMap { line in
let parts = line.split(separator: "\t", maxSplits: 2)
guard parts.count == 3 else { return nil }
// 二进制文件的增删是 "-",按 0 算。
return FileChange(
path: String(parts[2]),
added: Int(parts[0]) ?? 0,
removed: Int(parts[1]) ?? 0
)
}
}
/// 供人阅读的 diff 正文。`limit` 是字符上限。
///
/// 上限是必须的:一次大重构的 diff 能有几十万字符,全喂给模型既烧钱
/// 又会把真正相关的部分挤出上下文窗口。
public static func patch(
_ cwd: String, since: String?, paths: [String] = [], limit: Int = 24_000
) -> String {
var args = ["diff"]
if let since, !since.isEmpty { args.append(since) }
if !paths.isEmpty {
args.append("--")
args.append(contentsOf: paths)
}
guard let output = run(cwd, args) else { return "" }
guard output.count > limit else { return output }
return String(output.prefix(limit)) + "\n…(diff 过长,已截断)"
}
/// 每个改动文件都露一段的 diff。
///
/// ## 为什么不能直接截断整份 diff
///
/// `git diff` 按文件名字母序输出。整份截断的话,排在前面的几个文件就把预算
/// 吃光了,后面的文件**一行都进不到模型眼里**。
///
/// 实测撞到过:复核 13 条要点,其中一条对应 `MainWindow.swift`(真的改了 79 行),
/// 但它排在字母序后段,24KB 预算在它之前就用完了 —— 模型给的理由是
/// 「实际 diff 未展示(被截断),无法确认」,于是一条真做了的要点被判成存疑。
///
/// **任何真实规模的改动都会这样**,所以这不是调大上限能解决的(多大都会被
/// 吃光),必须按文件均分预算:每个文件都露一段,宁可每段短一点。
///
/// - Parameter limit: 总字符预算。
/// - Parameter minimumPerFile: 每个文件至少给多少字符 —— 太少的话
/// 露出来的只有文件头,等于没露。文件多到连这个都保证不了时,
/// 按改动量从大到小取,其余的只在概览里出现。
public static func balancedPatch(
_ cwd: String, since: String?, limit: Int = 40_000, minimumPerFile: Int = 1_200
) -> String {
let changes = numstat(cwd, since: since)
guard !changes.isEmpty else { return "" }
// 改动大的优先 —— 一个文件被改了 400 行,它多半就是这一轮的主体。
let ranked = changes.sorted { $0.added + $0.removed > $1.added + $1.removed }
let affordable = max(1, limit / minimumPerFile)
let selected = Array(ranked.prefix(affordable))
let perFile = max(minimumPerFile, limit / selected.count)
var parts: [String] = []
for change in selected {
let single = patch(cwd, since: since, paths: [change.path], limit: perFile)
guard !single.isEmpty else { continue }
parts.append(single)
}
if selected.count < ranked.count {
let omitted = ranked.count - selected.count
parts.append("…(另有 \(omitted) 个改动较小的文件没有展开,见上面的改动概览)")
}
return parts.joined(separator: "\n")
}
/// 一行一个文件的概览,给模型看的。
public static func summary(_ cwd: String, since: String?) -> String {
numstat(cwd, since: since)
.map { "\($0.path) +\($0.added) -\($0.removed)" }
.joined(separator: "\n")
}
private static func run(_ cwd: String, _ args: [String]) -> String? {
// -C 而不是改工作目录:Shell.run 是全进程共享的,改 cwd 会影响别的调用。
let result = Shell.run(git, ["-C", cwd] + args, timeout: 20)
guard result.succeeded else { return nil }
return result.stdout
}
}