三件事:
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 层。这条要改行为、且影响真实名录的解析
结果,留给你拍板,不在本提交里动手。
232 lines
12 KiB
PowerShell
232 lines
12 KiB
PowerShell
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
|
||
}
|
||
}
|
||
}
|