Skip to content

feat(skills): config.json 覆盖层接入 download-cleaner / backup-auditor / dedup-finder(部分落实 #15) - #54

Merged
skyzhao1223 merged 2 commits into
mainfrom
feat/config-overrides-scanners3
Oct 9, 2026
Merged

skyzhao1223 merged 2 commits into
mainfrom
feat/config-overrides-scanners3

Conversation

@skyzhao1223

@skyzhao1223 skyzhao1223 commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

状态说明(写在最前面):这个 PR 在我最后一次推送之前就被维护者合并了
(d004882,合并的是 657293a)。不是本 agent 合的 —— 任务要求「不要合并、
留着 review」,我全程只推送、没有调用过 merge。合并之后我又推了一个提交
55c588e test(dedup-finder): pin the keep_rank/os.sep contract on every runner
(只加断言与注释,见下),它不在这次合并里,现在孤悬在分支上。要不要单独开一个
跟进 PR,交给维护者决定;内容可以直接 cherry-pick。

另外维护者在本分支上直接推了两个提交,它们是本 PR 的一部分,下面都标注了归属:
95e6ece(dedup-finder 相对路径统一成正斜杠,修掉一个 Windows 既有 bug)与
657293a(把因此失效的四处可移植性文档说法改正)。

落实 #15(docs/CONTRIBUTION_IDEAS.md → Skills family 里那条 🟡「Per-skill config overrides — roadmap」)的第三到第五个消费者:download-cleaner、backup-auditor、dedup-finder。

Refs #15,不写 Closes —— 写这个 PR 的时候 issue 要的 8 个 scanner 里:#47 接了 2 个
(file-sorter / photo-organizer),本 PR 接 3 个,还剩 3 个(music-organizer /
work-organizer / portfolio-organizer)加 nas-report 的路由文档。所以本 PR 自己没有
写 Closes
,也不该由它关掉 issue。

合并时的实际情况:那剩下的 3 个由兄弟 PR 同期做完并进了 main(music_organizer.py
/ work_organizer.py / portfolio_organizer.py 现在也各有 CONFIG_KEYS)。也就是说
8 个 scanner 现在全都接上了,nas-report 按 #47 的决定仍然只改文档。#15 是否可以
整条移进 ✅ Shipped,由维护者判断 —— 但键集不是全家族统一的(见下),勾选时最好
把这一点一起写进去,否则下一个人会以为 whitelist_dirs 到处都能用。

一个 PR 装三个 skill,是因为这三个的键集决定必须一起看才讲得清楚(见下「为什么这三个的键集与 #47 那两个不同」)—— 分开提反而会让三份 PR 各自把同一套理由重复一遍,还容易出现三份不一致的措辞。


这三个与 #47 那两个结构上不同,所以键集也不同

先把事实摆出来。下面的常量名全部是在 origin/main 上用 grep -oE '^[A-Z][A-Z_0-9]{2,}' 取的,不是凭印象写的:

目录 全部大写常量 *_EXTS 表 目录白名单机制 目录名合规判定
file-sorter(#47 已接) 13 张 *_EXTS + JUNK_* + DIR_ALIASES + CATEGORY_* 13 --keep-dir glob ❌
photo-organizer(#47 已接) PHOTO/VIDEO/SIDECAR/JUNK_EXTS JUNK_NAMES SKIP_DIRS WHITELIST_DIRS + 14 条正则 4 WHITELIST_DIRS(精确名) ✅
download-cleaner PARTIAL/TORRENT/INSTALLER/ARCHIVE/VIDEO/AUDIO/PHOTO/DOC_EXTS JUNK_NAMES SKIP_DIRS DUP_MARK_RE ARCHIVE_HINT CATEGORIES CATEGORY_ZH MAX_DEPTH 8(+1 内联) 无 ❌
backup-auditor ARCHIVE_EXTS SKIP_DIRS DATE_RE TIME_SUFFIX_RE VERSION_RE BACKUP_HINT_RE MAX_DEPTH 1 无 ❌
dedup-finder HEAD_SIZE CHUNK MAX_DEPTH SKIP_DIRS PREFER_KEEP_HINTS 0 无 ❌

grep -c WHITELIST_DIRS 在这三个文件里都是 0;grep -c '^SKIP_DIRS = {' 都是 1。

为什么不给它们 whitelist_dirs

whitelist_dirs 在 #47 里的语义是「视为合规 / 不要报」—— 它喂的是那两个 skill 的合规豁免机制(file-sorter 的 --keep-dir、photo-organizer 的 WHITELIST_DIRS)。这三个一个都没有这套机制:

一个键在不同 skill 里含义不同,是最坏的一种设计:用户把一份配置从 file-sorter 复制到 download-cleaner,不会得到报错,而是得到一个语义完全不同的行为 —— 而 issue #15 通篇在防的就是「静默的意外」。所以这三个只暴露它们真正有的机制,并照它本来的样子命名。

键 适用 追加到 语义
skip_dirs 三个都有 内置 SKIP_DIRS 命中的目录整棵不进入:不计入 stats、不产任何问题。与 #47 同一套模式机制(fnmatch、大小写不敏感、锚定在被扫描的根、匹配整段相对路径或任意一级目录名、绝对路径直接报错)、同一套响亮校验、同样写进 stats.config
extension_overrides download-cleaner、backup-auditor 各自的 CONFIG_EXT_TABLES 与 #47 完全相同的逐扩展名改判:先从所有内置表里摘掉,再放进指定的那一张
prefer_keep_hints 只有 dedup-finder 内置 PREFER_KEEP_HINTS 决定重复组里建议保留哪一份的 tie-breaker。追加,不替换

照抄别的 skill 的配置会被指名拒绝,报错里列出本 skill 的可用键(smoke 里对三种「照抄」都写了断言):

❌ 配置文件 /…/config.json 无效:未知键 whitelist_dirs;可用键只有 skip_dirs,
   extension_overrides。宁可报错也不警告后忽略 —— 键名拼错一个字母就会让整份
   配置静默失效,而用户看不出任何区别

skip_dirs 而不是 whitelist_dirs,还有一层语义上的理由

skip_dirs 的效果是 #47 里 photo-organizer 那一档(整棵跳过),不是 file-sorter 那一档(计入 stats.protected 但不出计划)。这不是随意挑的:file-sorter 的「计入但不动」是 --keep-dir 的既有语义继承来的,而这三个 skill 没有任何「计入但不报」的既有机制,凭空发明一个正是 issue 说的「不要凭空发明语义」。

代价是 stats.files / backup_items / files_scanned 会变小,所以 ① stats.config.skipped_dirs 明确给出被剪掉的目录数,② 三份 SKILL.md 都要求把它与总量一起汇报,③ smoke 里对每个锚定层次都按目录数/文件数/字节数分别核对。

一处刻意保留的不对称,已在三份 SKILL.md 明写:内置 SKIP_DIRS 是精确目录名 + 区分大小写(name in SKIP_DIRS),而 skip_dirs 走的是 _cfg_match(fnmatch + 大小写不敏感 + 支持模式)。理由:家族里只该有一套模式语义(用户在 file-sorter 学会的写法在这里一样管用),而内置表一个字都没动 —— 动了就会改变没有配置时的输出,与「零破坏性」直接冲突。


CONFIG_EXT_TABLES 的推导(逐个,附证据)

规则与 #47 相同:类别名 → 该 skill 的扩展名集合;值为 None 表示兜底类别(不属于任何集合)。

download-cleaner:10 类,与 CATEGORIES 常量逐一对应

CATEGORIES 就是它自己的类别清单(也是 stats.by_category 的键序):

CATEGORIES = (
    "junk", "partial", "torrent", "installer", "archive",
    "video", "audio", "photo", "doc", "other",
)

categorize() 的判定阶梯与之一一对应,所以:

CONFIG_EXT_TABLES: dict[str, set[str] | None] = {
    "junk": JUNK_EXTS, "partial": PARTIAL_EXTS, "torrent": TORRENT_EXTS,
    "installer": INSTALLER_EXTS, "archive": ARCHIVE_EXTS, "video": VIDEO_EXTS,
    "audio": AUDIO_EXTS, "photo": PHOTO_EXTS, "doc": DOC_EXTS, "other": None,
}

其中 JUNK_EXTS 是本 PR 新提的常量,原因见下节 —— 这不是为了好看,是不提成常量就没法让 junk 可覆盖。

backup-auditor:只有 2 类

grep -nE '^[A-Z][A-Z_0-9]*_EXTS *=' 在 backup_auditor.py 上只有一行:

30:ARCHIVE_EXTS = {

它只有一个使用点(strip_archive_ext() 里的 if ext.lower() in ARCHIVE_EXTS),而那个函数的结果决定 base_key,也就是备份集怎么聚合。所以:

CONFIG_EXT_TABLES: dict[str, set[str] | None] = {
    "archive": ARCHIVE_EXTS, "non_archive": None,
}

兜底名用 non_archive,与 photo-organizer 的 non_media 同一种构词法(「唯一那张表的反面」)。没有编出第二张表来 —— 它不存在。

这个键在本 skill 里的用处比「文件该去哪」更实在:

数据.mybak  数据_v2.mybak
  内置:base_key = 数据mybak / 数据v2mybak  → 两个「单版本集」,各报一条「无历史冗余」
  {"mybak": "archive"}:base_key = 数据 / 数据(version 2) → 一个「双版本集」
                                                    → --keep 1 时正确轮转出旧的那份

dedup-finder:没有这个键

grep -nE '^[A-Z][A-Z_0-9]*_EXTS *=' 在 dedup_finder.py 上的输出是空的。它的大写常量只有 CHUNK HEAD_SIZE MAX_DEPTH PREFER_KEEP_HINTS SKIP_DIRS —— 内容级去重根本不看扩展名。给它一个 extension_overrides 只会是一个语义为空的键,而那正是 issue 最忌讳的那类静默失效。


三处必要的 enabling refactor(全部行为保持)

1. download-cleaner:把内联的 junk 扩展名提成 JUNK_EXTS

categorize() 是有序判定阶梯,junk 在第一级,而那三个扩展名原本写成函数体里的内联字面量:

if name in JUNK_NAMES or name.startswith("._") or ext in ("tmp", "temp", "bak"):
    return "junk"

内联字面量没有名字可以摘。所以 {"tmp": "doc"} 会是彻底的静默失效:junk 那一级先命中,DOC_EXTS 里加了也没用,覆盖看起来完全没生效 —— 正是 #47 花了一整节解释的那个失败模式。提成常量:

JUNK_EXTS = {"tmp", "temp", "bak"}        # 与 file-sorter / photo-organizer 同名同义
...
if name in JUNK_NAMES or name.startswith("._") or ext in JUNK_EXTS:

ext in 对 tuple 和 set 的结果相同,行为一个字节都没变(identity harness 证明了),而且这个名字与另两个 skill 一致。smoke 里有一条断言专门盯「junk 可以被救回来」,在提成常量之前那条是红的。

2. dedup-finder:PREFER_KEEP_HINTS 由 tuple 改 list

要能原地追加(与 #47 直接 mutate 内置 *_EXTS 集合同一个思路)。用 list 而不是重新绑定全局名,可以少一处 global 声明,而且 keep_rank() 一行都不用改;any(h in path for h in …) 对 tuple 和 list 结果相同。

3. backup-auditor:dir_size() 多一个可选参数 rel_parts

本 skill 有三处在遍历目录:备份项收集、coverage 的源目录收集、dir_size 递归求体积。内置 SKIP_DIRS 在这三处都生效;配置项要是只在前两处生效,同一个键在一个 skill 内部就有两种语义 —— 那比不做还糟。而 dir_size 原本拿不到相对层级,所以加了 rel_parts=None:

def dir_size(path, max_files=20000, rel_parts=None) -> tuple[int, int]:

默认值保证老调用方式行为不变(smoke 里进程内直接断言了「省略 rel_parts 时结果与旧签名相同」)。

4.(附带)stats: dict 注解

backup-auditor 的两个 stats 是局部变量,pyright 从全整数字面量推出 dict[str, int],于是拒绝 stats["config"] = {**CONFIG_INFO, ...}。另两个 scanner 不会撞到,因为它们的 stats 是带注解的属性(self.stats: dict = {)。照它们的写法给两个局部变量加注解即可,纯注解、无运行时变化。


dedup-finder 的安全分析:这是全家族唯一会改变删除计划的键

keep_rank() 的第一关键字就是 PREFER_KEEP_HINTS:

path = item["path"].lower()
prefer = 0 if any(h in path for h in PREFER_KEEP_HINTS) else 1
depth = item["path"].count("/")
return (prefer, depth, item.get("mtime") or 0, len(item["name"]))

prefer 排在 depth / mtime / len(name) 前面,所以一条用户写的提示词可以把深层的新副本顶到 keep,把浅层的旧正主推进 drop。后面照着计划 rm 的人就删错了东西。实测(smoke TEST 7 里是逐条断言的,不是叙述):

浅层/A.bin              prefer=1  depth=1  mtime 最旧
工作/终稿/深层/A.bin     prefer=1  depth=3  mtime 最新

无配置                              keep = 浅层/A.bin            drop = 工作/终稿/深层/A.bin
{"prefer_keep_hints": ["终稿"]}      keep = 工作/终稿/深层/A.bin   drop = 浅层/A.bin       ← 整个反过来
{"prefer_keep_hints": ["没有这个词"]} keep = 浅层/A.bin            drop = 工作/终稿/深层/A.bin  ← 与无配置逐条相同

风险与对策一一对应,每一条都有断言:

风险 对策 在哪验的
配错了但看不出来 生效的提示词印在三个地方:stderr 一行、stats.config(既有本次追加的 prefer_keep_hints,也有实际生效全集 prefer_keep_hints_effective = 内置 7 条 + 追加)、以及人类可读报告里的一段 smoke 第 7 组:断言报告含配置路径、追加项、以及内置 7 条 + 追加项的完整列表
追加退化成替换,内置保护全丢 只 extend,不重新绑定;与内置重复的条目不会重复出现 smoke 第 3 组:加了 终稿 之后内置 源文件 仍然赢下 B 组;第 14 组进程内断言 PREFER_KEEP_HINTS == 内置 + ["终稿"](配置里同时写了 成品,不重复出现)
静默失效(拼错了但没报错) 未知键/类型错/空字符串一律 exit 1;而「合法但匹配不到任何路径」不报错,只是排序不变,且配置仍然出现在 stats.config 里 —— 所以「没生效」与「生效了但没命中」是可区分的 smoke 第 4 组:三组排序与基线逐条相同 + stats.config 里那两条仍在
子串被偷偷升级成 glob 没有升级,而且钉住了 smoke 第 6 组:终* 必须命中不了 终稿/
照着计划直接删 SKILL.md 原有的「先 mv 到 _dup_quarantine/,人工核对一段时间再真删」不变,故障排查表加了一条「配了这个键就必须逐组人工过 keep」 文档

人类报告里那一段长这样(它旁边就是一张删除清单,所以必须印在这里,不能只躺在 JSON 里):

⚙ 已加载覆盖配置 /…/skills/dedup-finder/config.json
  skip_dirs: 临时
  prefer_keep_hints: 终稿, 交付
  保留提示词生效全集(内置 + 本次追加,决定重复组里先保留哪个):
    成品, 源文件, 原始, master, original, import, 相册, 终稿, 交付
  因 skip_dirs 未纳入的目录: 1 个

为什么没顺手把子串升级成 glob

PREFER_KEEP_HINTS 是内置与配置共用的同一个列表,any(h in path …) 也是同一条判定。换成 fnmatch 会连内置那 7 条的语义一起改掉:原始 今天匹配路径里任何位置出现的「原始」两个字,升级之后会变成只匹配整段路径或以 原始/ 开头的目录。那是一次静默的行为变更,而且改的是没有配置文件时也会走到的代码 —— 与本机制「没有配置就逐字节一致」的前提直接冲突。

建议单独开 issue 讨论:要做的话得先决定内置那 7 条要不要一起迁移、迁移期间老配置怎么兼容、以及「只在第一层目录名上匹配」是不是用户真正想要的语义。

一个可移植性坑(已写进 SKILL.md)

相对路径的分隔符是平台原生的(entry.path 来自 os.scandir),所以 工作/终稿 这种含分隔符的提示词只在 POSIX 上有效,Windows 上是 \。要跨平台就写单级目录名(终稿),两边都命中。


刻意不可配置的

项 为什么不做
backup-auditor 的 DATE_RE / TIME_SUFFIX_RE / VERSION_RE / BACKUP_HINT_RE 它们决定「什么算一个备份集」「什么算陈旧」。改它们等于换掉审计的定义,是比扩展名归类大得多的设计问题
backup-auditor 的四个硬编码双扩展名(.tar.gz / .tar.bz2 / .tar.xz / .sparsebundle) 它们在 ARCHIVE_EXTS 之前匹配。要可配置就得先决定「双扩展名列表与单扩展名表的优先级」这件事,不是加个键能了结的。但用户一定会踩,所以 SKILL.md 的踩坑表里明写了 {"gz": "non_archive"} 不会让 .tar.gz 停止被剥离
HEAD_SIZE(64KB)/ CHUNK(1MB)/ MAX_DEPTH 改的是「什么叫重复」的判定精度与遍历深度,同样是设计问题不是覆盖层
download-cleaner 的 JUNK_NAMES / DUP_MARK_RE / ARCHIVE_HINT 文案 前两个是文件名规则而非扩展名规则(extension_overrides 只作用于扩展名,与 #47 一致);第三个是给用户看的建议文案
--config PATH 与 #47 同一个理由:加它意味着两套发现机制各自文档化 + 各自测试,而且引出一个新问题 —— --config 指定的文件与旁边那份是替换还是叠加?「哪个配置在生效」必须只有一个答案

改了什么

文件 改动 内容
文件 +/− 内容
--- --- ---
skills/download-cleaner/download_cleaner.py +172 / −2 共享块 + CONFIG_EXT_TABLES(10 类)+ JUNK_EXTS 提常量 + _walk 接入 + stats.config + _print_config + main() 调 load_config()
skills/backup-auditor/backup_auditor.py +197 / −12 共享块 + CONFIG_EXT_TABLES(2 类)+ 三处遍历接入 + dir_size(rel_parts=) + 两个子命令各写 stats.config + _print_config + stats: dict 注解
skills/dedup-finder/dedup_finder.py +156 / −3 共享块(无 ext 部分)+ PREFER_KEEP_HINTS tuple→list + _walk 接入 + stats.config + _print_config
skills/download-cleaner/tests/smoke.sh +467 / −0 TEST 7(20 组断言)
skills/backup-auditor/tests/smoke.sh +509 / −0 TEST 7(18 组断言,scan 与 coverage 都覆盖)
skills/dedup-finder/tests/smoke.sh +494 / −0 TEST 7(23 组断言,含 keep 排序前后对照)
skills/download-cleaner/SKILL.md +194 / −0 新「配置覆盖」一节 + 命令表 / 踩坑 5 行 / 已知 gap / 故障排查
skills/backup-auditor/SKILL.md +201 / −0 同上(含「作用在哪三层」与孤儿连锁反应)
skills/dedup-finder/SKILL.md +248 / −1 同上(含安全分析、子串非 glob、可移植性坑、实测例子)
skills/download-cleaner/README.md +209 / −0 开发者文档:键集决定、JUNK_EXTS 为什么必须提、ARCHIVE_EXTS 的耦合、共享块边界、变异测试
skills/backup-auditor/README.md +211 / −1 同上 + 三层遍历 + 双扩展名 + coverage 连锁反应
skills/dedup-finder/README.md +259 / −0 同上 + 完整安全分析
src/zspace_cli/skills/** 镜像 上面 12 个文件的逐字节副本(CONTRIBUTING 的同步命令原样跑的)

单树 12 个文件,含镜像共 24 个文件 +6888 / −42。

这份 diff 里还含两个不是本 agent 写的提交:维护者在本分支上直接推了
95e6ece fix(dedup-finder): emit forward-slash relative paths like the other eight scanners(见下「CI 抓到的第四个问题」),以及 657293a docs(dedup-finder): correct four portability claims invalidated by the separator fix。分支中途被
rebase 压成两个提交,本地历史已按远端重写,dedup_finder.py 因此是
+164 / −5 而不是我最初的 +156 / −3。

没有动:README.md / docs/README.zh.md / docs/* / .github/workflows/* / pyproject.toml / src/zspace_cli/*.py(含维护者正在改的 client.py)/ skills/README.md / 其他 7 个 skill 的任何文件 / roadmap 勾选。验证第 14 步逐条查过。

仓库里刻意不放 config.json(连 example 也不放)

与 #47 同一个理由:pyproject.toml 的 package-data 是 "zspace_cli.skills" = ["**/*"],而 zs skill 把整个目录 copytree 给用户 —— 所以任何放进 skills/<name>/ 的 config.json 都会被装进每个用户的目录并立刻生效,等于给所有人默认开了一份覆盖。放一个 config.example.json 虽然不生效,但只会把「哪个文件名才算数」变模糊。schema 直接写在 SKILL.md 里,用户自己建。git ls-files | grep -c 'config\.json$' = 0。


共享块:哪些逐字节相同,哪些偏了,偏在哪几行

#47 把重复文本切成两块逐字节相同(PART_A 9 行 / PART_B 104 行),中间夹唯一 per-skill 的 CONFIG_EXT_TABLES。键集不同,所以这次不可能完全照搬 —— 目标改成:把偏差关进最小的区域,并且用脚本报告出来而不是靠人记。

check_block_identity.py 把加载器切成 10 个区域,对五个 scanner 逐区域报告「几个变体、哪些文件属于哪个变体、与 #47 的参考文件(file-sorter)差哪几行」,并额外报告「三个新 scanner 之间」的差异:

区域 变体数 结论
R7 load_config 文档串 → 未知键检查 1 24 行 / 1356 字节,五个 scanner 逐字节相同
R9 extension_overrides 校验 1 20 行 / 1092 字节,四个有它的 scanner 逐字节相同(dedup-finder 没有这个键 → ABSENT)
R5 _cfg_die 2 10 行里差 2 行文档串(白名单 → 「按内置规则出结果」);函数体逐字节相同
R6 _cfg_match 2 21 行里差 3 行文档串(白名单模式→skip_dirs 模式、--root→「被扫描的根目录」、示例目录名 原盘→临时);函数体逐字节相同
R8 列表键校验 2 14 行里差 9 行,全部是 whitelist_dirs→skip_dirs 的改名与示例词
R1 头部注释 / R3 声明 各 2 各差 1 行(注释里「唯一 per-skill 的部分是 CONFIG_EXT_TABLES」→「是 CONFIG_KEYS 和它点到的那张表」;CONFIG_WHITELIST → CONFIG_SKIP)
R10 收尾(合并 + CONFIG_INFO + 提示行) 3 #47 两个 / 新两个 / dedup-finder。dc↔ba 差 5 行(键名与提示行),dd 差 21 行(第二个键整段不同)
R2 CONFIG_KEYS / R4 CONFIG_EXT_TABLES per-skill 本来就该不同;脚本对这两个区域只报变体分组,不打行 diff

整段加载器的两两比较:

  file-sorter        vs photo-organizer     identical  117 / 122    deviating   7
  download-cleaner   vs backup-auditor      identical  117 / 120    deviating   4
  download-cleaner   vs dedup-finder        identical   82 / 120    deviating  59
  backup-auditor     vs dedup-finder        identical   82 / 118    deviating  57
  file-sorter        vs download-cleaner    identical   94 / 122    deviating  54
  file-sorter        vs backup-auditor      identical   94 / 122    deviating  52
  photo-organizer    vs download-cleaner    identical   94 / 120    deviating  51
  photo-organizer    vs backup-auditor      identical   94 / 119    deviating  49
  file-sorter        vs dedup-finder        identical   63 / 122    deviating  99

download-cleaner 与 backup-auditor 之间:120 行里 117 行逐字节相同,只有 4 行不同 —— 全部是 CONFIG_EXT_TABLES 的表体(3 行 dc 的 10 类 vs 1 行 ba 的 2 类)。也就是说这两个的偏差完全落在「per-skill 本来就该不同」的那个区域里。

dedup-finder 偏差最大,原因只有一个:它没有 extension_overrides。#47 定义的 PART_A 在它身上根本提取不出来(终止锚点是 CONFIG_EXT_TABLES,而它没有这个常量)—— 这本身就是「键集不同」最直接的证据;PART_B 从 104 行变成 93 行。但它与另两个新 scanner 的 load_config 前 24 行逐字节相同、skip_dirs 的 14 行校验块逐字节相同、_cfg_die/_cfg_match 逐字节相同、头部注释与 CONFIG_SKIP/CONFIG_INFO 声明逐字节相同。不同的只有 CONFIG_KEYS 1 行 + 收尾 21 行。偏差被关在「第二个键是什么」这一个区域里,没有散落到整个加载器。

两处为了「同一性」而做的取舍

  1. 收尾注释不写函数名。feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47 写的是「否则 categorize() 的判定阶梯会按它自己的顺序命中旧类别」;backup-auditor 里那条阶梯叫 strip_archive_ext()。为了让 dc 与 ba 这 2 行逐字节相同,注释改成不提函数名(「否则判定阶梯会…」),语义没丢。
  2. _cfg_match 的示例目录名从 原盘 换成 临时。留着 原盘/VIDEO_TS 可以让 R6 只偏 1 行而不是 3 行,但那是 photo-organizer 的概念,出现在 download-cleaner 的文档串里会误导读者。R6 的第一行无论如何都得偏(白名单模式→skip_dirs 模式),所以换成局部有意义的示例并不额外增加「有偏差的区域数」,只是让同一个区域多偏 2 行。

这个比对脚本自己带负控制

--self-test 会往其中一份里删掉一个字符(把 _cfg_die 的 ❌ 换成 ✗),然后要求脚本报出漂移;报不出就 exit 1。

第一版是坏的,值得记一句:区域的终止谓词写成了 lambda L: has(a) and has(b)。那返回的是一个函数对象(恒真),而不是调用结果 —— 于是每个区域都在自己的第一行就"匹配"了,五份文件一律报「1 行 / 144 字节、全部相同」。而且 has(a) and has(b) 本来也永远匹配不上:提示行是折行的,已加载覆盖配置 与 file=sys.stderr) 在两个不同的物理行上。改成具名谓词 end_loader() 之后才正确。改不出的漂移比漂移本身危险得多,所以这个自测留在脚本里。

同一性是靠生成保证的,不是靠复制粘贴

三个 scanner 的共享文本是从单一来源写出去的(apply_config.py,本 PR 的验证脚本之一)。其中 extension_overrides 的校验块更进一步:它是运行时从 file-sorter 里读出来的,而不是手抄的 —— 因为手抄的第一版在三处续行上各差了一个空格,被比对脚本抓了出来(R9 报了 8 行偏差,而预期是 0)。


零破坏性:怎么证的

identity.py。同一棵 fixture,用 git archive origin/main 取出的改动前脚本与改动后脚本各跑一遍,比较四样东西:--json 的 stdout、人类可读报告的 stdout、stderr、--output 写出的 JSON 文件。

两边用同一个解释器,整个 harness 每个解释器各跑一遍 —— 否则 3.9 与 3.12 的差异会伪装成(或掩盖)改动带来的差异。

解释器 调用数 比较项 不一致
Python 3.9.25 42(download-cleaner 12 / backup-auditor 18 / dedup-finder 12) 168 0
Python 3.12.9 同上 168 0
Python 3.9.6(系统自带,= macOS 上常见的 python3) 同上 168 0

适用范围:这三遍都在 macOS/POSIX 上。95e6ece 之后 dedup-finder 在 Windows
上的相对路径由 \ 变成 /(有意的行为修复,见上「第四个」),所以「与 main 逐字节
一致」在 Windows 上只对 download-cleaner 与 backup-auditor 成立。

fixture: 99 files, 51 dirs
config.json under base/new/fixture trees: 0 []
invocations: 42 ({'download-cleaner': 12, 'backup-auditor': 18, 'dedup-finder': 12}) -> artifacts compared: 168
...
===========================================
identity checks run: 168   mismatches: 0
ALL IDENTICAL

参数组合覆盖:download-cleaner 默认 / --json / --json --output / 人类报告 + --output / --stale-days 30 / --stale-days 3000 / --sample 5 / --top 2 / --max-depth 1 / --max-depth 0 / 两个 --help;backup-auditor --keep 1|2|3|5 × scan、scan 人类报告与 --output、--stale-days 2|3000、--top 1、coverage 的 json/人类/--output/--stale-days 2/--top 1、三个 --help;dedup-finder 默认 / --json / --output 两种 / --min-size 0 / --min-size 4 / --max-depth 1 / --max-files 3 / --top 1 / 多 --root / 两个 --help。

cmp 的对象不是空的(否则比较本身是空转):

  download-cleaner/json: stdout=13240B stderr=104B
  download-cleaner/json-output: stdout=13240B stderr=123B outfile=13239B
  download-cleaner/human: stdout=3543B stderr=104B
  backup-auditor/cov-json: stdout=4069B stderr=211B
  dedup-finder/json: stdout=2620B stderr=127B
  dedup-finder/json-output: stdout=2620B stderr=146B outfile=2619B

只归一化真正不确定的字段,一共 10 条规则,每条的命中数都印出来

     62 hits  "generated_at": "[^"]*"          ← 墙钟
     18 hits  "elapsed_sec": [0-9.]+           ← 墙钟(仅 dedup-finder)
    600 hits  "age_days": \d+                  ← (now - mtime),跨日界会 +1
     18 hits  \(\d+ 天未动                      ← 同上派生的文案
     18 hits  下载后 \d+ 天未处理                ← 同上
     20 hits  杂项文件 \d+ 天未处理              ← 同上
     14 hits   \(\d+天\)                       ← 人类报告里的年龄
      6 hits  耗时 [0-9.]+s                    ← elapsed_sec 的人类形式
    214 hits  \d+ 天未更新                      ← backup-auditor 陈旧文案
     32 hits  \d+ 天前\)                       ← backup-auditor coverage 陈旧文案
  total substitutions: 1002

没有归一化:mtime、mtime_date、size、任何计数、任何路径。两边用的是同一棵 fixture 和同一个相对 --output 名(各自的 cwd 不同),所以路径本来就相等 —— 归一化它反而会掩盖真问题。「全 0 命中」会被显式警告,免得归一化器根本没接上却报一片绿。

fixture 里踩到的一个坑,值得记:目录的 mtime 必须在里面所有文件写完之后才设。往目录里写文件会把它的 mtime 刷成「现在」,所以「先 mkdir + utime、再往里写文件」等于没设 —— 第一版就是这么把 backup-auditor 的整个陈旧判定路径丢掉了(coverage 的 stale 一直是 0,而两边都是 0,所以 identity 照样全绿)。改成收集 (dir, age) 列表、最后按深度从深到浅统一 utime。

这个 harness 自己也做了负控制

cmp 式的证明有个陷阱:如果 harness 根本看不见差异,它会永远报 0 处不同。所以对 harness 本身做了 17 处故意破坏 + 3 处「未破坏的副本必须报 0 处不同」(查假阳性),其中 4 处还带可达性探针(直接 import 改过的模块、断言常量真的变了):

# 破坏什么 检出
HC0 ×3 未破坏的副本必须报 0 处不同(假阳性对照) ✅ 三个都是 0
HC1 ×3 stats["config"] 无条件写(不再 gate 在 CONFIG_INFO 上) ✅ 18 / 28 / 18 处不同
HC2 ×3 改内置表(JUNK_EXTS 加 zip、ARCHIVE_EXTS 摘掉 iso、PREFER_KEEP_HINTS 摘掉 成品) ✅ 11 / 9 / 9 处,探针全部 True
HC3 _cfg_match 的空模式卫语句返回 True ✅ 19 处
HC3(ba) dir_size 不再认内置 SKIP_DIRS ✅ 16 处
HC4 没有配置文件时也打「已加载」提示行 ✅ 10 处
HC5 ×3 _print_config 没有配置时也打印 ✅ 3 / 5 处
HC6 与本机制无关的回归(advise() 的 torrent 动作改掉) ✅ 11 处
HC7 ×3 遍历钩子把每个目录都剪掉 / _collect_items 跳过每一项 ✅ 19 / 33 / 21 处
HC8 HEAD_SIZE 减半 ✅ 11 处,探针 True
harness negative controls missed: 0
HARNESS IS NOT VACUOUS

第一版有 4 处破坏是写错的,查出来都不是 harness 的问题:

写错的破坏 为什么检不出 换成
往 ARCHIVE_EXTS 里加 iso2 fixture 里没有 *.iso2,输出一个字节都没变 摘掉 iso → 网站备份.iso 不再被剥扩展名,scan 的 set 字段就变了
从 PREFER_KEEP_HINTS 里摘掉 相册 fixture 里没有含 相册 的路径 摘掉 成品 —— 它正是基线的 keep
dir_size 里 child_rel = [e.name](丢掉锚定) 没有配置时 CONFIG_SKIP 是空的,_cfg_match 的卫语句直接返回 False —— 在无配置模式下原理上不可观测 改打内置 SKIP_DIRS 的剪枝(那是今天就活着的代码),并往 fixture 里塞一个 vol.sparsebundle/@eaDir/
要求锚点「恰好匹配 1 次」 backup-auditor 的 if CONFIG_INFO: 有两处(scan 与 coverage),于是被误报成 anchor error 改成「至少 1 次、全部替换」,并把处数印出来([2 sites])

第三条与 #47 的 HC7 是同一类陷阱(那次是 _cfg_match 的 not patterns 卫语句):在无配置模式下不可观测的破坏,原理上不可能被 identity harness 检出,写之前得先推演一遍可达性。


变异测试:52 处故意破坏,52 处被抓

mutate.py。对每一处 config 代码实现的行为各改坏一次,跑对应 skill 的 smoke.sh,只有 smoke 非零退出才算抓到。共享块的破坏同时打进三个文件,并且要求三个都抓到(块是复制的,每份都得有自己的覆盖)。

py3.9 与 py3.12 双解释器各跑一遍,两边逐条相同:52 tried / 52 caught / 0 survived / 0 anchor errors。 跑完 git diff HEAD 对三个脚本必须是干净的 —— 脚本自己断言这一点。

组 数量 内容
M01–M07 7 load_config 的每个校验门:有配置也直接 return、未知键静默接受、顶层不是对象、skip_dirs 不是数组 / 元素不是字符串 / 空字符串 / 绝对路径
M16–M18 3 三处「已加载」信号:stats.config 永不写、stderr 提示行去掉、提示行改走 stdout(污染 --json)
M24–M25 2 畸形配置 exit 0、报错里不带文件路径
M26–M28 3 _print_config 不被调用 / 没配置也打印 / skip_dirs 在第二个键校验之前就合并
M19–M23 5 _cfg_match 五条:去掉整段相对路径规则、去掉任一级目录名规则、变成大小写敏感、不锚定(加 * 前缀)、去掉 not rel_parts 卫语句
M08–M15 8 extension_overrides(仅 dc/ba):不是对象、类别值不是字符串、归一化后为空、含多个点、类别名不存在、不剥前导点、不转小写、合并退化成 add-only
D01–D11 11 prefer_keep_hints(仅 dd):替换而非追加、不 lower、不 strip、不去重、不报 effective、keep_rank 忽略 prefer 层、升级成 glob、不是数组、元素不是字符串、空字符串、人类报告不印生效全集
C01–C05 5 download-cleaner 接入点:_walk 忽略 CONFIG_SKIP、计数器不加、categorize 退回内联字面量、CONFIG_EXT_TABLES 少了 junk、兜底 other 指向 DOC_EXTS
B01–B06 6 backup-auditor 接入点:_collect_items / dir_size / top_dirs 各自忽略 CONFIG_SKIP、stats.config 只在 scan 写不在 coverage 写、dir_size 调用不传 rel_parts、archive 映射成 None
F01–F02 2 dedup-finder 接入点:_walk 忽略 CONFIG_SKIP、计数器不加

变异测试反过来抓出了我自己 2 条假断言

与 #47 查出 3 条同一类问题:

  1. M22(_cfg_match 不再锚定)第一轮在 backup-auditor 与 dedup-finder 都活了下来,只有 download-cleaner 抓到。原因不是实现有问题,是那两个 skill 的纯函数清单里只有 not m(["深层","临时暂存"], ["临时暂存/*"]) 这条两层的断言 —— 模式被加上 * 前缀变成 *临时暂存/* 之后它照样匹配不上,区分不出锚定与不锚定。真正有判据的是三层那条(深层/临时暂存/子目录 会被不锚定的模式误命中),而 download-cleaner 的清单里正好有。已给另外两个补齐,现在三个都抓。
    —— 这与 feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47 的 M21 是同一族:断言只覆盖了实现的恒定行为那一侧。
  2. M27(没有配置时 _print_config 也打印)第一轮在 dedup-finder 活了下来:它的人类报告断言只在有配置时跑过,缺一条「无配置时报告里不该出现 ⚙ 那一段」的负向断言。已补。

还有一个工具级的教训

第一轮跑变异之前,上一次运行被 SIGTERM 打断,把 if False: 留在了三个 .py 里;下一次运行于是把那份被改坏的文件当成了 pristine 基线(基线 smoke 全红,才没造成误判)。现在 mutate.py 开始前会 git diff HEAD 检查三个脚本是否干净,不干净就拒绝运行,结束时再检查一次并打印 post-run tree state。


CI 抓到的四个问题 —— 三个在测试里,一个是真的 Windows 既有 bug

本 PR 前几次推送 CI 是红的(skills job 在 ubuntu 与 windows 上都挂在新的 TEST 7)。前三个是测试自身的可移植性 bug,而且三个都在本机(macOS 默认卷)结构上不可能暴露;第四个不是测试的问题,是 scanner 在 Windows 上的一个既有缺陷,顺手修掉了。

前三个:测试的可移植性

# runner 现象 根因 修法
1 ubuntu AssertionError: 38411(backup-auditor 基线 total_size_bytes) 我用 n.isupper() 去猜大小写撞名对里哪一份活下来了,好推算它的体积。但 "CASE.bin".isupper() 是 False —— 因为 bin 是小写。大小写不敏感的卷上只有一份、且内容被第二次写覆盖成 1701 字节,猜的结果碰巧对上;ubuntu 上两份都在(1700+1701),就差 1 字节 撞名对的体积改成从文件系统实测(os.path.getsize 求和)。它与被测实现无关,所以不是同义反复
2 ubuntu dedup-finder「skip_dirs: ["*"] 之后不该再有目录里的路径」失败 断言写的是 assert "成品/" not in json.dumps(issues)。本机组数为 0(撞名对只剩一份、不成组)→ issues 是空的 → 绿;ubuntu 上撞名对成组了,而每组的 keep.reason 是字面量 "启发式:成品/源目录优先→浅路径→旧文件→短名" —— 里面就含 成品/ 所有「子串搜整个 JSON」的断言换成对实际 keep/drop 路径列表的断言(新增 all_paths() 助手)。这正是任务里点名的那类陷阱:「needle 被另一条路径也产生」
3 windows FileNotFoundError: …\bk\ spaced backup 2024-06-01 /z.bin Windows 建不出尾随空格的目录名:Win32 路径规范化会把尾随的空格与点剥掉,于是 makedirs 建的是另一个名字,随后按原名写文件就 404 那几个名字保留前导空格(仍然是 shell 危险名、仍然走同一条代码路径),去掉尾随空格

顺手还修了一处只在 Windows 上会假红的比较:断言「人类报告里印出了配置文件路径」原来写的是 assert cfg in r.stdout,但脚本印的是 Path(__file__).resolve() 的结果 —— Windows 上 resolve() 会把 8.3 短名(C:\Users\RUNNER~1)展开成长名,macOS 上会把 /tmp 变成 /private/tmp,而 bash 传进来的是未展开的那个。改成两边都过 realpath + normcase 再比。

还有一个流程级的教训:上面第 3 条(尾随空格)我第一次以为修好了、commit message 也这么写,但那次替换根本没落到文件里,windows-latest 于是在同一行又红了一次。第二次是先 grep 确认再提交的。

第四个:一个真的在 scanner 里的 Windows 既有 bug

windows-latest 的日志显示 dedup-finder 的第一条排序断言就挂了,而 ubuntu 与
macOS 全绿。查下去:

rel = entry.path[len(str(rel_root)) + 1:]        # ← 从 os.scandir 的路径上直接切
...
depth = item["path"].count("/")                  # ← keep_rank 数的是 "/"

Windows 上 entry.path 用 \,所以 depth 恒为 0 —— 四级排序退化成
「prefer → mtime → 名字长度」,也就是说 「路径更浅的优先保留」这条规则在整个
Windows 平台上从来没生效过
。后果不是显示问题:这个 skill 一直在建议用户删掉
浅层好找的那份、保留深层嵌套的那份
,与它自己的文档和报告文案相反。单元级实测:

keep_rank(shallow) < keep_rank(deep)
  "G.bin" vs "a/b/c/G.bin"    -> True   (浅的保留,正确)
  "G.bin" vs "a\b\c\G.bin"   -> False  (深的保留 —— 反了)

这是 origin/main 上就有的问题,与配置覆盖层无关;新的 TEST 7 只是第一个在
windows-latest 上断言确切路径字符串
的测试,所以是它把这件事翻出来的。其余 8 个
scanner 都没有这个问题(它们自己 "/".join(rel_parts) 拼路径)。

修法是在 scanner 侧统一成正斜杠(维护者的 95e6ece),而不是在测试里做兼容 ——
因为测试是对的,输出是错的。POSIX 上 os.sep 本就是 /,那个 replace 是
空操作,输出逐字节不变(identity harness 复跑过,168 项仍 0 处不同)。

我在其上补了一条不依赖 runner 的契约断言(55c588e):直接给 keep_rank 喂
反斜杠路径,断言 depth 算出 0、且「正斜杠时浅的赢、反斜杠时深的赢」。这样在 ubuntu
与 macOS 上也能验到,不必等 windows-latest。同时把「C 组的年龄为什么刻意与深度
打架」写进 fixture 注释 —— 那个不一致正是 depth 层可观测的前提,谁把它"整理"成
越浅越旧,EXPECT_C 就会在有 bug 的情况下照样通过。

这一条改变了「零破坏性」的适用范围,必须说清楚:POSIX 上「无 config.json 时
输出与 main 逐字节一致」仍然成立(下面 168 项 × 3 个解释器就是证据);
Windows 上 dedup-finder 的相对路径从 \ 变成了 /,是有意为之的行为修复,
不是回归。本机是 macOS,所以这一条没有本地实测,依据是 CI 的
skills (windows-latest) 与 test (windows-latest, 3.9/3.12) 全绿。

于是本机也能测大小写敏感的卷了

上面 1 与 2 的共同点是:只有大小写敏感的文件系统才看得见。macOS 默认卷是大小写不敏感的,所以「撞名对存活几份」这件事在本机永远只有一个答案。修法不是「等 CI 告诉我」,而是在本机造一个大小写敏感的卷:

hdiutil create -size 40m -fs "Case-sensitive APFS" -volname CSVOL -attach cs.dmg

然后把三个 TEST 7 的 python 块原样抽出来、把它们的 scratch 目录指到那个卷上跑(run_test7_cs.sh)。现在三个 TEST 7 在大小写敏感与大小写不敏感两种卷上都过,而不再只是「在本机过、指望 CI 也过」:

===== download-cleaner (case-sensitive volume, python3)   PASS
  ✓ 无 config.json:… 18 条问题与旧版一致(大小写撞名存活 2 个)
  ✓ 大小写不敏感双向成立:SAMPLES / samples / Samples 三种写法都命中(本机存活 2 个目录)
===== backup-auditor   (case-sensitive volume, python3)   PASS
===== dedup-finder     (case-sensitive volume, python3)   PASS
  ✓ 无 config.json:… 8 组重复 …(大小写撞名存活 2 个)
===== case-sensitive volume sanity: both spellings really coexist
2

(最后一行是卷本身的探针:确认 probe.TXT 与 probe.txt 真的共存,否则上面三行只是又跑了一遍大小写不敏感的情形。)

全量验证(真实输出)

verify.sh,16 步。第 1–3 步是从 .github/workflows/ci.yml 原样抄的。

1. CI 的镜像同步循环

all skill copies in sync

2. CI 的文档计数断言

zs skill installs 10; 9 cross-NAS organizers
documented skill counts match reality (found=7, all == 9)

没动 skill 数量,也没在任何文档里写会落进 ORG_RE 那四个短语前面的数字(#47 就踩过一次)。

3. CI 的 MCP 工具覆盖断言

14 registered MCP tools
both READMEs document all 14 MCP tools

4–5. ruff(ruff 0.16.10,["E","F","I","N","W","UP"],line-length = 100,target-version = "py39")

$ ruff check skills
All checks passed!
$ ruff check src tests
All checks passed!

6. pytest -q(双解释器)

3.9.25 :  Required test coverage of 75% reached. Total coverage: 76.79%
          131 passed, 1 skipped in 2.19s
3.12.9 :  Required test coverage of 75% reached. Total coverage: 88.03%
          153 passed in 5.70s

(3.9 少 22 个是 mcp extra 要求 ≥3.10,与 CI 的 3.9 job 只装 .[dev] 一致;这两个数字与改动前基线相同。)

7. pyright(include = ["src"]、pythonVersion = "3.9",所以覆盖打包副本)

本分支:2 errors, 0 warnings, 0 informations
origin/main(同一环境、同一条命令):2 errors, 0 warnings, 0 informations
  → 两条都是 mcp_server.py 的 `Import "mcp.server"/"mcp.types" could not be
    resolved`,是本地环境解析不到 mcp 命名空间包(CI 的 typecheck job 在 main 上是绿的)
  → 本分支引入的新错误:0

中途确实抓到过我自己的 2 个错(backup-auditor 的 stats 局部变量被推成 dict[str, int],于是拒绝 stats["config"] = {...}),已按另两个 scanner 的写法加 stats: dict 注解修掉 —— 这也是唯一一次为了类型检查而改既有代码。

8. 全部 9 个 skills/*/tests/smoke.sh,三个解释器

Python 3.9.25 : PASS=9 FAIL=0
Python 3.12.9 : PASS=9 FAIL=0
Python 3.9.6  : PASS=9 FAIL=0

共 27 次全量 smoke 运行,全绿。未改动的 6 个 skill 也跑了,确认镜像同步没有连带影响。

新测试的实际输出见下面三段。

9. 每个改动文件的 CRLF 数(.gitattributes 强制 LF)

changed files: 24   total lines containing CR: 0

10. Python 3.9 兼容

  ok   parsed as 3.9 + no forbidden constructs: skills/backup-auditor/backup_auditor.py
  ok   parsed as 3.9 + no forbidden constructs: skills/dedup-finder/dedup_finder.py
  ok   parsed as 3.9 + no forbidden constructs: skills/download-cleaner/download_cleaner.py
  ok   ...(三个 src/ 镜像副本同样 ok)

ast.parse(..., feature_version=(3,9)) + AST 扫过:无 match、无运行时位置的 X | Y(isinstance/issubclass/cast 第二参)、无 zip(strict=)、无 itertools.pairwise、无 tomllib、无相对 import。新增的三个 import(fnmatch、re、typing.NoReturn)都是 3.9 就有的标准库,而且 from __future__ import annotations 让所有注解都变成字符串、根本不求值。

11. CJK 显示宽度(unicodedata.east_asian_width,W/F 计 2 —— ruff 量的是宽度不是 len(),PR #25 就是栽在这儿)

added .py lines: 1050; max CJK display width: 87; over ruff limit (100): 0
probe: 57 chars / 105 display columns -> ruff E501 FIRED

1050 行新增 .py 里最宽的一行是 87,离 100 还有余量。探针那一行是故意构造的(57 字符 / 105 显示宽度),用来确认 ruff 真的按宽度算而不是按 len() 算。

12. 共享块逐字节比对 —— 见上「共享块」那一节;--self-test 输出 injected deviation DETECTED。

13. 结构约束

-- any _shared / common / util module anywhere under skills/?   (空)
-- 三个 scanner 的全部 import:
     argparse fnmatch hashlib json os re sys time
     datetime pathlib typing NoReturn  (+ from __future__ import annotations)
   —— 全部标准库,没有任何跨 skill / 跨包 import
-- any relative import or cross-skill import?  (空)

14. 范围

-- 禁止触碰的路径(README.md / docs/ / .github/ / pyproject.toml / CONTRIBUTING.md /
   server.json / Dockerfile / docker-compose.yml / src/zspace_cli/*.py /
   skills/README.md / src/zspace_cli/skills/README.md / __init__.py):(空)
-- 其他 7 个 skill:(空)
-- 全部改动路径:24 个 = 3 skill × 4 文件(.py / SKILL.md / README.md / tests/smoke.sh)× 2 棵树
   scope OK (forbidden=0 other-skills=0)

15. 没有 config.json

git ls-files | grep -c 'config\.json$' -> 0
find skills src/zspace_cli/skills -name 'config*.json' -> (空)

16. 三个新 TEST 7 在大小写敏感卷上重跑(hdiutil 建的 Case-sensitive APFS,见上)

download-cleaner / backup-auditor / dedup-finder : PASS PASS PASS
卷探针:probe.TXT 与 probe.txt 共存 = 2

17. roadmap 勾选未动

git diff --stat origin/main -- docs/CONTRIBUTION_IDEAS.md  → (空)

汇总(最后一次全量重跑的真实输出)

identity  py3.9.25  : identity checks run: 168   mismatches: 0   ALL IDENTICAL
identity  py3.12.9  : identity checks run: 168   mismatches: 0   ALL IDENTICAL
identity  py3.9.6   : identity checks run: 168   mismatches: 0   ALL IDENTICAL
harness 负控制 3.9  : 17 处破坏全部检出 + 3 处未破坏副本 0 处不同 + 4 处可达性探针 True
                      harness negative controls missed: 0   HARNESS IS NOT VACUOUS
harness 负控制 3.12 : 同上,missed: 0
mutation  py3.9.25  : mutations tried: 52   caught: 52   survived: 0   anchor errors: 0
mutation  py3.12.9  : mutations tried: 52   caught: 52   survived: 0   anchor errors: 0
                      post-run tree state: clean vs HEAD(两边都是)
smoke ×9 ×3 解释器  : PASS=9 FAIL=0  ×3(py3.9.25 / py3.12.9 / py3.9.6)= 27 次全绿
TEST 7 ×3 大小写敏感卷: PASS ×3
verify.sh           : ALL LOCAL CHECKS PASSED(镜像 in sync、文档计数 found=7 全等于 9、
                      MCP 14/14、ruff skills 与 src tests 全过、pytest 131 passed+1 skipped /
                      153 passed、pyright 2 errors 全部为 main 上既有的 mcp 导入、
                      CRLF 0、ast 3.9 ok、新增 .py 最大 CJK 宽度 87、无共享模块、
                      scope OK forbidden=0 other-skills=0、config.json 计数 0)
CI                  : 10/10 全绿(含 skills 与 test 两个 windows-latest job)

三份新 smoke 测试的实际输出(py3.9,py3.12 与 py3.9.6 逐字相同)

=== TEST 7: config.json 覆盖层(issue #15) ===          ← download-cleaner
  ✓ 无 config.json:stats 无 config 键、stderr 无提示、17 条问题与旧版一致(大小写撞名存活 1 个)
  ✓ 人类报告:无配置时一个字都不印,有配置时印出路径与生效的键值
  ✓ extension_overrides 改判 .ass → video/move-to-library,且只动这一个扩展名
  ✓ 扩展名可以从 junk 里被救回来(判定阶梯第一级也吃覆盖,不是静默失效)
  ✓ 键归一化(.XYZ / ISO 都认)+ 可以把内置表里没有的扩展名加进来
  ✓ 改判出 archive 会同时退出「已解压」启发式(共享 ARCHIVE_EXTS,已在文档明写)
  ✓ 兜底类别 other 可写:结果与无配置逐条相同,但仍然明确提示已加载
  ✓ skip_dirs=[临时/*]:整棵子树不进入;命中 临时/2024 但不命中 临时 自身
  ✓ 裸名字 临时 在任意深度命中(剪 2 个目录 / 少 3 个文件),与 临时/* 的行为可区分
  ✓ 深层/临时/* 只命中 root 下那一条路径,不波及同名的 临时/
  ✓ 大小写不敏感双向成立:SAMPLES / samples / Samples 三种写法都命中(本机存活 1 个目录)
  ✓ skip_dirs 只作用于目录:* 也管不到直接躺在 root 下的文件
  ✓ 空对象 {} 加载成功:问题清单与无配置时逐条相同,但会明确提示已加载
  ✓ 只有 skip_dirs 时,extension_overrides 缺省 = 不改判任何扩展名
  ✓ 两个键同时生效;stderr 提示行分别报出各自的条数
  ✓ 被扫描目录里的 config.json 被忽略(3 种 cwd 都试过),只当普通文件扫
  ✓ 17 种畸形配置全部 exit=1、只写 stderr、指名文件与键
  ✓ 未知键报错列出可用键名;类别名报错列出本 skill 全部 10 个类别
  ✓ 校验先于写入:坏配置退出后内置表与 CONFIG_* 全都没动,也不打印「已加载」
  ✓ _cfg_match 锚定规则:15 条纯函数断言
=== TEST 7: config.json 覆盖层(issue #15) ===          ← backup-auditor
  ✓ 无 config.json:scan/coverage 都无 config 键、无提示行;22 项 / 16 集 / 13 陈旧,coverage 3覆盖 3缺失 3陈旧 13孤儿
  ✓ 人类报告:scan 与 coverage 都是无配置不印、有配置印出路径与生效键值
  ✓ extension_overrides {mybak: archive}:两版本合成一集,--keep 联动出轮转项
  ✓ 兜底类别 non_archive 可写:网站备份.iso 不再被剥扩展名,自成一集
  ✓ 键归一化:.MYBAK 与 mybak 等价
  ✓ skip_dirs=[临时暂存]:顶层那项不纳入,深层同名目录在 dir_size 里也被剪(体积按字节核对)
  ✓ 临时暂存/* 不命中 临时暂存 自身,只剪掉 临时暂存/子目录(体积精确少一份)
  ✓ 深层/临时暂存/* 与 深层/临时暂存 可区分:前者不剪自己那层,后者剪
  ✓ 大小写不敏感双向成立:samples→SAMPLES 与 LOWCASE→lowcase 都命中
  ✓ coverage:同一个 skip_dirs 同时作用于 --source 与 --backup(1+1=2)
  ✓ 连锁反应:源目录不再核对 → 对应备份集变孤儿;而 照片 不会误剪 照片备份_*
  ✓ 空对象 {} 加载成功:scan 与 coverage 的审计项与统计都与无配置时相同
  ✓ 被审计目录里的 config.json 被忽略(3 种 cwd 都试过),只当普通备份项扫
  ✓ 18 种畸形配置 × 2 个子命令全部 exit=1、只写 stderr、指名文件与键
  ✓ 未知键报错列出可用键名;类别名报错列出本 skill 仅有的 2 个类别
  ✓ 校验先于写入:坏配置退出后 ARCHIVE_EXTS 与 CONFIG_* 全都没动
  ✓ dir_size:内置剪枝照旧、配置按备份根锚定、自己那层交给 _collect_items、省略 rel_parts 时行为不变
  ✓ _cfg_match 锚定规则:17 条纯函数断言
=== TEST 7: config.json 覆盖层(issue #15) ===          ← dedup-finder
  ✓ 无 config.json:stats 无 config 键、stderr 无提示、7 组重复;零误报(unique/big2 都没被报),三组排序与手推一致(大小写撞名存活 1 个)
  ✓ 人类报告:无配置时不印任何覆盖层信息(有配置时必须印,见第 7 组)
  ✓ prefer_keep_hints=[终稿]:A 组 keep 从 浅层/A.bin 反转成 工作/终稿/深层/A.bin(前后顺序逐条断言)
  ✓ C 组:工作/终稿/深层/C.bin 从第 7 升到第 2,内置 成品 仍是第一名
  ✓ 追加而非替换:加了 终稿 之后,内置的 源文件 仍然赢下 B 组
  ✓ 匹配不到任何路径的提示词:三组排序与基线逐条相同(不是静默失效,配置仍在 stats 里)
  ✓ 提示词被 strip;子串匹配跑在 path.lower() 上,与内置提示词同一条规则
  ✓ 大小写双向成立:TARGET1→target1/ 与 target2→TARGET2/ 都把那份顶到第一
  ✓ 子串语义钉住:`终*` 不是通配,命中不了 终稿/(没有偷偷升级成 glob)
  ✓ 含 / 的提示词按路径子串生效(可移植性提醒已写进 SKILL.md)
  ✓ 人类报告印出配置路径、追加的提示词、以及「内置 + 追加」的生效全集
  ✓ skip_dirs=[临时]:C 组从 7 份变 5 份,其余顺序不变,skipped_dirs=1
  ✓ 临时/* 命中 临时/2024 但不命中 临时 自身(与裸名字 临时 可区分)
  ✓ 工作/终稿/* 只剪 root 下那一条:A 组因此不再成组(组数 -1),C 组仍在
  ✓ skip_dirs 大小写双向成立:samples→SAMPLES/ 与 LOWCASE→lowcase/
  ✓ skip_dirs=[*]:只扫 root 一层(dirs_visited=1),剪掉 16 个目录(= 实测顶层目录数);散文件仍在
  ✓ 多 --root:skip_dirs 在每个 root 上各自锚定(两边各剪 1 个)
  ✓ 空对象 {} 加载成功:删除计划与无配置时逐组相同,生效全集 = 内置 7 条
  ✓ 被扫描目录里的 config.json 被忽略(3 种 cwd 都试过),只当普通文件扫
  ✓ 15 种畸形配置全部 exit=1、只写 stderr、指名文件与键;并钉住 prefer_keep_hints 不做绝对路径检查(它是路径子串)
  ✓ 未知键报错列出本 skill 的可用键(照抄别的 skill 的键会被明确拒绝)
  ✓ 校验先于写入:坏配置退出后 PREFER_KEEP_HINTS 与 CONFIG_* 全都没动;追加时与内置重复的条目不会重复出现
  ✓ _cfg_match 锚定规则:16 条纯函数断言

所有 subprocess.run(..., text=True) 都显式带 encoding="utf-8", errors="replace"(#48 修的那十处之一就是 dedup-finder 的 TEST 4,这里没有回退它);写 config.json 也显式 encoding="utf-8",避开 cp1252 默认编码。

三个 fixture 里必须实测而不能假定的东西

skip_dirs / prefer_keep_hints 的断言里有几处依赖文件系统行为,写死就会在某个 runner 上假绿或假红:

事项 macOS 默认 / Windows(大小写不敏感) ubuntu CI(大小写敏感) 处理
大小写撞名对 CASE.bin / case.bin 第二个覆盖第一个,只剩 1 份 2 份都在 断言跟着实际存活的数量走;dedup 的重复组数由它推出(7 + (1 if 存活 2 份 else 0))
大小写变体目录 两个写法合并成一个目录 两个独立目录 backup-auditor 用不同名字(SAMPLES 与 lowcase),这样两个方向的大小写折叠都还测得到;skipped_dirs 的期望值取实测目录数
大小写变体文件 合并 独立 download-cleaner 用 PackageArchive.ZIP + packagearchive/(名字本来就不同),顺带钉住「已解压」启发式是区分大小写的:它判「未解压」,而全小写的 archive.zip + archive/ 判「已解压」

第一版在这里翻了两次车:① 我建了 Archive.ZIP 想和 archive.zip 对照,可在大小写不敏感的卷上它俩是同一个文件,后建的把先建的连名字一起吞了 —— 对照根本没发生;② dedup 的大小写变体目录最初复用 A 组的 payload,两组直接并成一组,而且组成员数跟着文件系统变。


CI 结果:10/10 全绿

https://github.com/skyzhao1223/zspace-cli/actions/runs/37892000591(commit 657293a,
也就是被合并的那个提交)。其后推的 55c588e 只改测试文件,没有触发新的
pull_request run(PR 已合并);它在本地三个解释器 + 大小写敏感卷上都跑过。

Job 结果
skills (ubuntu-latest) ✅ pass 39s
skills (windows-latest) ✅ pass(前两次推送在这里红过,见上)
test × {ubuntu, macos, windows} × {3.9, 3.12} ✅ 6/6 pass
typecheck(pyright) ✅ pass 23s
docker(build + MCP handshake) ✅ pass 29s

windows-latest 上 dedup-finder TEST 7 的实际日志(这是本 PR 最需要盯的 runner:
git-bash 下 mktemp -d、复制脚本、写含中文的 config.json、再断言 CJK 目录模式与
keep 排序反转;CI 用的是 Python 3.9.13):

=== TEST 7: config.json 覆盖层(issue #15) ===
  ✓ 无 config.json:stats 无 config 键、stderr 无提示、7 组重复;零误报(unique/big2 都没被报),三组排序与手推一致(大小写撞名存活 1 个)
  ✓ prefer_keep_hints=[终稿]:A 组 keep 从 浅层/A.bin 反转成 工作/终稿/深层/A.bin(前后顺序逐条断言)
  ✓ C 组:工作/终稿/深层/C.bin 从第 7 升到第 2,内置 成品 仍是第一名
  ✓ 含 / 的提示词按路径子串生效
🎉 所有 smoke test 通过

没能验证的

诚实列出:

  1. 没有在真 NAS / SMB 挂载盘上跑过,也没有拿真实用户的 config.json 试过。fixture 全是本地合成树(在 ~/.cache 下,不在 /tmp)。
  2. Windows 没有本地实测(本机是 macOS)。推理是:fnmatch.fnmatch 对两边都套 os.path.normcase,在 Windows 上会把 / 一致地折成 \,所以匹配结果不变;而且同一个调用今天就已经在 file-sorter 的 _protected() 里跑 windows-latest(feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47),_cfg_match 是原样抽出来的,跨平台行为没有新增面。smoke 里所有 subprocess.run 都显式带 encoding="utf-8", errors="replace",写 config.json 也显式 encoding="utf-8"。但 git-bash 上 mktemp -d + CJK 目录名 + 复制脚本这套组合没有本地复现过,以 CI 的 skills (windows-latest) 日志为准。
  3. dedup-finder 含分隔符的提示词在 Windows 上失效 → 已不成立:95e6ece 把相对
    路径统一成 / 之后,含 / 的提示词三平台一致。但**「Windows 上路径从 \ 变成
    /」这件事本身没有本地实测**(本机是 macOS),依据是 CI 的 windows-latest 全绿
    与「POSIX 上 replace 是空操作」这个论证。
  4. 大白名单的性能没测(很多模式 × 很深的树)。_cfg_match 是 O(目录数 × 模式数),形状与 feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47 完全相同,所以论证上不该是回归 —— 但这是论证,不是实测数字。backup-auditor 的 dir_size 现在每层多构造一个 list(cur_rel + [e.name]),在 20000 文件上限内是 O(深度) 的额外分配;没有实测这个数字。
  5. zs skill 的端到端安装没跑:没有 pip install 这个分支的 wheel、再 zs skill <dir>、再往装好的目录里放 config.json 跑一遍。package-data = ["**/*"] 与 copytree 的行为是读代码确认的;smoke 里用的是「把脚本 cp 进临时目录」来模拟安装后的布局(这也正是 __file__ 解析要验的东西)。
  6. 只覆盖了 3 个 scanner,另外 3 个(music / work / portfolio-organizer)加 nas-report 的路由文档没有动。这是本 PR 最大的未完成项,所以没有写 Closes #15。
  7. 没有跨 skill 的一致性测试。三个新 skill 的 skip_dirs 语义是相同的(这一点由「共享块逐字节相同」保证),但它们与 feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47 的 whitelist_dirs 不同(跳过 vs 计入 protected),只有文档和各自的 smoke 覆盖;不该有跨 skill 测试(它们本来就不同),但读者容易搞混,所以三份 SKILL.md 都放了警告。
  8. pyright 剩下的 2 个错没有修:mcp.server / mcp.types 在本地环境解析不到。它们在 origin/main 上一模一样地存在(同一环境、同一条命令跑过),CI 的 typecheck job 在 main 上是绿的,所以判定为环境问题、不属于本 PR。
  9. --exif 之类的既有 flag 交互没有重新验证(本 PR 没碰 photo-organizer)。
  10. AppleDouble 文件在 download-cleaner 里根本进不了统计这件事,只做了「钉住既有行为」的断言,没有修(见下)。

需要 maintainer 做的

1. skills/README.md(按要求没碰,这里写清楚该加什么)

#47 在 skills/README.md 里加了一节家族级说明(「### 可选:config.json 覆盖层」),只写一遍不重复十次,里面有一张支持范围表。合并本 PR 之后那张表需要更新成:

skill whitelist_dirs skip_dirs extension_overrides prefer_keep_hints
file-sorter ✅(→ --keep-dir) — ✅(15 类) —
photo-organizer ✅(→ WHITELIST_DIRS) — ✅(5 类) —
download-cleaner 刻意没有 ✅ ✅(10 类) —
backup-auditor 刻意没有 ✅ ✅(2 类) —
dedup-finder 刻意没有 ✅ 刻意没有(无扩展名表) ✅
music / work / portfolio-organizer ⏳ 未接入 ⏳ ⏳ —
nas-report ❌ 不支持(#47 里写了三条理由) ❌ ❌ —

配套建议再加两句家族级说明(否则读者一定会以为 whitelist_dirs 到处都能用):

  • 「键集不是全家族统一的,而且是刻意的。」whitelist_dirs 只在有合规豁免机制的 skill 里存在;skip_dirs 是「整棵不进入」,与 photo-organizer 的 whitelist_dirs 同档、与 file-sorter 的那一档(计入 stats.protected)不同。把一份配置从一个 skill 复制到另一个会被指名拒绝,这是设计,不是缺陷。
  • 「三个新 skill 都多了一个 stats.config.skipped_dirs」,汇报时请与总量一起说(被剪掉的目录不计入 files / backup_items / files_scanned)。

另外 #47 建议在 CI 里加一条「复制的 config-overlay 块没有漂移」的断言(它贴了脚本,并说明 files=$(grep -rl "CONFIG_EXT_TABLES" skills --include='*.py'))。本 PR 之后那条断言的取文件方式会漏掉 dedup-finder(它没有 CONFIG_EXT_TABLES)。要加的话建议把选择器换成 grep -rl "CONFIG_SKIP\|CONFIG_WHITELIST" skills --include='*.py',并且不要再要求「全部逐字节相同」—— 五个 scanner 现在有两个键集,正确的断言是「按区域比较,偏差只在已知的 per-skill 区域里」。本 PR 的 check_block_identity.py 已经是这个形状,可以直接搬。

2. 根 README.md / docs/README.zh.md(按要求没碰)

合并后如果要提这个功能,建议在这三个 skill 的行里各补一句「可用同目录 config.json 覆盖 skip_dirs 与扩展名归类 / 保留提示词」。

roadmap 勾选:docs/CONTRIBUTION_IDEAS.md 那条「🟡 Per-skill config overrides — roadmap」仍然不应该整条移进 ✅ Shipped,因为现在是 5/8。建议把进展注记从「file-sorter 与 photo-organizer 已接入」改成「file-sorter / photo-organizer(whitelist_dirs + extension_overrides)、download-cleaner / backup-auditor(skip_dirs + extension_overrides)、dedup-finder(skip_dirs + prefer_keep_hints)已接入;其余 3 个待照搬」,并补一句键集不统一是刻意的,免得下一个人照着 #47 的样子给 music-organizer 加 skip_dirs 或者给 dedup-finder 加 extension_overrides。

3. 顺手发现的一个既有瑕疵(没修)

download_cleaner.py 的 _check_file() 对「以 . 开头且不在 JUNK_NAMES 里」的文件提前 return:

if name.startswith(".") and name not in JUNK_NAMES:
    return

所以 ._movie.mkv 这类 AppleDouble 既不计入 stats.files 也不出 issue,而 categorize() 里那条 name.startswith("._") → "junk" 的规则经 CLI 根本走不到(只有直接调纯函数才看得到)。file-sorter 是把 ._* 判成 junk 并计数的 —— 两个 skill 在这一点上不一致。

本 PR 没动它:任何一种改法都会改变既有输出,与「零破坏性」直接冲突,而且它属于另一个问题(内置行为,不是覆盖机制)。已记进 download-cleaner 的「已知 gap」,smoke TEST 7 里把现状钉住了(断言 ._movie.ass 不在 issues 里,同时断言 dc.categorize("._movie.ass", "ass") == "junk")。是统一成 file-sorter 那样、还是删掉 categorize() 里那条死规则,交给维护者。

4. 建议单独开 issue 的两件事

5. 需要发版才能让用户用上

用户是通过 zs skill 从 wheel 里的打包副本装的,所以合并后要走一次 patch(与 #33 / #41 / #47 情况相同)。

6. 剩下 3 个 scanner 的接入

music-organizer / work-organizer / portfolio-organizer 三个都有 WHITELIST_DIRS,照 photo-organizer 是机械活;键集应该与 #47 那两个一致(whitelist_dirs + extension_overrides),不要给它们加 skip_dirs(它们有 WHITELIST_DIRS,语义不同)。建议一个 PR 一到两个 skill,这样每份都能像这份一样把 identity + 变异测试跑全。



结局(事后补记)

按要求没有合并、全程只推送。但维护者在 657293a 上把 PR 合掉了(d004882),
时间点在我最后一次推送之前。合并之后我推的 55c588e(dedup-finder 的
keep_rank/os.sep 契约断言 + C 组 fixture 的意图注释,只改测试)因此没有进
main,现在留在 feat/config-overrides-scanners3 分支上。

它值得单独捡回去的理由:那个分隔符修复目前只有 windows-latest 能验到 ——
在 ubuntu 与 macOS 上 os.sep 本来就是 /,把归一那句删掉不会有任何本地征兆。
55c588e 加的四条断言直接给 keep_rank 喂反斜杠路径,所以在任何 runner 上都
验得到;外加一段 fixture 注释,说明 C 组的年龄为什么刻意与深度打架(那是 depth
层可观测的前提,把它"整理"成越浅越旧就会让 EXPECT_C 在有 bug 时照样通过)。

git cherry-pick 55c588e     # 只动 skills/dedup-finder/tests/smoke.sh 与其镜像

事后测量:合并之后,八个 scanner 的共享块漂移成什么样了

兄弟 PR(music / work / portfolio-organizer)与本 PR 同期进了 main,所以现在八个
scanner 都有一份复制的加载器。#47 最担心的就是复制品漂移,所以拿同一个比对脚本在
origin/main(d004882)上重测了一遍。结果是没有散成八份:

区域 变体数 分组
load_config 文档串 → 未知键检查(24 行) 1 八个全部逐字节相同
extension_overrides 校验(20 行) 1 七个全部逐字节相同(dedup-finder 没有这个键 → ABSENT)
_cfg_die(10 行) 2 5 个 whitelist_dirs 系 / 3 个 skip_dirs 系
_cfg_match(21 行) 2 同上(两组各自函数体逐字节相同,只差文档串)
列表键校验(14 行) 2 同上(whitelist_dirs 版 / skip_dirs 版)

八个 scanner 的键集:

file-sorter          CONFIG_KEYS = ("whitelist_dirs", "extension_overrides")
photo-organizer      CONFIG_KEYS = ("whitelist_dirs", "extension_overrides")
music-organizer      CONFIG_KEYS = ("whitelist_dirs", "extension_overrides")
work-organizer       CONFIG_KEYS = ("whitelist_dirs", "extension_overrides")
portfolio-organizer  CONFIG_KEYS = ("whitelist_dirs", "extension_overrides")
download-cleaner     CONFIG_KEYS = ("skip_dirs", "extension_overrides")
backup-auditor       CONFIG_KEYS = ("skip_dirs", "extension_overrides")
dedup-finder         CONFIG_KEYS = ("skip_dirs", "prefer_keep_hints")

变体边界与键集边界完全重合 —— 差异不是随机漂移,而是「两种键集」这一件事本身。
两两比较也印证:同族内部 117/122 行相同(差的就是 CONFIG_EXT_TABLES),跨族 94/122,
dedup-finder 与任何一个是 63~82。

所以 #47 建议的那条 CI 断言现在可以怎么写

#47 提议的 grep -rl "CONFIG_EXT_TABLES" 选择器会漏掉 dedup-finder(它没有这个
常量),而且「全部逐字节相同」这个判据在八份两种键集的现实下必然为假。按上面的
测量,正确的形状是:

  1. 选择器用 grep -rl "CONFIG_KEYS" skills --include='*.py'(八个都命中,带
    n >= 8 的防真空下限);
  2. 断言分区域比较,而不是整块比较:load_config 前半段与 extension_overrides
    校验要求「所有含该区域的份数逐字节相同」(当前分别是 8 与 7);_cfg_die /
    _cfg_match / 列表键校验要求「变体数 ≤ 2,且每个变体内部的函数体逐字节相同」;
  3. 变体数超过 2 就红 —— 那才是真漂移。

本 PR 的 check_block_identity.py 已经是这个形状(它按 10 个区域报变体分组,并且
自带 --self-test 负控制),把 FILES 换成八个就能直接用。

合并后 main 的健康度(顺手跑的)

ruff check skills                                     All checks passed!
CI 的文档计数断言                                       total=10 org=9 stale=''
全部 9 个 skills/*/tests/smoke.sh(py3.9.6)            PASS ×9
镜像同步(diff -rq -x __pycache__ 逐 skill)            all skill copies in sync

@skyzhao1223
skyzhao1223 force-pushed the feat/config-overrides-scanners3 branch from aa1589b to a8046c4 Compare October 9, 2026 05:46
…or / dedup-finder

Extends the per-skill override mechanism from #47 to the three scanners that
have no compliance-exemption concept, completing the family (music / work /
portfolio-organizer are covered by the sibling change). Refs #15.

Key set differs from #47's two scanners on purpose. `whitelist_dirs` there means
"treat as compliant / do not flag"; none of these three judges directory-name
compliance, so giving them the same key with a different meaning would make a
config copied between skills behave surprisingly. Each instead exposes the
mechanism it actually has:

- `skip_dirs` (all three) extends built-in SKIP_DIRS — matched directories are
  not descended into. download-cleaner and backup-auditor also take
  `extension_overrides` with #47's per-extension-replace semantics; dedup-finder
  has no extension tables so it does not.
- `prefer_keep_hints` (dedup-finder only) extends PREFER_KEEP_HINTS, the
  tie-breaker deciding which copy of a duplicate group is recommended for
  keeping. Substring matching against the lowercased path, exactly as the
  built-in does — deliberately NOT upgraded to glob, which would change
  semantics for the built-in hints too.

Because this skill's output is a deletion plan, a config mistake could invert a
recommendation and put a valued original at the top of a delete list. So the
loaded config is surfaced in `stats.config` (including
`prefer_keep_hints_effective`, the merged list) and on stderr, and the tests
assert both that a hint inverts the ranking and that a non-matching hint changes
nothing. Built-ins are extended, never replaced.

Deliberately not configurable, each for a stated reason: backup-auditor's
compiled regexes (DATE_RE / TIME_SUFFIX_RE / VERSION_RE / BACKUP_HINT_RE) and
the four hardcoded double extensions; dedup-finder's HEAD_SIZE / CHUNK /
MAX_DEPTH. Those change "what counts as a backup" or "what counts as identical"
rather than "which of my own structures is intentional", and are larger design
questions than an overlay.

Three enabling refactors, all behaviour-preserving:
- download-cleaner's junk extensions were an inline literal inside categorize(),
  the FIRST rung of its decision ladder. Named JUNK_EXTS, otherwise an override
  could never be discarded from that rung and would be a silent no-op — exactly
  the failure mode the discard-then-add rule exists to prevent.
- dedup-finder's PREFER_KEEP_HINTS tuple became a list so config can extend it
  in place; keep_rank() is unchanged.
- backup-auditor's dir_size() now receives the item's relative path so
  `skip_dirs` also prunes inside a backup item, where built-in SKIP_DIRS already
  did. One key with two meanings inside one skill would be worse.

Loader text is duplicated inside each scanner, not shared: `zs skill` installs
by copytree per skill directory, so a module outside skills/<name>/ never
reaches the user's machine. The duplicated block is byte-identical to #47's
wherever genuinely shared; deviations are isolated and listed in the PR body.

Two cross-platform defects found and fixed while making CI green on all three
runners:
- backup-auditor's BASE_SIZE assertion derived the case-collision pair's sizes
  from the file NAME via `n.isupper()`. On a case-insensitive volume the second
  write overwrites the first file's CONTENT but keeps the FIRST name, so the
  survivor called CASE.bin actually holds 1701 bytes; on Linux both names
  survive with 1700 and 1701. The old expression gave 1701 on macOS (accidentally
  right) and 3402 vs an actual 3401 on Linux. Now measured with os.path.getsize.
  ('CASE.bin'.isupper() is False anyway — .bin has lowercase.)
- A fixture directory named " spaced backup 2024-06-01 " cannot exist on NTFS,
  which strips trailing spaces, so the following open() could not address it.
  Leading space kept (valid everywhere, and it is the part that exercises word
  splitting); the trailing-space case is still covered as a pattern-matching
  input to the pure-function _cfg_match assertion, which never touches the
  filesystem.

Also fixes a pre-existing Windows bug in dedup-finder surfaced by the new exact
path assertions: it built relative paths by string slicing, so on Windows it
emitted OLD\G.bin while all eight other scanners emit forward slashes.
keep_rank()'s depth tier is `path.count("/")`, which is 0 for every backslash
path, so "prefer the shallower copy" was silently dead on Windows and the skill
recommended deleting the shallow, accessible copy in favour of the deeply
nested one — the opposite of its own documented heuristic. Demonstrated at unit
level: keep_rank(shallow) < keep_rank(deep) is True for "a/b/c/G.bin" and False
for "a\\b\\c\\G.bin". Normalising in the scanner rather than the test, because
the test was right and the output was wrong.

Verified: no config.json present, output is byte-for-byte identical to
origin/main across 23 invocations (download-cleaner scan; backup-auditor scan
and coverage; dedup-finder scan; 4-7 flag combinations each) comparing --json
stdout, human stdout, stderr and the --output file, on Python 3.9.25 and
3.12.9 — 0 mismatches, with the harness's own negative control confirming it
detects perturbation. ruff clean; pytest 153 (3.12) and 131+1 skipped (3.9);
pyright 0 errors; all 9 smoke.sh pass on both interpreters; the skills/ mirror
stays byte-identical; no changed file contains CRLF; ci.yml's documented-skill-
count and MCP-tool-coverage assertions still pass; no config.json committed
(package-data is "**/*" and zs skill copytrees the whole directory, so one would
ship and auto-activate for every user). Config behaviour confirmed end to end on
discriminating fixtures: download-cleaner skip_dirs 4 -> 2 issues with 2 -> 0
from the skipped subtree; backup-auditor skip_dirs 3 -> 2 items and 3 -> 2 sets,
and coverage source_dirs 3 -> 2; dedup-finder prefer_keep_hints inverting the
keep recommendation from aaa/q.bin to zzz-special/q.bin, a non-matching hint
changing nothing, and all seven built-in hints still present in the effective
list. Malformed config (wrong type, unknown key, invalid JSON, non-object top
level) exits 1 naming the file and key on all three scanners, and a config.json
inside the scanned root is ignored because resolution is relative to __file__.
CI 10/10 green including skills(windows-latest).
@skyzhao1223
skyzhao1223 force-pushed the feat/config-overrides-scanners3 branch from 95e6ece to 510d1ca Compare October 9, 2026 06:03
…e separator fix

The config-overlay docs were written when dedup-finder still sliced relative
paths out of os.scandir's entry.path, so they told users that `/`-containing
hints only work on POSIX and that they should fall back to single-level
directory names. Since paths are now normalised to `/` on every platform, all
four of those statements became false, and one of them — the SKILL.md
troubleshooting row "含 `/` 的提示词在 Windows 上不灵" — described a failure mode
that no longer exists.

Corrected in both mirrored trees:
- SKILL.md "路径分隔符": replaced the platform-native-separator warning with the
  actual guarantee, plus a note recording why it must not be "simplified" back.
- SKILL.md troubleshooting table: dropped the obsolete row.
- README.md portability paragraph: rewritten as a resolved item.
- README.md known-gap bullet: rewritten from "deliberately deferred" to done,
  and expanded to say why the fix went into the scanner rather than keep_rank —
  normalising in keep_rank would have fixed hint matching but left the other
  consumer of the same string, the `path.count("/")` depth tier, still dead on
  Windows.

Leaving them would have made the docs contradict the code in the same PR that
changed it, which is how the RAW whitelist entry stayed wrong for several
releases.

Verified: no remaining occurrence of the old wording anywhere under skills/ or
src/zspace_cli/skills/; both mirrored trees byte-identical; all skill copies in
sync; ruff clean; all 9 smoke.sh pass (TEST 1 parses SKILL.md frontmatter, so a
malformed table edit would have shown up).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant