# 变更日志 面向使用者的变更记录。更早的历史在文末「相对旧版修了什么」一节。 ## 2026-09 强化与重构 本次改造的每一处修复都有实测证据,不是"看起来更规范了"。 ### 修复 | 问题 | 现象 | 现在 | | --- | --- | --- | | 源文件是无 BOM 的 UTF-8 | **6/6 个文件在 Windows PowerShell 5.1 上连语法都过不去** —— 5.1 没有 BOM 就按 ANSI 解码源码,中文变乱码、全角问号吃掉引号 | 全仓改存 UTF-8 with BOM;100 个文件在 5.1 与 7 上解析零错 | | 带 `[CmdletBinding()]` 的脚本在 5.1 上,`param()` 默认值拿不到 `$PSScriptRoot` | `Backup.ps1` 在 5.1 上不传路径参数直接报错 —— 而计划任务恰恰不传 | 8 处默认值移到 `param()` 之后解析 | | 行首方向标记贴在目标上时不被识别(`+WindowsTerminal`) | 清单 28 条里 **27 条被当成"源不存在"静默跳过**,退出码仍是 0;只有不写标记的 `Scoop` 真被备份 | 两种写法都认;干跑从"重打 1 个"变成"重打 11 个" | | 暂存目录半途失败会留下 junction | `%TEMP%` 里留下**指向真实数据**的连接点,而临时目录迟早会被 `Remove-Item -Recurse` 扫到 | 建暂存的函数自己带 try/catch 自清理,不依赖调用方 | | 口令随 `-Verbose` 落进日志 | DEBUG 级打印整条命令行,而 7z 只接受命令行口令 | 打印前把 `-p` 参数换成占位符;新增断言 + 红绿证明 | | `manifest.json` 先删后移 | `Move-Item` 一失败,旧账本已经没了 | 复用原子写;目标存在时用 `File.Replace` | | 5.1 上拿不到原子替换 | 三参数 `File.Move` 是 .NET Core 3.0+ 才有的重载,5.1 上必然退化成"先删后移" | 改用 `File.Replace`(.NET Framework 上同样可用) | | 空目录的 `TotalSize` 是 `$null` | `$null / 1GB` 得 0,而空间守卫判的是 `-gt 0` —— **空间不足时不再拦截** | 补成整数 0 | | 计划任务与手动运行撞车 | 两边互踩 `manifest.json`,还会抢同一个 `<归档>.tmp` | 备份目录上加一把跨进程锁(独占文件句柄,进程崩溃自动释放) | | 口令文件的出厂默认值指向仓库内 | 一次 `git add -A` 就把口令提交进版本库 | 默认留空;口令只来自 `-Password` / 环境变量 / 仓库外的文件 / 交互询问 | | lab 的 ACL 场景在 5.1 上自爆 | 清理函数偏偏在它要处理的"悬空连接点"场景里被原生命令的 stderr 中断 | 原生命令统一走 `Invoke-NativeTolerant` | ### 结构与规范 | 变化 | 说明 | | --- | --- | | 模块源码拆分 | 3152 行、66 个函数的 `Common.psm1` → `BakNRet/{Public,Private}` 一函数一文件 + 薄加载器 + 显式导出白名单;`tools/Build-BakNRetModule.ps1` 可随时合回单文件 | | 函数命名 | 16 个公共函数补 `BakNRet` 前缀、全仓大小写统一。这不是审美:静态分析拓出 `Write-Log` 与本机某个已装模块**重名**,后果是导入两个模块时一方的命令被静默遮蔽 | | 静态分析 | 装上 PSScriptAnalyzer 并全仓重排:**706 → 71 条告警**。注意那 6 条格式规则默认是 Disabled —— 不带 `-Settings` 的调用会静默漏掉全部排版问题 | | 编码与行尾 | `.editorconfig`(含 `charset = utf-8-bom`)与 `.gitattributes` 把约定钉成可执行的文件 | | 验收入口 | `test.ps1` 是唯一入口(Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍);`tests/Run-RealSmoke.ps1` 拿**真实清单**跑只读冒烟 | | 术语与决策 | `CONTEXT.md` 术语表 + `docs/adr/` 8 条决策记录 | # 相对旧版修了什么(更早的历史) | 问题 | 旧行为 | 现行为 | | --- | --- | --- | | `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 | | 排除模式写成 `-x!"路径"` | 引号成为模式的一部分,**排除对所有条目都失效** | 不再嵌引号;含空格自动转 `?`,`!` 前缀走 `-xr!` | | 解析器用 `;` 分隔,清单里写的是 `,` | 整串被当成一个模式,等于没有排除 | `,` 与 `;` 都支持 | | `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,2.8 GB 归档成了孤儿 | 先按空白分词切出修饰符,再处理引号 | | 归档名由路径拼出 | 加一条备份要自己算名字,名字随路径变动 | 清单写软件名,归档名就是软件名 | | 一个软件里两个同名目录(例如两个 `persist`) | 静默混成一棵树,两边的数据都错 | 名录改成 **Slot 结构**,每个 Slot 是归档内的一层目录,同名不再冲突 | | 清单只能"备份 + 恢复"一把抓 | 想只备份的、只恢复的条目得另开文件 | 行首 `+` / `-` 直接标方向,两条路径共用一份清单 | | `::` 既是"排除"又是历史别名 | 语义含糊:`::` 一会儿是排除、一会儿是路径 | `::` 只表示**覆盖 Path**,排除一律写 `:-` | | 加密只能靠裸标记 `@encrypt` | 名录里的加密意图没法表达 | `:encrypt` / `:!encrypt` / `@ Encrypt='$false'`,名录的 Slot 也能写 `Encrypt` | | 排除/追加只能写在清单行里 | 名录里的 Slot 光有路径,规则全堆在清单里 | `Exclude` / `Include` / `Encrypt` 都可以写在 Slot 上,清单按需覆盖 | | `!` 只能按通配符匹配 | 想按正则排除做不到 | 新增 `!re:<正则>`(脚本遍历源目录翻译成精确排除项) | | 名录路径只支持 `%变量%` | `scoop prefix xxx` 这类动态路径写不出来 | `Path` 支持 `$( ... )` 子表达式,并在一次运行内缓存求值结果 | | 名录每解析一个条目就重新 Import 一次 | 同一个文件被反复读取、`$( ... )` 被反复执行 | 按内容指纹缓存,一次运行只读一次 | | 直接更新已有归档(7z `u`) | 固实归档下收益极小,且排除规则与"源里已删的文件"永远反映不到归档里 | 临时文件 → `7z t` 校验 → 原子替换 | | 没有校验、没有记录 | 中断留下的半个归档会被下次 `u` 续写;跳过/失败只有一行滚过去的 WARN | 校验 + 原子替换 + `manifest.json` + 日志文件 | | 结尾不 `exit` | 全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 | | 恢复用 `-Filter "$baseName.*"` | 含 `[` `]` 的路径会失配 | 精确比较 `BaseName`,且优先查 manifest | | tar 分支 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0` | `$LASTEXITCODE` 是上一条原生命令的残留值,恰为 0 时把解压失败吞掉 | 三条分支统一走同一个取退出码的封装 | | 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` | | `manifest.json` 的 `roots` | 记的是软件名,与归档里真实的顶层目录对不上(`Edge` vs `User Data`) | 记归档内真实的顶层条目名,并且和归档内容对账过 | | `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) | | 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报;`+` / `-` 的条目也算"有主" | | 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录 / 追加项) | | 软件名录的多目录写法 | 一个软件可以挂多个目录,但目录名不能重复,否则包内混成一棵树 | 改成 **Slot 结构**:每个 Slot 是包内一层目录,同名目录(两个 `persist`)不再冲突 | | 一个条目挂多个目录的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个归档项只解出**它自己那棵子树** | | 归档内路径冲突 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示改 Slot 名 / 归档内相对路径 | | 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 | | manifest 的 `archive` 字段 | 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 | | 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |