windowsweep 1.0.0 → 1.1.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.
@@ -0,0 +1,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 &lt;caches&gt;') -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
+ } catch {
352
+ & $unmute
353
+ Write-Err "extra self-test crashed: $($_.Exception.Message)"; $fails++
354
+ } finally {
355
+ & $unmute
356
+ foreach ($k in @($saved.Keys)) { $ws[$k] = $saved[$k] }
357
+ try { if (Test-Path -LiteralPath $fx) { [IO.Directory]::Delete((Get-LongPath $fx), $true) } } catch { $null = $_ }
358
+ }
359
+ return [pscustomobject]@{ Checks = $checks; Fails = $fails }
360
+ }
@@ -0,0 +1,126 @@
1
+ # startup_audit.ps1 - section 25: everything that starts with the user or the machine. Report only.
2
+ #
3
+ # The tool shows; the user decides in Task Manager. That non-goal is deliberate: disabling a startup item is
4
+ # a system configuration change, not cleanup, and a wrong one is invisible until the next boot.
5
+
6
+ function Get-StartupApprovedState {
7
+ <# .SYNOPSIS enabled | disabled | unknown from an Explorer StartupApproved value.
8
+ The value is 12 bytes: a DWORD of flags then a FILETIME. Explorer stamps that FILETIME when the item is
9
+ DISABLED and zeroes it when the item is enabled, so a non-zero tail is the reliable signal.
10
+ Measured on Windows 10 19045 (2026-09-04): five items carried a real FILETIME under a leading 0x01, and
11
+ two enabled ones (0x01 and 0x04) carried an all-zero tail - the widely quoted "byte 0 is 02/03" table
12
+ does not hold there, so the tail is read first and 03 / 07 are kept only as a fallback marker.
13
+ A value shorter than 12 bytes is reported as unknown; it is never guessed. #>
14
+ param([byte[]] $Value)
15
+ if ($null -eq $Value -or $Value.Length -lt 12) { return 'unknown' }
16
+ for ($i = 4; $i -lt 12; $i++) { if ($Value[$i] -ne 0) { return 'disabled' } }
17
+ if ($Value[0] -eq 3 -or $Value[0] -eq 7) { return 'disabled' }
18
+ return 'enabled'
19
+ }
20
+
21
+ function Get-StartupApprovedMap {
22
+ <# .SYNOPSIS name -> enabled|disabled|unknown, from the StartupApproved keys Explorer maintains. #>
23
+ $map = @{}
24
+ $keys = @(
25
+ 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
26
+ 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32',
27
+ 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder',
28
+ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
29
+ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder'
30
+ )
31
+ foreach ($k in $keys) {
32
+ $props = $null
33
+ try { $props = Get-ItemProperty -LiteralPath $k -ErrorAction Stop } catch { continue }
34
+ foreach ($prop in $props.PSObject.Properties) {
35
+ if ($prop.Name.StartsWith('PS')) { continue }
36
+ if (-not ($prop.Value -is [byte[]])) { continue }
37
+ $map[$prop.Name] = Get-StartupApprovedState -Value $prop.Value
38
+ }
39
+ }
40
+ return $map
41
+ }
42
+
43
+ function Get-StartupItems {
44
+ <# .SYNOPSIS Every startup entry the user can see, from all five places Windows keeps them. #>
45
+ $P = $Script:P
46
+ $approved = Get-StartupApprovedMap
47
+ $items = @()
48
+ $runKeys = @(
49
+ @('HKCU Run', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'),
50
+ @('HKCU RunOnce', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'),
51
+ @('HKLM Run', 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'),
52
+ @('HKLM RunOnce', 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'),
53
+ @('HKLM Run (32)', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'),
54
+ @('HKLM RunOnce (32)', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce')
55
+ )
56
+ foreach ($rk in $runKeys) {
57
+ # NOT $p: PowerShell variable names are case-insensitive, so $p would silently null the $P roots table.
58
+ $props = $null
59
+ try { $props = Get-ItemProperty -LiteralPath $rk[1] -ErrorAction Stop } catch { continue }
60
+ foreach ($prop in $props.PSObject.Properties) {
61
+ if ($prop.Name.StartsWith('PS')) { continue }
62
+ $state = 'enabled'
63
+ if ($approved.ContainsKey($prop.Name)) { $state = $approved[$prop.Name] }
64
+ $items += [pscustomobject]@{ Source = $rk[0]; Name = $prop.Name; State = $state; Command = [string]$prop.Value }
65
+ }
66
+ }
67
+ foreach ($folder in @(@('Startup (user)', (Join-Path $P.A 'Microsoft\Windows\Start Menu\Programs\Startup')), @('Startup (all users)', (Join-Path $P.PD 'Microsoft\Windows\Start Menu\Programs\Startup')))) {
68
+ if (-not (Test-DirPresent $folder[1])) { continue }
69
+ foreach ($e in (Get-ChildEntries $folder[1])) {
70
+ if ($e -is [IO.DirectoryInfo] -or $e.Name -eq 'desktop.ini') { continue }
71
+ $state = 'enabled'
72
+ if ($approved.ContainsKey($e.Name)) { $state = $approved[$e.Name] }
73
+ $items += [pscustomobject]@{ Source = $folder[0]; Name = $e.Name; State = $state; Command = (Remove-LongPrefix $e.FullName) }
74
+ }
75
+ }
76
+ try {
77
+ foreach ($t in (Get-ScheduledTask -ErrorAction Stop)) {
78
+ if (-not (@($t.Triggers) | Where-Object { $_.CimClass.CimClassName -eq 'MSFT_TaskLogonTrigger' })) { continue }
79
+ $state = 'enabled'
80
+ if ($t.State -eq 'Disabled') { $state = 'disabled' }
81
+ $cmd = ''
82
+ foreach ($a in @($t.Actions)) { if ($a.Execute) { $cmd = [string]$a.Execute } }
83
+ $items += [pscustomobject]@{ Source = 'Scheduled task (logon)'; Name = $t.TaskName; State = $state; Command = $cmd }
84
+ }
85
+ } catch { $null = $_ }
86
+ try {
87
+ foreach ($c in (Get-CimInstance Win32_StartupCommand -ErrorAction Stop)) {
88
+ if (@($items | Where-Object { $_.Name -eq $c.Name }).Count -gt 0) { continue }
89
+ $state = 'enabled'
90
+ if ($approved.ContainsKey([string]$c.Name)) { $state = $approved[[string]$c.Name] }
91
+ $items += [pscustomobject]@{ Source = "WMI ($($c.Location))"; Name = [string]$c.Name; State = $state; Command = [string]$c.Command }
92
+ }
93
+ } catch { $null = $_ }
94
+ return @($items | Sort-Object Source, Name)
95
+ }
96
+
97
+ function Get-Targets25 {
98
+ <# .SYNOPSIS Informational only. Section 25 changes nothing: it never disables or removes a startup item. #>
99
+ return @((New-Target 25 'Startup items (registry, folders, tasks)' 'Run keys + Startup folders + logon tasks' -Kind cmd -Note 'report only; change them yourself in Task Manager > Startup'))
100
+ }
101
+
102
+ function Invoke-Section25 {
103
+ $ws = $Script:WS
104
+ Write-SectionIntro @(
105
+ 'Everything that launches when you sign in or the machine boots: Run and RunOnce keys, both Startup',
106
+ 'folders, scheduled tasks with a logon trigger, and what Explorer records as enabled or disabled.',
107
+ 'This section only shows them - change them in Task Manager > Startup, where you can undo it.'
108
+ )
109
+ $items = @(Get-StartupItems)
110
+ if ($items.Count -eq 0) { Write-Info 'no startup items found'; return }
111
+ Write-UiLine (" {0,-22} {1,-30} {2,-9} {3}" -f 'SOURCE', 'NAME', 'STATE', 'COMMAND') 'White'
112
+ $lines = @("startup items - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
113
+ foreach ($it in $items) {
114
+ $color = 'Gray'
115
+ if ($it.State -eq 'disabled') { $color = 'DarkGray' }
116
+ $line = " {0,-22} {1,-30} {2,-9} {3}" -f $it.Source, $it.Name, $it.State, $it.Command
117
+ Write-UiLine $line $color
118
+ $lines += $line.Trim()
119
+ }
120
+ $on = @($items | Where-Object { $_.State -eq 'enabled' }).Count
121
+ Write-Info "$($items.Count) startup item(s), $on enabled"
122
+ if (-not $ws.NoReport) {
123
+ $out = Join-Path $ws.ReportsDir ('startup-items-' + $ws.Stamp + '.txt')
124
+ try { [IO.File]::WriteAllLines($out, $lines); Write-Note "list saved: $out" } catch { $null = $_ }
125
+ }
126
+ }
@@ -31,6 +31,7 @@ function Get-Targets09 {
31
31
  (New-Target 9 'Certificate URL cache' "$($P.LL)\Microsoft\CryptnetUrlCache" -Mode clear)
32
32
  (New-Target 9 'Diagnostics results' "$($P.L)\Diagnostics" -Mode clear-old -Days 30)
33
33
  (New-Target 9 'DNS resolver cache' 'ipconfig /flushdns' -Kind cmd)
34
+ (New-Target 9 'Microsoft Store download cache' 'wsreset.exe' -Kind cmd -Note 'printed as a next step, never run for you: wsreset has no silent mode and always opens the Store')
34
35
  )
35
36
  }
36
37
 
@@ -46,6 +47,11 @@ function Invoke-Section09 {
46
47
  $null = Invoke-TargetList $targets
47
48
  $r = Invoke-External -FilePath 'ipconfig.exe' -ArgumentList @('/flushdns') -Destructive -Quiet -Label 'ipconfig /flushdns'
48
49
  if ($r.Ran -and $r.ExitCode -eq 0) { Write-Ok 'DNS resolver cache flushed' }
50
+ # wsreset is the only lever for the Store download cache, and it has no silent switch: every build opens
51
+ # the Store window when it finishes. A cleanup run must not pop a window, so it is offered, never executed.
52
+ if (Test-PathPresent (Join-Path $Script:P.SR 'System32\wsreset.exe')) {
53
+ $Script:WS.Hints += 'Microsoft Store cache: run wsreset.exe yourself when you want it cleared (it opens the Store when it finishes)'
54
+ }
49
55
  }
50
56
 
51
57
  # ---------------------------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windowsweep",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Safe-by-default Windows cleanup CLI - developer-aware, dry-run first, zero install via npx.",
5
5
  "bin": {
6
6
  "windowsweep": "bin/windowsweep.js"
@@ -24,7 +24,8 @@
24
24
  "LICENSE",
25
25
  "CHANGELOG.md",
26
26
  "SECURITY.md",
27
- "VERSION"
27
+ "VERSION",
28
+ "AI-INTEGRATION-GUIDE.md"
28
29
  ],
29
30
  "engines": {
30
31
  "node": ">=14"
@@ -38,7 +39,6 @@
38
39
  "disk-cleanup",
39
40
  "cache-cleanup",
40
41
  "disk-space",
41
- "temp-files",
42
42
  "node-modules",
43
43
  "npm-cache",
44
44
  "yarn-cache",
package/windowsweep.ps1 CHANGED
@@ -20,7 +20,11 @@ $Script:WS = @{
20
20
  ScanRoots = @(); ExcludePaths = @(); Hiberfil = ''; ResetBase = $false; Permanent = $false
21
21
  OnlyList = ''; Profile = ''; Exclude = ''; ExportFmt = 'both'; ExportId = 'latest'; PruneDays = 90
22
22
  LogsDir = ''; ReportsDir = ''; NoReport = $false; CleanupLogs = $false
23
- JsonMode = $false; Quiet = $false; NoColor = $false; Ascii = $false
23
+ JsonMode = $false; Quiet = $false; NoColor = $false; Ascii = $false; Notify = $false
24
+ # Scripted selection (--select / --select-file): a person's choice made in advance. SelectActive is LATCHED
25
+ # once at parse time because SelectQueue empties as prompts consume it.
26
+ SelectQueue = @(); SelectPaths = @(); SelectActive = $false; LastSelectionScripted = $false
27
+ Candidates = @(); ScanTargets = @()
24
28
  TotalFreed = [long]0; TotalEstimated = [long]0; SectionFreed = [long]0
25
29
  Refusals = @(); Hints = @(); ExitCode = 0; Finished = $false; AllowOwnData = $false
26
30
  }
@@ -79,6 +83,11 @@ OPTIONS
79
83
  --large-file-mb N Minimum size for section 19 (default 100)
80
84
  --hiberfil off|reduced|keep What section 15 does --reset-base DISM /ResetBase in 14
81
85
  --permanent Sections 18/19 delete instead of using the Recycle Bin
86
+ --select L Pre-answer the next interactive selection, e.g. --select 1,3-5. Repeatable: the
87
+ lists are consumed in the order the prompts appear
88
+ --select-file P UTF-8 file of one full path per line, matched against each prompt's candidates.
89
+ Either flag lets sections 17/18/19/23 run unattended - a person did choose
90
+ --notify Show a Windows notification when the run ends
82
91
  --logs-dir P / --reports-dir P / --no-report / --cleanup-logs
83
92
  --json One-line JSON summary on stdout (everything else on stderr)
84
93
  -q, --quiet Less chatter --no-color --ascii (plain glyphs)
@@ -167,6 +176,15 @@ function Read-Arguments {
167
176
  '--reports-dir' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ReportsDir = $v }
168
177
  '--no-report' { $ws.NoReport = $true }
169
178
  '--cleanup-logs' { $ws.CleanupLogs = $true }
179
+ '--notify' { $ws.Notify = $true }
180
+ '--select' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.SelectQueue += $v }
181
+ '--select-file' {
182
+ $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }
183
+ $f = Get-FullPath $v
184
+ if (-not $f -or -not (Test-Path -LiteralPath $f)) { throw "--select-file not found: $v" }
185
+ $ws.SelectPaths = @([IO.File]::ReadAllLines($f) | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
186
+ if ($ws.SelectPaths.Count -eq 0) { throw "--select-file has no paths in it: $v" }
187
+ }
170
188
  '--json' { $ws.JsonMode = $true; $ws.Quiet = $true }
171
189
  { $_ -in '-q', '--quiet' } { $ws.Quiet = $true }
172
190
  '--no-color' { $ws.NoColor = $true }
@@ -179,6 +197,8 @@ function Read-Arguments {
179
197
  if ($ws.Profile -and -not $Script:WS_PROFILES.ContainsKey($ws.Profile)) {
180
198
  throw "unknown profile '$($ws.Profile)' (known: $($Script:WS_PROFILES.Keys -join ', '))"
181
199
  }
200
+ # Latched here, once: the queue is drained by the prompts, so a recomputed flag would flip to false mid-run.
201
+ $ws.SelectActive = ($ws.SelectQueue.Count -gt 0 -or $ws.SelectPaths.Count -gt 0)
182
202
  }
183
203
 
184
204
  function Initialize-Paths {
@@ -275,7 +295,7 @@ if ($Script:WS.Elevate -and -not $Script:WS.IsAdmin) {
275
295
  }
276
296
  if ($Script:WS.Elevate -and $Script:WS.IsAdmin) { Write-Note 'already elevated; --elevate is a no-op' }
277
297
 
278
- if ($Script:WS.Mode -in @('walkthrough', 'menu', 'all', 'only')) { Resolve-DeveloperMode }
298
+ if ($Script:WS.Mode -in @('walkthrough', 'menu', 'all', 'only')) { Resolve-DeveloperMode; Confirm-PurgeAllOnce }
279
299
 
280
300
  try {
281
301
  Invoke-Main
@@ -288,5 +308,9 @@ try {
288
308
  if ($Script:WS.CleanupLogs -and $Script:WS.LogFile -and (Test-Path -LiteralPath $Script:WS.LogFile)) {
289
309
  try { Remove-Item -LiteralPath $Script:WS.LogFile -Force -ErrorAction SilentlyContinue } catch { $null = $_ }
290
310
  }
311
+ if (-not $Script:WS.Finished -and $Script:WS.ExitCode -eq 0) {
312
+ Write-LogLine 'run interrupted before it finished (Ctrl-C or host stop) - exit 130'
313
+ exit $Script:WS_EXIT_INTERRUPT
314
+ }
291
315
  }
292
316
  exit $Script:WS.ExitCode