Sourcegraph指出Claude Code的@文件选择器仅匹配路径字符,无法定位函数所在文件,并开源了改进方案。
Claude Code 的 @ 选取器按路径中的字符匹配,所以它找不到函数所在的文件。这个 fileSuggestion hook 改为按符号排名。
8 月 7 日,Samuel Colvin 发问:为什么 Claude Code 的文件选择器这么差。他的截图显示,他在 Monty(Pydantic 用 Rust 写的 Python 解释器)里输入 @os.rs,收到了十五个建议:run.rs、lib.rs、repl.rs、heap.rs、hash.rs,还有十个诸如此类的文件。
没有一个叫 os.rs 的。Monty 里其实有两个。
那天下午我在机场有空,我想起了 Boris Cherny 四月份发过一篇关于 Claude Code 的 fileSuggestion 设置的文章。你指向一个命令。当你输入 @ 时,Claude Code 每次按键都会启动那个命令一次,向它 stdin 传入 {"query": "..."},然后把 stdout 输出的仓库相对路径展示出来。这里面没有任何告诉你如何对列表排名的信息——这才是让我兴奋的部分,所以我在登机口开始写一个。
选取器没坏,它回答的是另一个问题
模糊路径匹配问的是:你的查询中每个字符是否按顺序出现在路径中。用 crates/monty/src/run.rs 对抗 os.rs,你会找到 "monty" 中的 o、"src" 中的 s,然后是末尾的 .、r、s。有效匹配。lib.rs、repl.rs 和另外十二个文件同理。
所以 Sam 得到了"哪些路径按顺序包含这些字符"的正确答案。他问的是"哪个文件叫 os.rs"。这不是同一个问题,而人们实际输入的恰恰是第二个。
更糟的还不止排序问题。输入一个函数名,文件名索引完全无法提供任何结果,因为函数名不在文件名里。在 Monty 里,@resolve_virtual_path 返回零结果。不是糟糕的列表,是空的。
三个信号代替一个
这个 hook 对每个追踪文件打一次分,混合了三样东西:
文件名匹配,通过 nucleo-matcher 做模糊匹配。
符号匹配,通过对所在仓库做 Sourcegraph type:symbol 搜索,所以输入函数名的查询能找到定义它的文件。
Git 最近性,所以最近 25 次提交中改动过的文件会排在同等得分但没改过的文件前面。
每个候选得到一个分数,且各档位之间的差距足够大,以至于再多的弱信号叠加也永远超越不了一个强信号:
完全匹配的 basename 直接胜出,完全跳过符号搜索。网络永远不会出现在关键路径上:符号结果按四字符前缀缓存到磁盘,所以输入 dropg、dropgu、dropgua、dropguard 只需一次请求而不是四次。短于四个字符的查询根本不会走网络。冷前缀立即返回文件名结果,并启动一个分离的请求来为下一次按键填充缓存。
一次端到端查询,对象是 Monty 的 70fe3f57 版本。以下是那次运行的实际数字,不是我构建当天测的。
一半的修复是更好的排序
这是我没有预料到的部分。关闭符号通道后,@os.rs 的结果是正确的:
1 crates/monty-types/src/os.rs
2 crates/monty/src/modules/os.rs
3 crates/monty-fs/src/overlay_state.rs
两个真正的 os.rs 文件,分别排第 1 和第 2,仅凭文件名打分。开启符号不会改变这个列表。Sam 的抱怨是路径打分问题,nucleo 加上"exact basename wins"就是全部解决方案。
符号搜索真正发挥作用的地方,是文件名无法回答的查询:
@resolve_virtual_path
仅文件名: (无结果)
开启符号: crates/monty-fs/src/path_security.rs
@dropguard
仅文件名: (无结果)
开启符号: crates/monty/src/heap_traits.rs
这两个查询都不是其应返回路径的子序列,所以 nucleo 对两者都打了零分。DropGuard 定义在 heap_traits.rs,resolve_virtual_path 定义在 path_security.rs,再多的路径技巧也永远无法到达那里。这就是索引符号的理由,而且这是一个比"选取器很差"更窄的场景。
有时候它什么都不做,这才是正确的。@value 无论开不开启符号都返回相同的四个路径,因为 value.rs 和 value.ts 都以 exact basename 胜出,按设计跳过了符号通道。没有什么能区分它们,所以 git ls-files 的顺序决定了一切。扩展名或路径深度偏好可以解决这个问题。今天什么都没有。
它的代价,包括那个坏掉的部分
Warm p95 在 200 个真实子进程启动上是 11.12ms,p50 是 9.32ms,而我自己设的界限是 15ms。排序本身在 Monty 的 1,139 个文件上不到一毫秒,其余的都是进程启动加 git。排序是唯一随仓库增长而增长的代价,所以在 19,000 个文件的 monorepo 上它跑到了几毫秒,而进程启动不再占预算的几乎全部。首次调用支付 git ls-files 和 git log 一次,耗时 66ms。
然后是那个诚实的失败——这篇文章在审核期间它自我修复了。@collect_cycles 以前排在 heap.rs 第一位,而 8 月 20 日它在两侧都返回了空。Monty 已经把 heap.rs 重构成了 heap/mod.rs,sourcegraph.com 的索引仍然带着旧路径,而 hook 会把符号命中结果与 git ls-files 求交集,所以你永远不会被提供一个你根本没有的文件。路径过时了,被丢弃,列表为空。到本文发布时索引已经追上来了,查询返回 crates/monty/src/heap/mod.rs。符号这部分只和它背后的索引一样新鲜,所以当上游移动了一个文件你会得到文件名那一半,直到索引追上。
还有两个值得知道的限制:macro_rules! 宏不在符号索引里,所以 @defer_drop 永远无法解析。还有一个有三十个几乎相同重写的 trait 方法没有单一的 定义文件,所以没有任何打分信号能替你选出其中一个。
它在 Sourcegraph Community cookbook 里,Apache-2.0,约 1,200 行 Rust。复制这一行,一分钟后你就有了一个可用的二进制文件:
curl -sL https://github.com/sourcegraph-community/cookbook/archive/refs/heads/main.zip -o cookbook.zip && unzip -qo cookbook.zip 'cookbook-main/symbol-ranked-file-picker/*' 'cookbook-main/LICENSE' && (cd cookbook-main/symbol-ranked-file-picker && env -u RUSTUP_TOOLCHAIN cargo build --release && cp target/release/file-suggestion ~/.claude/file-suggestion)
env -u RUSTUP_TOOLCHAIN 在那里是因为 mise export 这类工具会设置那个变量,而目录里又有 rust-toolchain.toml,不去掉它构建就会失败。加上这个 env -u,构建无论如何都会选 stable。
然后在 ~/.claude/settings.json 里添加一个顶层 key:
{
"fileSuggestion": { "type": "command", "command": "~/.claude/file-suggestion" }
}
删除那个 block 就是完整的卸载。
公开代码不需要 Sourcegraph 账号。hook 默认向 sourcegraph.com 做匿名查询,所以任何已索引的公开仓库在你安装后立刻可用。私有代码需要你自己的实例和一个 token:
export CLAUDE_SG_ENDPOINT="https://sourcegraph.example.com"
export SRC_ENDPOINT="https://sourcegraph.example.com"
export SRC_ACCESS_TOKEN="sgp_..."
两个 endpoint 变量,不是一个。早期版本的 hook 在每个请求上附加了 SRC_ACCESS_TOKEN,导致我在测试时把自己实例的 token 发给了 sourcegraph.com,只好去轮换它。cookbook 里没有任何构建曾经这样做过,所以你不需要轮换任何东西。现在 token 只在 SRC_ENDPOINT 与实际调用的 endpoint 匹配时才发出。没有已索引的仓库你只会得到文件名那一半,不会出任何问题。
感谢 Sam 的那条推文引发了这一切,感谢 Boris 在我知道自己需要它四个月前就推出了这个逃生舱。
特别感谢 Stephanie Jarmak 对这篇博客文章的贡献。
Unblock your organization.Ship faster.
With Sourcegraph, the code understanding platform for enterprise.