Files
BakNRet/BakNRet/Public/ConvertFrom-BackupListLine.ps1
T
Shuery 187d2759fd style: 按微软规范落地静态分析,并全仓机械重排
三件事:

1) tools\Install-TestDependencies.ps1 现在也把 PSScriptAnalyzer 装进仓库内的 .tools\modules
(不动机器上的全局模块,与 Pester 同一策略)。

2) PSScriptAnalyzerSettings.psd1:这是必要的,不是装饰 —— 那 6 条格式规则
(括号、缩进、空格、对齐、大小写)默认全是 Disabled,所以不带 -Settings 的
`Invoke-ScriptAnalyzer -Severity Warning,Error` 会**静默漏掉全部排版问题**。本文件用 Rules
把它们打开(而不是用 IncludeRules 换一套),于是默认规则与格式规则同时生效。
三条有意的排除都写明了理由:PSAvoidUsingWriteHost(彩色控制台输出是这份工具的刻意设计)、
PSUseShouldProcessForStateChangingFunctions(WhatIf 的边界在入口脚本,给库里 27 个改状态的
函数都加上反而会"静默跳过",备份看着成功却什么都没做)、PSAvoidUsingPlainTextForPassword
(7z 只接受命令行口令,这是 7z 的限制,README 里写明了取舍)。

3) tools\Invoke-Analyzer.ps1:独立门禁(不塞进 Pester 用例 —— 套件跑一次二十多秒,
混进去会让"测试红了"这句话失去分辨力),路径过滤与验收门槛的 Encode/Parse 两层一致。

全仓重排结果:706 条告警 -> 67 条。修掉的 639 条全部是格式(闭括号 168、空格 80、
对齐 68、缩进 60、行长 229)。重排后 9/9 验收全绿、100 个文件两版解析零错、
276 个断言原样通过 —— 机械重排没有改变任何可观察行为。

如实说明两件事:

  * 行长上限设成 160,**不是**官方默认的 120。120 在本仓库意味着 270 处改动(主要是
    中文注释与测试夹具里的一行式目录),160 意味着 41 处。160 仍是"宽但可读",而理由是写在
    配置文件里的:这不是悄悄放宽,想收紧到 120 时那份清单就在分析器输出里。

  * 剩余 67 条里,41 条是上面那批行长,其余 26 条是分析器找出的真问题(未使用参数 6、
    空 catch 6、MD5 指纹 1、覆盖内置命令 1、switch 默认值 1 等)。其中
    Find-ChildDirectoryByName 的 MaxDepth 参数从未被使用 —— 也就是配置里的
    CatalogMaxDepth = 5 是假的,前缀补全实际只查 1 层。这条要改行为、且影响真实名录的解析
    结果,留给你拍板,不在本提交里动手。
2026-09-27 09:46:08 +08:00

