docs: 决策记录 8 条、变更日志、README 的验收与口令两节

0007 与 0008 是本次改造新产生的决策(原计划 6 条)。它们同样满足「后人会问为什么」:把运行锁改成互斥体、或给库里 27 个函数补上 SupportsShouldProcess,都是看起来更规范的错法。

CHANGELOG.md:把 README 里那 33 行「相对旧版修了什么」整节搬过去,并补上本次改造的记录(11 条修复 + 6 条结构与规范,每条修复都写明现象与现状)。README 那一节换成指针。

README 新增两节:「验收与静态分析」(六个层次、Run-RealSmoke 为什么独立存在、以及那 6 条格式规则默认 Disabled 这个容易漏掉的事实);「口令放在哪里」(默认留空不是遗漏,附迁移与验证命令)。

.markdownlint.json 照 PowerShell 主仓库的实践(default true、行长 240),只把 MD024 从关闭改成「仅同级不重复」,因为变更日志需要重复标题。顺带按 .editorconfig 把 .md 的 BOM 去掉。
This commit is contained in:
Shuery committed 2026-09-27 10:01:54 +08:00
1 parent 42f02d0eca
commit 3a3a57a6a1
12 files changed
+282 -33

No files matched your search

+70 -32
View File
@@ -54,13 +54,20 @@
| `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要处理什么"来源 |
| `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 |
| `Backup.ps1` / `Restore.ps1` | 备份 / 恢复入口 |
| `Common.psm1` | 公共模块(日志、外部命令、清单与名录解析、归档布局、暂存、manifest) |
| `BakNRet/` | 模块:`BakNRet.psd1`(清单,`FunctionsToExport` 是显式白名单)+ `BakNRet.psm1`(薄加载器,点源顺序只在这里出现一次)+ `Public/`(62 个对外函数,一函数一文件)+ `Private/`(内部函数与模块级状态) |
| `test.ps1` | **唯一验收入口**:Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍 |
| `PSScriptAnalyzerSettings.psd1` | 静态分析配置(三条有意排除与 160 字符行长,理由见 `docs/adr/0008`) |
| `CONTEXT.md` | 术语表(本项目里每个概念只有一个叫法) |
| `CHANGELOG.md` | 变更日志 |
| `docs/adr/` | 决策记录(8 条:为什么这么设计、拒绝了什么) |
| `tools/Build-BakNRetModule.ps1` | 把模块的多个源文件按加载器顺序合回单文件(发布形态、代码签名时需要) |
| `tools/Invoke-Analyzer.ps1` | 静态分析门禁(默认规则 + 格式规则) |
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
| `logs/` | 每次运行的日志(已 gitignore) |
| `tests/` | 测试:Pester 套件(`*.Tests.ps1`)、零依赖套件、端到端验收、真实归档恢复演练 |
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
| `tools/Rename-Archives.ps1` | 把归档名对齐到当前清单规则(默认试运行) |
| `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) |
| `tools/Install-TestDependencies.ps1` | 把 Pester 与 PSScriptAnalyzer 装到仓库内的 `.tools/`(不动机器上的全局模块) |
## SoftwareCatalog.psd1 —— 软件名 → Slot 组
@@ -488,6 +495,30 @@ $env:BAKNRET_PASSWORD = '...' # 或
> ⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。
## 口令放在哪里
**口令文件必须放在仓库之外。** `BackupConfig.psd1` 的 `PasswordFile` 默认留空,这不是遗漏:
出厂默认值指向仓库里的某个文件,等于鼓励把口令提交进版本库。`.gitignore` 里排除 `*.key` 只是
第二道防线。三种给它口令的方式:
```
powershell
# A. 环境变量(计划任务用这个最省事)
$env:BAKNRET_PASSWORD = '...'
# B. 配置文件里指到一个仓库外的文件
Encryption = @{ Enabled = $false; PasswordFile = (Join-Path $env:USERPROFILE '.baknret.key'); EncryptHeaders = $true }
# C. 单次指定
.\Backup.ps1 -KeyFile (Join-Path $env:USERPROFILE '.baknret.key')
```
取不到口令时,加密条目**明确失败**,绝不退化成明文归档 —— 这条行为没有放宽。
> 从旧版本迁移的人注意:如果口令文件原先就放在仓库根(`baknret.key`),请把它移到仓库之外,
> 再按上面任一种方式指过去。搬之前可以先用 `pwsh -File .\Restore.ps1 -VerifyOnly -Only "WindowsTerminal" -KeyFile <新路径>`
> 验一下口令对不对(那份归档是加密的,口令不对会报错)。
## 计划任务
```powershell
@@ -532,37 +563,44 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore
## 相对旧版修了什么
| 问题 | 旧行为 | 现行为 |
见 [CHANGELOG.md](./CHANGELOG.md)。本次改造(2026-09)的修复、结构变化与规范落地都在那里。
## 验收与静态分析
一条命令跑完全部验收层次,**两个 PowerShell 版本各跑一遍**:
```
powershell
.\test.ps1 # Encode + Parse + Unit + Smoke + E2E
.\test.ps1 -Suite Parse # 只跑一层
.\test.ps1 -Suite E2E -PSVersion 5.1
.\tests\Run-RealSmoke.ps1 # 拿真实清单与真实归档做只读冒烟
```
| 层次 | 内容 | 依赖 |
| --- | --- | --- |
| `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 仓库 | — | 都有 |
| Encode | 受管文件的 BOM / 行尾 / 制表符 —— 丢了 BOM 只在 5.1 上出错,所以这条必须单独检查 | 无 |
| Parse | 全仓 `.ps1` / `.psm1` / `.psd1` 在 5.1 与 7 上解析零错 | 无 |
| Unit | Pester 套件(单元面) | Pester 5+ |
| Smoke | 零依赖套件(关键冒烟) | PowerShell + 7z |
| E2E | 真的调用 7z 打包 → 删源 → 恢复 → 逐字节对拍 | 7z |
| Drill | 真实归档恢复演练(只读,要显式点名) | 本机真实归档 |
`Run-RealSmoke.ps1` 刻意独立于上面几层:其它套件都在临时目录里自造夹具,跑得快、可重复;
它专门跑真实清单,用来挡住"夹具全绿、真实数据全废"。它检查四件事:方向标记全部被剥掉、
没有软件名退化成"名录里没有"、两个只读模式退出 0、`manifest.json` 的 SHA256 前后不变。
静态分析是**独立门禁**,不塞进上面几层(套件跑一次二十多秒,混进去会让"测试红了"这句话
失去分辨力):
```
powershell
.\tools\Invoke-Analyzer.ps1 # 默认规则 + 格式规则
.\tools\Invoke-Analyzer.ps1 -Quiet # 只看按规则汇总
```
那 6 条格式规则(括号、缩进、空格、对齐、大小写)在 PSScriptAnalyzer 里**默认是 Disabled** ——
不带 `-Settings` 的 `Invoke-ScriptAnalyzer -Severity Warning,Error` 会静默漏掉全部排版问题。
三条有意排除与 160 字符行长上限的理由见 [docs/adr/0008](./docs/adr/0008-analyzer-deviations.md)。
## 设计取舍(有意为之,不是遗漏)