首页
/ Mole 安全缺陷事件目录:17 类破坏性清理缺陷的 Agent Skill 路由设计与红绿验证契约

Mole 安全缺陷事件目录:17 类破坏性清理缺陷的 Agent Skill 路由设计与红绿验证契约

2026-09-04 20:23:44作者:劳婵绚Shirley

Mole 是一款 macOS 系统清理与优化工具,其核心风险面集中在"删除"这一类破坏性操作上。本篇基于仓库中 .claude/skills/bugs/SKILL.md 这份"事件目录"型 Agent Skill,完整拆解它如何用一张 17 行的路由表把 Mole 历史上反复出现的安全缺陷归为 4 个参照家族、如何沿"发现到落盘"的完整变异生命周期审查删除链路,以及如何用红绿测试与隔离 HOME 等手段让回归测试真正具备"能够失败"的能力;读完后你可以复现这套"先路由、再取证、后验证"的缺陷审查方法,并将其应用到任何涉及删除、超时、并发与持久化派生数据的 Shell 工具中。

一、这份 SKILL 是什么:按需路由的事件目录

.claude/skills/bugs/SKILL.md 不是普通的项目文档,而是一个面向 AI 编码代理(Claude Code 等)的技能文件,其 frontmatter 自述用途:

Mole incident catalog for destructive cleanup safety, bounded Shell/macOS probes, cancellation and concurrency, dry-run/real parity, async freshness, cache and accounting consistency, Bats validity, and actionable gates.

即:一份覆盖"破坏性清理安全、有界 Shell/macOS 探测、取消与并发、dry-run 与真实执行一致性、异步新鲜度、缓存与计数一致性、Bats 测试有效性、可执行的门禁诊断"的缺陷事件目录(incident catalog)

它在 Mole 的代理工作流中被明确定位为"按需加载的路由器",而非通用预审清单。AGENTS.md 的 Working Rules 一节写道:

Treat .claude/skills/bugs/SKILL.md as an on-demand router, not a universal review preflight. Load only the linked reference families signaled by deletion evidence, uncertain probes, bounded Shell/macOS work, persisted state or accounting, progress, test validity, or refusal diagnostics.

也就是说,只有在当前缺陷的证据(diff、症状、调用点)真正命中某类信号时,才加载对应的参照文件。SKILL.md 正文也划定了职责边界:通用代码审查归 check,线上故障的根因调查归 hunt,而本目录用于"已读到当前症状、diff、实现与调用点之后"的模式匹配。

二、先路由再加载:17 类复发缺陷形状的路由表

SKILL.md 的核心是一张路由表。它要求"只选择被证据触碰到的参照家族;全项目审计应先分类面,而不是加载每一条事件叙述"。完整继承如下表(原文中的 references/... 局部相对路径已转换为从仓库根目录起算的路径):

