🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

回归归因 ca610/dsh-culprit

DeepSeek Harness
ca61021 天前有提交已提供中文文档扫描:含敏感能力

二分 git 历史,定位让命令变红的肇事提交

30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/10 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add ca610/dsh-culprit
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:待复验
是什么
dsh 原生插件 · dev
装得上吗
曾在 dsh 0.2.0-rc.2 下真实安装成功,但当前 dsh 已更新至 0.2.1-rc.5,本站尚未在新版下复验
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 21 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

可执行外部命令读写/删除本地文件
查看扫描证据(共 9 处,展示前 9 条)
  • 执行外部命令来自配置/变量
    index.mjs:106
    child = execFile(
  • 读取本地文件
    index.mjs:208
    if (!existsSync(file)) return null;
  • 读取本地文件
    index.mjs:209
    const parsed = JSON.parse(readFileSync(file, "utf8"));
  • 写入本地文件
    index.mjs:218
    mkdirSync(join(file, ".."), { recursive: true });
  • 写入本地文件
    index.mjs:219
    writeFileSync(file, JSON.stringify(state, null, 2), "utf8");
  • 读取本地文件
    index.mjs:228
    if (!existsSync(stateDir)) return [];
  • 读取本地文件
    index.mjs:229
    return readdirSync(stateDir)
  • 读取本地文件
    index.mjs:491
    if (gitDir && existsSync(join(gitDir, "BISECT_LOG"))) {
  • 删除本地文件
    index.mjs:774
    rmSync(stateFile, { force: true });
扫描于 2026/10/31(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 二分git历史
  • 定位肇事提交
  • 获取diff与探测证据

找出搞坏它的那次提交:在 git 历史里对失败断言做二分,拿到元凶、它的 diff,以及每次探测的证据。

README

由 DeepSeek 最新模型翻译生成
dsh-culprit · 回归归因 「这段代码是哪次提交改挂的?」 给一条有 bug 就退出非零的命令,dsh-culprit 在仓库历史里二分,直接告诉你肇事提交是哪一个,并附上它的 diff、改了哪些文件、以及每一步的判定证据。 它不是「再跑一遍测试」,是把测试变红这件事本身当成一个可以追溯的事实。 仓库:github.com/ca610/dsh-culprit · MIT 为什么需要它 生态里的最近邻是 slow-stack/euthyna:它能按行/按历史回答「这一行是谁引进的」(--origins),那是历史归因——它读历史,不执行任何东西。 dsh-culprit 回答的是另一个问题:「从哪个提交开始,这个行为坏了?」 它必须真的在那些提交上把命令跑一遍。历史归因猜不到「这一版依赖装不上」、「这个 commit 的测试超时」、「这个改动只在运行时才炸」。 代价是它会 checkout 你的工作树,所以这个插件在工程上有一半的力气都花在「怎么才能不伤到你」上(见下面的安全保证)。
展开完整文档(README 剩余 1 节)
dsh-culprit · 回归归因

「这段代码是哪次提交改挂的?」

给一条有 bug 就退出非零的命令,dsh-culprit 在仓库历史里二分,直接告诉你肇事提交是哪一个,并附上它的 diff、改了哪些文件、以及每一步的判定证据。

它不是「再跑一遍测试」,是把测试变红这件事本身当成一个可以追溯的事实。

仓库:github.com/ca610/dsh-culprit · MIT

为什么需要它

生态里的最近邻是 slow-stack/euthyna:它能按行/按历史回答「这一行是谁引进的」(--origins),那是历史归因——它读历史,不执行任何东西。

dsh-culprit 回答的是另一个问题:「从哪个提交开始,这个行为坏了?」 它必须真的在那些提交上把命令跑一遍。历史归因猜不到「这一版依赖装不上」、「这个 commit 的测试超时」、「这个改动只在运行时才炸」。

代价是它会 checkout 你的工作树,所以这个插件在工程上有一半的力气都花在「怎么才能不伤到你」上(见下面的安全保证)。

安装

先拿到代码:

git clone https://github.com/ca610/dsh-culprit

常规路径(用户自管 profile)——由 agent 调用插件管理器:

plugin_manager(action: "install_bundle", target: "C:\\path\\to\\dsh-culprit")

装完需要重启 profile,宿主才会加载新的 JS。卸载用 remove_bundle。

Windows 桌面版要绕一下:desktop profile 由 Electron 应用独占管理, dsh plugin --profile desktop add 会被 rejectElectronProfile() 直接拒掉(理由就是 "managed exclusively by the Electron application")。手接三样东西即可,这也是本插件在 Windows 上实际验证过的装法:

1. <profile>/node_modules/<包名> 目录 junction(cmd /c mklink /J)指向仓库路径 ——指向仓库而不是复制,改完 JS 只要重启、不必重装;
2. profile 的 package.json 里 dependencies.<包名> = "link:<仓库绝对路径>";
3. 同一个 dsh.profile.bundles 数组里追加 <包名>——这一步才是让加载器去读插件自己的 cordis.patch.yml 的开关,漏了它前两步都白做。

改 profile 的 package.json 之前先备份;bundles 追加到末尾(宿主自己的 reconcile() 也是 if (!bundles.includes(name)) bundles.push(name))。

装完必须重启 DSH:插件只在启动时加载,没有热更。

怎么确认它真的装上了

「装成功」是最容易骗人的一件事,而且它有两层,得分别去问。

第一层:profile 里到底有没有它。 加载不成功的 bundle 会被静默跳过,表现是「重启后一切正常,只是没有这个工具」:

node test/profile-install.mjs

它用宿主自己的 profile 解析器解析 profile,逐项检查 dependencies 里的 link:、 dsh.profile.bundles 有没有选中它(漏了这步,插件自己的 patch 根本不会被读)、 node_modules 里的 junction 通不通、解析出来的 patch 载荷里有没有那条 insert 行。
它只读 profile,不启动应用,所以对正在运行的应用是安全的。

第二层:工具真的注册上了吗。 宿主的 register() 要做 schema 校验,而它的异常被
apply() 的兜底 try/catch 吞掉了——表现同样是日志干净、插件列表里也有、但工具根本不存在:

node test/host-dispatch.mjs

它起一个真的 cordis 上下文 + 真的 dsh-tools 服务,从 profile 的 node_modules 加载插件 (也就是安装后的那一份),走 app.tools.execute() 真正派发一次归因,并断言返回值对 output.schema 零违规、render 真的在真值上跑过、工作树被还回去了。

两个脚本在缺宿主 runtime 时都以退出码 2 明确跳过——跳过不算通过。

用法

regression_culprit(predicate: "npm test -- src/clamp.test.js")

就这一个必填参数。它会自己找到健康的那个提交、自己二分、自己把工作树还回去。

带已知的好点最快也最准:

regression_culprit(predicate: "node --test calc.test.mjs", goodRef: "v2.1.0")

判据怎么写

判据就是一条在 bug 存在时退出非零的命令,跟 git bisect run 完全同一套语义:

| 退出码 | 含义 |
| 0 | 健康,这个提交没问题 |
| 非零 | 有问题,bug 在这个提交上存在 |
| 125 | 跳过(git bisect 的约定)——这个提交判定不了,不算好也不算坏 |
| 超时 | 跳过——同上 |

超时绝不算「坏」,这一条是刻意的:往历史深处走,依赖没装、服务起不来、数据库连不上都是常态。把这类失败读成「bug 在这里」,会把你精准地指向一个无关的提交——比报错还糟糕,因为它看起来很确定。

跑之前最好自己先执行一遍,确认它在当前 HEAD 上真的是红的。如果它在 HEAD 上是绿的,工具会直接告诉你是 not-reproduced,而不是硬凑一个答案给你。

参数

| 参数 | 说明 |
| predicate | 必填。判据命令,退出码语义见上。 |
| goodRef | 已知健康的提交/tag。给了就跳过自动寻点,最快、最可信。 |
| badRef | 已知有问题的提交,默认 HEAD。 |
| root | 仓库里的任意路径,默认当前会话工作目录。 |
| mode | bisect(默认)或 status(只列未完成的状态,不碰工作树)。 |
| resume | 接着上次没跑完的区间继续,不重来。 |
| maxSteps | 最多探测多少提交,默认 16(上限 40)。 |
| timeoutPerStep | 单次判据超时毫秒,默认 120000。 |
| totalBudgetMs | 整个调用的时间预算毫秒,默认 600000。 |

返回状态

found(找到)· not-reproduced(HEAD 上判据是绿的)· no-good-baseline(找不到健康基线)· budget-exhausted(预算用尽,可续跑)· inconclusive(跳过太多,没收敛)· dirty(工作树不干净,拒绝)· not-a-repo · bisect-in-progress(仓库里已有 bisect)· invalid-args

只有 found 会给出 result.culprit。budget-exhausted 故意不给——没收敛就不认领肇事者,免得你把一个"目前最像"的提交当成结论用。

安全保证

这个工具会反复 git checkout --detach,所以:

1. 工作树不干净就直接拒绝(dirty),一个探针都不跑。你要的是归因,不是丢改动。
2. 仓库里已有 git bisect 时拒绝(bisect-in-progress),不和你的交互式 bisect 抢状态。
3. 无论成功、失败还是抛异常,最后一定切回原来的分支/HEAD——finally 里做,测试里专门验证了这一点(连"分支名还原"都验)。
4. 只走 --first-parent 历史,不会 checkout 到旁支上去。
5. 没有破坏性 git 操作:不用 reset --hard,不用 clean,不用 stash。

断点续跑

每探一个提交就落盘一次状态(默认 %TEMP%\dsh-culprit\<sha1>.json,键是 仓库路径 + 判据 + 坏点 的 sha1)。跑太久被预算掐断时,结果里会带 state.resumable = true,再调一次加 resume: true 就接着跑,不会从头再来。
收敛之后状态文件会被删掉。

mode: "status" 可以列出所有未完成的状态——不碰工作树,纯只读。

现场演示

node examples/demo.mjs 会在临时目录里造一个真的会挂的仓库现场跑一遍(一个 clamp(value, max) 把 > 写成 >= 的经典边界 bug),跑完打印报告、删掉目录。
下面是它的真实输出:

回归归因 · 找到肇事提交判据 node --test calc.test.mjs 坏点 HEAD (09f35356) · 好点 f5c0abe3 (自动探测,位置 2) · 候选历史 5 个提交肇事提交 c7b82059「refactor: 整理 clamp 的边界判断」:它上面判据失败,它的前一个提交上判据通过。

肇事提交 c7b82059 refactor: 整理 clamp 的边界判断作者 Culprit Demo · 日期 2026-10-10 · 父提交 f5c0abe3 改了 2 个文件:+3 −1 CHANGELOG.md +2 −0 calc.mjs +1 −1

探测轨迹(good=判据通过 / bad=判据失败 / skip=跳过):
1. 09f35356 bad exit=1 285ms 坏点自检
2. c7b82059 bad exit=1 283ms 自动寻好点(第 1 次,位置 1)
3. f5c0abe3 good exit=0 319ms 自动寻好点(第 2 次,位置 2)

diff: --- a/calc.mjs +++ b/calc.mjs @@ -1,5 +1,5 @@
export function clamp(value, max) {
- return value > max ? max : value;
+ return value >= max ? max - 1 : value;
}

步数 3/16 · 预算 1s/300s · 总耗时 1s · 置信度 high

5 个提交、3 次探测、1 秒收敛。diff 里那两行就是答案—— 只看 diff 很容易划过去(>= 加一个 -1),但判据把它钉死了。

配置

写在 cordis.patch.yml 的 config 里:

- insert:
- id: culprit
name: dsh-culprit
config:
maxSteps: 24
timeoutPerStep: 300000

| 配置项 | 默认 | 说明 |
| workdir | 会话工作目录 | 默认在哪儿找仓库 |
| maxHistoryScan | 500 | 最多往回扫多少条历史 |
| maxSteps | 16 | 二分最多探多少次(上限 40) |
| maxAutoProbes | 8 | 自动寻好点最多探几次 |
| skipLimit | 3 | 连续跳过多少次就放弃 |
| timeoutPerStep | 120000 | 单次判据超时(毫秒) |
| totalBudgetMs | 600000 | 单次调用总预算(毫秒) |
| stateDir | %TEMP%/dsh-culprit | 续跑状态放哪儿 |
| allowDirty | false | 允许在脏工作树上跑(不推荐,checkout 可能失败并记为跳过) |
| shell | 自动 | powershell / pwsh / cmd / bash / sh |
| firstParent | true | 只看第一父历史 |
| requireBadAtStart | true | 先验证坏点确实坏 |
| keepState | true | 是否落盘续跑状态 |

手写工具定义的两个硬约束

本插件不 import 任何 @deepseek-ai/(profile 是 link 目录,裸包名解析不到 → MODULE_NOT_FOUND),所以工具定义是普通对象,而不是 defineTool() 的 spec 写法。代价是下面两条得自己扛,它们各埋过一次坑,都有回归测试守着:

1. output.schema 必须是宿主校验器认识的 JSON Schema 子集。 合法关键字只有 type(单个字符串,数组形式不支持)、oneOf、properties、required、 additionalProperties(布尔)、items、enum、const,外加注解类。
required 只能是字符串数组,且只能挂在 type: "object" 上——defineTool() 的 spec 写法(每个属性挂 required: true)会被 assertSupportedJsonSchema 整份拒绝。
可空字段必须写成 oneOf: [{ type: "string" }, { type: "null" }]:宿主不是只看看 schema,它还会拿 schema 校验真实返回值(createSuccessResult),写窄了会在最常走的那条路径上抛 ToolOutputError。
2. 参数校验得自己来。 register() 只校验 output;宿主的参数校验只存在于
defineTool() 生成的 execute 包装里。手写定义拿不到那一层,所以本插件的 parameters
只是"给模型看的文档"——invalid-args 这类兜底由插件自己在代码里做。

已知限制

- 只看 --first-parent 历史。 快、确定,但如果肇事者藏在某个已合并分支内部,肇事点会落在那个 merge commit 上(结果里会用警告点明这一点)。
- merge commit 的 diff 默认拿不到(git 的 combined diff 默认关闭),插件会自动退回 git diff <sha>^1 <sha>。
- 旧提交上跑不起来的东西会被跳过,跳太多就是 inconclusive——这是诚实的失败,不是 bug。给一个更近的 goodRef 通常就能收敛。
- 需要 git 和能启动子进程的环境。 沙箱里如果禁止管道 stdio,
git 拿不到输出,工具会报 not-a-repo。

一个值得记下来的 Windows 坑

Windows PowerShell 会把任何非零退出码压成 1:

node skip.mjs; exit=125
powershell -Command "node skip.mjs" exit=1 ← 真实码丢了 powershell -Command "node skip.mjs; exit $LASTEXITCODE" exit=125 ← 只能这么写

所以插件在 Windows 上是把判据包成 ... ; exit $LASTEXITCODE 再执行的(cmd 那条路用 /v:on 加 !ERRORLEVEL!,因为 %ERRORLEVEL% 在解析期就展开了)。
不这么干,"退出码 125 = 跳过"这条约定在 Windows 上直接失效。

测试

node test/culprit.test.mjs # 108 条断言,不需要宿主
node test/profile-install.mjs # 10 条断言,只读 profile(见"怎么确认它真的装上了")
node test/host-dispatch.mjs # 26 条断言,需要宿主 runtime(同上)

第一套全是真的临时 git 仓库、真的 git、真的子进程,不用任何测试框架。(npm test)重点验证:

- 肇事提交判得对(6 个提交的历史,bug 在第 4 个);
- 工作树一定被还原(HEAD、分支名、git status 都验);
- 超时和退出码 125 被当作跳过,绝不被读成"坏";
- 脏工作树 / 已有 bisect / 非 git 目录全都拒绝,且不碰工作树;
- 预算用尽 → 落盘 → resume 续跑 → 收敛;
- 工具 schema 符合宿主校验器能接受的形状——一个零依赖的遍历器专门盯六类坏写法(布尔型 required、非数组 required、required 挂在非 object 上、required 列了未声明的属性、非布尔 additionalProperties、数组型 type),不用连宿主也能守住这个坑;
- 用假 ctx 把 apply() 真跑一遍:注册得到工具、dispose 摘得掉、 ctx 残缺时也不抛错(apply 里同步抛错 = 整个插件加载失败,这条最要命),且兜底日志恰好打一条。

第二套(test/host-dispatch.mjs)补的正是第一套的结构性盲区:第一套从不经过宿主的
register() / 派发 / 取值校验 / render 投影。当初那两个真故障就是它抓出来的
(属性级 required: true 导致工具静默不注册;可空字段写窄导致派发时 ToolOutputError)。

结构

dsh-culprit/
├─ index.mjs 宿主半边,唯一被加载的文件(不 import 任何 @deepseek-ai/) ├─ cordis.patch.yml 挂载声明 ├─ locale/{zh,en}.json 插件列表里的标题与描述 ├─ test/culprit.test.mjs 108 条断言,不依赖宿主 ├─ test/profile-install.mjs 10 条断言,只读 profile,验"装进去了没有" ├─ test/host-dispatch.mjs 26 条断言,挂到真宿主服务上,验"工具真的注册上了吗" ├─ examples/demo.mjs 临时造仓库的现场演示 └─ README.md

MIT
预览分享卡片长什么样 ↓ca610/dsh-culprit
分享 · DeepSeek Harness Hub
ca610/dsh-culprit

二分 git 历史,定位让命令变红的肇事提交

回到顶部