Repository navigation
feat(skills): config.json 覆盖层接入 download-cleaner / backup-auditor / dedup-finder(部分落实 #15) - #54
Merged
Merged
Conversation
skyzhao1223
force-pushed
the
feat/config-overrides-scanners3
branch
from
October 9, 2026 05:46
aa1589b to
a8046c4
Compare
…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
force-pushed
the
feat/config-overrides-scanners3
branch
from
October 9, 2026 06:03
95e6ece to
510d1ca
Compare
…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).
skyzhao1223
marked this pull request as ready for review
October 9, 2026 06:11
This was referenced Oct 9, 2026
skyzhao1223
added a commit
that referenced
this pull request
Oct 9, 2026
test(dedup-finder): 把 keep_rank 的深度契约钉在所有 runner 上(#54 遗留的搁浅 commit)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
落实 #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。
一个 PR 装三个 skill,是因为这三个的键集决定必须一起看才讲得清楚(见下「为什么这三个的键集与 #47 那两个不同」)—— 分开提反而会让三份 PR 各自把同一套理由重复一遍,还容易出现三份不一致的措辞。
这三个与 #47 那两个结构上不同,所以键集也不同
先把事实摆出来。下面的常量名全部是在
origin/main上用grep -oE '^[A-Z][A-Z_0-9]{2,}'取的,不是凭印象写的:*_EXTS表*_EXTS+JUNK_*+DIR_ALIASES+CATEGORY_*--keep-dirglobPHOTO/VIDEO/SIDECAR/JUNK_EXTSJUNK_NAMESSKIP_DIRSWHITELIST_DIRS+ 14 条正则WHITELIST_DIRS(精确名)PARTIAL/TORRENT/INSTALLER/ARCHIVE/VIDEO/AUDIO/PHOTO/DOC_EXTSJUNK_NAMESSKIP_DIRSDUP_MARK_REARCHIVE_HINTCATEGORIESCATEGORY_ZHMAX_DEPTHARCHIVE_EXTSSKIP_DIRSDATE_RETIME_SUFFIX_REVERSION_REBACKUP_HINT_REMAX_DEPTHHEAD_SIZECHUNKMAX_DEPTHSKIP_DIRSPREFER_KEEP_HINTSgrep -c WHITELIST_DIRS在这三个文件里都是 0;grep -c '^SKIP_DIRS = {'都是 1。为什么不给它们
whitelist_dirswhitelist_dirs在 #47 里的语义是「视为合规 / 不要报」—— 它喂的是那两个 skill 的合规豁免机制(file-sorter 的--keep-dir、photo-organizer 的WHITELIST_DIRS)。这三个一个都没有这套机制:--keep N是版本保留数,不是目录白名单(feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47 的表格自己就写了这一条);一个键在不同 skill 里含义不同,是最坏的一种设计:用户把一份配置从 file-sorter 复制到 download-cleaner,不会得到报错,而是得到一个语义完全不同的行为 —— 而 issue #15 通篇在防的就是「静默的意外」。所以这三个只暴露它们真正有的机制,并照它本来的样子命名。
skip_dirsSKIP_DIRSstats、不产任何问题。与 #47 同一套模式机制(fnmatch、大小写不敏感、锚定在被扫描的根、匹配整段相对路径或任意一级目录名、绝对路径直接报错)、同一套响亮校验、同样写进stats.configextension_overridesCONFIG_EXT_TABLESprefer_keep_hintsPREFER_KEEP_HINTS照抄别的 skill 的配置会被指名拒绝,报错里列出本 skill 的可用键(smoke 里对三种「照抄」都写了断言):
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 里对每个锚定层次都按目录数/文件数/字节数分别核对。CONFIG_EXT_TABLES的推导(逐个,附证据)规则与 #47 相同:类别名 → 该 skill 的扩展名集合;值为
None表示兜底类别(不属于任何集合)。download-cleaner:10 类,与
CATEGORIES常量逐一对应CATEGORIES就是它自己的类别清单(也是stats.by_category的键序):categorize()的判定阶梯与之一一对应,所以:其中
JUNK_EXTS是本 PR 新提的常量,原因见下节 —— 这不是为了好看,是不提成常量就没法让junk可覆盖。backup-auditor:只有 2 类
grep -nE '^[A-Z][A-Z_0-9]*_EXTS *='在backup_auditor.py上只有一行:它只有一个使用点(
strip_archive_ext()里的if ext.lower() in ARCHIVE_EXTS),而那个函数的结果决定base_key,也就是备份集怎么聚合。所以:兜底名用
non_archive,与 photo-organizer 的non_media同一种构词法(「唯一那张表的反面」)。没有编出第二张表来 —— 它不存在。这个键在本 skill 里的用处比「文件该去哪」更实在:
dedup-finder:没有这个键
grep -nE '^[A-Z][A-Z_0-9]*_EXTS *='在dedup_finder.py上的输出是空的。它的大写常量只有CHUNKHEAD_SIZEMAX_DEPTHPREFER_KEEP_HINTSSKIP_DIRS—— 内容级去重根本不看扩展名。给它一个extension_overrides只会是一个语义为空的键,而那正是 issue 最忌讳的那类静默失效。三处必要的 enabling refactor(全部行为保持)
1. download-cleaner:把内联的 junk 扩展名提成
JUNK_EXTScategorize()是有序判定阶梯,junk在第一级,而那三个扩展名原本写成函数体里的内联字面量:内联字面量没有名字可以摘。所以
{"tmp": "doc"}会是彻底的静默失效:junk 那一级先命中,DOC_EXTS里加了也没用,覆盖看起来完全没生效 —— 正是 #47 花了一整节解释的那个失败模式。提成常量: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:默认值保证老调用方式行为不变(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:prefer排在depth/mtime/len(name)前面,所以一条用户写的提示词可以把深层的新副本顶到keep,把浅层的旧正主推进drop。后面照着计划rm的人就删错了东西。实测(smoke TEST 7 里是逐条断言的,不是叙述):风险与对策一一对应,每一条都有断言:
stats.config(既有本次追加的prefer_keep_hints,也有实际生效全集prefer_keep_hints_effective= 内置 7 条 + 追加)、以及人类可读报告里的一段extend,不重新绑定;与内置重复的条目不会重复出现终稿之后内置源文件仍然赢下 B 组;第 14 组进程内断言PREFER_KEEP_HINTS == 内置 + ["终稿"](配置里同时写了成品,不重复出现)stats.config里 —— 所以「没生效」与「生效了但没命中」是可区分的stats.config里那两条仍在终*必须命中不了终稿/mv到_dup_quarantine/,人工核对一段时间再真删」不变,故障排查表加了一条「配了这个键就必须逐组人工过keep」人类报告里那一段长这样(它旁边就是一张删除清单,所以必须印在这里,不能只躺在 JSON 里):
为什么没顺手把子串升级成 glob
PREFER_KEEP_HINTS是内置与配置共用的同一个列表,any(h in path …)也是同一条判定。换成fnmatch会连内置那 7 条的语义一起改掉:原始今天匹配路径里任何位置出现的「原始」两个字,升级之后会变成只匹配整段路径或以原始/开头的目录。那是一次静默的行为变更,而且改的是没有配置文件时也会走到的代码 —— 与本机制「没有配置就逐字节一致」的前提直接冲突。建议单独开 issue 讨论:要做的话得先决定内置那 7 条要不要一起迁移、迁移期间老配置怎么兼容、以及「只在第一层目录名上匹配」是不是用户真正想要的语义。
一个可移植性坑(已写进 SKILL.md)
相对路径的分隔符是平台原生的(
entry.path来自os.scandir),所以工作/终稿这种含分隔符的提示词只在 POSIX 上有效,Windows 上是\。要跨平台就写单级目录名(终稿),两边都命中。刻意不可配置的
DATE_RE/TIME_SUFFIX_RE/VERSION_RE/BACKUP_HINT_RE.tar.gz/.tar.bz2/.tar.xz/.sparsebundle)ARCHIVE_EXTS之前匹配。要可配置就得先决定「双扩展名列表与单扩展名表的优先级」这件事,不是加个键能了结的。但用户一定会踩,所以 SKILL.md 的踩坑表里明写了{"gz": "non_archive"}不会让.tar.gz停止被剥离HEAD_SIZE(64KB)/CHUNK(1MB)/MAX_DEPTHJUNK_NAMES/DUP_MARK_RE/ARCHIVE_HINT文案extension_overrides只作用于扩展名,与 #47 一致);第三个是给用户看的建议文案--config PATH--config指定的文件与旁边那份是替换还是叠加?「哪个配置在生效」必须只有一个答案改了什么
skills/download-cleaner/download_cleaner.pyCONFIG_EXT_TABLES(10 类)+JUNK_EXTS提常量 +_walk接入 +stats.config+_print_config+main()调load_config()skills/backup-auditor/backup_auditor.pyCONFIG_EXT_TABLES(2 类)+ 三处遍历接入 +dir_size(rel_parts=)+ 两个子命令各写stats.config+_print_config+stats: dict注解skills/dedup-finder/dedup_finder.pyPREFER_KEEP_HINTStuple→list +_walk接入 +stats.config+_print_configskills/download-cleaner/tests/smoke.shskills/backup-auditor/tests/smoke.shskills/dedup-finder/tests/smoke.shskills/download-cleaner/SKILL.mdskills/backup-auditor/SKILL.mdskills/dedup-finder/SKILL.mdskills/download-cleaner/README.mdJUNK_EXTS为什么必须提、ARCHIVE_EXTS的耦合、共享块边界、变异测试skills/backup-auditor/README.mdskills/dedup-finder/README.mdsrc/zspace_cli/skills/**单树 12 个文件,含镜像共 24 个文件 +6888 / −42。
没有动:
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 之间」的差异:load_config文档串 → 未知键检查extension_overrides校验_cfg_die白名单→ 「按内置规则出结果」);函数体逐字节相同_cfg_match白名单模式→skip_dirs 模式、--root→「被扫描的根目录」、示例目录名原盘→临时);函数体逐字节相同whitelist_dirs→skip_dirs的改名与示例词CONFIG_WHITELIST→CONFIG_SKIP)CONFIG_INFO+ 提示行)CONFIG_KEYS/ R4CONFIG_EXT_TABLES整段加载器的两两比较:
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_KEYS1 行 + 收尾 21 行。偏差被关在「第二个键是什么」这一个区域里,没有散落到整个加载器。两处为了「同一性」而做的取舍
categorize()的判定阶梯会按它自己的顺序命中旧类别」;backup-auditor 里那条阶梯叫strip_archive_ext()。为了让 dc 与 ba 这 2 行逐字节相同,注释改成不提函数名(「否则判定阶梯会…」),语义没丢。_cfg_match的示例目录名从原盘换成临时。留着原盘/VIDEO_TS可以让 R6 只偏 1 行而不是 3 行,但那是 photo-organizer 的概念,出现在 download-cleaner 的文档串里会误导读者。R6 的第一行无论如何都得偏(白名单模式→skip_dirs 模式),所以换成局部有意义的示例并不额外增加「有偏差的区域数」,只是让同一个区域多偏 2 行。这个比对脚本自己带负控制
--self-test会往其中一份里删掉一个字符(把_cfg_die的❌换成✗),然后要求脚本报出漂移;报不出就 exit 1。同一性是靠生成保证的,不是靠复制粘贴
三个 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 的差异会伪装成(或掩盖)改动带来的差异。
python3)适用范围:这三遍都在 macOS/POSIX 上。
95e6ece之后 dedup-finder 在 Windows上的相对路径由
\变成/(有意的行为修复,见上「第四个」),所以「与 main 逐字节一致」在 Windows 上只对 download-cleaner 与 backup-auditor 成立。
参数组合覆盖: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的对象不是空的(否则比较本身是空转):只归一化真正不确定的字段,一共 10 条规则,每条的命中数都印出来
没有归一化:
mtime、mtime_date、size、任何计数、任何路径。两边用的是同一棵 fixture 和同一个相对--output名(各自的 cwd 不同),所以路径本来就相等 —— 归一化它反而会掩盖真问题。「全 0 命中」会被显式警告,免得归一化器根本没接上却报一片绿。这个 harness 自己也做了负控制
cmp式的证明有个陷阱:如果 harness 根本看不见差异,它会永远报 0 处不同。所以对 harness 本身做了 17 处故意破坏 + 3 处「未破坏的副本必须报 0 处不同」(查假阳性),其中 4 处还带可达性探针(直接 import 改过的模块、断言常量真的变了):stats["config"]无条件写(不再 gate 在CONFIG_INFO上)JUNK_EXTS加zip、ARCHIVE_EXTS摘掉iso、PREFER_KEEP_HINTS摘掉成品)_cfg_match的空模式卫语句返回 Truedir_size不再认内置SKIP_DIRS_print_config没有配置时也打印advise()的 torrent 动作改掉)_collect_items跳过每一项HEAD_SIZE减半第一版有 4 处破坏是写错的,查出来都不是 harness 的问题:
ARCHIVE_EXTS里加iso2*.iso2,输出一个字节都没变iso→网站备份.iso不再被剥扩展名,scan的set字段就变了PREFER_KEEP_HINTS里摘掉相册相册的路径成品—— 它正是基线的 keepdir_size里child_rel = [e.name](丢掉锚定)CONFIG_SKIP是空的,_cfg_match的卫语句直接返回 False —— 在无配置模式下原理上不可观测SKIP_DIRS的剪枝(那是今天就活着的代码),并往 fixture 里塞一个vol.sparsebundle/@eaDir/if CONFIG_INFO:有两处(scan与coverage),于是被误报成 anchor error[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对三个脚本必须是干净的 —— 脚本自己断言这一点。load_config的每个校验门:有配置也直接 return、未知键静默接受、顶层不是对象、skip_dirs不是数组 / 元素不是字符串 / 空字符串 / 绝对路径stats.config永不写、stderr 提示行去掉、提示行改走 stdout(污染--json)_print_config不被调用 / 没配置也打印 /skip_dirs在第二个键校验之前就合并_cfg_match五条:去掉整段相对路径规则、去掉任一级目录名规则、变成大小写敏感、不锚定(加*前缀)、去掉not rel_parts卫语句extension_overrides(仅 dc/ba):不是对象、类别值不是字符串、归一化后为空、含多个点、类别名不存在、不剥前导点、不转小写、合并退化成 add-onlyprefer_keep_hints(仅 dd):替换而非追加、不 lower、不 strip、不去重、不报 effective、keep_rank忽略 prefer 层、升级成 glob、不是数组、元素不是字符串、空字符串、人类报告不印生效全集_walk忽略CONFIG_SKIP、计数器不加、categorize退回内联字面量、CONFIG_EXT_TABLES少了 junk、兜底other指向DOC_EXTS_collect_items/dir_size/top_dirs各自忽略CONFIG_SKIP、stats.config只在 scan 写不在 coverage 写、dir_size调用不传rel_parts、archive映射成None_walk忽略CONFIG_SKIP、计数器不加变异测试反过来抓出了我自己 2 条假断言
与 #47 查出 3 条同一类问题:
_cfg_match不再锚定)第一轮在 backup-auditor 与 dedup-finder 都活了下来,只有 download-cleaner 抓到。原因不是实现有问题,是那两个 skill 的纯函数清单里只有not m(["深层","临时暂存"], ["临时暂存/*"])这条两层的断言 —— 模式被加上*前缀变成*临时暂存/*之后它照样匹配不上,区分不出锚定与不锚定。真正有判据的是三层那条(深层/临时暂存/子目录会被不锚定的模式误命中),而 download-cleaner 的清单里正好有。已给另外两个补齐,现在三个都抓。—— 这与 feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47 的 M21 是同一族:断言只覆盖了实现的恒定行为那一侧。
_print_config也打印)第一轮在 dedup-finder 活了下来:它的人类报告断言只在有配置时跑过,缺一条「无配置时报告里不该出现 ⚙ 那一段」的负向断言。已补。还有一个工具级的教训
第一轮跑变异之前,上一次运行被 SIGTERM 打断,把
if False:留在了三个.py里;下一次运行于是把那份被改坏的文件当成了 pristine 基线(基线 smoke 全红,才没造成误判)。现在mutate.py开始前会git diff HEAD检查三个脚本是否干净,不干净就拒绝运行,结束时再检查一次并打印post-run tree state。CI 抓到的四个问题 —— 三个在测试里,一个是真的 Windows 既有 bug
本 PR 前几次推送 CI 是红的(
skillsjob 在 ubuntu 与 windows 上都挂在新的 TEST 7)。前三个是测试自身的可移植性 bug,而且三个都在本机(macOS 默认卷)结构上不可能暴露;第四个不是测试的问题,是 scanner 在 Windows 上的一个既有缺陷,顺手修掉了。前三个:测试的可移植性
AssertionError: 38411(backup-auditor 基线total_size_bytes)n.isupper()去猜大小写撞名对里哪一份活下来了,好推算它的体积。但"CASE.bin".isupper()是 False —— 因为bin是小写。大小写不敏感的卷上只有一份、且内容被第二次写覆盖成 1701 字节,猜的结果碰巧对上;ubuntu 上两份都在(1700+1701),就差 1 字节os.path.getsize求和)。它与被测实现无关,所以不是同义反复skip_dirs: ["*"]之后不该再有目录里的路径」失败assert "成品/" not in json.dumps(issues)。本机组数为 0(撞名对只剩一份、不成组)→issues是空的 → 绿;ubuntu 上撞名对成组了,而每组的keep.reason是字面量"启发式:成品/源目录优先→浅路径→旧文件→短名"—— 里面就含成品/all_paths()助手)。这正是任务里点名的那类陷阱:「needle 被另一条路径也产生」FileNotFoundError: …\bk\ spaced backup 2024-06-01 /z.binmakedirs建的是另一个名字,随后按原名写文件就 404顺手还修了一处只在 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 全绿。查下去:
Windows 上
entry.path用\,所以depth恒为 0 —— 四级排序退化成「prefer → mtime → 名字长度」,也就是说 「路径更浅的优先保留」这条规则在整个
Windows 平台上从来没生效过。后果不是显示问题:这个 skill 一直在建议用户删掉
浅层好找的那份、保留深层嵌套的那份,与它自己的文档和报告文案相反。单元级实测:
这是
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 的情况下照样通过。
于是本机也能测大小写敏感的卷了
上面 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 也过」:(最后一行是卷本身的探针:确认
probe.TXT与probe.txt真的共存,否则上面三行只是又跑了一遍大小写不敏感的情形。)全量验证(真实输出)
verify.sh,16 步。第 1–3 步是从.github/workflows/ci.yml原样抄的。1. CI 的镜像同步循环
2. CI 的文档计数断言
没动 skill 数量,也没在任何文档里写会落进
ORG_RE那四个短语前面的数字(#47 就踩过一次)。3. CI 的 MCP 工具覆盖断言
4–5. ruff(
ruff 0.16.10,["E","F","I","N","W","UP"],line-length = 100,target-version = "py39")6. pytest -q(双解释器)
(3.9 少 22 个是
mcpextra 要求 ≥3.10,与 CI 的 3.9 job 只装.[dev]一致;这两个数字与改动前基线相同。)7. pyright(
include = ["src"]、pythonVersion = "3.9",所以覆盖打包副本)中途确实抓到过我自己的 2 个错(backup-auditor 的
stats局部变量被推成dict[str, int],于是拒绝stats["config"] = {...}),已按另两个 scanner 的写法加stats: dict注解修掉 —— 这也是唯一一次为了类型检查而改既有代码。8. 全部 9 个
skills/*/tests/smoke.sh,三个解释器共 27 次全量 smoke 运行,全绿。未改动的 6 个 skill 也跑了,确认镜像同步没有连带影响。
新测试的实际输出见下面三段。
9. 每个改动文件的 CRLF 数(
.gitattributes强制 LF)10. Python 3.9 兼容
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 就是栽在这儿)1050 行新增
.py里最宽的一行是 87,离 100 还有余量。探针那一行是故意构造的(57 字符 / 105 显示宽度),用来确认 ruff 真的按宽度算而不是按len()算。12. 共享块逐字节比对 —— 见上「共享块」那一节;
--self-test输出injected deviation DETECTED。13. 结构约束
14. 范围
15. 没有 config.json
16. 三个新 TEST 7 在大小写敏感卷上重跑(
hdiutil建的 Case-sensitive APFS,见上)17. roadmap 勾选未动
汇总(最后一次全量重跑的真实输出)
三份新 smoke 测试的实际输出(py3.9,py3.12 与 py3.9.6 逐字相同)
所有
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 上假绿或假红:CASE.bin/case.bin7 + (1 if 存活 2 份 else 0))SAMPLES与lowcase),这样两个方向的大小写折叠都还测得到;skipped_dirs的期望值取实测目录数PackageArchive.ZIP+packagearchive/(名字本来就不同),顺带钉住「已解压」启发式是区分大小写的:它判「未解压」,而全小写的archive.zip+archive/判「已解压」CI 结果:10/10 全绿
https://github.com/skyzhao1223/zspace-cli/actions/runs/37892000591(commit
657293a,也就是被合并的那个提交)。其后推的
55c588e只改测试文件,没有触发新的pull_requestrun(PR 已合并);它在本地三个解释器 + 大小写敏感卷上都跑过。skills (ubuntu-latest)skills (windows-latest)test× {ubuntu, macos, windows} × {3.9, 3.12}typecheck(pyright)docker(build + MCP handshake)windows-latest上 dedup-finder TEST 7 的实际日志(这是本 PR 最需要盯的 runner:git-bash 下
mktemp -d、复制脚本、写含中文的config.json、再断言 CJK 目录模式与keep 排序反转;CI 用的是 Python 3.9.13):
没能验证的
诚实列出:
config.json试过。fixture 全是本地合成树(在~/.cache下,不在/tmp)。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)日志为准。dedup-finder 含分隔符的提示词在 Windows 上失效→ 已不成立:95e6ece把相对路径统一成
/之后,含/的提示词三平台一致。但**「Windows 上路径从\变成/」这件事本身没有本地实测**(本机是 macOS),依据是 CI 的 windows-latest 全绿与「POSIX 上
replace是空操作」这个论证。_cfg_match是 O(目录数 × 模式数),形状与 feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47 完全相同,所以论证上不该是回归 —— 但这是论证,不是实测数字。backup-auditor 的dir_size现在每层多构造一个 list(cur_rel + [e.name]),在 20000 文件上限内是 O(深度) 的额外分配;没有实测这个数字。zs skill的端到端安装没跑:没有pip install这个分支的 wheel、再zs skill <dir>、再往装好的目录里放config.json跑一遍。package-data = ["**/*"]与copytree的行为是读代码确认的;smoke 里用的是「把脚本cp进临时目录」来模拟安装后的布局(这也正是__file__解析要验的东西)。Closes #15。skip_dirs语义是相同的(这一点由「共享块逐字节相同」保证),但它们与 feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47 的whitelist_dirs不同(跳过 vs 计入 protected),只有文档和各自的 smoke 覆盖;不该有跨 skill 测试(它们本来就不同),但读者容易搞混,所以三份 SKILL.md 都放了警告。mcp.server/mcp.types在本地环境解析不到。它们在origin/main上一模一样地存在(同一环境、同一条命令跑过),CI 的 typecheck job 在 main 上是绿的,所以判定为环境问题、不属于本 PR。--exif之类的既有 flag 交互没有重新验证(本 PR 没碰 photo-organizer)。需要 maintainer 做的
1.
skills/README.md(按要求没碰,这里写清楚该加什么)#47 在
skills/README.md里加了一节家族级说明(「### 可选:config.json覆盖层」),只写一遍不重复十次,里面有一张支持范围表。合并本 PR 之后那张表需要更新成:whitelist_dirsskip_dirsextension_overridesprefer_keep_hints--keep-dir)WHITELIST_DIRS)配套建议再加两句家族级说明(否则读者一定会以为
whitelist_dirs到处都能用):whitelist_dirs只在有合规豁免机制的 skill 里存在;skip_dirs是「整棵不进入」,与 photo-organizer 的whitelist_dirs同档、与 file-sorter 的那一档(计入stats.protected)不同。把一份配置从一个 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:所以
._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 的两件事
prefer_keep_hints要不要支持 glob(见上「为什么没顺手升级」)。要做得先决定内置那 7 条怎么迁移。--config PATH(feat(skills): 可选 config.json 覆盖层 —— 目录白名单 + 扩展名改判(部分落实 #15) #47 也记在已知 gap 里)。要做的话得先定「与旁边那份是替换还是叠加」。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 行)extension_overrides校验(20 行)_cfg_die(10 行)whitelist_dirs系 / 3 个skip_dirs系_cfg_match(21 行)whitelist_dirs版 /skip_dirs版)八个 scanner 的键集:
变体边界与键集边界完全重合 —— 差异不是随机漂移,而是「两种键集」这一件事本身。
两两比较也印证:同族内部 117/122 行相同(差的就是
CONFIG_EXT_TABLES),跨族 94/122,dedup-finder 与任何一个是 63~82。
所以 #47 建议的那条 CI 断言现在可以怎么写
#47 提议的
grep -rl "CONFIG_EXT_TABLES"选择器会漏掉 dedup-finder(它没有这个常量),而且「全部逐字节相同」这个判据在八份两种键集的现实下必然为假。按上面的
测量,正确的形状是:
grep -rl "CONFIG_KEYS" skills --include='*.py'(八个都命中,带n >= 8的防真空下限);load_config前半段与extension_overrides校验要求「所有含该区域的份数逐字节相同」(当前分别是 8 与 7);
_cfg_die/_cfg_match/ 列表键校验要求「变体数 ≤ 2,且每个变体内部的函数体逐字节相同」;本 PR 的
check_block_identity.py已经是这个形状(它按 10 个区域报变体分组,并且自带
--self-test负控制),把FILES换成八个就能直接用。合并后 main 的健康度(顺手跑的)