# 复发形状 第一探针 读取的参照
1 删除候选由弱名称信号构建 检查名称、bundle-id 与回退 glob 删除证据与最终落点
2 存在性或空闲由单一探针决定 枚举所有合法位置与未知结果 删除证据与最终落点
3 保护只存在于一个分支 对 dry-run、真实、直接、回退、最终落点路径做差分 删除证据与最终落点
4 无界外部命令 数清生产者、消费者、内层循环与动作的边界 界限、Shell、TTY 与解析
5 Bash 3.2、errexit 或 pipefail 陷阱 检查空数组、`fn
6 TTY、stdin 或进程组被抢走 检查后台 worker 与可能提示的命令 界限、Shell、TTY 与解析
7 系统输出被当成稳定 API 解析 强制 locale 并在接受数据前校验形状 界限、Shell、TTY 与解析
8 持久化派生数据活得比算法久 追踪 schema、TTL、证据指纹与变异 状态、计数与进度
9 两条路径以不同方式计算同一个数 找到所有生产者并选定一个定义 状态、计数与进度
10 慢工作看起来像冻结 找到反馈窗口外约 1 秒以上的操作 状态、计数与进度
11 回归测试不可能失败 证明阳性对照与修复前的红色状态 测试有效性与拒绝诊断
12 门禁无法解释为什么拒绝 把每个原因码映射到单一成因与下一步动作 测试有效性与拒绝诊断
13 变异目标同时被接受为发现容器 对比递归扫描根命名空间与每个 purge 目标 basename 删除证据与最终落点
14 owner 元数据被当作完整原子清单 识别谁写它、缺失是否权威、什么锁住变异 删除证据与最终落点
15 取消停掉一个 helper 但后续工作继续 追踪 124 与信号状态跨循环、子 shell、worker 与章节编排 界限、Shell、TTY 与解析
16 异步或缓存数据没有代次/新鲜度契约 把结果绑定到请求纪元,保留每个样本的 time/stale/completeness 字段 状态、计数与进度
17 发布门禁信任含糊或预存在状态 要求精确 source/tag 相等、唯一生成目标与预期缺失 ref 租约 测试有效性与拒绝诊断

这张表的组织逻辑值得注意:第一列是"复发形状"(recurring shape)而非具体 bug——它描述的是代码结构层面的缺陷模式,因此可以跨 PR 复用;第二列给出"第一探针",即开始审查前 30 秒内就能执行的定向检查;第三列决定要加载哪个参照文件,把阅读成本控制在被触碰的家族内。

三、沿完整变异生命周期审查,而不是只盯报告分支

SKILL.md 要求:对 cleanup、purge、optimize、analyze 删除或 uninstall 类工作,审查完整链路而不是被报告的分支。链路如下(原文完整继承):

discover or plan
  -> cheap irreversible filters
  -> owner and open-handle probes
  -> size or metadata work
  -> final owner re-probe
  -> parent and target identity rebind
  -> deletion or Trash sink
  -> accounting, cancellation, and user output

在每一次状态迁移处,回答八个问题:

  • 存活或未知状态是否 fail closed?
  • 超时与信号状态是否仍可观察,且能阻止后续变异?
  • 探针与落点是否绑定到同一物理父目录与目标?
  • dry-run 与真实模式是否从同一合格计划出发、且不复用过期授权?
  • 廉价的"不存在、受保护、白名单、编译模型"过滤是否排在递归探针之前?
  • 一个累计截止时间是否覆盖动态扫描范围,且嵌套循环中有检查点?
  • 被拒、被过滤、超时或失败的条目是否留在"已清理计数"与"回收字节"之外?
  • 大候选能否跳过逐项尺寸统计,同时不让报告的总数失真?

最后一条红线同样保留在原文中:不要用最终落点重绑定或 fail-closed 的 owner 检查换速度。优先优化的对象是不存在的目标、重复的发现探针、仅报告用工作与错误范围的扫描。

这条链路在 Mole 源码中有直接对应物:lib/core/file_ops.sh 中集中了四个"漏斗级"安全函数——validate_path_for_deletionsafe_removemole_deletesafe_find_delete。参照文件 deletion-evidence-and-final-sink.md 明确建议"优先把这些策略放在漏斗级",理由正是模式 3(保护只存在于一条路径):调用点级别的保护会被下一个调用方遗忘。

从源码结构看,validate_path_for_deletion 的实现与这份目录的多个条目一一对应:它先拒绝空路径、非绝对路径、.. 路径穿越段与控制字符(lib/core/file_ops.sh#L937-L961);随后专门处理符号链接——不仅检查叶子链接目标是否指向受保护系统路径(lib/core/file_ops.sh#L966-L990),还实现了一段"祖先符号链接守卫":如果路径的任一祖先是符号链接(例如 ~/Library/Caches 被重定向到 ~/Documents),字面路径匹配会全部落空而实际删除穿透到真实目标,于是用纯 bash 内建 [[ -L ]] 沿祖先上溯,确认存在符号链接后才付出一次 cd -P 子 shell 代价做物理规范化并复跑拒绝谓词,且明确是 deny-only(规范化路径永远不能授予字面路径没有的权限,见 lib/core/file_ops.sh#L992-L1038)。这段注释还解释了性能取舍:热路径上每次调用若都做 dirname + cd -P 约 2ms,会使验证整体增加约 23%,所以只在内建探测命中后才支付子 shell 成本——这正是"廉价过滤在前、昂贵探针在后"原则的实例。

配套的强制契约见 AGENTS.md 的 Critical Safety Rules:删除必须走 lib/core/file_ops.sh 的安全 helper;裸 rm -rffind -delete 仅在同行带 # SAFE: <一句话理由> 注释时允许,这是 docs/SECURITY_DESIGN.md Layer 2 定义的契约并由 CI 强制执行。仓库的 Bats 测试也按这些家族归档,例如 tests/file_ops_mole_delete.batstests/file_ops_safe_remove_symlink.batstests/path_validation_fuzz.bats(配合 tests/fuzz_corpus/dangerous_paths.txt 语料),与目录中"验证依据"的要求形成闭环。

四、参照家族一:删除证据与最终落点

deletion-evidence-and-final-sink.md 覆盖模式 1、2、3、13、14,并给出"最终落点矩阵"。

4.1 弱名称证据不能授权删除(模式 1)

规则是:精确 bundle id 或精确 app 路径才是证据;vendor 前缀、通用词、回退通配符不是。显示名、bundle-id 前缀、TeamID 前缀或子串 glob 最终总会命中某个邻居。文件记录了四种历史形状:

  • find_app_files 曾从 GUI 显示名推导 ~/.config/<name>,导致卸载 Claude.app 时删除了 Claude Code CLI 的状态;大小写不敏感的 APFS 进一步扩大了碰撞面;
  • ${bundle_id}*.plistcom.foo 匹配到 com.foobar.plist
  • 子串拆除在卸载 Foo.app 时删掉了幸存的 Foo-beta.app 邻居;
  • TeamID 前缀回退曾被合并后又回退。

文件还给出针对该缺陷类的仓库级探针命令:

command grep -rnE '\*\$\{?(app_name|bundle_id|name)\}?\*|\$\{bundle_id\}\*' lib/ bin/

对每个命中,命名"授权落点的最窄事实",并分别审查主分支与回退分支。

4.2 单一探针决定存在性或空闲(模式 2)

每个 owner 谓词必须区分 presentabsentcould not tell 三态;超时、权限拒绝、元数据缺失或不完整的发现都属于"未知",永远不是"不存在"。历史案例包括:mdfind 漏掉 Homebrew cask 与内嵌 SMJobBless helper;command -v 加 LaunchAgents 漏掉 GUI Proton Mail Bridge 的 owner 进程,从而把 ~/.bridge 误判为孤儿;任何 UP 状态的 utun* 接口被当成 VPN,包括 iCloud Private Relay;brew list mole 能回答所有权问题却会重置用户的 sudo 时间戳,替换为 Cellar 检查消除副作用后,又因未从已装 brew 路径推导前缀而漏掉自定义前缀。

探针命令:

command grep -rn 'mdfind' lib/ bin/ | command grep -v run_with_timeout

4.3 保护只存在于一条路径(模式 3)

四个历史形状都指向同一教训——保护逻辑必须放在共享漏斗而不是调用点:

  • should_protect_path 只在真实模式运行,导致 dry-run 承诺了真实运行会拒绝的工作;
  • 某调用方忘记白名单,直到白名单移入共享 find 落点才补齐;
  • Raycast 排除条件写在了实际 find 谓词之外;
  • _safe_clean_impl 只在真实模式咨询删除守卫,预览阶段就登记并计数了活跃进程守卫会拒绝的条目。

审查方法是:枚举保护 helper 的每个调用方,再枚举每个落点,对两个列表做差分;dry-run 与真实模式必须计算同一合格计划。目标特定守卫应在"不存在、受保护、白名单、编译模型"候选被过滤之后、预览登记或删除之前运行。

4.4 最终落点矩阵

对每个破坏性家族,用实时代码填写这张矩阵(原文完整表格):

阶段 必需证据 失败行为
发现 精确受支持根 + 完整扫描 不完整结果被丢弃或标记为部分
廉价过滤 不存在、受保护、白名单、编译模型 候选被省略,不做昂贵探针
Owner 探针 进程与打开句柄三态判定 存活或未知即拒绝
尺寸/元数据 有界、不复用授权 超时可观察;不产生虚假回收字节
最终再探针 慢工作后的 owner 状态 新的存活或未知状态拒绝
身份重绑定 物理父目录 + 目标身份 重命名、替换或符号链接变化即拒绝
落点 共享安全 helper + 保留确认 无裸回退删除
计数 只计已完成的变异 被拒与失败条目保持排除

容器、SQLite、helper-app 与特权路径必须在落点前立即执行最终再探针与身份重绑定——发现快照不是所有权租约。信号与取消也是证据链的一部分:保留 >=128 的状态,让取消在 best-effort 调用方之间保持粘滞,并阻止取消后任何后续落点运行。

4.5 变异目标被当作发现容器(模式 13)与 owner 元数据不是删除授权(模式 14)

模式 13:递归发现不得穿过它本想提供的制品本身。若 node_modulesvendorPods 被接受为项目容器,包内清单会变成伪项目根,扫描从真实目标之下开始,嵌套 dist/build 目录进入删除列表;父制品永远无法用于嵌套目标折叠,删掉子项后包管理器会认为残缺的树已安装。文件给出的处理原则是:把目标列表本身当作"排除容器命名空间",而不是维护第二份手写拒绝表;每个扫描根入口(维护者默认、用户配置根、自动发现、发现消费者)可能有意拥有不同的信任契约;Mole 的显式 purge_paths 是"真实项目 basename 恰好撞名"的逃生舱,不能为它削弱自动发现。

模式 14 区分了三类数据:日志(journal)记录事件、注册表(registry)记录一个当前视图、缓存(cache)记录一次先前观察——除非 owner 文档化该契约并协调并发变异,否则没有任何一类是完整的删除清单。文件以编辑器扩展为例:.obsolete 是删除日志,它的空不能证明磁盘上的每个扩展都是活跃的;但反过来用目录对 profile 注册表做对账同样不安全,因为并集保留集可能漏掉未知 profile、新写入的注册、或扫描与落点之间 owner 的变异。安全的产品边界是回到"精确的 owner 撰写移除标记"。落地前必须回答五个问题:owner 称数据为清单/日志/缓存/尽力索引?能否枚举所有支持的 profile、安装与并发写者?Mole 是否共享 owner 的锁、代次或机器可读 GC 命令?保留集构建后、落点前是否可能出现新的 owner 引用?中断等价于缓存未命中,还是会留下残缺的安装/会话/创作状态?若完整性与同步性无保证,只能用 owner 撰写的移除标记、调用 owner 支持的清理命令、在恢复契约允许时提供整缓存重置,或干脆不动目标。

五、参照家族二:界限、Shell、TTY 与解析

shell-and-test-pitfalls.md 覆盖模式 4–7、15 与一批"聚焦陷阱"。这一家族在 Mole 仓库里有最直接的源码对应:lib/core/timeout.shrun_with_timeoutlib/core/timeout.sh#L141)实现了三级降级链——优先 gtimeout/timeout(coreutils),回退到带专用进程组清理的 Perl helper,最后才是有已知局限(可能清不完子进程、存在竞态)的纯 shell 兜底(lib/core/timeout.sh#L17-L31)。

5.1 无界外部命令(模式 4)

dumdfindfindxcrun simctlsystem_profilerioreg 与包管理工具在健康但缓慢的机器上都会卡住。文件给出的纪律包括:

  • 每个嵌套循环都要有检查点,不只是每个外层根;
  • 以最慢的健康场景调参——CoreSimulatorService 曾因 2 秒界限被误报不可用,需要预热重试;
  • 生产者必须完整物化,非零状态时丢弃输出;"过程替换 + || true"不得把部分 find 前缀喂进删除;
  • 探针与动作的模式、类型、年龄、深度必须一致;
  • 先分别给生产者与消费者计时再提高超时——2.3 秒的 lsregister dump 之后每行输入一次命令替换仍是分钟级;
  • 已安装二进制的 --version/--help 验证也要有界,坏掉的可执行文件最可能挂起;
  • 安装与更新按目标目录单飞(single-flight),一个进程不能验证另一个代次。

配套的仓库级核查脚本(原文完整继承):

for command_name in 'du -s' mdfind xcrun system_profiler ioreg brew; do
    printf '%-16s total=%-4s wrapped=%s\n' "$command_name" \
        "$(command grep -rn -- "$command_name" lib/ bin/ | wc -l | tr -d ' ')" \
        "$(command grep -rn -- "$command_name" lib/ bin/ | command grep -c run_with_timeout)"
done

tests/core_timeout.batslib/bin/ 范围内用源码不变量把"每条 du -s 路径都在 run_with_timeout 之后"钉为类级契约,磁盘验证超时常量使用 MOLE_TIMEOUT_DISK_VERIFY_SEC

5.2 Bash 3.2、errexit 与 pipefail(模式 5)

macOS 自带 Bash 3.2,且 Mole 以 nounset 运行,由此产生四条硬规则:

  • 空数组展开前必须先守卫——set -u 下空数组 "${arr[@]}" 可以中断扫描并让 spinner 成孤儿;
  • fn || handler 会在整个 fn 函数体内禁用 errexit;安全关键步骤必须用显式 if ! command; then return 1; fi
  • 不要依赖调用方的临时 set +e 窗口做优雅降级,在命令运行处捕获状态;
  • 可选的 [[ -n "$value" ]] && action 在变量为空时返回 1,状态敏感块内必须改用 if/fi

类级探针:

command grep -rn '\$\{[a-z_]*\[@\]\}' lib/ bin/

5.3 TTY、stdin 与进程组(模式 6)

Perl 超时兜底可能把控制终端交给子进程:一个后台元数据 worker 曾以 SIGTTIN 停住前台卸载提示;BSD mv/cp 在目标不可写时也可能在 stderr 提示并读 stdin。纪律是:每个调用 run_with_timeout 的后台 worker 都用 < /dev/null 关闭 stdin;可能提示的命令使用非交互或强制选项;菜单与扫描的 trap 保存并恢复调用方的 trap——参照实现是 lib/ui/menu_paginated.sh

5.4 系统输出被当成稳定 API(模式 7)

macOS 命令输出会被本地化、随版本漂移、在数据位置打印错误。规则包括:被解析的度量子进程强制 LC_ALL=C;信任字段前先校验形状(绝对路径、数值、预期键或精确枚举);DTSDKBuild 构建标识与 DTPlatformVersion 版本必须分开;把 PlistBuddy 的"文件不存在"散文当数据拒收;检查标志位时用 macOS 原生语义(BSD grep -Z--decompress);优先退出码、plist 键与机器可读输出。注意 BSD grep 没有 GNU 的 null 输出 -Z 契约,正确姿势是 find ... -print0 枚举、grep -qF 逐文件探测。

5.5 取消是局部的,除非编排让它粘滞(模式 15)

对破坏性命令,超时 124 与信号派生的 >=128 状态取消的是剩余全部命令,而不只是当前 helper。要让它穿过每个边界:best-effort 循环在探测/登记下一个候选前必须检查挂起取消;把普通缺失报成成功的 helper 必须在进入下一家族前返回挂起取消;命令替换、子 shell、后台 worker 不会修改父 shell 变量,必须显式回传状态;并行协调器要停掉并收割同伴 worker、让取消优先于普通失败、阻止下一渲染章节启动;dry-run 使用与真实清理相同的取消契约——预览台账也是下游工作。

回归测试的形状同样被规定:第一个候选返回 124 或 130,第二个候选若被触达则会成功;断言精确的顶层状态加"第二个探针、预览登记、落点与后续章节均无阳性痕迹"。若两个候选都独立超时,测试就无法证明取消是粘滞的。也不要把已取消的安全探针藏在 || true、"警告 + return 0"或 worker 局部导出变量后面——那些形状把全局停止变成了局部跳过。

文件末尾还有一批"聚焦陷阱",其中与本文主线强相关的包括:run_with_timeout 会 exec 二进制、绕过 shell 函数 mock,外部命令测试必须用 PATH stub 目录;MOLE_TEST_MODE=1 可能让被测函数提前返回,末尾负断言在空输出上假通过,必须以 || return 1 收尾并加阳性对照;大 payload 管道喂给 grep -q 会在首匹配退出后让仍在写入的 printf 吃到 SIGPIPE,把 Broken pipe 泄漏到用户输出(应改用 here-string);数值比较前必须用 10# 归一化,否则 08/09 不是合法八进制会直接中止。

六、参照家族三:状态、计数与进度

state-accounting-and-progress.md 覆盖模式 8–10、16。

6.1 持久化派生数据活得比算法久(模式 8)

改算法却不失效缓存,等于让旧结果在源修复后继续出厂。代表形状是一次硬链接去重变更同时提升了缓存 schema 并把依赖去重的子树标记为不可缓存。对每个持久化派生值必须识别五要素:schema 版本;TTL(若年龄有意义);每个输入变异的失效;是否有调用方把值当作"不存在"的证明;验证运行是否在读上一个发布的数据。

关键论断是:TTL 只能证明条目不够旧,永远证明不了完整性pkg_receipt_nonstandard_app_paths --require-complete 曾把一小时前仍"新鲜"的 receipt 缓存当作"没有兄弟安装"的证明——重查能删掉陈旧条目,却不能发现新安装的 owner;修复是把完整性绑定到 pkgutil --pkgs 的指纹上,新证据出现即失效条目。结论:当调用方用缓存数据授权删除时,要么绕过缓存,要么把它绑定到"任何出现都会改变结论的证据"的指纹。

6.2 两条路径以不同方式计算同一个数(模式 9)

任何被渲染两次的值终将不一致:dry-run 预览对最终摘要、条目数对原始目标数、子树尺寸对 du、十进制对二进制单位。方法是找到所有生产者、选定一个定义、优先把测量值传进落点或渲染器而不是重算,然后在回归测试里比较两个渲染面而不是钉死无关字面量。计数规则:

  • 被过滤、被拒、超时、失败或消失的候选不贡献清理条目也不贡献回收字节;
  • dry-run 与真实模式使用同一合格候选集,只有动作不同;
  • 尺寸超时可产生显式的"未知"或"部分"总数,绝不把虚构的零当作完整呈现;
  • 大候选快速路径跳过逐项尺寸统计时,输出必须声明总数是部分的或未扫描;
  • 硬链接按一个被命名的策略在子树与摘要路径间保持一致。

tests/clean_core.bats 包含"预览对摘要"的既有模式;子兆字节舍入到零、逐链接硬链接计数也属于这一家族。

6.3 沉默被读作冻结(模式 10)

慢工作在 spinner 窗口外看起来就是挂死。Mole 的输出节奏被固定为四行契约:

section title
loading state
content
one trailing blank line

spinner 在"会被覆盖的输出"之前立即停止,若其后还有静默工作则重启;超时警告不能替代健康慢扫描期间的进度。性能工作需要两张收据:一条隔离变更路径的有界微基准或调用次数不变量;一条同模式同机器条件下的端到端命令计时。文件同时警告:不要用缓存目录尺寸优化——APFS 不把后代 mtime 传播到父目录;对破坏性工作,即使最终 owner 探针与身份重绑定是昂贵部分也要保留。

6.4 异步代次与样本新鲜度是一个契约(模式 16)

异步结果可能在产生时有效、到达时过期。每个请求要打单调变化的代次/探针 ID 标签,结果消息携带该标签,只应用到匹配代次;刷新或导航跳转创建新请求时递增代次,而不是视图重绘时递增。生命周期要双向测试:旧结果不得覆盖新刷新;用户在钻取时到达的匹配结果值得保留,但返回 Overview 必须调度新探针而不是把旧结果当新测呈现。

缓存指标使用平行契约:把相关字段当一个原子样本——value group + collected_at + stale + completeness。瞬态刷新失败时返回错误但保留上一次成功组(带原始采集时间与 stale=true);不得把旧值与新刷新时间拼在一起、只清一半字段、或把未测量变成测得的零;后续成功样本整体替换该组并复位 stale。每个序列化器与 fast/full/watch 路径必须保持"新鲜数据、陈旧的 last-known-good、测得的零、从未测量"四态区分。

七、参照家族四:测试有效性与拒绝诊断

test-validity-and-refusal-diagnostics.md 覆盖模式 11–12、17。

7.1 不可能失败的测试(模式 11)

多条 Mole 回归曾被"覆盖"在修复前的实现上同样通过的断言。文件列出的断言与分支陷阱包括:非末尾的裸 [[ ... ]] 在后续命令成功时被吞掉——每条有意义断言以 || return 1 收尾(内层脚本用 || exit 1);MOLE_TEST_MODE=1 可让被测函数提前返回;函数 mock 可能选到与 PATH 可执行文件不同的分支(run_with_timeout exec 二进制,超时路径必须用 PATH stub);接受状态 0 的超时测试不证明超时传播,必须断言精确状态、被丢弃的部分输出与生产分支的阳性痕迹;对从未存在的字符串做负断言毫无意义。

文件给出最小复现夹具:

cat > tests/zz_min.bats <<'EOF'
@test "non-final [[ ]] false" { [[ 1 -eq 2 ]]; [[ 1 -eq 1 ]]; }
@test "non-final [ ] false"  { [ 1 -eq 2 ];  [ 1 -eq 1 ];  }
EOF
bats tests/zz_min.bats

隔离与 CI 对等一节规定了:setup_file 在同一 Bats 文件内共享 HOME,断言缺失、精确文件数、缓存新鲜度的测试要用专属子 HOME 或只清理自己创建的夹具;可变的 shell 计数器活不过命令替换(子 shell),要把调用状态持久化到测试自有的文件;通过 MOLE_TEST_NO_AUTH=1 ./scripts/test.sh 匹配 CI,窄复现需要 Bats jobs 时保留 --no-parallelize-within-files——裸 bats --jobs 6 file.bats 会改变共享状态契约;源码不变量的 grep 要跳过注释行,且在目标零命中时必须失败,否则注释会伪装成禁用调用、或改名后的目标让守卫空转变绿。

验收线是红绿:新测试先在修复前代码上跑、看到预期断言失败,再恢复修复看到通过。取消类测试多一条阳性对照要求:没有粘滞停止时,后续候选必须合格。

上述规则在 scripts/test.sh 中可见工程化落地:runner 开头即 export MOLE_TEST_NO_AUTH=1 以阻止脚本化测试触发真实 sudo/Touch ID 提示,并清扫 tests/tmp-* 下超过 60 分钟的孤儿测试 HOME 目录;启用 --jobs 时始终附加 --no-parallelize-within-files(见 scripts/test.sh#L235-L245),与 SKILL.md Working Contract 中"共享 setup_file home 不构成隔离"、"保留 --no-parallelize-within-files"两条形成互证。

7.2 无法解释拒绝的门禁(模式 12)

多个独立成因共用一条兜底消息,会迫使报告人反向工程源码。Mole 的 acquire_install_lock 遇到过不安全祖先、被拒的 sudo -n、不可用锁目录、被植入的符号链接/FIFO 锁路径、不可用锁原语与真实竞争——这些成因需要稳定原因码与不同的下一步动作。三类复发失败:消息未暴露成因导致报告人代劳分诊;新门禁跑在旧的更可操作检查之前,把"用 sudo -v 缓存凭据"降级成错误的忙锁诊断;源码不变量测试钉住了含糊的兜底字符串,把诊断回归变成硬性要求。

每个可达拒绝都要填这张映射表:

成因 稳定原因 用户可见解释 下一步命令或动作
精确分支条件 机器可读码 一行事实 一个成因特定的下一步

两个成因共享一条消息、且补救不同时就是缺陷;"重装"在重装会再次进入同一门禁时不是补救;新门禁前移时至少保持同等可操作度。被吞掉的 stderr 可能藏着唯一的区分证据——诊断时用受控差分探针,再把结构化结果映射到原因码,而不是永久暴露特权 stderr。相关核查命令:

command grep -c 'return 1' install.sh
command grep -c 'log_error' install.sh
command grep -rn 'sudo .*2> */dev/null' install.sh lib/

测试钉住原因码路由与下一步分支,不钉兜底散文AGENTS.md 也把这条提升为项目级安全规则:acquire_install_lock 通过 INSTALL_LOCK_FAILURE 报告稳定原因,不安全祖先变体用 INSTALL_LOCK_UNSAFE_ANCESTOR_REASON,并保留"一条事实成因行 + 一条成因特定下一步动作"的形状。

7.3 发布门禁信任含糊或预存在状态(模式 17)

发布把解析出的源与远端名字变成不可变的公共状态,所以宽松匹配与 check-then-create 竞态必须 fail closed。三条精确契约:

  1. 提取一个非空源版本,要求触发 tag 精确等于 V<source-version>——前缀匹配或尽力回退可以在貌似合理的 tag 下发布错误的 commit;
  2. 改写生成的 formula 时要求恰好一个预期顶层 source URL 与一个配对的 source 校验和;零命中说明布局漂移,多命中说明改写目标含糊;bottle 校验和不算源字段;
  3. 拒绝覆盖预存在发布分支,并用"预期缺失租约"(如 --force-with-lease=refs/heads/<branch>:)关闭读与推之间的竞态——只读预检不是并发控制。

门禁失败应指名不匹配的值或占用的 ref,并告诉维护者重跑前检查什么;测试对隔离夹具证伪"空、不匹配、重复、预存在"四类情形。grep 证明守卫文本存在可以作为源码不变量,但不能证明 shell 分支真的拒绝了坏状态。该模式适用于工作流与脚本中的发布安全缺陷;发布规划、版本命名、notes 与公告文案不在 bugs skill 范围内。

八、工作契约与验证底线

SKILL.md 把审查过程的纪律压缩为六条工作契约(完整继承):

  • 调用点形状扫平级代码,而不是按文件名或 helper 名;报告格式为 checked N / defective M / not applicable K
  • 复发修复必须随附一条对修复前代码会失败的回归或源码不变量;
  • 测试只有在证明生产 helper 真的运行后才被当作生产消费者对待;负断言需要阳性痕迹;
  • 取消回归的标准形状:让下一个候选"在其他方面合格",然后证明其探针与落点从未运行;让每个候选因同一原因失败是假的粘滞取消测试;
  • 对缺失敏感的测试用隔离的 HOME 或夹具根;共享的 setup_file home 不构成隔离;
  • 尽可能用 MOLE_TEST_NO_AUTH=1 ./scripts/test.sh 复现 CI;直接调用 Bats jobs 时保留 --no-parallelize-within-files
  • 把专家或 AI 报告当作线索——亲自读实现、调用方、回退分支与最终落点。

验证底线(Verification bar)要求:使用 AGENTS.md 中的热点命令,不要猜一个更窄的验证器。一条典型 Shell 安全变更的收尾序列(原文命令块完整继承):

./scripts/check.sh --format
MOLE_TEST_NO_AUTH=1 bats tests/<area>.bats
MOLE_TEST_NO_AUTH=1 ./scripts/test.sh
go test ./...
MOLE_TEST_NO_AUTH=1 MOLE_DRY_RUN=1 ./mole clean --dry-run

这套命令与 AGENTS.md Commands 一节列出的仓库标准命令一致(./scripts/check.sh --formatMOLE_TEST_NO_AUTH=1 ./scripts/test.shgo test ./...MOLE_DRY_RUN=1 ./mole clean 等),其中 Go 侧对应 cmd/analyzecmd/status 两个 TUI 组件的单元测试。SKILL.md 的最后一句话划清了证据边界:

Never infer a production defect from a function name, comment, string, fixture, or _test.go match. Confirm the live call path and verify red-green before reporting the class fixed.

即:永远不要从函数名、注释、字符串、夹具或测试文件名的匹配推断生产缺陷;确认活调用路径并在报告"该类已修复"之前完成红绿验证。

九、小结

.claude/skills/bugs/SKILL.md 及其四份参照文件构成了一套可迁移的"破坏性操作缺陷审查方法论":以 17 类复发形状做证据驱动的路由,以八阶段变异生命周期为审查骨架,以"三态 owner 判定、fail-closed、落点矩阵、红绿验收、原因码映射"为具体纪律,并逐条落到 lib/core/file_ops.sh 的漏斗级守卫、lib/core/timeout.sh 的有界执行、scripts/test.sh 的 CI 对等契约与 tests/ 下按家族归档的 Bats 回归中。对维护类似"扫描—过滤—确认—删除—计数"链路的 Shell 工具团队,这套目录的价值不在于某一条历史事故,而在于它规定了每个状态迁移处该问的八个问题,以及什么样的测试才配称为回归。

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