windowsweep 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI-INTEGRATION-GUIDE.md +15 -11
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -21
- package/README.md +70 -42
- package/VERSION +1 -1
- package/lib/actions.ps1 +212 -212
- package/lib/config.ps1 +108 -99
- package/lib/constants.ps1 +103 -93
- package/lib/fs.ps1 +189 -180
- package/lib/log.ps1 +140 -140
- package/lib/safety.ps1 +541 -493
- package/lib/scan.ps1 +109 -100
- package/lib/ui.ps1 +275 -275
- package/modules/android_avd.ps1 +36 -36
- package/modules/app_caches.ps1 +58 -58
- package/modules/browsers.ps1 +33 -33
- package/modules/build_tools.ps1 +35 -35
- package/modules/crash_trap.ps1 +89 -89
- package/modules/disk_usage.ps1 +49 -49
- package/modules/docker.ps1 +93 -93
- package/modules/editors.ps1 +150 -150
- package/modules/globals_audit.ps1 +236 -236
- package/modules/health.ps1 +101 -101
- package/modules/installed_programs.ps1 +118 -118
- package/modules/menu.ps1 +63 -63
- package/modules/notify.ps1 +60 -60
- package/modules/orphaned_appdata.ps1 +189 -189
- package/modules/personal.ps1 +126 -126
- package/modules/pkg_managers.ps1 +73 -73
- package/modules/projects.ps1 +160 -160
- package/modules/release_helpers.ps1 +438 -438
- package/modules/reports.ps1 +212 -212
- package/modules/runner.ps1 +242 -239
- package/modules/self_test_extra.ps1 +537 -360
- package/modules/startup_audit.ps1 +126 -126
- package/modules/system_admin.ps1 +220 -220
- package/modules/test_runners.ps1 +24 -24
- package/modules/walkthrough.ps1 +80 -79
- package/modules/windows_user.ps1 +118 -118
- package/package.json +2 -2
- package/windowsweep.ps1 +322 -316
|
@@ -1,360 +1,537 @@
|
|
|
1
|
-
# self_test_extra.ps1 - extra self-test groups: the --yes asymmetry and its call sites (RW-002), pure helpers
|
|
2
|
-
# and fixtures for logic that is invisible when wrong (RW-030). Called by Invoke-SelfTest; verdict lines only.
|
|
3
|
-
|
|
4
|
-
function Invoke-SelfTestExtra {
|
|
5
|
-
<# .SYNOPSIS Run groups [12]-[17]. Returns an object with Checks and Fails counts. #>
|
|
6
|
-
$ws = $Script:WS
|
|
7
|
-
$checks = 0; $fails = 0
|
|
8
|
-
$saved = @{ DryRun = $ws.DryRun; Yes = $ws.Yes; Interactive = $ws.Interactive; Quiet = $ws.Quiet; NoReport = $ws.NoReport
|
|
9
|
-
ScanRoots = $ws.ScanRoots; AllowOwnData = $ws.AllowOwnData; Mute = $ws.Mute; Days = $ws.Days
|
|
10
|
-
SelectQueue = $ws.SelectQueue; SelectPaths = $ws.SelectPaths; SelectActive = $ws.SelectActive
|
|
11
|
-
LastSelectionScripted = $ws.LastSelectionScripted; Candidates = $ws.Candidates; ScanTargets = $ws.ScanTargets
|
|
12
|
-
JsonMode = $ws.JsonMode; Notify = $ws.Notify }
|
|
13
|
-
$fx = Join-Path $ws.Home ('selftest-extra-' + [guid]::NewGuid().ToString('N'))
|
|
14
|
-
$mute = { $Script:WS.Mute = $true }
|
|
15
|
-
$unmute = { $Script:WS.Mute = $false }
|
|
16
|
-
$ancient = (Get-Date).AddDays(-400)
|
|
17
|
-
try {
|
|
18
|
-
New-Item -ItemType Directory -Force -Path $fx | Out-Null
|
|
19
|
-
$ws.AllowOwnData = $true
|
|
20
|
-
$ws.NoReport = $true
|
|
21
|
-
$ws.DryRun = $false
|
|
22
|
-
$ws.Days = 100
|
|
23
|
-
|
|
24
|
-
Write-Section '[12] Prompts: --yes never selects personal or project items'
|
|
25
|
-
# 12a - the helper asymmetry
|
|
26
|
-
$checks++
|
|
27
|
-
$ws.Yes = $true; $ws.Interactive = $false
|
|
28
|
-
& $mute
|
|
29
|
-
$none = @(Read-MultiSelect -Total 5 -NoAutoYes)
|
|
30
|
-
$all = @(Read-MultiSelect -Total 5)
|
|
31
|
-
& $unmute
|
|
32
|
-
if ($none.Count -eq 0 -and ($all -join ',') -eq '1,2,3,4,5') { Write-Ok 'Read-MultiSelect: -NoAutoYes selects nothing under --yes; without it every index' } else { Write-Err "Read-MultiSelect asymmetry broken: none=[$($none -join ',')] all=[$($all -join ',')]"; $fails++ }
|
|
33
|
-
# 12b - section 17 call site (a fixture project with an artefact idle 400 days)
|
|
34
|
-
$checks++
|
|
35
|
-
$proj = Join-Path $fx 'proj17\app'
|
|
36
|
-
New-Item -ItemType Directory -Force -Path (Join-Path $proj 'node_modules') | Out-Null
|
|
37
|
-
Set-Content -LiteralPath (Join-Path $proj 'package.json') -Value '{"name":"fixture"}'
|
|
38
|
-
Set-Content -LiteralPath (Join-Path $proj 'node_modules\x.txt') -Value 'x'
|
|
39
|
-
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath (Join-Path $proj 'package.json') -Name $ts -Value $ancient }
|
|
40
|
-
$ws.ScanRoots = @((Join-Path $fx 'proj17'))
|
|
41
|
-
& $mute
|
|
42
|
-
# The fixture must be a real candidate, or the removal check below proves nothing.
|
|
43
|
-
$listed = @(Find-StaleArtefacts -Roots $ws.ScanRoots -Days 100)
|
|
44
|
-
Invoke-Section17
|
|
45
|
-
& $unmute
|
|
46
|
-
if ($listed.Count -eq 1 -and (Test-Path -LiteralPath (Join-Path $proj 'node_modules\x.txt'))) {
|
|
47
|
-
Write-Ok 'section 17 under --yes with no console lists the artefact and removes nothing'
|
|
48
|
-
} elseif ($listed.Count -ne 1) {
|
|
49
|
-
Write-Err "section 17 fixture not detected as a candidate ($($listed.Count) listed) - the check cannot prove anything"; $fails++
|
|
50
|
-
} else {
|
|
51
|
-
Write-Err 'DANGEROUS: section 17 removed an artefact under --yes with no selection'; $fails++
|
|
52
|
-
}
|
|
53
|
-
# 12c - personal picker call site (sections 18/19)
|
|
54
|
-
$checks++
|
|
55
|
-
$pfile = Join-Path $fx 'personal\old.iso'
|
|
56
|
-
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $pfile) | Out-Null
|
|
57
|
-
Set-Content -LiteralPath $pfile -Value 'x'
|
|
58
|
-
$row = [pscustomobject]@{ Path = $pfile; Bytes = [long]1; Idle = 400; Root = (Split-Path -Parent $pfile) }
|
|
59
|
-
& $mute
|
|
60
|
-
Invoke-PersonalPicker -Rows @($row) -Prompt 'fixture'
|
|
61
|
-
& $unmute
|
|
62
|
-
if (Test-Path -LiteralPath $pfile) { Write-Ok 'sections 18/19 under --yes with no console recycle nothing' } else { Write-Err 'DANGEROUS: the personal picker removed a file under --yes with no selection'; $fails++ }
|
|
63
|
-
# 12d - every Read-MultiSelect call in modules/ carries -NoAutoYes (AST, not text)
|
|
64
|
-
$checks++
|
|
65
|
-
$offenders = @()
|
|
66
|
-
foreach ($f in (Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' -File)) {
|
|
67
|
-
if ($f.Name -like 'self_test_extra*') { continue }
|
|
68
|
-
$tokens = $null; $errors = $null
|
|
69
|
-
$ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tokens, [ref]$errors)
|
|
70
|
-
$calls = $ast.FindAll({ param($n) ($n -is [System.Management.Automation.Language.CommandAst]) -and ($n.GetCommandName() -eq 'Read-MultiSelect') }, $true)
|
|
71
|
-
foreach ($c in $calls) {
|
|
72
|
-
$has = $false
|
|
73
|
-
foreach ($el in $c.CommandElements) { if (($el -is [System.Management.Automation.Language.CommandParameterAst]) -and ($el.ParameterName -eq 'NoAutoYes')) { $has = $true } }
|
|
74
|
-
if (-not $has) { $offenders += "$($f.Name):$($c.Extent.StartLineNumber)" }
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if ($offenders.Count -eq 0) { Write-Ok 'every Read-MultiSelect call in modules/ carries -NoAutoYes' } else { Write-Err "Read-MultiSelect without -NoAutoYes: $($offenders -join ', ')"; $fails++ }
|
|
78
|
-
$ws.Yes = $saved.Yes; $ws.Interactive = $saved.Interactive; $ws.ScanRoots = $saved.ScanRoots
|
|
79
|
-
|
|
80
|
-
Write-Section '[13] Pure helpers: arguments, section lists, sizes, cache leaves, JSON summary'
|
|
81
|
-
# 13a - argument parser
|
|
82
|
-
$checks++
|
|
83
|
-
$keep = @{}
|
|
84
|
-
foreach ($k in 'Mode', 'BatchMode', 'OnlyList', 'ExportFmt', 'ExportId', 'Days', 'ScanRoots', 'ExcludePaths', 'RawArgs') { $keep[$k] = $ws[$k] }
|
|
85
|
-
$ok = $false
|
|
86
|
-
try {
|
|
87
|
-
$ws.RawArgs = @('--only=1,3', '--export', 'html', '2', '--days=30', '--scan-roots', 'a;b', '--exclude-path', 'x', '--exclude-path', 'y')
|
|
88
|
-
$ws.ExcludePaths = @()
|
|
89
|
-
Read-Arguments
|
|
90
|
-
$ok = ($ws.OnlyList -eq '1,3') -and ($ws.ExportFmt -eq 'html') -and ($ws.ExportId -eq '2') -and ($ws.Days -eq 30) -and (($ws.ScanRoots -join ',') -eq 'a,b') -and (($ws.ExcludePaths -join ',') -eq 'x,y')
|
|
91
|
-
} catch { $ok = $false }
|
|
92
|
-
$threw = $false
|
|
93
|
-
try { $ws.RawArgs = @('--days', 'x'); Read-Arguments } catch { $threw = $true }
|
|
94
|
-
foreach ($k in @($keep.Keys)) { $ws[$k] = $keep[$k] }
|
|
95
|
-
if ($ok -and $threw) { Write-Ok 'Read-Arguments: --only=, --export F ID, --days=, --scan-roots, repeated --exclude-path; --days x throws' } else { Write-Err "Read-Arguments wrong (values ok=$ok, bad --days threw=$threw)"; $fails++ }
|
|
96
|
-
# 13b - section id list
|
|
97
|
-
$checks++
|
|
98
|
-
& $mute
|
|
99
|
-
$ids = @(Get-SectionIdList '1,3,5-7,9-8,99,x')
|
|
100
|
-
& $unmute
|
|
101
|
-
if (($ids -join ',') -eq '1,3,5,6,7,8,9') { Write-Ok "Get-SectionIdList '1,3,5-7,9-8,99,x' -> $($ids -join ',')" } else { Write-Err "Get-SectionIdList wrong: $($ids -join ',')"; $fails++ }
|
|
102
|
-
# 13c - docker size text (decimal units)
|
|
103
|
-
$checks++
|
|
104
|
-
$sizes = @(@('2.891GB', 2891000000), @('12.5 MB', 12500000), @('20.48kB', 20480), @('0B', 0))
|
|
105
|
-
$badSizes = @($sizes | Where-Object { [math]::Abs((ConvertFrom-SizeText $_[0]) - [long]$_[1]) -gt 1 })
|
|
106
|
-
if ($badSizes.Count -eq 0) { Write-Ok 'ConvertFrom-SizeText: 2.891GB, 12.5 MB, 20.48kB, 0B' } else { Write-Err "ConvertFrom-SizeText wrong for: $(($badSizes | ForEach-Object { $_[0] }) -join ', ')"; $fails++ }
|
|
107
|
-
# 13d - cache leaf guard
|
|
108
|
-
$checks++
|
|
109
|
-
if ((Test-KnownCacheLeaf "$fx\User Data\Default\Cache") -and -not (Test-KnownCacheLeaf "$fx\User Data\Default\Local Storage") -and -not (Test-KnownCacheLeaf "$fx\User Data\Default")) { Write-Ok 'Test-KnownCacheLeaf: Cache yes; Local Storage and a profile root no' } else { Write-Err 'Test-KnownCacheLeaf is wrong'; $fails++ }
|
|
110
|
-
# 13e - JSON summary shape
|
|
111
|
-
$checks++
|
|
112
|
-
$json = (Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress
|
|
113
|
-
$back = $null
|
|
114
|
-
try { $back = $json | ConvertFrom-Json } catch { $back = $null }
|
|
115
|
-
$need = @('tool', 'version', 'mode', 'dry_run', 'freed_bytes', 'sections', 'refusals')
|
|
116
|
-
$missing = @($need | Where-Object { ($null -eq $back) -or ($null -eq $back.PSObject.Properties[$_]) })
|
|
117
|
-
if ($back -and $missing.Count -eq 0 -and -not $json.Contains("`n")) { Write-Ok 'Get-JsonSummary: one line, parses back, required keys present' } else { Write-Err "Get-JsonSummary wrong (missing: $($missing -join ','))"; $fails++ }
|
|
118
|
-
|
|
119
|
-
Write-Section '[14] Fixtures: superseded versions, Chromium layout, workspace storage, stale artefacts, report export'
|
|
120
|
-
# 14a - superseded Squirrel versions (version order, not string order)
|
|
121
|
-
$checks++
|
|
122
|
-
$sq = Join-Path $fx 'squirrel'
|
|
123
|
-
foreach ($v in 'app-1.0.0', 'app-1.9.0', 'app-1.10.0') { New-Item -ItemType Directory -Force -Path (Join-Path $sq $v) | Out-Null; Set-Content -LiteralPath (Join-Path $sq "$v\f.txt") -Value $v }
|
|
124
|
-
& $mute
|
|
125
|
-
$null = Remove-SupersededVersions -Root $sq -Prefix 'app-' -Label 'fixture'
|
|
126
|
-
& $unmute
|
|
127
|
-
if ((Test-Path -LiteralPath (Join-Path $sq 'app-1.10.0')) -and -not (Test-Path -LiteralPath (Join-Path $sq 'app-1.9.0')) -and -not (Test-Path -LiteralPath (Join-Path $sq 'app-1.0.0'))) { Write-Ok 'Remove-SupersededVersions keeps app-1.10.0 and removes 1.9.0 and 1.0.0' } else { Write-Err 'Remove-SupersededVersions kept the wrong folder'; $fails++ }
|
|
128
|
-
# 14b - Chromium layout: cache folders only, never profile data
|
|
129
|
-
$checks++
|
|
130
|
-
$ud = Join-Path $fx 'User Data'
|
|
131
|
-
foreach ($d in 'Default\Cache', 'Default\Local Storage', 'Profile 3\Code Cache') { New-Item -ItemType Directory -Force -Path (Join-Path $ud $d) | Out-Null }
|
|
132
|
-
$leaves = @(Get-ChromiumCacheDirs $ud | ForEach-Object { [IO.Path]::GetFileName($_) } | Sort-Object)
|
|
133
|
-
if (($leaves -join ',') -eq 'Cache,Code Cache') { Write-Ok 'Get-ChromiumCacheDirs returns the two cache folders and never Local Storage' } else { Write-Err "Get-ChromiumCacheDirs wrong: [$($leaves -join ',')]"; $fails++ }
|
|
134
|
-
# 14c - stale workspace storage: dry-run keeps both, real run removes only the dead entry
|
|
135
|
-
$checks++
|
|
136
|
-
$wsRoot = Join-Path $fx 'workspaceStorage'
|
|
137
|
-
$exists = Join-Path $fx 'exists'; New-Item -ItemType Directory -Force -Path $exists | Out-Null
|
|
138
|
-
foreach ($pair in @(@('a', $exists), @('b', (Join-Path $fx 'missing')))) {
|
|
139
|
-
$d = Join-Path $wsRoot $pair[0]; New-Item -ItemType Directory -Force -Path $d | Out-Null
|
|
140
|
-
$uri = 'file:///' + ([string]$pair[1] -replace '\\', '/')
|
|
141
|
-
Set-Content -LiteralPath (Join-Path $d 'workspace.json') -Value ('{"folder":"' + $uri + '"}')
|
|
142
|
-
}
|
|
143
|
-
& $mute
|
|
144
|
-
$ws.DryRun = $true
|
|
145
|
-
Remove-StaleWorkspaceStorage -Root $wsRoot -Label 'fixture'
|
|
146
|
-
$dryKept = (Test-Path -LiteralPath (Join-Path $wsRoot 'a')) -and (Test-Path -LiteralPath (Join-Path $wsRoot 'b'))
|
|
147
|
-
$ws.DryRun = $false
|
|
148
|
-
Remove-StaleWorkspaceStorage -Root $wsRoot -Label 'fixture'
|
|
149
|
-
& $unmute
|
|
150
|
-
if ($dryKept -and (Test-Path -LiteralPath (Join-Path $wsRoot 'a')) -and -not (Test-Path -LiteralPath (Join-Path $wsRoot 'b'))) { Write-Ok 'Remove-StaleWorkspaceStorage: dry-run keeps both; real run removes only the entry whose folder is gone' } else { Write-Err 'Remove-StaleWorkspaceStorage behaved wrongly'; $fails++ }
|
|
151
|
-
# 14d - stale artefacts: three orientations, so a missing gate in either direction shows up.
|
|
152
|
-
# A marker + source idle 400 days -> listed (the finder works)
|
|
153
|
-
# B marker + fresh source -> not listed (the idle gate works)
|
|
154
|
-
# C NO marker + source idle 400 days -> not listed (the project-marker test works)
|
|
155
|
-
# C must be OLD, or the marker test is never what excludes it and the check is half-oriented.
|
|
156
|
-
$checks++
|
|
157
|
-
$pr = Join-Path $fx 'projects'
|
|
158
|
-
foreach ($n in 'A', 'B', 'C') { New-Item -ItemType Directory -Force -Path (Join-Path $pr "$n\node_modules") | Out-Null; Set-Content -LiteralPath (Join-Path $pr "$n\node_modules\x.txt") -Value 'x' }
|
|
159
|
-
foreach ($n in 'A', 'B') { Set-Content -LiteralPath (Join-Path $pr "$n\package.json") -Value '{}' }
|
|
160
|
-
Set-Content -LiteralPath (Join-Path $pr 'A\main.js') -Value 'x'
|
|
161
|
-
Set-Content -LiteralPath (Join-Path $pr 'C\main.js') -Value 'x'
|
|
162
|
-
foreach ($f in (Join-Path $pr 'A\package.json'), (Join-Path $pr 'A\main.js'), (Join-Path $pr 'C\main.js')) {
|
|
163
|
-
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath $f -Name $ts -Value $ancient }
|
|
164
|
-
}
|
|
165
|
-
$found = @(Find-StaleArtefacts -Roots @($pr) -Days 100)
|
|
166
|
-
if ($found.Count -eq 1 -and $found[0].Path -like '*\A\node_modules' -and $found[0].Age -ge 100) { Write-Ok 'Find-StaleArtefacts lists the idle project with a marker, and neither the fresh one nor the idle one without a marker' } else { Write-Err "Find-StaleArtefacts wrong: $($found.Count) result(s) [$(($found | ForEach-Object { $_.Path }) -join '; ')]"; $fails++ }
|
|
167
|
-
# 14e - report export smoke (schema-1 fixture; HTML must escape the title)
|
|
168
|
-
$checks++
|
|
169
|
-
$rep = Join-Path $fx 'report-fixture.json'
|
|
170
|
-
$fixture = '{"schema_version":1,"credits":{"tool":"windowsweep","tool_version":"0.0.0","tool_homepage":"https://github.com/aoneahsan/windowsweep","tool_license":"MIT License","author":{"name":"Ahsan Mahmood","email":"e","website":"w","linkedin":"l"}},"meta":{"started_at":"2026-09-03T10:00:00+05:00","finished_at":"2026-09-03T10:01:00+05:00","duration_seconds":60,"host":"h","user":"u","os":"Windows","powershell":"5.1","mode":"all","dry_run":false,"elevated":false,"developer_mode":true,"idle_days":100,"temp_days":3,"log_file":"C:\\x.log","launcher":"node","via_npx":false,"tool_version":"0.0.0"},"disk":{"before":[{"drive":"C:","size_bytes":100,"free_bytes":10}],"after":[{"drive":"C:","size_bytes":100,"free_bytes":20}]},"steps":[{"n":1,"section":1,"title":"Package <caches>","status":"ran","freed_bytes":4096,"note":""}],"totals":{"total_reclaimed_bytes":4096,"total_reclaimed_human":"4.0 KB","total_estimated_bytes":0,"total_estimated_human":"0 B","steps_run":1,"steps_skipped":0}}'
|
|
171
|
-
[IO.File]::WriteAllText($rep, $fixture, (New-Object System.Text.UTF8Encoding($false)))
|
|
172
|
-
& $mute
|
|
173
|
-
$md = Convert-ReportToMarkdown -Json $rep
|
|
174
|
-
$html = Convert-ReportToHtml -Json $rep
|
|
175
|
-
& $unmute
|
|
176
|
-
$mdText = ''; $htmlText = ''
|
|
177
|
-
if ($md -and (Test-Path -LiteralPath $md)) { $mdText = Get-Content -LiteralPath $md -Raw }
|
|
178
|
-
if ($html -and (Test-Path -LiteralPath $html)) { $htmlText = Get-Content -LiteralPath $html -Raw }
|
|
179
|
-
if ($mdText.Contains('4.0 KB') -and $mdText.Contains('windowsweep') -and $htmlText.Contains('4.0 KB') -and $htmlText.Contains('Package <caches>') -and -not $htmlText.Contains('Package <caches>')) { Write-Ok 'report export: Markdown and HTML written, totals present, HTML escapes the title' } else { Write-Err 'report export smoke failed'; $fails++ }
|
|
180
|
-
|
|
181
|
-
Write-Section '[15] Catalogue: every section is reachable, and the new ones declare what they promise'
|
|
182
|
-
# 15a - Get-AllTargets is driven by the catalogue, not by a literal 0..21 range
|
|
183
|
-
$checks++
|
|
184
|
-
# The ids each Get-TargetsNN really RETURNS - sections 0 and 21 own a function that yields no rows.
|
|
185
|
-
$declared = @()
|
|
186
|
-
foreach ($s in $Script:WS_SECTIONS) {
|
|
187
|
-
$fn = 'Get-Targets{0:00}' -f $s.Id
|
|
188
|
-
if (-not (Get-Command $fn -ErrorAction SilentlyContinue)) { continue }
|
|
189
|
-
if (@(& $fn).Count -gt 0) { $declared += $s.Id }
|
|
190
|
-
}
|
|
191
|
-
$reached = @(Get-AllTargets | ForEach-Object { $_.Section } | Sort-Object -Unique)
|
|
192
|
-
$missed = @($declared | Where-Object { $reached -notcontains $_ })
|
|
193
|
-
if ($declared.Count -gt 0 -and $missed.Count -eq 0) { Write-Ok "Get-AllTargets reaches all $($declared.Count) section(s) whose Get-TargetsNN returns rows" } else { Write-Err "Get-AllTargets misses section(s): $($missed -join ',')"; $fails++ }
|
|
194
|
-
# 15b - every catalogue row is well formed
|
|
195
|
-
$checks++
|
|
196
|
-
$bad = @()
|
|
197
|
-
$ids = New-Object System.Collections.Generic.HashSet[int]
|
|
198
|
-
foreach ($s in $Script:WS_SECTIONS) {
|
|
199
|
-
if (-not $ids.Add([int]$s.Id)) { $bad += "duplicate id $($s.Id)" }
|
|
200
|
-
if (-not (Get-Command $s.Fn -ErrorAction SilentlyContinue)) { $bad += "section $($s.Id): no function $($s.Fn)" }
|
|
201
|
-
if ($s.Tier -notin 'report', 'rebuilds', 'slow', 'recycle', 'permanent', 'config') { $bad += "section $($s.Id): unknown tier $($s.Tier)" }
|
|
202
|
-
if ($s.Batch -notin 'safe', 'optin', 'deep', 'interactive') { $bad += "section $($s.Id): unknown batch $($s.Batch)" }
|
|
203
|
-
}
|
|
204
|
-
if ($bad.Count -eq 0) { Write-Ok "$($Script:WS_SECTIONS.Count) catalogue rows: unique ids, existing functions, known tier and batch" } else { Write-Err ("catalogue is wrong: " + ($bad -join '; ')); $fails++ }
|
|
205
|
-
# 15c - section 23 derives its exclusions from the declared targets, so the list cannot drift
|
|
206
|
-
$checks++
|
|
207
|
-
$ex = Get-OrphanExclusions
|
|
208
|
-
$wantSegments = @()
|
|
209
|
-
foreach ($t in (Get-AllTargets)) {
|
|
210
|
-
foreach ($pre in @("$($Script:P.A)\", "$($Script:P.L)\")) {
|
|
211
|
-
if ([string]$t.Path -match [regex]::Escape($pre)) { $seg = (([string]$t.Path).Substring(([string]$t.Path).IndexOf($pre) + $pre.Length) -split '\\')[0]; if ($seg) { $wantSegments += $seg } }
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
$wantSegments = @($wantSegments | Sort-Object -Unique)
|
|
215
|
-
$lost = @($wantSegments | Where-Object { -not $ex.Contains($_) })
|
|
216
|
-
if ($wantSegments.Count -gt 0 -and $lost.Count -eq 0) { Write-Ok "section 23 excludes all $($wantSegments.Count) AppData folder(s) other sections already clean" } else { Write-Err "section 23 would offer a folder we clean elsewhere: $($lost -join ', ')"; $fails++ }
|
|
217
|
-
# 15d - the fail-closed gate, in two halves: an empty index WOULD call everything orphaned, and does not
|
|
218
|
-
$checks++
|
|
219
|
-
$emptyTokens = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
|
|
220
|
-
$emptyIndex = [pscustomobject]@{ Tokens = $emptyTokens; RegistryCount = 0; Rows = @() }
|
|
221
|
-
$wouldOrphan = Test-OrphanFolderName -Name 'Slack' -Index $emptyIndex
|
|
222
|
-
& $mute
|
|
223
|
-
$gated = @(Find-OrphanedAppData -Days 100 -Index $emptyIndex)
|
|
224
|
-
& $unmute
|
|
225
|
-
if ($wouldOrphan -and $gated.Count -eq 0) { Write-Ok 'section 23 fails closed: an unreadable registry means zero candidates, not everything' } else { Write-Err "section 23 fail-closed gate broken (predicate=$wouldOrphan candidates=$($gated.Count))"; $fails++ }
|
|
226
|
-
# 15e - section 22 declares nothing deletable: its roots are protected subtrees
|
|
227
|
-
$checks++
|
|
228
|
-
$notCmd = @(Get-Targets22 | Where-Object { $_.Kind -ne 'cmd' })
|
|
229
|
-
if ($notCmd.Count -eq 0) { Write-Ok 'section 22 declares no deletable target (audit only)' } else { Write-Err "section 22 declares a deletable target: $(($notCmd | ForEach-Object { $_.Path }) -join ', ')"; $fails++ }
|
|
230
|
-
# 15f - PowerShell variable names are case-insensitive: $p silently destroys the $P roots table
|
|
231
|
-
$checks++
|
|
232
|
-
$clash = @()
|
|
233
|
-
foreach ($f in (Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' -File)) {
|
|
234
|
-
$tk = $null; $er = $null
|
|
235
|
-
$ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tk, [ref]$er)
|
|
236
|
-
foreach ($fn in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
|
|
237
|
-
$sets = @($fn.FindAll({ param($n) ($n -is [System.Management.Automation.Language.AssignmentStatementAst]) -and ($n.Left -is [System.Management.Automation.Language.VariableExpressionAst]) -and ($n.Left.VariablePath.UserPath -eq 'P') }, $true))
|
|
238
|
-
$loops = @($fn.FindAll({ param($n) ($n -is [System.Management.Automation.Language.ForEachStatementAst]) -and ($n.Variable.VariablePath.UserPath -eq 'P') }, $true))
|
|
239
|
-
if (@($sets | Where-Object { $_.Right.Extent.Text -match '\$Script:P\b' }).Count -eq 0) { continue }
|
|
240
|
-
if ($sets.Count -gt 1 -or $loops.Count -gt 0) { $clash += "$($f.Name):$($fn.Name)" }
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
if ($clash.Count -eq 0) { Write-Ok 'no function reuses $p after taking $P from $Script:P (they are the same variable)' } else { Write-Err "case-insensitive `$P clash in: $($clash -join ', ')"; $fails++ }
|
|
244
|
-
|
|
245
|
-
Write-Section '[16] Scripted selection and the machine-readable contract'
|
|
246
|
-
$ws.SelectPaths = @(); $ws.SelectQueue = @(); $ws.Yes = $false; $ws.Interactive = $false
|
|
247
|
-
# 16a - --select answers one prompt, in order
|
|
248
|
-
$checks++
|
|
249
|
-
$ws.SelectQueue = @('1,3')
|
|
250
|
-
& $mute
|
|
251
|
-
$sel = @(Read-MultiSelect -Total 5 -NoAutoYes)
|
|
252
|
-
& $unmute
|
|
253
|
-
if (($sel -join ',') -eq '1,3' -and $ws.SelectQueue.Count -eq 0 -and $ws.LastSelectionScripted) { Write-Ok "--select '1,3' of 5 -> 1,3, consumed once, marked as a scripted choice" } else { Write-Err "--select wrong: [$($sel -join ',')] queueLeft=$($ws.SelectQueue.Count) scripted=$($ws.LastSelectionScripted)"; $fails++ }
|
|
254
|
-
# 16b - --select-file matches paths, and warns about a line that matches nothing here
|
|
255
|
-
$checks++
|
|
256
|
-
$cands = @("$fx\one.txt", "$fx\two.txt")
|
|
257
|
-
$ws.SelectPaths = @("$fx\TWO.TXT", "$fx\not-offered.txt")
|
|
258
|
-
& $mute
|
|
259
|
-
$sel2 = @(Read-MultiSelect -Total 2 -NoAutoYes -Candidates $cands)
|
|
260
|
-
& $unmute
|
|
261
|
-
$ws.SelectPaths = @()
|
|
262
|
-
if (($sel2 -join ',') -eq '2' -and $ws.LastSelectionScripted) { Write-Ok '--select-file matches case-insensitively and ignores a line no prompt offers' } else { Write-Err "--select-file wrong: [$($sel2 -join ',')]"; $fails++ }
|
|
263
|
-
# 16c - RW-002 still holds under the new code: --yes alone selects nothing and is not a scripted choice
|
|
264
|
-
$checks++
|
|
265
|
-
$ws.Yes = $true; $ws.SelectQueue = @(); $ws.SelectPaths = @()
|
|
266
|
-
& $mute
|
|
267
|
-
$sel3 = @(Read-MultiSelect -Total 4 -NoAutoYes -Candidates @('a', 'b', 'c', 'd'))
|
|
268
|
-
& $unmute
|
|
269
|
-
$stillOff = (-not $ws.LastSelectionScripted)
|
|
270
|
-
$ws.Yes = $false
|
|
271
|
-
if ($sel3.Count -eq 0 -and $stillOff) { Write-Ok '--yes alone still selects nothing and never counts as a scripted choice' } else { Write-Err "DANGEROUS: --yes selected [$($sel3 -join ',')] scripted=$(-not $stillOff)"; $fails++ }
|
|
272
|
-
# 16d - the --json document keeps a stable shape even when nothing was collected
|
|
273
|
-
$checks++
|
|
274
|
-
$ws.JsonMode = $true; $ws.Candidates = @(); $ws.ScanTargets = @()
|
|
275
|
-
$doc = (Get-JsonSummary) | ConvertTo-Json -Depth 6 -Compress
|
|
276
|
-
$back = $null
|
|
277
|
-
try { $back = $doc | ConvertFrom-Json } catch { $back = $null }
|
|
278
|
-
$ws.JsonMode = $false
|
|
279
|
-
$hasBoth = ($back -and $null -ne $back.PSObject.Properties['candidates'] -and $null -ne $back.PSObject.Properties['targets'])
|
|
280
|
-
if ($hasBoth -and -not $doc.Contains("`n")) { Write-Ok '--json carries candidates and targets as keys even when empty, on one line' } else { Write-Err 'the --json shape is missing candidates/targets or spans lines'; $fails++ }
|
|
281
|
-
# 16e - --list --json covers the whole catalogue
|
|
282
|
-
$checks++
|
|
283
|
-
$cat = (Get-CatalogueJson) | ConvertTo-Json -Depth 6 -Compress | ConvertFrom-Json
|
|
284
|
-
$catIds = @($cat.sections | ForEach-Object { [int]$_.id } | Sort-Object)
|
|
285
|
-
$realIds = @($Script:WS_SECTIONS | ForEach-Object { [int]$_.Id } | Sort-Object)
|
|
286
|
-
if (($catIds -join ',') -eq ($realIds -join ',') -and $cat.safe_batch.Count -gt 0 -and $cat.walkthrough.Count -gt 0) { Write-Ok "--list --json covers all $($catIds.Count) sections plus the batch and profile lists" } else { Write-Err '--list --json does not match the catalogue'; $fails++ }
|
|
287
|
-
# 16f - the progress line a GUI parses round-trips
|
|
288
|
-
$checks++
|
|
289
|
-
$pl = Get-MachineProgressLine -Section 7 -Stage 'end' -Status 'ran' -Freed 4096
|
|
290
|
-
$ok16f = ($pl -match "^##$Script:WS_NAME section=(\d+) event=(\w+) status=([\w-]+) freed_bytes=(\d+)$") -and ([int]$Matches[1] -eq 7) -and ($Matches[2] -eq 'end') -and ($Matches[3] -eq 'ran') -and ([long]$Matches[4] -eq 4096)
|
|
291
|
-
$plStart = Get-MachineProgressLine -Section 7 -Stage 'start'
|
|
292
|
-
if ($ok16f -and $plStart -eq "##$Script:WS_NAME section=7 event=start") { Write-Ok 'the ##windowsweep progress line parses back into section, event, status and freed_bytes' } else { Write-Err "progress line format wrong: $pl"; $fails++ }
|
|
293
|
-
|
|
294
|
-
Write-Section '[17] New helpers: global packages, startup state, artefact list'
|
|
295
|
-
# 17a - only roots that exist are reported
|
|
296
|
-
$checks++
|
|
297
|
-
$roots = @(Get-GlobalPackageRoots)
|
|
298
|
-
$ghost = @($roots | Where-Object { -not (Test-DirPresent $_.Root) })
|
|
299
|
-
if ($ghost.Count -eq 0) { Write-Ok "Get-GlobalPackageRoots returned $($roots.Count) root(s), all of them present" } else { Write-Err "Get-GlobalPackageRoots returned a missing root: $(($ghost | ForEach-Object { $_.Root }) -join ', ')"; $fails++ }
|
|
300
|
-
# 17b - three orientations: A the finder works, B the reference test works, C the idle gate works
|
|
301
|
-
$checks++
|
|
302
|
-
$names = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
303
|
-
$null = $names.Add('kept-because-referenced')
|
|
304
|
-
$idx = [pscustomobject]@{ Names = $names; Bins = (New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)); ScriptText = '' }
|
|
305
|
-
$pkgA = [pscustomobject]@{ Manager = 'npm'; Name = 'gone-stale'; Version = '1.0.0'; Path = ''; Bytes = [long]1; Idle = 400; Bins = @() }
|
|
306
|
-
$pkgB = [pscustomobject]@{ Manager = 'npm'; Name = 'kept-because-referenced'; Version = '1.0.0'; Path = ''; Bytes = [long]1; Idle = 400; Bins = @() }
|
|
307
|
-
$pkgC = [pscustomobject]@{ Manager = 'npm'; Name = 'kept-because-fresh'; Version = '1.0.0'; Path = ''; Bytes = [long]1; Idle = 1; Bins = @() }
|
|
308
|
-
$vA = (Get-GlobalPackageVerdict -Package $pkgA -Index $idx -Days 100).Verdict
|
|
309
|
-
$vB = (Get-GlobalPackageVerdict -Package $pkgB -Index $idx -Days 100).Verdict
|
|
310
|
-
$vC = (Get-GlobalPackageVerdict -Package $pkgC -Index $idx -Days 100).Verdict
|
|
311
|
-
if ($vA -eq 'candidate' -and $vB -eq 'keep' -and $vC -eq 'keep') { Write-Ok 'global verdict: idle+unreferenced is a candidate; referenced and fresh are both kept' } else { Write-Err "global verdict wrong: A=$vA B=$vB C=$vC"; $fails++ }
|
|
312
|
-
# 17c - startup state comes from Explorer's disable timestamp, not from a guessed first byte
|
|
313
|
-
$checks++
|
|
314
|
-
$disabledVal = [byte[]](1, 0, 0, 0, 39, 190, 225, 241, 9, 168, 220, 1)
|
|
315
|
-
$enabledVal = [byte[]](1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
|
316
|
-
$markerVal = [byte[]](3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
|
317
|
-
$shortVal = [byte[]](1, 0, 0, 0)
|
|
318
|
-
$s1 = Get-StartupApprovedState -Value $disabledVal
|
|
319
|
-
$s2 = Get-StartupApprovedState -Value $enabledVal
|
|
320
|
-
$s3 = Get-StartupApprovedState -Value $markerVal
|
|
321
|
-
$s4 = Get-StartupApprovedState -Value $shortVal
|
|
322
|
-
if ($s1 -eq 'disabled' -and $s2 -eq 'enabled' -and $s3 -eq 'disabled' -and $s4 -eq 'unknown') { Write-Ok 'startup state: a disable timestamp means disabled, a zero tail means enabled, 03 still counts, a short value is unknown' } else { Write-Err "startup state wrong: stamped=$s1 zeroTail=$s2 marker03=$s3 short=$s4"; $fails++ }
|
|
323
|
-
# 17d - the artefact list gained the seven additions and still refuses the three that hold real work
|
|
324
|
-
$checks++
|
|
325
|
-
$added = @('.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
|
|
326
|
-
$forbidden = @('.venv', 'venv', '.terraform')
|
|
327
|
-
$missingAdd = @($added | Where-Object { $Script:WS_ARTEFACT_DIRS -notcontains $_ })
|
|
328
|
-
$present = @($forbidden | Where-Object { $Script:WS_ARTEFACT_DIRS -contains $_ })
|
|
329
|
-
if ($missingAdd.Count -eq 0 -and $present.Count -eq 0) { Write-Ok 'artefact list has the seven additions and none of .venv, venv, .terraform' } else { Write-Err "artefact list wrong (missing: $($missingAdd -join ',') / forbidden present: $($present -join ','))"; $fails++ }
|
|
330
|
-
# 17e - .cache is an artefact only beside a Gatsby or Parcel marker; the normal rule must still work
|
|
331
|
-
$checks++
|
|
332
|
-
$mk = Join-Path $fx 'marked'
|
|
333
|
-
foreach ($n in 'gatsby', 'plain', 'normal') { New-Item -ItemType Directory -Force -Path (Join-Path $mk "$n\.cache") | Out-Null; Set-Content -LiteralPath (Join-Path $mk "$n\.cache\f.txt") -Value 'x' }
|
|
334
|
-
New-Item -ItemType Directory -Force -Path (Join-Path $mk 'normal\node_modules') | Out-Null
|
|
335
|
-
Set-Content -LiteralPath (Join-Path $mk 'normal\node_modules\f.txt') -Value 'x'
|
|
336
|
-
Set-Content -LiteralPath (Join-Path $mk 'gatsby\gatsby-config.js') -Value 'module.exports = {}'
|
|
337
|
-
foreach ($n in 'gatsby', 'plain', 'normal') { Set-Content -LiteralPath (Join-Path $mk "$n\package.json") -Value '{}' }
|
|
338
|
-
# EVERY file ages, not just the markers: an unmarked .cache counts as source, so a fresh file inside
|
|
339
|
-
# plain\.cache or normal\.cache would make its project look active and the check would prove nothing.
|
|
340
|
-
foreach ($f in (Get-ChildItem -LiteralPath $mk -Recurse -File -Force)) {
|
|
341
|
-
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath $f.FullName -Name $ts -Value $ancient }
|
|
342
|
-
}
|
|
343
|
-
& $mute
|
|
344
|
-
$hits = @(Find-StaleArtefacts -Roots @($mk) -Days 100 | ForEach-Object { $_.Path })
|
|
345
|
-
& $unmute
|
|
346
|
-
$gotGatsbyCache = @($hits | Where-Object { $_ -like "*\gatsby\.cache" }).Count -eq 1
|
|
347
|
-
$noPlainCache = @($hits | Where-Object { $_ -like "*\plain\.cache" }).Count -eq 0
|
|
348
|
-
$gotNodeModules = @($hits | Where-Object { $_ -like "*\normal\node_modules" }).Count -eq 1
|
|
349
|
-
if ($gotGatsbyCache -and $noPlainCache -and $gotNodeModules) { Write-Ok '.cache is an artefact only beside a Gatsby/Parcel marker, and node_modules is unaffected' } else { Write-Err "marked-artefact rule wrong: gatsby=$gotGatsbyCache plainExcluded=$noPlainCache nodeModules=$gotNodeModules"; $fails++ }
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1
|
+
# self_test_extra.ps1 - extra self-test groups: the --yes asymmetry and its call sites (RW-002), pure helpers
|
|
2
|
+
# and fixtures for logic that is invisible when wrong (RW-030). Called by Invoke-SelfTest; verdict lines only.
|
|
3
|
+
|
|
4
|
+
function Invoke-SelfTestExtra {
|
|
5
|
+
<# .SYNOPSIS Run groups [12]-[17]. Returns an object with Checks and Fails counts. #>
|
|
6
|
+
$ws = $Script:WS
|
|
7
|
+
$checks = 0; $fails = 0
|
|
8
|
+
$saved = @{ DryRun = $ws.DryRun; Yes = $ws.Yes; Interactive = $ws.Interactive; Quiet = $ws.Quiet; NoReport = $ws.NoReport
|
|
9
|
+
ScanRoots = $ws.ScanRoots; AllowOwnData = $ws.AllowOwnData; Mute = $ws.Mute; Days = $ws.Days
|
|
10
|
+
SelectQueue = $ws.SelectQueue; SelectPaths = $ws.SelectPaths; SelectActive = $ws.SelectActive
|
|
11
|
+
LastSelectionScripted = $ws.LastSelectionScripted; Candidates = $ws.Candidates; ScanTargets = $ws.ScanTargets
|
|
12
|
+
JsonMode = $ws.JsonMode; Notify = $ws.Notify }
|
|
13
|
+
$fx = Join-Path $ws.Home ('selftest-extra-' + [guid]::NewGuid().ToString('N'))
|
|
14
|
+
$mute = { $Script:WS.Mute = $true }
|
|
15
|
+
$unmute = { $Script:WS.Mute = $false }
|
|
16
|
+
$ancient = (Get-Date).AddDays(-400)
|
|
17
|
+
try {
|
|
18
|
+
New-Item -ItemType Directory -Force -Path $fx | Out-Null
|
|
19
|
+
$ws.AllowOwnData = $true
|
|
20
|
+
$ws.NoReport = $true
|
|
21
|
+
$ws.DryRun = $false
|
|
22
|
+
$ws.Days = 100
|
|
23
|
+
|
|
24
|
+
Write-Section '[12] Prompts: --yes never selects personal or project items'
|
|
25
|
+
# 12a - the helper asymmetry
|
|
26
|
+
$checks++
|
|
27
|
+
$ws.Yes = $true; $ws.Interactive = $false
|
|
28
|
+
& $mute
|
|
29
|
+
$none = @(Read-MultiSelect -Total 5 -NoAutoYes)
|
|
30
|
+
$all = @(Read-MultiSelect -Total 5)
|
|
31
|
+
& $unmute
|
|
32
|
+
if ($none.Count -eq 0 -and ($all -join ',') -eq '1,2,3,4,5') { Write-Ok 'Read-MultiSelect: -NoAutoYes selects nothing under --yes; without it every index' } else { Write-Err "Read-MultiSelect asymmetry broken: none=[$($none -join ',')] all=[$($all -join ',')]"; $fails++ }
|
|
33
|
+
# 12b - section 17 call site (a fixture project with an artefact idle 400 days)
|
|
34
|
+
$checks++
|
|
35
|
+
$proj = Join-Path $fx 'proj17\app'
|
|
36
|
+
New-Item -ItemType Directory -Force -Path (Join-Path $proj 'node_modules') | Out-Null
|
|
37
|
+
Set-Content -LiteralPath (Join-Path $proj 'package.json') -Value '{"name":"fixture"}'
|
|
38
|
+
Set-Content -LiteralPath (Join-Path $proj 'node_modules\x.txt') -Value 'x'
|
|
39
|
+
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath (Join-Path $proj 'package.json') -Name $ts -Value $ancient }
|
|
40
|
+
$ws.ScanRoots = @((Join-Path $fx 'proj17'))
|
|
41
|
+
& $mute
|
|
42
|
+
# The fixture must be a real candidate, or the removal check below proves nothing.
|
|
43
|
+
$listed = @(Find-StaleArtefacts -Roots $ws.ScanRoots -Days 100)
|
|
44
|
+
Invoke-Section17
|
|
45
|
+
& $unmute
|
|
46
|
+
if ($listed.Count -eq 1 -and (Test-Path -LiteralPath (Join-Path $proj 'node_modules\x.txt'))) {
|
|
47
|
+
Write-Ok 'section 17 under --yes with no console lists the artefact and removes nothing'
|
|
48
|
+
} elseif ($listed.Count -ne 1) {
|
|
49
|
+
Write-Err "section 17 fixture not detected as a candidate ($($listed.Count) listed) - the check cannot prove anything"; $fails++
|
|
50
|
+
} else {
|
|
51
|
+
Write-Err 'DANGEROUS: section 17 removed an artefact under --yes with no selection'; $fails++
|
|
52
|
+
}
|
|
53
|
+
# 12c - personal picker call site (sections 18/19)
|
|
54
|
+
$checks++
|
|
55
|
+
$pfile = Join-Path $fx 'personal\old.iso'
|
|
56
|
+
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $pfile) | Out-Null
|
|
57
|
+
Set-Content -LiteralPath $pfile -Value 'x'
|
|
58
|
+
$row = [pscustomobject]@{ Path = $pfile; Bytes = [long]1; Idle = 400; Root = (Split-Path -Parent $pfile) }
|
|
59
|
+
& $mute
|
|
60
|
+
Invoke-PersonalPicker -Rows @($row) -Prompt 'fixture'
|
|
61
|
+
& $unmute
|
|
62
|
+
if (Test-Path -LiteralPath $pfile) { Write-Ok 'sections 18/19 under --yes with no console recycle nothing' } else { Write-Err 'DANGEROUS: the personal picker removed a file under --yes with no selection'; $fails++ }
|
|
63
|
+
# 12d - every Read-MultiSelect call in modules/ carries -NoAutoYes (AST, not text)
|
|
64
|
+
$checks++
|
|
65
|
+
$offenders = @()
|
|
66
|
+
foreach ($f in (Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' -File)) {
|
|
67
|
+
if ($f.Name -like 'self_test_extra*') { continue }
|
|
68
|
+
$tokens = $null; $errors = $null
|
|
69
|
+
$ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tokens, [ref]$errors)
|
|
70
|
+
$calls = $ast.FindAll({ param($n) ($n -is [System.Management.Automation.Language.CommandAst]) -and ($n.GetCommandName() -eq 'Read-MultiSelect') }, $true)
|
|
71
|
+
foreach ($c in $calls) {
|
|
72
|
+
$has = $false
|
|
73
|
+
foreach ($el in $c.CommandElements) { if (($el -is [System.Management.Automation.Language.CommandParameterAst]) -and ($el.ParameterName -eq 'NoAutoYes')) { $has = $true } }
|
|
74
|
+
if (-not $has) { $offenders += "$($f.Name):$($c.Extent.StartLineNumber)" }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if ($offenders.Count -eq 0) { Write-Ok 'every Read-MultiSelect call in modules/ carries -NoAutoYes' } else { Write-Err "Read-MultiSelect without -NoAutoYes: $($offenders -join ', ')"; $fails++ }
|
|
78
|
+
$ws.Yes = $saved.Yes; $ws.Interactive = $saved.Interactive; $ws.ScanRoots = $saved.ScanRoots
|
|
79
|
+
|
|
80
|
+
Write-Section '[13] Pure helpers: arguments, section lists, sizes, cache leaves, JSON summary'
|
|
81
|
+
# 13a - argument parser
|
|
82
|
+
$checks++
|
|
83
|
+
$keep = @{}
|
|
84
|
+
foreach ($k in 'Mode', 'BatchMode', 'OnlyList', 'ExportFmt', 'ExportId', 'Days', 'ScanRoots', 'ExcludePaths', 'RawArgs') { $keep[$k] = $ws[$k] }
|
|
85
|
+
$ok = $false
|
|
86
|
+
try {
|
|
87
|
+
$ws.RawArgs = @('--only=1,3', '--export', 'html', '2', '--days=30', '--scan-roots', 'a;b', '--exclude-path', 'x', '--exclude-path', 'y')
|
|
88
|
+
$ws.ExcludePaths = @()
|
|
89
|
+
Read-Arguments
|
|
90
|
+
$ok = ($ws.OnlyList -eq '1,3') -and ($ws.ExportFmt -eq 'html') -and ($ws.ExportId -eq '2') -and ($ws.Days -eq 30) -and (($ws.ScanRoots -join ',') -eq 'a,b') -and (($ws.ExcludePaths -join ',') -eq 'x,y')
|
|
91
|
+
} catch { $ok = $false }
|
|
92
|
+
$threw = $false
|
|
93
|
+
try { $ws.RawArgs = @('--days', 'x'); Read-Arguments } catch { $threw = $true }
|
|
94
|
+
foreach ($k in @($keep.Keys)) { $ws[$k] = $keep[$k] }
|
|
95
|
+
if ($ok -and $threw) { Write-Ok 'Read-Arguments: --only=, --export F ID, --days=, --scan-roots, repeated --exclude-path; --days x throws' } else { Write-Err "Read-Arguments wrong (values ok=$ok, bad --days threw=$threw)"; $fails++ }
|
|
96
|
+
# 13b - section id list
|
|
97
|
+
$checks++
|
|
98
|
+
& $mute
|
|
99
|
+
$ids = @(Get-SectionIdList '1,3,5-7,9-8,99,x')
|
|
100
|
+
& $unmute
|
|
101
|
+
if (($ids -join ',') -eq '1,3,5,6,7,8,9') { Write-Ok "Get-SectionIdList '1,3,5-7,9-8,99,x' -> $($ids -join ',')" } else { Write-Err "Get-SectionIdList wrong: $($ids -join ',')"; $fails++ }
|
|
102
|
+
# 13c - docker size text (decimal units)
|
|
103
|
+
$checks++
|
|
104
|
+
$sizes = @(@('2.891GB', 2891000000), @('12.5 MB', 12500000), @('20.48kB', 20480), @('0B', 0))
|
|
105
|
+
$badSizes = @($sizes | Where-Object { [math]::Abs((ConvertFrom-SizeText $_[0]) - [long]$_[1]) -gt 1 })
|
|
106
|
+
if ($badSizes.Count -eq 0) { Write-Ok 'ConvertFrom-SizeText: 2.891GB, 12.5 MB, 20.48kB, 0B' } else { Write-Err "ConvertFrom-SizeText wrong for: $(($badSizes | ForEach-Object { $_[0] }) -join ', ')"; $fails++ }
|
|
107
|
+
# 13d - cache leaf guard
|
|
108
|
+
$checks++
|
|
109
|
+
if ((Test-KnownCacheLeaf "$fx\User Data\Default\Cache") -and -not (Test-KnownCacheLeaf "$fx\User Data\Default\Local Storage") -and -not (Test-KnownCacheLeaf "$fx\User Data\Default")) { Write-Ok 'Test-KnownCacheLeaf: Cache yes; Local Storage and a profile root no' } else { Write-Err 'Test-KnownCacheLeaf is wrong'; $fails++ }
|
|
110
|
+
# 13e - JSON summary shape
|
|
111
|
+
$checks++
|
|
112
|
+
$json = (Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress
|
|
113
|
+
$back = $null
|
|
114
|
+
try { $back = $json | ConvertFrom-Json } catch { $back = $null }
|
|
115
|
+
$need = @('tool', 'version', 'mode', 'dry_run', 'freed_bytes', 'sections', 'refusals')
|
|
116
|
+
$missing = @($need | Where-Object { ($null -eq $back) -or ($null -eq $back.PSObject.Properties[$_]) })
|
|
117
|
+
if ($back -and $missing.Count -eq 0 -and -not $json.Contains("`n")) { Write-Ok 'Get-JsonSummary: one line, parses back, required keys present' } else { Write-Err "Get-JsonSummary wrong (missing: $($missing -join ','))"; $fails++ }
|
|
118
|
+
|
|
119
|
+
Write-Section '[14] Fixtures: superseded versions, Chromium layout, workspace storage, stale artefacts, report export'
|
|
120
|
+
# 14a - superseded Squirrel versions (version order, not string order)
|
|
121
|
+
$checks++
|
|
122
|
+
$sq = Join-Path $fx 'squirrel'
|
|
123
|
+
foreach ($v in 'app-1.0.0', 'app-1.9.0', 'app-1.10.0') { New-Item -ItemType Directory -Force -Path (Join-Path $sq $v) | Out-Null; Set-Content -LiteralPath (Join-Path $sq "$v\f.txt") -Value $v }
|
|
124
|
+
& $mute
|
|
125
|
+
$null = Remove-SupersededVersions -Root $sq -Prefix 'app-' -Label 'fixture'
|
|
126
|
+
& $unmute
|
|
127
|
+
if ((Test-Path -LiteralPath (Join-Path $sq 'app-1.10.0')) -and -not (Test-Path -LiteralPath (Join-Path $sq 'app-1.9.0')) -and -not (Test-Path -LiteralPath (Join-Path $sq 'app-1.0.0'))) { Write-Ok 'Remove-SupersededVersions keeps app-1.10.0 and removes 1.9.0 and 1.0.0' } else { Write-Err 'Remove-SupersededVersions kept the wrong folder'; $fails++ }
|
|
128
|
+
# 14b - Chromium layout: cache folders only, never profile data
|
|
129
|
+
$checks++
|
|
130
|
+
$ud = Join-Path $fx 'User Data'
|
|
131
|
+
foreach ($d in 'Default\Cache', 'Default\Local Storage', 'Profile 3\Code Cache') { New-Item -ItemType Directory -Force -Path (Join-Path $ud $d) | Out-Null }
|
|
132
|
+
$leaves = @(Get-ChromiumCacheDirs $ud | ForEach-Object { [IO.Path]::GetFileName($_) } | Sort-Object)
|
|
133
|
+
if (($leaves -join ',') -eq 'Cache,Code Cache') { Write-Ok 'Get-ChromiumCacheDirs returns the two cache folders and never Local Storage' } else { Write-Err "Get-ChromiumCacheDirs wrong: [$($leaves -join ',')]"; $fails++ }
|
|
134
|
+
# 14c - stale workspace storage: dry-run keeps both, real run removes only the dead entry
|
|
135
|
+
$checks++
|
|
136
|
+
$wsRoot = Join-Path $fx 'workspaceStorage'
|
|
137
|
+
$exists = Join-Path $fx 'exists'; New-Item -ItemType Directory -Force -Path $exists | Out-Null
|
|
138
|
+
foreach ($pair in @(@('a', $exists), @('b', (Join-Path $fx 'missing')))) {
|
|
139
|
+
$d = Join-Path $wsRoot $pair[0]; New-Item -ItemType Directory -Force -Path $d | Out-Null
|
|
140
|
+
$uri = 'file:///' + ([string]$pair[1] -replace '\\', '/')
|
|
141
|
+
Set-Content -LiteralPath (Join-Path $d 'workspace.json') -Value ('{"folder":"' + $uri + '"}')
|
|
142
|
+
}
|
|
143
|
+
& $mute
|
|
144
|
+
$ws.DryRun = $true
|
|
145
|
+
Remove-StaleWorkspaceStorage -Root $wsRoot -Label 'fixture'
|
|
146
|
+
$dryKept = (Test-Path -LiteralPath (Join-Path $wsRoot 'a')) -and (Test-Path -LiteralPath (Join-Path $wsRoot 'b'))
|
|
147
|
+
$ws.DryRun = $false
|
|
148
|
+
Remove-StaleWorkspaceStorage -Root $wsRoot -Label 'fixture'
|
|
149
|
+
& $unmute
|
|
150
|
+
if ($dryKept -and (Test-Path -LiteralPath (Join-Path $wsRoot 'a')) -and -not (Test-Path -LiteralPath (Join-Path $wsRoot 'b'))) { Write-Ok 'Remove-StaleWorkspaceStorage: dry-run keeps both; real run removes only the entry whose folder is gone' } else { Write-Err 'Remove-StaleWorkspaceStorage behaved wrongly'; $fails++ }
|
|
151
|
+
# 14d - stale artefacts: three orientations, so a missing gate in either direction shows up.
|
|
152
|
+
# A marker + source idle 400 days -> listed (the finder works)
|
|
153
|
+
# B marker + fresh source -> not listed (the idle gate works)
|
|
154
|
+
# C NO marker + source idle 400 days -> not listed (the project-marker test works)
|
|
155
|
+
# C must be OLD, or the marker test is never what excludes it and the check is half-oriented.
|
|
156
|
+
$checks++
|
|
157
|
+
$pr = Join-Path $fx 'projects'
|
|
158
|
+
foreach ($n in 'A', 'B', 'C') { New-Item -ItemType Directory -Force -Path (Join-Path $pr "$n\node_modules") | Out-Null; Set-Content -LiteralPath (Join-Path $pr "$n\node_modules\x.txt") -Value 'x' }
|
|
159
|
+
foreach ($n in 'A', 'B') { Set-Content -LiteralPath (Join-Path $pr "$n\package.json") -Value '{}' }
|
|
160
|
+
Set-Content -LiteralPath (Join-Path $pr 'A\main.js') -Value 'x'
|
|
161
|
+
Set-Content -LiteralPath (Join-Path $pr 'C\main.js') -Value 'x'
|
|
162
|
+
foreach ($f in (Join-Path $pr 'A\package.json'), (Join-Path $pr 'A\main.js'), (Join-Path $pr 'C\main.js')) {
|
|
163
|
+
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath $f -Name $ts -Value $ancient }
|
|
164
|
+
}
|
|
165
|
+
$found = @(Find-StaleArtefacts -Roots @($pr) -Days 100)
|
|
166
|
+
if ($found.Count -eq 1 -and $found[0].Path -like '*\A\node_modules' -and $found[0].Age -ge 100) { Write-Ok 'Find-StaleArtefacts lists the idle project with a marker, and neither the fresh one nor the idle one without a marker' } else { Write-Err "Find-StaleArtefacts wrong: $($found.Count) result(s) [$(($found | ForEach-Object { $_.Path }) -join '; ')]"; $fails++ }
|
|
167
|
+
# 14e - report export smoke (schema-1 fixture; HTML must escape the title)
|
|
168
|
+
$checks++
|
|
169
|
+
$rep = Join-Path $fx 'report-fixture.json'
|
|
170
|
+
$fixture = '{"schema_version":1,"credits":{"tool":"windowsweep","tool_version":"0.0.0","tool_homepage":"https://github.com/aoneahsan/windowsweep","tool_license":"MIT License","author":{"name":"Ahsan Mahmood","email":"e","website":"w","linkedin":"l"}},"meta":{"started_at":"2026-09-03T10:00:00+05:00","finished_at":"2026-09-03T10:01:00+05:00","duration_seconds":60,"host":"h","user":"u","os":"Windows","powershell":"5.1","mode":"all","dry_run":false,"elevated":false,"developer_mode":true,"idle_days":100,"temp_days":3,"log_file":"C:\\x.log","launcher":"node","via_npx":false,"tool_version":"0.0.0"},"disk":{"before":[{"drive":"C:","size_bytes":100,"free_bytes":10}],"after":[{"drive":"C:","size_bytes":100,"free_bytes":20}]},"steps":[{"n":1,"section":1,"title":"Package <caches>","status":"ran","freed_bytes":4096,"note":""}],"totals":{"total_reclaimed_bytes":4096,"total_reclaimed_human":"4.0 KB","total_estimated_bytes":0,"total_estimated_human":"0 B","steps_run":1,"steps_skipped":0}}'
|
|
171
|
+
[IO.File]::WriteAllText($rep, $fixture, (New-Object System.Text.UTF8Encoding($false)))
|
|
172
|
+
& $mute
|
|
173
|
+
$md = Convert-ReportToMarkdown -Json $rep
|
|
174
|
+
$html = Convert-ReportToHtml -Json $rep
|
|
175
|
+
& $unmute
|
|
176
|
+
$mdText = ''; $htmlText = ''
|
|
177
|
+
if ($md -and (Test-Path -LiteralPath $md)) { $mdText = Get-Content -LiteralPath $md -Raw }
|
|
178
|
+
if ($html -and (Test-Path -LiteralPath $html)) { $htmlText = Get-Content -LiteralPath $html -Raw }
|
|
179
|
+
if ($mdText.Contains('4.0 KB') -and $mdText.Contains('windowsweep') -and $htmlText.Contains('4.0 KB') -and $htmlText.Contains('Package <caches>') -and -not $htmlText.Contains('Package <caches>')) { Write-Ok 'report export: Markdown and HTML written, totals present, HTML escapes the title' } else { Write-Err 'report export smoke failed'; $fails++ }
|
|
180
|
+
|
|
181
|
+
Write-Section '[15] Catalogue: every section is reachable, and the new ones declare what they promise'
|
|
182
|
+
# 15a - Get-AllTargets is driven by the catalogue, not by a literal 0..21 range
|
|
183
|
+
$checks++
|
|
184
|
+
# The ids each Get-TargetsNN really RETURNS - sections 0 and 21 own a function that yields no rows.
|
|
185
|
+
$declared = @()
|
|
186
|
+
foreach ($s in $Script:WS_SECTIONS) {
|
|
187
|
+
$fn = 'Get-Targets{0:00}' -f $s.Id
|
|
188
|
+
if (-not (Get-Command $fn -ErrorAction SilentlyContinue)) { continue }
|
|
189
|
+
if (@(& $fn).Count -gt 0) { $declared += $s.Id }
|
|
190
|
+
}
|
|
191
|
+
$reached = @(Get-AllTargets | ForEach-Object { $_.Section } | Sort-Object -Unique)
|
|
192
|
+
$missed = @($declared | Where-Object { $reached -notcontains $_ })
|
|
193
|
+
if ($declared.Count -gt 0 -and $missed.Count -eq 0) { Write-Ok "Get-AllTargets reaches all $($declared.Count) section(s) whose Get-TargetsNN returns rows" } else { Write-Err "Get-AllTargets misses section(s): $($missed -join ',')"; $fails++ }
|
|
194
|
+
# 15b - every catalogue row is well formed
|
|
195
|
+
$checks++
|
|
196
|
+
$bad = @()
|
|
197
|
+
$ids = New-Object System.Collections.Generic.HashSet[int]
|
|
198
|
+
foreach ($s in $Script:WS_SECTIONS) {
|
|
199
|
+
if (-not $ids.Add([int]$s.Id)) { $bad += "duplicate id $($s.Id)" }
|
|
200
|
+
if (-not (Get-Command $s.Fn -ErrorAction SilentlyContinue)) { $bad += "section $($s.Id): no function $($s.Fn)" }
|
|
201
|
+
if ($s.Tier -notin 'report', 'rebuilds', 'slow', 'recycle', 'permanent', 'config') { $bad += "section $($s.Id): unknown tier $($s.Tier)" }
|
|
202
|
+
if ($s.Batch -notin 'safe', 'optin', 'deep', 'interactive') { $bad += "section $($s.Id): unknown batch $($s.Batch)" }
|
|
203
|
+
}
|
|
204
|
+
if ($bad.Count -eq 0) { Write-Ok "$($Script:WS_SECTIONS.Count) catalogue rows: unique ids, existing functions, known tier and batch" } else { Write-Err ("catalogue is wrong: " + ($bad -join '; ')); $fails++ }
|
|
205
|
+
# 15c - section 23 derives its exclusions from the declared targets, so the list cannot drift
|
|
206
|
+
$checks++
|
|
207
|
+
$ex = Get-OrphanExclusions
|
|
208
|
+
$wantSegments = @()
|
|
209
|
+
foreach ($t in (Get-AllTargets)) {
|
|
210
|
+
foreach ($pre in @("$($Script:P.A)\", "$($Script:P.L)\")) {
|
|
211
|
+
if ([string]$t.Path -match [regex]::Escape($pre)) { $seg = (([string]$t.Path).Substring(([string]$t.Path).IndexOf($pre) + $pre.Length) -split '\\')[0]; if ($seg) { $wantSegments += $seg } }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
$wantSegments = @($wantSegments | Sort-Object -Unique)
|
|
215
|
+
$lost = @($wantSegments | Where-Object { -not $ex.Contains($_) })
|
|
216
|
+
if ($wantSegments.Count -gt 0 -and $lost.Count -eq 0) { Write-Ok "section 23 excludes all $($wantSegments.Count) AppData folder(s) other sections already clean" } else { Write-Err "section 23 would offer a folder we clean elsewhere: $($lost -join ', ')"; $fails++ }
|
|
217
|
+
# 15d - the fail-closed gate, in two halves: an empty index WOULD call everything orphaned, and does not
|
|
218
|
+
$checks++
|
|
219
|
+
$emptyTokens = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
|
|
220
|
+
$emptyIndex = [pscustomobject]@{ Tokens = $emptyTokens; RegistryCount = 0; Rows = @() }
|
|
221
|
+
$wouldOrphan = Test-OrphanFolderName -Name 'Slack' -Index $emptyIndex
|
|
222
|
+
& $mute
|
|
223
|
+
$gated = @(Find-OrphanedAppData -Days 100 -Index $emptyIndex)
|
|
224
|
+
& $unmute
|
|
225
|
+
if ($wouldOrphan -and $gated.Count -eq 0) { Write-Ok 'section 23 fails closed: an unreadable registry means zero candidates, not everything' } else { Write-Err "section 23 fail-closed gate broken (predicate=$wouldOrphan candidates=$($gated.Count))"; $fails++ }
|
|
226
|
+
# 15e - section 22 declares nothing deletable: its roots are protected subtrees
|
|
227
|
+
$checks++
|
|
228
|
+
$notCmd = @(Get-Targets22 | Where-Object { $_.Kind -ne 'cmd' })
|
|
229
|
+
if ($notCmd.Count -eq 0) { Write-Ok 'section 22 declares no deletable target (audit only)' } else { Write-Err "section 22 declares a deletable target: $(($notCmd | ForEach-Object { $_.Path }) -join ', ')"; $fails++ }
|
|
230
|
+
# 15f - PowerShell variable names are case-insensitive: $p silently destroys the $P roots table
|
|
231
|
+
$checks++
|
|
232
|
+
$clash = @()
|
|
233
|
+
foreach ($f in (Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' -File)) {
|
|
234
|
+
$tk = $null; $er = $null
|
|
235
|
+
$ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tk, [ref]$er)
|
|
236
|
+
foreach ($fn in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
|
|
237
|
+
$sets = @($fn.FindAll({ param($n) ($n -is [System.Management.Automation.Language.AssignmentStatementAst]) -and ($n.Left -is [System.Management.Automation.Language.VariableExpressionAst]) -and ($n.Left.VariablePath.UserPath -eq 'P') }, $true))
|
|
238
|
+
$loops = @($fn.FindAll({ param($n) ($n -is [System.Management.Automation.Language.ForEachStatementAst]) -and ($n.Variable.VariablePath.UserPath -eq 'P') }, $true))
|
|
239
|
+
if (@($sets | Where-Object { $_.Right.Extent.Text -match '\$Script:P\b' }).Count -eq 0) { continue }
|
|
240
|
+
if ($sets.Count -gt 1 -or $loops.Count -gt 0) { $clash += "$($f.Name):$($fn.Name)" }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if ($clash.Count -eq 0) { Write-Ok 'no function reuses $p after taking $P from $Script:P (they are the same variable)' } else { Write-Err "case-insensitive `$P clash in: $($clash -join ', ')"; $fails++ }
|
|
244
|
+
|
|
245
|
+
Write-Section '[16] Scripted selection and the machine-readable contract'
|
|
246
|
+
$ws.SelectPaths = @(); $ws.SelectQueue = @(); $ws.Yes = $false; $ws.Interactive = $false
|
|
247
|
+
# 16a - --select answers one prompt, in order
|
|
248
|
+
$checks++
|
|
249
|
+
$ws.SelectQueue = @('1,3')
|
|
250
|
+
& $mute
|
|
251
|
+
$sel = @(Read-MultiSelect -Total 5 -NoAutoYes)
|
|
252
|
+
& $unmute
|
|
253
|
+
if (($sel -join ',') -eq '1,3' -and $ws.SelectQueue.Count -eq 0 -and $ws.LastSelectionScripted) { Write-Ok "--select '1,3' of 5 -> 1,3, consumed once, marked as a scripted choice" } else { Write-Err "--select wrong: [$($sel -join ',')] queueLeft=$($ws.SelectQueue.Count) scripted=$($ws.LastSelectionScripted)"; $fails++ }
|
|
254
|
+
# 16b - --select-file matches paths, and warns about a line that matches nothing here
|
|
255
|
+
$checks++
|
|
256
|
+
$cands = @("$fx\one.txt", "$fx\two.txt")
|
|
257
|
+
$ws.SelectPaths = @("$fx\TWO.TXT", "$fx\not-offered.txt")
|
|
258
|
+
& $mute
|
|
259
|
+
$sel2 = @(Read-MultiSelect -Total 2 -NoAutoYes -Candidates $cands)
|
|
260
|
+
& $unmute
|
|
261
|
+
$ws.SelectPaths = @()
|
|
262
|
+
if (($sel2 -join ',') -eq '2' -and $ws.LastSelectionScripted) { Write-Ok '--select-file matches case-insensitively and ignores a line no prompt offers' } else { Write-Err "--select-file wrong: [$($sel2 -join ',')]"; $fails++ }
|
|
263
|
+
# 16c - RW-002 still holds under the new code: --yes alone selects nothing and is not a scripted choice
|
|
264
|
+
$checks++
|
|
265
|
+
$ws.Yes = $true; $ws.SelectQueue = @(); $ws.SelectPaths = @()
|
|
266
|
+
& $mute
|
|
267
|
+
$sel3 = @(Read-MultiSelect -Total 4 -NoAutoYes -Candidates @('a', 'b', 'c', 'd'))
|
|
268
|
+
& $unmute
|
|
269
|
+
$stillOff = (-not $ws.LastSelectionScripted)
|
|
270
|
+
$ws.Yes = $false
|
|
271
|
+
if ($sel3.Count -eq 0 -and $stillOff) { Write-Ok '--yes alone still selects nothing and never counts as a scripted choice' } else { Write-Err "DANGEROUS: --yes selected [$($sel3 -join ',')] scripted=$(-not $stillOff)"; $fails++ }
|
|
272
|
+
# 16d - the --json document keeps a stable shape even when nothing was collected
|
|
273
|
+
$checks++
|
|
274
|
+
$ws.JsonMode = $true; $ws.Candidates = @(); $ws.ScanTargets = @()
|
|
275
|
+
$doc = (Get-JsonSummary) | ConvertTo-Json -Depth 6 -Compress
|
|
276
|
+
$back = $null
|
|
277
|
+
try { $back = $doc | ConvertFrom-Json } catch { $back = $null }
|
|
278
|
+
$ws.JsonMode = $false
|
|
279
|
+
$hasBoth = ($back -and $null -ne $back.PSObject.Properties['candidates'] -and $null -ne $back.PSObject.Properties['targets'])
|
|
280
|
+
if ($hasBoth -and -not $doc.Contains("`n")) { Write-Ok '--json carries candidates and targets as keys even when empty, on one line' } else { Write-Err 'the --json shape is missing candidates/targets or spans lines'; $fails++ }
|
|
281
|
+
# 16e - --list --json covers the whole catalogue
|
|
282
|
+
$checks++
|
|
283
|
+
$cat = (Get-CatalogueJson) | ConvertTo-Json -Depth 6 -Compress | ConvertFrom-Json
|
|
284
|
+
$catIds = @($cat.sections | ForEach-Object { [int]$_.id } | Sort-Object)
|
|
285
|
+
$realIds = @($Script:WS_SECTIONS | ForEach-Object { [int]$_.Id } | Sort-Object)
|
|
286
|
+
if (($catIds -join ',') -eq ($realIds -join ',') -and $cat.safe_batch.Count -gt 0 -and $cat.walkthrough.Count -gt 0) { Write-Ok "--list --json covers all $($catIds.Count) sections plus the batch and profile lists" } else { Write-Err '--list --json does not match the catalogue'; $fails++ }
|
|
287
|
+
# 16f - the progress line a GUI parses round-trips
|
|
288
|
+
$checks++
|
|
289
|
+
$pl = Get-MachineProgressLine -Section 7 -Stage 'end' -Status 'ran' -Freed 4096
|
|
290
|
+
$ok16f = ($pl -match "^##$Script:WS_NAME section=(\d+) event=(\w+) status=([\w-]+) freed_bytes=(\d+)$") -and ([int]$Matches[1] -eq 7) -and ($Matches[2] -eq 'end') -and ($Matches[3] -eq 'ran') -and ([long]$Matches[4] -eq 4096)
|
|
291
|
+
$plStart = Get-MachineProgressLine -Section 7 -Stage 'start'
|
|
292
|
+
if ($ok16f -and $plStart -eq "##$Script:WS_NAME section=7 event=start") { Write-Ok 'the ##windowsweep progress line parses back into section, event, status and freed_bytes' } else { Write-Err "progress line format wrong: $pl"; $fails++ }
|
|
293
|
+
|
|
294
|
+
Write-Section '[17] New helpers: global packages, startup state, artefact list'
|
|
295
|
+
# 17a - only roots that exist are reported
|
|
296
|
+
$checks++
|
|
297
|
+
$roots = @(Get-GlobalPackageRoots)
|
|
298
|
+
$ghost = @($roots | Where-Object { -not (Test-DirPresent $_.Root) })
|
|
299
|
+
if ($ghost.Count -eq 0) { Write-Ok "Get-GlobalPackageRoots returned $($roots.Count) root(s), all of them present" } else { Write-Err "Get-GlobalPackageRoots returned a missing root: $(($ghost | ForEach-Object { $_.Root }) -join ', ')"; $fails++ }
|
|
300
|
+
# 17b - three orientations: A the finder works, B the reference test works, C the idle gate works
|
|
301
|
+
$checks++
|
|
302
|
+
$names = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
303
|
+
$null = $names.Add('kept-because-referenced')
|
|
304
|
+
$idx = [pscustomobject]@{ Names = $names; Bins = (New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)); ScriptText = '' }
|
|
305
|
+
$pkgA = [pscustomobject]@{ Manager = 'npm'; Name = 'gone-stale'; Version = '1.0.0'; Path = ''; Bytes = [long]1; Idle = 400; Bins = @() }
|
|
306
|
+
$pkgB = [pscustomobject]@{ Manager = 'npm'; Name = 'kept-because-referenced'; Version = '1.0.0'; Path = ''; Bytes = [long]1; Idle = 400; Bins = @() }
|
|
307
|
+
$pkgC = [pscustomobject]@{ Manager = 'npm'; Name = 'kept-because-fresh'; Version = '1.0.0'; Path = ''; Bytes = [long]1; Idle = 1; Bins = @() }
|
|
308
|
+
$vA = (Get-GlobalPackageVerdict -Package $pkgA -Index $idx -Days 100).Verdict
|
|
309
|
+
$vB = (Get-GlobalPackageVerdict -Package $pkgB -Index $idx -Days 100).Verdict
|
|
310
|
+
$vC = (Get-GlobalPackageVerdict -Package $pkgC -Index $idx -Days 100).Verdict
|
|
311
|
+
if ($vA -eq 'candidate' -and $vB -eq 'keep' -and $vC -eq 'keep') { Write-Ok 'global verdict: idle+unreferenced is a candidate; referenced and fresh are both kept' } else { Write-Err "global verdict wrong: A=$vA B=$vB C=$vC"; $fails++ }
|
|
312
|
+
# 17c - startup state comes from Explorer's disable timestamp, not from a guessed first byte
|
|
313
|
+
$checks++
|
|
314
|
+
$disabledVal = [byte[]](1, 0, 0, 0, 39, 190, 225, 241, 9, 168, 220, 1)
|
|
315
|
+
$enabledVal = [byte[]](1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
|
316
|
+
$markerVal = [byte[]](3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
|
317
|
+
$shortVal = [byte[]](1, 0, 0, 0)
|
|
318
|
+
$s1 = Get-StartupApprovedState -Value $disabledVal
|
|
319
|
+
$s2 = Get-StartupApprovedState -Value $enabledVal
|
|
320
|
+
$s3 = Get-StartupApprovedState -Value $markerVal
|
|
321
|
+
$s4 = Get-StartupApprovedState -Value $shortVal
|
|
322
|
+
if ($s1 -eq 'disabled' -and $s2 -eq 'enabled' -and $s3 -eq 'disabled' -and $s4 -eq 'unknown') { Write-Ok 'startup state: a disable timestamp means disabled, a zero tail means enabled, 03 still counts, a short value is unknown' } else { Write-Err "startup state wrong: stamped=$s1 zeroTail=$s2 marker03=$s3 short=$s4"; $fails++ }
|
|
323
|
+
# 17d - the artefact list gained the seven additions and still refuses the three that hold real work
|
|
324
|
+
$checks++
|
|
325
|
+
$added = @('.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
|
|
326
|
+
$forbidden = @('.venv', 'venv', '.terraform')
|
|
327
|
+
$missingAdd = @($added | Where-Object { $Script:WS_ARTEFACT_DIRS -notcontains $_ })
|
|
328
|
+
$present = @($forbidden | Where-Object { $Script:WS_ARTEFACT_DIRS -contains $_ })
|
|
329
|
+
if ($missingAdd.Count -eq 0 -and $present.Count -eq 0) { Write-Ok 'artefact list has the seven additions and none of .venv, venv, .terraform' } else { Write-Err "artefact list wrong (missing: $($missingAdd -join ',') / forbidden present: $($present -join ','))"; $fails++ }
|
|
330
|
+
# 17e - .cache is an artefact only beside a Gatsby or Parcel marker; the normal rule must still work
|
|
331
|
+
$checks++
|
|
332
|
+
$mk = Join-Path $fx 'marked'
|
|
333
|
+
foreach ($n in 'gatsby', 'plain', 'normal') { New-Item -ItemType Directory -Force -Path (Join-Path $mk "$n\.cache") | Out-Null; Set-Content -LiteralPath (Join-Path $mk "$n\.cache\f.txt") -Value 'x' }
|
|
334
|
+
New-Item -ItemType Directory -Force -Path (Join-Path $mk 'normal\node_modules') | Out-Null
|
|
335
|
+
Set-Content -LiteralPath (Join-Path $mk 'normal\node_modules\f.txt') -Value 'x'
|
|
336
|
+
Set-Content -LiteralPath (Join-Path $mk 'gatsby\gatsby-config.js') -Value 'module.exports = {}'
|
|
337
|
+
foreach ($n in 'gatsby', 'plain', 'normal') { Set-Content -LiteralPath (Join-Path $mk "$n\package.json") -Value '{}' }
|
|
338
|
+
# EVERY file ages, not just the markers: an unmarked .cache counts as source, so a fresh file inside
|
|
339
|
+
# plain\.cache or normal\.cache would make its project look active and the check would prove nothing.
|
|
340
|
+
foreach ($f in (Get-ChildItem -LiteralPath $mk -Recurse -File -Force)) {
|
|
341
|
+
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath $f.FullName -Name $ts -Value $ancient }
|
|
342
|
+
}
|
|
343
|
+
& $mute
|
|
344
|
+
$hits = @(Find-StaleArtefacts -Roots @($mk) -Days 100 | ForEach-Object { $_.Path })
|
|
345
|
+
& $unmute
|
|
346
|
+
$gotGatsbyCache = @($hits | Where-Object { $_ -like "*\gatsby\.cache" }).Count -eq 1
|
|
347
|
+
$noPlainCache = @($hits | Where-Object { $_ -like "*\plain\.cache" }).Count -eq 0
|
|
348
|
+
$gotNodeModules = @($hits | Where-Object { $_ -like "*\normal\node_modules" }).Count -eq 1
|
|
349
|
+
if ($gotGatsbyCache -and $noPlainCache -and $gotNodeModules) { Write-Ok '.cache is an artefact only beside a Gatsby/Parcel marker, and node_modules is unaffected' } else { Write-Err "marked-artefact rule wrong: gatsby=$gotGatsbyCache plainExcluded=$noPlainCache nodeModules=$gotNodeModules"; $fails++ }
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
Write-Section '[18] The 1.2.0 contract additions: newest_write_utc, protected, --exclude-path'
|
|
353
|
+
|
|
354
|
+
# 18a - targets[].newest_write_utc reports the newest timestamp under a target, in ISO 8601 UTC.
|
|
355
|
+
# A fixture with a KNOWN mtime, so the assertion is about the value and not merely about the shape.
|
|
356
|
+
$checks++
|
|
357
|
+
$nw = Join-Path $fx 'newest'
|
|
358
|
+
New-Item -ItemType Directory -Force -Path (Join-Path $nw 'deep') | Out-Null
|
|
359
|
+
Set-Content -LiteralPath (Join-Path $nw 'old.txt') -Value 'x'
|
|
360
|
+
Set-Content -LiteralPath (Join-Path $nw 'deep\\new.txt') -Value 'x'
|
|
361
|
+
$stampOld = [datetime]::new(2021, 3, 4, 5, 6, 7, [DateTimeKind]::Utc)
|
|
362
|
+
$stampNew = [datetime]::new(2023, 11, 12, 13, 14, 15, [DateTimeKind]::Utc)
|
|
363
|
+
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') {
|
|
364
|
+
Set-ItemProperty -LiteralPath (Join-Path $nw 'old.txt') -Name $ts -Value $stampOld.ToLocalTime()
|
|
365
|
+
Set-ItemProperty -LiteralPath (Join-Path $nw 'deep\\new.txt') -Name $ts -Value $stampNew.ToLocalTime()
|
|
366
|
+
}
|
|
367
|
+
$st = Get-DirectoryStats $nw
|
|
368
|
+
$iso = Format-Utc8601 $st.Newest
|
|
369
|
+
# The DEEPER file is the newer one, so a walk that stopped at the top level would report the older stamp
|
|
370
|
+
# and still look like a working timestamp - which is the failure this fixture is shaped to catch.
|
|
371
|
+
$wantIso = '2023-11-12T13:14:15Z'
|
|
372
|
+
$nullIso = Format-Utc8601 ([datetime]::MinValue)
|
|
373
|
+
if ($iso -eq $wantIso -and $null -eq $nullIso) { Write-Ok "newest_write_utc reads the deepest newest file ($iso) and is null for an empty tree" } else { Write-Err "newest_write_utc wrong: got $iso want $wantIso; MinValue gave '$nullIso'"; $fails++ }
|
|
374
|
+
|
|
375
|
+
# 18b - --list --json publishes the SAME protected lists the console prints, from one constant.
|
|
376
|
+
$checks++
|
|
377
|
+
$cat = Get-CatalogueJson
|
|
378
|
+
$prot = $cat.protected
|
|
379
|
+
$sameSub = ($null -ne $prot) -and ((@($prot.subtrees) -join '|') -eq (@($Script:WS_PROTECT.Subtrees) -join '|'))
|
|
380
|
+
$sameCat = ($null -ne $prot) -and ((@($prot.categories) -join '|') -eq (@($Script:WS_PROTECT_CATEGORIES) -join '|'))
|
|
381
|
+
$nonEmpty = ($null -ne $prot) -and (@($prot.subtrees).Count -gt 0) -and (@($prot.categories).Count -gt 0)
|
|
382
|
+
if ($sameSub -and $sameCat -and $nonEmpty) { Write-Ok "--list --json publishes the same $(@($prot.subtrees).Count) protected subtrees and $(@($prot.categories).Count) categories the console prints" } else { Write-Err "--list --json protected does not match the printed lists (subtrees=$sameSub categories=$sameCat nonEmpty=$nonEmpty)"; $fails++ }
|
|
383
|
+
|
|
384
|
+
# 18c - an excluded path SURVIVES a real fixture run while its sibling goes. Not a dry-run: the whole
|
|
385
|
+
# point is that the chokepoint refuses the deletion rather than merely declining to print it.
|
|
386
|
+
$checks++
|
|
387
|
+
$exRoot = Join-Path $fx 'excl'
|
|
388
|
+
$keep = Join-Path $exRoot 'keep-me'
|
|
389
|
+
$go = Join-Path $exRoot 'go-away'
|
|
390
|
+
foreach ($d in $keep, $go) { New-Item -ItemType Directory -Force -Path $d | Out-Null; Set-Content -LiteralPath (Join-Path $d 'f.txt') -Value 'xxxx' }
|
|
391
|
+
$savedEx = $ws.ExcludePaths
|
|
392
|
+
$savedExcluded = $ws.Excluded
|
|
393
|
+
$savedTable = $Script:WS_EXCLUDE
|
|
394
|
+
try {
|
|
395
|
+
$ws.ExcludePaths = @($keep)
|
|
396
|
+
$ws.Excluded = @()
|
|
397
|
+
Initialize-Exclusions
|
|
398
|
+
& $mute
|
|
399
|
+
$rKeep = Remove-PathSafe -Path $keep -Within $exRoot
|
|
400
|
+
$rGo = Remove-PathSafe -Path $go -Within $exRoot
|
|
401
|
+
& $unmute
|
|
402
|
+
$keptOnDisk = Test-Path -LiteralPath $keep
|
|
403
|
+
# A CHILD, not just the excluded path itself: testing only the exact path let a prefix bug
|
|
404
|
+
# (a doubled backslash) pass this check while nothing inside an excluded folder was refused.
|
|
405
|
+
$childReason = Get-ProtectionReason (Join-Path $keep 'f.txt')
|
|
406
|
+
$childRefused = ($childReason -like 'excluded: *')
|
|
407
|
+
$goneFromDisk = -not (Test-Path -LiteralPath $go)
|
|
408
|
+
$reported = (@($ws.Excluded).Count -eq 1)
|
|
409
|
+
$reasonOk = $rKeep.Reason -like 'excluded: *'
|
|
410
|
+
if ($keptOnDisk -and $goneFromDisk -and $rGo.Removed -and $reported -and $reasonOk -and $childRefused) { Write-Ok "--exclude-path is honoured by the chokepoint for every section: the excluded folder AND a file inside it are both refused, its sibling is not, and it is reported once" } else { Write-Err "--exclude-path not honoured (kept=$keptOnDisk siblingGone=$goneFromDisk reported=$reported childRefused=$childRefused reason='$($rKeep.Reason)')"; $fails++ }
|
|
411
|
+
} finally {
|
|
412
|
+
$ws.ExcludePaths = $savedEx
|
|
413
|
+
$ws.Excluded = $savedExcluded
|
|
414
|
+
$Script:WS_EXCLUDE = $savedTable
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# 18d - the rehearsal and the run agree about an EXCLUDED file inside a pruned cache.
|
|
419
|
+
# Remove-StaleFiles used to apply Get-ProtectionReason only in its real-run loop, so a dry-run
|
|
420
|
+
# counted files the run then skipped and the estimate came out higher than the run - for exactly
|
|
421
|
+
# the files a person most wants the two numbers to agree about.
|
|
422
|
+
$checks++
|
|
423
|
+
$pr = Join-Path $fx 'prune'
|
|
424
|
+
$prKeep = Join-Path $pr 'keep'
|
|
425
|
+
New-Item -ItemType Directory -Force -Path $prKeep | Out-Null
|
|
426
|
+
$goFile = Join-Path $pr 'go.bin'
|
|
427
|
+
$keepFile = Join-Path $prKeep 'keep.bin'
|
|
428
|
+
Set-Content -LiteralPath $goFile -Value ('x' * 400)
|
|
429
|
+
Set-Content -LiteralPath $keepFile -Value ('y' * 400)
|
|
430
|
+
foreach ($f in @($goFile, $keepFile)) {
|
|
431
|
+
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') {
|
|
432
|
+
Set-ItemProperty -LiteralPath $f -Name $ts -Value $ancient
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
$savedEx2 = $ws.ExcludePaths
|
|
436
|
+
$savedTbl2 = $Script:WS_EXCLUDE
|
|
437
|
+
$savedDry = $ws.DryRun
|
|
438
|
+
$savedFreed = $ws.TotalFreed
|
|
439
|
+
$savedEst = $ws.TotalEstimated
|
|
440
|
+
try {
|
|
441
|
+
$ws.ExcludePaths = @($prKeep)
|
|
442
|
+
Initialize-Exclusions
|
|
443
|
+
& $mute
|
|
444
|
+
$ws.DryRun = $true
|
|
445
|
+
$dry = Remove-StaleFiles -Root $pr -Within $pr -Days 1
|
|
446
|
+
$ws.DryRun = $false
|
|
447
|
+
$real = Remove-StaleFiles -Root $pr -Within $pr -Days 1
|
|
448
|
+
& $unmute
|
|
449
|
+
$agree = ($dry.Files -eq $real.Files) -and ($dry.Freed -eq $real.Freed)
|
|
450
|
+
$keptOnDisk2 = Test-Path -LiteralPath $keepFile
|
|
451
|
+
$goneFromDisk2 = -not (Test-Path -LiteralPath $goFile)
|
|
452
|
+
if ($agree -and $keptOnDisk2 -and $goneFromDisk2 -and $dry.Files -eq 1) {
|
|
453
|
+
Write-Ok "a dry-run and a real prune agree exactly when a file is excluded: both report $($dry.Files) file, the excluded one survives"
|
|
454
|
+
} else {
|
|
455
|
+
Write-Err "rehearsal and run disagree (dryFiles=$($dry.Files) realFiles=$($real.Files) dryBytes=$($dry.Freed) realBytes=$($real.Freed) kept=$keptOnDisk2 siblingGone=$goneFromDisk2)"; $fails++
|
|
456
|
+
}
|
|
457
|
+
} finally {
|
|
458
|
+
$ws.ExcludePaths = $savedEx2
|
|
459
|
+
$Script:WS_EXCLUDE = $savedTbl2
|
|
460
|
+
$ws.DryRun = $savedDry
|
|
461
|
+
$ws.TotalFreed = $savedFreed
|
|
462
|
+
$ws.TotalEstimated = $savedEst
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# 18e - a read-only scan REPORTS the developer answer it was given, and invents one when it was not.
|
|
467
|
+
# --scan parsed --developer / --not-developer into DeveloperFlag and then never read it, because
|
|
468
|
+
# Resolve-DeveloperMode ran only for walkthrough, menu, all and only. So section 0 printed
|
|
469
|
+
# "Developer mode: not decided yet" whatever you passed, and --json returned developer: null. The
|
|
470
|
+
# desktop window sends those flags to --scan and could not read back the setting it had just sent.
|
|
471
|
+
# Three orientations, because two of them would pass on the broken code by accident:
|
|
472
|
+
# A the flag is honoured, B a saved answer is honoured, C neither exists -> still undecided.
|
|
473
|
+
$checks++
|
|
474
|
+
$savedFlag = $ws.DeveloperFlag
|
|
475
|
+
$savedDev = $ws.Developer
|
|
476
|
+
$savedSrc = $ws.DeveloperSource
|
|
477
|
+
$savedCfg = $ws.Config
|
|
478
|
+
$savedInter = $ws.Interactive
|
|
479
|
+
$savedForget = $ws.ForgetDeveloper
|
|
480
|
+
try {
|
|
481
|
+
$ws.Interactive = $true # -ReadOnly must not ask even here; a prompt would hang the suite
|
|
482
|
+
$ws.ForgetDeveloper = $false
|
|
483
|
+
|
|
484
|
+
# A - the flag wins, and --not-developer is the case that was silently dropped
|
|
485
|
+
$ws.Config = @{}
|
|
486
|
+
$ws.DeveloperFlag = $false
|
|
487
|
+
$ws.Developer = $null
|
|
488
|
+
$ws.DeveloperSource = $null
|
|
489
|
+
& $mute
|
|
490
|
+
Resolve-DeveloperMode -ReadOnly
|
|
491
|
+
& $unmute
|
|
492
|
+
$aOk = ($ws.Developer -eq $false) -and ($ws.DeveloperSource -eq 'flag')
|
|
493
|
+
|
|
494
|
+
# B - no flag, but a saved answer exists
|
|
495
|
+
$ws.Config = @{ developer = $true }
|
|
496
|
+
$ws.DeveloperFlag = $null
|
|
497
|
+
$ws.Developer = $null
|
|
498
|
+
$ws.DeveloperSource = $null
|
|
499
|
+
& $mute
|
|
500
|
+
Resolve-DeveloperMode -ReadOnly
|
|
501
|
+
& $unmute
|
|
502
|
+
$bOk = ($ws.Developer -eq $true) -and ($ws.DeveloperSource -eq 'config')
|
|
503
|
+
|
|
504
|
+
# C - neither: a read-only mode must NOT invent a decision, and must not write config.json
|
|
505
|
+
$ws.Config = @{}
|
|
506
|
+
$ws.DeveloperFlag = $null
|
|
507
|
+
$ws.Developer = $null
|
|
508
|
+
$ws.DeveloperSource = $null
|
|
509
|
+
& $mute
|
|
510
|
+
Resolve-DeveloperMode -ReadOnly
|
|
511
|
+
& $unmute
|
|
512
|
+
$cOk = ($null -eq $ws.Developer) -and ($null -eq $ws.Config.developer)
|
|
513
|
+
|
|
514
|
+
if ($aOk -and $bOk -and $cOk) {
|
|
515
|
+
Write-Ok "a read-only scan honours --not-developer (flag) and a saved answer (config), and stays undecided when it has neither"
|
|
516
|
+
} else {
|
|
517
|
+
Write-Err "read-only developer resolution wrong (flagHonoured=$aOk savedHonoured=$bOk stillUndecided=$cOk)"; $fails++
|
|
518
|
+
}
|
|
519
|
+
} finally {
|
|
520
|
+
$ws.DeveloperFlag = $savedFlag
|
|
521
|
+
$ws.Developer = $savedDev
|
|
522
|
+
$ws.DeveloperSource = $savedSrc
|
|
523
|
+
$ws.Config = $savedCfg
|
|
524
|
+
$ws.Interactive = $savedInter
|
|
525
|
+
$ws.ForgetDeveloper = $savedForget
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
} catch {
|
|
529
|
+
& $unmute
|
|
530
|
+
Write-Err "extra self-test crashed: $($_.Exception.Message)"; $fails++
|
|
531
|
+
} finally {
|
|
532
|
+
& $unmute
|
|
533
|
+
foreach ($k in @($saved.Keys)) { $ws[$k] = $saved[$k] }
|
|
534
|
+
try { if (Test-Path -LiteralPath $fx) { [IO.Directory]::Delete((Get-LongPath $fx), $true) } } catch { $null = $_ }
|
|
535
|
+
}
|
|
536
|
+
return [pscustomobject]@{ Checks = $checks; Fails = $fails }
|
|
537
|
+
}
|