232 lines
12 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function ConvertFrom-BackupListLine {
<#
.SYNOPSIS
解析 BackupList.txt 的一行。
.DESCRIPTION
返回 $null 表示注释 / 空行。正常返回包含:
Direction —— 'both' | 'backup'(行首 +,仅备份)| 'restore'(行首 -,仅恢复)
Path —— 目标原文(软件名或字面路径),**归档命名以它为准**
IsName —— 是否按软件名去名录里查
Overrides —— 显式给出的覆盖字段(hashtable,用 ContainsKey 判断有没有写)
Path / Exclude / Include / Encrypt
ExcludePatterns / Includes —— Overrides 的便捷视图(没写时是空数组)
Flags —— 兼容的历史标记(pathname / root=<名>)
Comment —— 行尾 `# 说明`
Raw —— 原始行
与旧实现的区别:
* `::` 现在表示"覆盖 Path"(旧版是 `:-` 的历史别名),排除一律写 `:-`;
* 新增行首 `+` / `-` 方向、`:encrypt` / `:!encrypt`、`@ Key='Value'` 覆盖;
* 修饰符必须是独立记号(前后加空格),所以 `C:\a:-b` 仍然是路径;
* 行首方向标记是唯一例外:`+` / `-` 贴在目标上(`+Edge`)或独立成记号
(`+ Edge`)都认。详见下面判定处的注释。
#>
param([Parameter(ValueFromPipeline = $true)][AllowEmptyString()][string]$Line)
process {
$content = ([string]$Line).Trim()
if ([string]::IsNullOrEmpty($content) -or $content.StartsWith('#')) {
return $null
}
# 行内注释:`#` 前面有空白时,它后面整段是"这条为什么这么配"的说明。
# 解析时摘出来单独放在 Comment 里,运行时打印,让人一眼看懂排除/追加的理由。
# (路径里的 `#` 必须紧贴前一个字符,所以 `C:\a#b` 不会受影响。)
$comment = $null
$commentIndex = $content.IndexOf(' #')
if ($commentIndex -ge 0) {
$comment = $content.Substring($commentIndex + 1).Trim().TrimStart('#').Trim()
$content = $content.Substring(0, $commentIndex).Trim()
if ([string]::IsNullOrEmpty($content)) { return $null }
}
$tokens = @(Split-BaknretToken -Text $content)
if ($tokens.Count -eq 0) { return $null }
# 整行被一对引号包住是**历史写法**(`"C:\a b\CodeSpace :: X\"`)。
# 现在修饰符必须是独立记号,所以引号里的 `::` / `:-` 不再是修饰符。
# 这里刻意**不**替用户重新切分:老写法里的 `::` 当年是"排除",现在 `::` 是
# "覆盖 Path"——猜着切会把排除表当成新的源路径,比报错更糟。只告警。
if ($tokens.Count -eq 1) {
$raw = $tokens[0]
if ($raw.Length -ge 2) {
$first = $raw[0]
$last = $raw[$raw.Length - 1]
if ($first -eq $last -and ($first -eq '"' -or $first -eq "'")) {
$inner = $raw.Substring(1, $raw.Length - 2)
foreach ($innerToken in @(Split-BaknretToken -Text $inner)) {
if (Test-BaknretMarker -Token $innerToken) {
Write-Log "整行被引号包住,引号里的修饰符不会被识别(历史写法)。请去掉外层引号,并注意现在 `:-` 才是排除、`::` 是覆盖 Path:$Line" -Level WARN
break
}
}
}
}
}
# 行首方向标记:`+` 仅备份、`-` 仅恢复。
#
# 两种写法都认:独立成记号(`+ Edge`)与贴在目标上(`+Edge`)。后者是本仓库清单
# 里的主流写法,而过去只认前者 —— 于是 `+WindowsTerminal` 被当成一个名叫
# `+WindowsTerminal` 的软件名,名录里查不到就退回当目录名,目录又不存在,
# 整条静默记成 missing-source 跳过;备份按"跳过不算失败"退出 0,所以一直没暴露。
#
# 只放宽"行首"这一个位置:修饰符(:: / :- / :+ / @)仍然必须是独立记号,
# 否则 `C:\a:-b` 这类路径会被切坏 —— 那是另一条已经钉住的行为。
$direction = 'both'
if ($tokens[0] -eq '+') {
$direction = 'backup'
$tokens = @($tokens | Select-Object -Skip 1)
}
elseif ($tokens[0] -eq '-') {
$direction = 'restore'
$tokens = @($tokens | Select-Object -Skip 1)
}
elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '+') {
$direction = 'backup'
$tokens[0] = $tokens[0].Substring(1)
}
elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '-') {
$direction = 'restore'
$tokens[0] = $tokens[0].Substring(1)
}
if ($tokens.Count -eq 0) { return $null }
# 第一个修饰符之前是目标。目标可以带空格(比如带引号的 "C:\Program Files\App"),
# 所以这里取"第一个修饰符记号之前的全部记号",而不是只取第一个记号。
$firstMarker = -1
for ($index = 0; $index -lt $tokens.Count; $index++) {
if (Test-BaknretMarker -Token $tokens[$index]) { $firstMarker = $index; break }
}
if ($firstMarker -eq 0) {
Write-Log "清单行缺少目标,已忽略:$Line" -Level WARN
return $null
}
if ($firstMarker -lt 0) {
$targetText = ($tokens -join ' ')
$markerTokens = @()
}
else {
$targetText = (($tokens[0..($firstMarker - 1)]) -join ' ')
$markerTokens = @($tokens[$firstMarker..($tokens.Count - 1)])
}
$target = Remove-BaknretQuote -Text $targetText
if ([string]::IsNullOrWhiteSpace($target)) { return $null }
$overrides = @{}
$flags = @()
$unknownKeys = @()
$index = 0
while ($index -lt $markerTokens.Count) {
$kind = Test-BaknretMarker -Token $markerTokens[$index]
$inline = $null
if ($kind -eq 'at') { $inline = $markerTokens[$index].Substring(1) }
$index++
$values = @()
if (-not [string]::IsNullOrWhiteSpace($inline)) { $values += $inline }
while ($index -lt $markerTokens.Count -and -not (Test-BaknretMarker -Token $markerTokens[$index])) {
$values += $markerTokens[$index]
$index++
}
switch ($kind) {
'path' {
$value = Remove-BaknretQuote -Text ($values -join ' ')
if (-not [string]::IsNullOrWhiteSpace($value)) {
if ($overrides.ContainsKey('Path')) {
Write-Log "同一条目里给了多次路径覆盖,用最后一个:$Line" -Level WARN
}
$overrides['Path'] = $value
}
}
# 同类记号可以出现多次(`Foo :- a :- b`),**累积**而不是后者覆盖前者:
# 静默丢掉前一条排除规则正是这个工具最不该犯的错。
'exclude' {
$parsed = @(ConvertFrom-BaknretPatternList -Values $values)
if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed }
else { $overrides['Exclude'] = $parsed }
}
'include' {
$parsed = @(ConvertFrom-BaknretPatternList -Values $values)
if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed }
else { $overrides['Include'] = $parsed }
}
'encrypt' {
if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN }
$overrides['Encrypt'] = $true
}
'noencrypt' {
if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN }
$overrides['Encrypt'] = $false
}
'at' {
$text = Remove-BaknretQuote -Text ($values -join ' ')
if ([string]::IsNullOrWhiteSpace($text)) { continue }
$equals = $text.IndexOf('=')
if ($equals -lt 0) {
# 兼容历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=名`
foreach ($legacy in @(ConvertFrom-BaknretPatternList -Values @($text))) {
$name = $legacy.Trim().TrimStart('@')
if ($name -ieq 'encrypt') { $overrides['Encrypt'] = $true }
elseif ($name -ieq '!encrypt') { $overrides['Encrypt'] = $false }
elseif ($name) { $flags += $name }
}
continue
}
$key = $text.Substring(0, $equals).Trim()
$value = Remove-BaknretQuote -Text $text.Substring($equals + 1)
switch -Regex ($key) {
'(?i)^path$' { $overrides['Path'] = $value }
'(?i)^exclude$' {
$parsed = @(ConvertFrom-BaknretPatternList -Values @($value))
if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed }
else { $overrides['Exclude'] = $parsed }
}
'(?i)^include$' {
$parsed = @(ConvertFrom-BaknretPatternList -Values @($value))
if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed }
else { $overrides['Include'] = $parsed }
}
'(?i)^encrypt$' { $overrides['Encrypt'] = [bool]($value -match '(?i)^(\$?true|1|yes|on)$') }
'(?i)^root$' { $flags += "root=$value" }
default { $unknownKeys += $key }
}
}
}
}
foreach ($unknown in $unknownKeys) {
Write-Log "清单里的 @ 字段 '$unknown' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:$Line" -Level WARN
}
$resolvedExclude = @()
if ($overrides.ContainsKey('Exclude')) { $resolvedExclude = @($overrides['Exclude']) }
$resolvedInclude = @()
if ($overrides.ContainsKey('Include')) { $resolvedInclude = @($overrides['Include']) }
return [pscustomobject]@{
Direction = $direction
Path = $target
# 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径;
# 带分隔符或 %变量% 的写法按字面路径处理。
IsName = (-not (Test-LiteralPath -Path $target))
Overrides = $overrides
ExcludePatterns = $resolvedExclude
Includes = $resolvedInclude
Flags = @($flags)
UnknownKeys = @($unknownKeys)
Comment = $comment
Raw = $Line
}
}
}