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 层。这条要改行为、且影响真实名录的解析
    结果,留给你拍板,不在本提交里动手。
This commit is contained in:
Shuery committed 2026-09-27 09:46:08 +08:00
1 parent 102a3e038d
commit 187d2759fd
60 files changed
+769 -377

No files matched your search

+11 -9
View File
@@ -34,7 +34,8 @@ function New-TextFile {
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
if ($Count -le 1) {
Set-Content -LiteralPath $Path -Value $Content -Encoding UTF8
} else {
}
else {
Set-Content -LiteralPath $Path -Value (1..$Count | ForEach-Object { "$Content #$_" }) -Encoding UTF8
}
}
@@ -73,7 +74,7 @@ New-TextFile (Join-Path $appC '数据 备份\记录.txt') '记录内容' -Count
# --- 4. 真 NTFS 连接点 -------------------------------------------------------
$realTarget = Join-Path $Root 'AppMultiSlot\Data'
$junction = Join-Path $Root 'JunctionToData'
$junction = Join-Path $Root 'JunctionToData'
if (-not (Test-Path -LiteralPath $junction)) {
$null = New-Item -ItemType Junction -Path $junction -Target $realTarget -ErrorAction SilentlyContinue
}
@@ -86,21 +87,22 @@ New-TextFile (Join-Path $cursor 'bottom.txt') 'deep content'
Write-Host ("最长路径长度:{0} 字符" -f (Join-Path $cursor 'bottom.txt').Length)
# --- 6. 50 MB 大文件 ---------------------------------------------------------
$bigDir = Join-Path $Root 'AppBig'
$bigDir = Join-Path $Root 'AppBig'
$bigFile = Join-Path $bigDir 'blob-50mb.bin'
if (-not (Test-Path -LiteralPath $bigFile)) {
New-Item -ItemType Directory -Force -Path $bigDir | Out-Null
$fs = [IO.File]::Create($bigFile)
try {
$rng = [Random]::new(20260926)
$rng = [Random]::new(20260926)
$chunk = [byte[]]::new(1MB)
for ($i = 0; $i -lt 50; $i++) { $rng.NextBytes($chunk); $fs.Write($chunk, 0, $chunk.Length) }
} finally { $fs.Dispose() }
}
finally { $fs.Dispose() }
}
Write-Host ("大文件:{0} MB" -f [math]::Round((Get-Item $bigFile).Length/1MB,1))
Write-Host ("大文件:{0} MB" -f [math]::Round((Get-Item $bigFile).Length / 1MB, 1))
# --- 7. 被占用文件(后台进程持句柄 90 秒后释放)-----------------------------
$lockDir = Join-Path $Root 'AppLocked'
$lockDir = Join-Path $Root 'AppLocked'
$lockFile = Join-Path $lockDir 'locked.bin'
New-Item -ItemType Directory -Force -Path $lockDir | Out-Null
New-TextFile $lockFile 'this file is held open by another process'
@@ -111,7 +113,7 @@ $holderLines = @(
)
$holderPath = 'C:\BakNRet-Lab\state\hold-lock.ps1'
Set-Content -LiteralPath $holderPath -Value $holderLines -Encoding UTF8
Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$holderPath,$lockFile) -WindowStyle Hidden
Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $holderPath, $lockFile) -WindowStyle Hidden
Write-Host "已启动占用者进程(持句柄 90 秒):$lockFile"
# --- 8. 「源不存在」条目对应的目录:故意不建 ---------------------------------
@@ -121,6 +123,6 @@ Write-Host ''
Write-Host '--- 沙盒源清单 ---'
Get-ChildItem -LiteralPath $Root -Force | ForEach-Object {
$files = @(Get-ChildItem -LiteralPath $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue)
$mb = [math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 2)
$mb = [math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 2)
" {0,-24} {1,4} 个文件 {2,8} MB 连接点={3}" -f $_.Name, $files.Count, $mb, [bool]($_.Attributes -band [IO.FileAttributes]::ReparsePoint)
}
+11 -11
View File
@@ -19,10 +19,10 @@
#>
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
$lab = 'C:\BakNRet-Lab'
$logDir = Join-Path $lab 'logs'
$lab = 'C:\BakNRet-Lab'
$logDir = Join-Path $lab 'logs'
$stateDir = Join-Path $lab 'state'
New-Item -ItemType Directory -Force -Path $logDir, $stateDir | Out-Null
@@ -40,24 +40,24 @@ try {
Set-ExecutionPolicy -Scope LocalMachine -ExecutionPolicy Bypass -Force
Step '3/7 工具上机器 PATH:7-Zip 与 PowerShell 7'
$zipSrc = Join-Path $lab 'payload\7zip'
$zipDst = 'C:\Program Files\7-Zip'
$zipSrc = Join-Path $lab 'payload\7zip'
$zipDst = 'C:\Program Files\7-Zip'
$pwshSrc = Join-Path $lab 'payload\pwsh'
$pwshDst = 'C:\Program Files\PowerShell\7'
if (-not (Test-Path $zipDst)) { robocopy $zipSrc $zipDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
if (-not (Test-Path $zipDst)) { robocopy $zipSrc $zipDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
if (-not (Test-Path $pwshDst)) { robocopy $pwshSrc $pwshDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
$machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
foreach ($p in @($zipDst, $pwshDst)) {
if ($machinePath -notlike "*$p*") { $machinePath = $machinePath.TrimEnd(';') + ';' + $p }
if ($env:Path -notlike "*$p*") { $env:Path = $env:Path.TrimEnd(';') + ';' + $p }
if ($env:Path -notlike "*$p*") { $env:Path = $env:Path.TrimEnd(';') + ';' + $p }
}
[Environment]::SetEnvironmentVariable('Path', $machinePath, 'Machine')
Step '4/7 安装 Pester 5.9.1(Windows PowerShell 与 pwsh 各一份)'
$pesterSrc = Join-Path $lab 'payload\Pester\5.9.1'
foreach ($dst in @("$env:ProgramFiles\WindowsPowerShell\Modules\Pester\5.9.1",
"$env:ProgramFiles\PowerShell\Modules\Pester\5.9.1")) {
"$env:ProgramFiles\PowerShell\Modules\Pester\5.9.1")) {
if (-not (Test-Path $dst)) { robocopy $pesterSrc $dst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
}
@@ -75,7 +75,7 @@ try {
New-ItemProperty -Path $scoobe -Name 'ScoobeSystemSettingEnabled' -Value 0 -PropertyType DWord -Force | Out-Null
Step '7/7 采集真机事实并落盘'
$zipExe = Join-Path $zipDst '7z.exe'
$zipExe = Join-Path $zipDst '7z.exe'
$pwshExe = Join-Path $pwshDst 'pwsh.exe'
$pwshVer = '缺失'
if (Test-Path $pwshExe) { $pwshVer = (& $pwshExe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>&1) -join ' ' }
@@ -100,8 +100,8 @@ try {
CpuCount = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
RamGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 1)
Drives = @(Get-Volume | Where-Object DriveLetter | ForEach-Object {
[ordered]@{ Letter = "$($_.DriveLetter):"; Fs = $_.FileSystemType; SizeGB = [math]::Round($_.Size / 1GB, 1) }
})
[ordered]@{ Letter = "$($_.DriveLetter):"; Fs = $_.FileSystemType; SizeGB = [math]::Round($_.Size / 1GB, 1) }
})
}
$facts | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $stateDir 'provisioned.json') -Encoding UTF8
$facts.GetEnumerator() | ForEach-Object { Write-Host (" {0,-16} {1}" -f $_.Key, $_.Value) }
+31 -19
View File
@@ -66,7 +66,8 @@ function Invoke-NativeTolerant {
$ErrorActionPreference = 'Continue'
try {
return @(& $FilePath @ArgumentList 2>&1)
} finally {
}
finally {
$ErrorActionPreference = $previous
}
}
@@ -75,7 +76,8 @@ function Test-Scenario {
if ($Ok) {
$script:Passed++
Write-Host (" [PASS] {0}" -f $Name) -ForegroundColor Green
} else {
}
else {
$script:Failures += $Name
Write-Host (" [FAIL] {0}{1}" -f $Name, $(if ($Detail) { ' —— ' + $Detail } else { '' })) -ForegroundColor Red
}
@@ -94,8 +96,8 @@ function Get-SecurityFingerprint {
$acl = Get-Acl -LiteralPath $Path
$sid = [System.Security.Principal.SecurityIdentifier]
$aces = @($acl.GetAccessRules($true, $true, $sid) |
ForEach-Object { '{0}|{1}|{2}' -f $_.AccessControlType, $_.IdentityReference.Value, [int]$_.FileSystemRights } |
Sort-Object)
ForEach-Object { '{0}|{1}|{2}' -f $_.AccessControlType, $_.IdentityReference.Value, [int]$_.FileSystemRights } |
Sort-Object)
return ('O={0} G={1} P={2} [{3}]' -f $acl.GetOwner($sid).Value, $acl.GetGroup($sid).Value, $acl.AreAccessRulesProtected, ($aces -join ' '))
}
@@ -156,7 +158,8 @@ function Stop-VscodeProcesses {
if ($path -and $path.StartsWith($AppRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
} catch { }
}
catch { }
}
}
Start-Sleep -Milliseconds 700
@@ -184,7 +187,7 @@ function Remove-TreeHard {
if (-not (Test-Path -LiteralPath $Path)) { return }
$links = @(Get-ChildItem -LiteralPath $Path -Recurse -Force -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
foreach ($link in $links) {
$null = Invoke-NativeTolerant -FilePath 'cmd.exe' -ArgumentList @('/c', ('rmdir "{0}"' -f $link.FullName))
Remove-BaknretJunction -Path $link.FullName
@@ -207,7 +210,8 @@ function Remove-TreeHard {
if (Test-Path -LiteralPath $WorkRoot) {
Get-ChildItem -LiteralPath $WorkRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object { Remove-TreeHard -Path $_.FullName }
} else {
}
else {
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
}
$BackupDir = Join-Path $WorkRoot 'backups'
@@ -242,10 +246,12 @@ if (-not $SkipScoop) {
try {
Invoke-Expression "& {$(Invoke-RestMethod -Uri 'https://get.scoop.sh')} -RunAsAdmin"
Write-Host ('[A] 安装器退出码:{0}' -f $LASTEXITCODE)
} catch {
}
catch {
Test-Scenario 'A: 安装 scoop' $false $_.Exception.Message
}
} else {
}
else {
Write-Host '[A] scoop 已存在,跳过安装'
}
@@ -299,9 +305,11 @@ $vscodeReady = [bool]$codeCmd
if ($vscodeReady) {
Test-Scenario 'A: vscode 已安装(找到 CLI)' $true $codeCmd
} elseif ($SkipScoop) {
}
elseif ($SkipScoop) {
Write-Host (' [SKIP] 按 -SkipScoop 跳过 vscode({0} 不存在)' -f $vscodeCli) -ForegroundColor Yellow
} else {
}
else {
Test-Scenario 'A: vscode 已安装(找到 CLI)' $false $vscodeCli
}
@@ -357,7 +365,7 @@ $sourceOwner = (Get-Acl -LiteralPath $bData).GetOwner([System.Security.Principal
Write-Host ('[B] 备份前 data 的属主:{0}' -f (Get-Acl -LiteralPath $bData).Owner)
$currentSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value
Test-Scenario 'B: 现场造对了 —— 属主既不是当前账户、也不是新建对象的默认属主' `
(($sourceOwner -ne $currentSid) -and ($sourceOwner -ne $creatorOwner)) `
(($sourceOwner -ne $currentSid) -and ($sourceOwner -ne $creatorOwner)) `
"owner=$sourceOwner current=$currentSid creatorDefault=$creatorOwner"
# ---------------------------------------------------------------------------
@@ -398,7 +406,7 @@ $backup = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Backup.ps1') -Parame
$backup.LastLog | ForEach-Object { ' ' + $_ }
Test-Scenario '备份退出码 0' ($backup.ExitCode -eq 0) ('exit=' + $backup.ExitCode)
Test-Scenario '每个条目都写了 .acl.json' (@(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count -ge $entries.Count) `
('acl.json=' + @(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count + ' 条目=' + $entries.Count)
('acl.json=' + @(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count + ' 条目=' + $entries.Count)
# ---------------------------------------------------------------------------
# 删源 → 恢复
@@ -445,7 +453,8 @@ if ($vscodeReady) {
[System.IO.File]::WriteAllText($probeFile, 'write probe')
$writeOk = (Test-Path -LiteralPath $probeFile)
Remove-Item -LiteralPath $probeFile -Force -ErrorAction SilentlyContinue
} catch {
}
catch {
$detail = $_.Exception.Message
}
Test-Scenario 'A: vscode 的数据目录可写(新建文件成功)' $writeOk $detail
@@ -455,9 +464,10 @@ if ($vscodeReady) {
$expectedNormalized = $expected[$pair[1]] -replace ' P=(True|False) ', ' P='
$actualNormalized = (Get-SecurityFingerprint -Path $pair[0]) -replace ' P=(True|False) ', ' P='
Test-Scenario ("A: {0} 的安全指纹与备份前一致" -f $pair[1]) ($actualNormalized -eq $expectedNormalized) `
("want: " + $expectedNormalized + " / got: " + $actualNormalized)
("want: " + $expectedNormalized + " / got: " + $actualNormalized)
}
} else {
}
else {
Write-Host ' [SKIP] vscode 没装上,A 段的功能断言跳过(-SkipScoop 或下载失败)' -ForegroundColor Yellow
}
@@ -488,7 +498,7 @@ $negative = Join-Path $WorkRoot 'negative-data'
& robocopy.exe $bData $negative /E /COPY:DAT /NFL /NDL /NJH /NJS /NP | Out-Null
$negativeOwner = (Get-Acl -LiteralPath $negative).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
Test-Scenario 'B 负对照: 只搬文件时,属主变成"跑脚本的账户"而不再是原账户' `
(($negativeOwner -ne $sourceOwner) -and ($negativeOwner -eq $creatorOwner)) `
(($negativeOwner -ne $sourceOwner) -and ($negativeOwner -eq $creatorOwner)) `
"negative=$negativeOwner creatorDefault=$creatorOwner"
Write-Host (' 原属主 = {0}' -f $sourceOwner)
Write-Host (' 恢复后属主 = {0}(应与原属主相同)' -f $restoredOwner)
@@ -501,14 +511,16 @@ Write-Host ''
$total = $script:Passed + $script:Failures.Count
if ($script:Failures.Count -eq 0) {
Write-Host ('ACL 演练:全部通过 {0} 项' -f $total) -ForegroundColor Green
} else {
}
else {
Write-Host ('ACL 演练:通过 {0} 项,失败 {1} 项' -f $script:Passed, $script:Failures.Count) -ForegroundColor Red
foreach ($failure in $script:Failures) { Write-Host (' - ' + $failure) -ForegroundColor Red }
}
if ($KeepWorkRoot) {
Write-Host ('临时目录保留:{0}' -f $WorkRoot) -ForegroundColor Yellow
} else {
}
else {
Remove-TreeHard -Path $bRoot
Remove-TreeHard -Path (Join-Path $WorkRoot 'negative-data')
# 备份与日志留着,便于事后核对(归档可能有几百 MB,要腾空间就手工删这个目录)
+2 -2
View File
@@ -38,8 +38,8 @@ $drillParams = [ordered]@{
ConfigPath = $ConfigPath
}
if ($entries.Count -gt 0) { $drillParams['Entries'] = $entries }
if ($KeepWorkRoot) { $drillParams['KeepWorkRoot'] = $true }
if ($AllowChanged) { $drillParams['AllowChanged'] = $true }
if ($KeepWorkRoot) { $drillParams['KeepWorkRoot'] = $true }
if ($AllowChanged) { $drillParams['AllowChanged'] = $true }
Write-Host ("[lab] 恢复演练:{0} 个条目 -> {1}" -f $entries.Count, ($entries -join ' | '))
& 'C:\BakNRet\tests\Restore-Drill.ps1' @drillParams
+2 -2
View File
@@ -29,8 +29,8 @@ param(
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
Write-Host ("[lab] 包装器:PS {0},[Console]::OutputEncoding = {1}" -f $PSVersionTable.PSVersion, [Console]::OutputEncoding.WebName)
Write-Host ("[lab] 套件:{0} {1}" -f $Suite, ($SuiteArgs -join ' '))
+1 -1
View File
@@ -21,5 +21,5 @@
ToolOutput = 'quiet'
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
Encryption = @{ Enabled = $false; PasswordFile = 'C:\BakNRet-Lab\state\baknret.key'; EncryptHeaders = $true }
DefaultExcludes = @('!Thumbs.db','!desktop.ini')
DefaultExcludes = @('!Thumbs.db', '!desktop.ini')
}
@@ -5,18 +5,18 @@
带连字符的键必须加引号(PSD1 会把它当减法);这里用中文键名,也统一加引号。
#>
@{
AppMultiSlot = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Data'; Description = '主数据(含 node_modules、session.log、空目录)' }
AppMultiSlot = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Data'; Description = '主数据(含 node_modules、session.log、空目录)' }
DefaultConfig = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Config'; Description = '配置(含 .bak)' }
CacheSlot = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Cache'; Description = '缓存(清单里再排除一条 cache-01.tmp)' }
CacheSlot = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Cache'; Description = '缓存(清单里再排除一条 cache-01.tmp)' }
}
AppFileSlot = @{
AppFileSlot = @{
Profile = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\profile.json'; Description = '单文件 Slot:归档内是名为 Profile 的文件' }
Readme = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\readme.txt'; Description = '另一个单文件 Slot' }
Readme = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\readme.txt'; Description = '另一个单文件 Slot' }
}
'软件目录甲' = @{
'软件目录甲' = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\软件 目录.甲'; Description = '中文 + 空格 + 点的路径' }
}
@@ -24,7 +24,7 @@
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\JunctionToData'; Description = '真 NTFS 连接点(指向 AppMultiSlot\Data)' }
}
MissingApp = @{
MissingApp = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\MissingApp'; Description = '源不存在:备份跳过并记 missing-source,恢复仍知道目标位置' }
}