windowsweep 1.3.2 → 1.4.1

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,388 @@
1
+ # leftovers.ps1 - section 27: folders uninstalled programs left in Program Files, Common Files and ProgramData
2
+ # (D78), and the finder section 26 runs right after an uninstall. Section 23 owns the top level of AppData.
3
+ # A folder is offered only when no installed program's name claims it, nothing registered still points into it
4
+ # (an uninstall entry, a service or driver, an environment variable or PATH entry, a Run entry, a shortcut, a
5
+ # scheduled task or a running process) and - in section 27 - it has been idle --days. An unreadable registry
6
+ # offers nothing (fail closed). Picked folders go to the Recycle Bin; one inside a protected folder is only
7
+ # reported. Seen on the P52 (2026-11-02): AVAST Software and Malwarebytes Anti-Exploit folders years after
8
+ # their uninstall. ASCII only.
9
+
10
+ # Never offered: Windows, runtime, hardware-vendor and shared folders, whatever claims them or not.
11
+ $Script:WS_LEFTOVER_NEVER = @(
12
+ 'Common Files', 'Internet Explorer', 'Microsoft', 'Microsoft.NET', 'Microsoft Office', 'Microsoft OneDrive', 'Microsoft SDKs',
13
+ 'Microsoft SQL Server', 'Microsoft Update Health Tools', 'Microsoft Visual Studio', 'Microsoft Analysis Services', 'MSBuild',
14
+ 'dotnet', 'Reference Assemblies', 'Uninstall Information', 'Windows Defender', 'Windows Defender Advanced Threat Protection',
15
+ 'Windows Kits', 'Windows Mail', 'Windows Media Player', 'Windows Multimedia Platform', 'Windows NT', 'Windows Photo Viewer',
16
+ 'Windows Portable Devices', 'Windows Security', 'Windows Sidebar', 'WindowsApps', 'WindowsPowerShell', 'ModifiableWindowsApps',
17
+ 'PowerShell', 'Windows', 'WindowsHolographicDevices', 'USOPrivate', 'USOShared', 'SoftwareDistribution', 'ssh', 'Packages',
18
+ 'Package Cache', 'regid.1991-06.com.microsoft', 'Comms', 'Application Data', 'Desktop', 'Documents', 'Start Menu',
19
+ 'Templates', 'Favorites', 'DeviceSync', 'Temp', 'Tmp', 'IIS', 'IIS Express', 'Intel', 'NVIDIA', 'NVIDIA Corporation', 'AMD',
20
+ # Shared Microsoft components and install caches, and a fingerprint driver's data - offered on the P52 (2026-11-03)
21
+ 'MSECache', 'MSOCache', 'DESIGNER', 'Microsoft Shared', 'System', 'Services', 'SpeechEngines', 'InstallShield',
22
+ 'InstallShield Installation Information', 'Windows Live', 'Validity',
23
+ 'ATI Technologies', 'Realtek', 'Synaptics', 'Lenovo', 'Dell', 'HP', 'Hewlett-Packard', 'Thunderbolt', 'Conexant', 'Dolby',
24
+ 'Waves', 'Qualcomm', 'Broadcom', 'Killer Networking', 'Rivet Networks', 'Elantech', 'Alps', 'Bonjour', 'Apple', 'Google',
25
+ 'Mozilla', 'Oracle', 'Java', 'Git', 'nodejs', 'Python', 'WSL', 'windowsweep', 'com.aoneahsan.windowsweep'
26
+ )
27
+ # Words too common to tie a folder to one program: never used as a program's key name.
28
+ $Script:WS_LEFTOVER_STOPWORDS = @('corporation', 'corp', 'software', 'systems', 'system', 'technologies', 'technology', 'limited',
29
+ 'company', 'group', 'studio', 'studios', 'microsoft', 'windows', 'google', 'apps', 'tools', 'tool', 'program', 'programs', 'data',
30
+ 'update', 'updater', 'setup', 'desktop', 'free', 'easy', 'best', 'simple', 'smart', 'super', 'quick', 'total', 'advanced',
31
+ 'auto', 'mini', 'open', 'global', 'universal', 'portable', 'online', 'digital', 'power', 'master', 'magic', 'ultimate',
32
+ 'express', 'visual', 'version', 'edition', 'professional', 'enterprise', 'community', 'client', 'server', 'service',
33
+ 'services', 'helper', 'player', 'manager', 'runtime', 'driver', 'drivers', 'support', 'common', 'files', 'shared')
34
+
35
+ function Get-CommandPaths {
36
+ <# .SYNOPSIS The absolute paths a registered command names - its program and any absolute path among its
37
+ arguments - with environment variables expanded and the kernel forms (\SystemRoot\, \??\, system32\) resolved. #>
38
+ param([string] $Text)
39
+ $t = [Environment]::ExpandEnvironmentVariables(([string]$Text).Trim())
40
+ if (-not $t) { return @() }
41
+ $t = $t -replace '^\\\?\?\\', ''
42
+ if ($t -match '^\\SystemRoot\\(.*)$') { $t = Join-Path $Script:P.SR $Matches[1] }
43
+ elseif ($t -match '^(system32|syswow64)\\') { $t = Join-Path $Script:P.SR $t }
44
+ $c = Split-CommandLine $t
45
+ $out = @($c.FilePath)
46
+ foreach ($m in [regex]::Matches([string]$c.Arguments, '"([A-Za-z]:\\[^"]+)"|(?<![^\s,=])([A-Za-z]:\\[^\s",]+)')) {
47
+ $v = $m.Groups[1].Value
48
+ if (-not $v) { $v = $m.Groups[2].Value }
49
+ $out += $v
50
+ }
51
+ return @($out | Where-Object { $_ -match '^[A-Za-z]:\\' })
52
+ }
53
+
54
+ function Get-ShortcutTargets {
55
+ <# .SYNOPSIS Targets of the Start Menu, desktop and pinned-taskbar shortcuts, for this user and all users. #>
56
+ $dirs = @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('CommonStartMenu'),
57
+ [Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('CommonDesktopDirectory'),
58
+ (Join-Path $Script:P.A 'Microsoft\Internet Explorer\Quick Launch'))
59
+ $out = New-Object System.Collections.Generic.List[string]
60
+ $shell = $null
61
+ try { $shell = New-Object -ComObject WScript.Shell } catch { return @() }
62
+ try {
63
+ foreach ($d in $dirs) {
64
+ if (-not $d -or -not (Test-DirPresent $d)) { continue }
65
+ foreach ($f in @(Get-ChildItem -LiteralPath $d -Recurse -Filter '*.lnk' -File -ErrorAction SilentlyContinue | Select-Object -First 3000)) {
66
+ try { $target = [string]$shell.CreateShortcut($f.FullName).TargetPath; if ($target) { $out.Add($target) } } catch { $null = $_ }
67
+ }
68
+ }
69
+ } finally { try { $null = [Runtime.InteropServices.Marshal]::ReleaseComObject($shell) } catch { $null = $_ } }
70
+ return $out.ToArray()
71
+ }
72
+
73
+ function Get-ComServerPaths {
74
+ <# .SYNOPSIS Files registered COM servers and type libraries load (machine 64- and 32-bit, and this user). Office
75
+ 2007's add-in designer in Common Files\DESIGNER is registered only this way (P52, 2026-11-03). #>
76
+ $out = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
77
+ foreach ($v in @(@('LocalMachine', 'Registry64'), @('LocalMachine', 'Registry32'), @('CurrentUser', 'Default'))) {
78
+ $base = $null
79
+ try { $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]$v[0], [Microsoft.Win32.RegistryView]$v[1]) } catch { continue }
80
+ try {
81
+ $clsid = $base.OpenSubKey('SOFTWARE\Classes\CLSID')
82
+ if ($clsid) {
83
+ foreach ($name in $clsid.GetSubKeyNames()) {
84
+ foreach ($sub in 'InprocServer32', 'LocalServer32') {
85
+ $k = $null
86
+ try { $k = $clsid.OpenSubKey("$name\$sub") } catch { $k = $null }
87
+ if ($k) { $val = [string]$k.GetValue(''); $k.Close(); if ($val) { $null = $out.Add($val) } }
88
+ }
89
+ }
90
+ $clsid.Close()
91
+ }
92
+ $tl = $base.OpenSubKey('SOFTWARE\Classes\TypeLib')
93
+ if ($tl) {
94
+ foreach ($g in $tl.GetSubKeyNames()) {
95
+ $gk = $null
96
+ try { $gk = $tl.OpenSubKey($g) } catch { $gk = $null }
97
+ if (-not $gk) { continue }
98
+ foreach ($ver in $gk.GetSubKeyNames()) {
99
+ foreach ($lcid in @(try { $gk.OpenSubKey($ver).GetSubKeyNames() } catch { @() })) {
100
+ foreach ($plat in 'win32', 'win64') {
101
+ $pk = $null
102
+ try { $pk = $gk.OpenSubKey("$ver\$lcid\$plat") } catch { $pk = $null }
103
+ if ($pk) { $val = [string]$pk.GetValue(''); $pk.Close(); if ($val) { $null = $out.Add($val) } }
104
+ }
105
+ }
106
+ }
107
+ $gk.Close()
108
+ }
109
+ $tl.Close()
110
+ }
111
+ } catch { $null = $_ } finally { $base.Close() }
112
+ }
113
+ return @($out)
114
+ }
115
+
116
+ function Expand-ShortPath {
117
+ <# .SYNOPSIS C:\PROGRA~2\COMMON~1\x -> C:\Program Files (x86)\Common Files\x. COM registrations often store 8.3
118
+ names; a claim must compare with the long path a folder is listed by. Unchanged when it cannot resolve. #>
119
+ param([string] $Path)
120
+ if ($Path -notmatch '~') { return $Path }
121
+ $parts = $Path.Split('\')
122
+ $cur = $parts[0] + '\'
123
+ for ($i = 1; $i -lt $parts.Count; $i++) {
124
+ $seg = $parts[$i]
125
+ if (-not $seg) { continue }
126
+ if ($seg.Contains('~')) {
127
+ try { $hit = @(([IO.DirectoryInfo]$cur).GetFileSystemInfos($seg)); if ($hit.Count -eq 1) { $seg = $hit[0].Name } } catch { return $Path }
128
+ }
129
+ $cur = Join-Path $cur $seg
130
+ }
131
+ return $cur
132
+ }
133
+
134
+ function Get-LeftoverClaims {
135
+ <# .SYNOPSIS What still claims a folder. Names: the installed-program index without the folders being judged.
136
+ Paths: existing files and folders an uninstall entry, a service or driver, an environment variable (PATH
137
+ included), a Run entry, a shortcut, a scheduled task or a running process names. #>
138
+ param([string[]] $IgnorePaths = @())
139
+ $index = Get-InstalledProgramIndex -IgnorePaths $IgnorePaths
140
+ $raw = New-Object System.Collections.Generic.List[string]
141
+ foreach ($r in $index.Rows) {
142
+ if ($r.InstallLocation) { $raw.Add(([string]$r.InstallLocation).Trim().Trim('"')) }
143
+ foreach ($s in @($r.UninstallString, $r.QuietUninstallString)) { if ($s) { foreach ($x in (Get-CommandPaths $s)) { $raw.Add($x) } } }
144
+ }
145
+ foreach ($cls in 'Win32_Service', 'Win32_SystemDriver') {
146
+ try { foreach ($s in @(Get-CimInstance $cls -Property PathName -ErrorAction Stop)) { foreach ($x in (Get-CommandPaths ([string]$s.PathName))) { $raw.Add($x) } } } catch { $null = $_ }
147
+ }
148
+ foreach ($scope in 'Machine', 'User') {
149
+ try {
150
+ $vars = [Environment]::GetEnvironmentVariables($scope)
151
+ foreach ($k in @($vars.Keys)) {
152
+ foreach ($part in ([string]$vars[$k] -split ';')) { $v = [Environment]::ExpandEnvironmentVariables($part.Trim().Trim('"')); if ($v -match '^[A-Za-z]:\\') { $raw.Add($v) } }
153
+ }
154
+ } catch { $null = $_ }
155
+ }
156
+ foreach ($key in @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run', 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
157
+ 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
158
+ 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce')) {
159
+ try {
160
+ $item = Get-ItemProperty -LiteralPath $key -ErrorAction Stop
161
+ foreach ($prop in $item.PSObject.Properties) { if ($prop.Name -notlike 'PS*') { foreach ($x in (Get-CommandPaths ([string]$prop.Value))) { $raw.Add($x) } } }
162
+ } catch { $null = $_ }
163
+ }
164
+ foreach ($x in (Get-ShortcutTargets)) { $raw.Add($x) }
165
+ foreach ($x in (Get-ComServerPaths)) { foreach ($y in (Get-CommandPaths $x)) { $raw.Add($y) } }
166
+ try {
167
+ foreach ($task in @(Get-ScheduledTask -ErrorAction Stop)) {
168
+ foreach ($a in @($task.Actions)) {
169
+ if ($a.Execute) { foreach ($x in (Get-CommandPaths ('"' + ([string]$a.Execute).Trim('"') + '" ' + [string]$a.Arguments))) { $raw.Add($x) } }
170
+ }
171
+ }
172
+ } catch { $null = $_ }
173
+ try {
174
+ foreach ($proc in @(Get-CimInstance Win32_Process -Property ExecutablePath, CommandLine -ErrorAction Stop)) {
175
+ if ($proc.ExecutablePath) { $raw.Add([string]$proc.ExecutablePath) }
176
+ if ($proc.CommandLine) { foreach ($x in (Get-CommandPaths ([string]$proc.CommandLine))) { $raw.Add($x) } }
177
+ }
178
+ } catch { $null = $_ }
179
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
180
+ foreach ($r in $raw) {
181
+ $f = ''
182
+ try { $f = Expand-ShortPath (Get-FullPath $r) } catch { $f = '' }
183
+ if ($f -and -not $set.Contains($f) -and (Test-Path -LiteralPath $f)) { $null = $set.Add($f) }
184
+ }
185
+ return [pscustomobject]@{ Tokens = $index.Tokens; RegistryCount = $index.RegistryCount; Rows = $index.Rows; Paths = @($set) }
186
+ }
187
+
188
+ function Test-LeftoverClaimed {
189
+ <# .SYNOPSIS Why a folder still belongs to something, or $null when nothing claims it. #>
190
+ param([string] $Path, [pscustomobject] $Claims)
191
+ if (-not (Test-OrphanFolderName -Name (Split-Path -Leaf $Path) -Index $Claims)) { return 'its name belongs to an installed program' }
192
+ $prefix = $Path.TrimEnd('\') + '\'
193
+ foreach ($c in $Claims.Paths) {
194
+ if ($c.Equals($Path, [StringComparison]::OrdinalIgnoreCase) -or $c.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return "still in use: $c" }
195
+ }
196
+ return $null
197
+ }
198
+
199
+ function Get-LeftoverRoots {
200
+ <# .SYNOPSIS Where uninstalled programs leave folders. -WithAppData adds Roaming and Local - for section 26's
201
+ pass right after an uninstall only; section 23 owns them otherwise. #>
202
+ param([switch] $WithAppData)
203
+ $P = $Script:P
204
+ $roots = @($P.PF, $P.PF86, (Join-Path $P.PF 'Common Files'), (Join-Path $P.PF86 'Common Files'), $P.PD, (Join-Path $P.L 'Programs'))
205
+ if ($WithAppData) { $roots += @($P.A, $P.L) }
206
+ $seen = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
207
+ return @($roots | Where-Object { $_ -and (Test-DirPresent $_) -and $seen.Add($_) })
208
+ }
209
+
210
+ function Get-ProgramKeyTokens {
211
+ <# .SYNOPSIS The names a program's leftover folders carry: the first word of its name and of its publisher, its
212
+ whole name and its install folder's name - 4+ characters, with a letter, never a common word. #>
213
+ param([pscustomobject] $Program)
214
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
215
+ $texts = @()
216
+ foreach ($text in @($Program.DisplayName, $Program.Publisher)) {
217
+ $words = @(([string]$text) -split '[^A-Za-z0-9]+' | Where-Object { $_ })
218
+ if ($words.Count -gt 0) { $texts += $words[0] }
219
+ $texts += [string]$text
220
+ }
221
+ $loc = ([string]$Program.InstallLocation).Trim().Trim('"').TrimEnd('\')
222
+ if ($loc) { $texts += (Split-Path -Leaf $loc) }
223
+ foreach ($x in $texts) {
224
+ $t = ConvertTo-MatchToken $x
225
+ if ($t.Length -ge 4 -and $t -match '[a-z]' -and $Script:WS_LEFTOVER_STOPWORDS -notcontains $t) { $null = $set.Add($t) }
226
+ }
227
+ return , $set
228
+ }
229
+
230
+ function Test-NameMatchesTokens {
231
+ <# .SYNOPSIS A folder named exactly like a key name or starting with one ("SteamLibrary" for "steam"); for a
232
+ reverse-DNS name, any segment after the first. #>
233
+ param([string] $Name, [System.Collections.Generic.HashSet[string]] $Tokens)
234
+ $names = @($Name)
235
+ if ($Name -match '^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+){2,}$') { $names += @($Name.Split('.') | Select-Object -Skip 1) }
236
+ foreach ($nm in $names) {
237
+ $n = ConvertTo-MatchToken $nm
238
+ if ($n.Length -lt 4) { continue }
239
+ foreach ($t in $Tokens) { if ($n -eq $t -or $n.StartsWith($t)) { return $true } }
240
+ }
241
+ return $false
242
+ }
243
+
244
+ function New-NeverSet {
245
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
246
+ foreach ($n in @($Script:WS_LEFTOVER_NEVER + $Script:WS_ORPHAN_EXCLUDE)) { $null = $set.Add($n) }
247
+ return , $set
248
+ }
249
+
250
+ function Find-ProgramLeftovers {
251
+ <# .SYNOPSIS Section 26, after an uninstall: the removed programs' install folders, and folders in the leftover
252
+ roots and AppData named like them. Rows carry Reason = $null to offer, else why the folder stays. Fails
253
+ closed. -Roots and -Claims are for the self-test. #>
254
+ param([pscustomobject[]] $Programs, [string[]] $Roots = @(), [pscustomobject] $Claims = $null)
255
+ if ($Roots.Count -eq 0) { $Roots = @(Get-LeftoverRoots -WithAppData) }
256
+ $tokens = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
257
+ foreach ($prog in @($Programs)) { foreach ($t in (Get-ProgramKeyTokens $prog)) { $null = $tokens.Add($t) } }
258
+ $never = New-NeverSet
259
+ $cand = New-Object System.Collections.Generic.List[object]
260
+ $seen = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
261
+ foreach ($prog in @($Programs)) {
262
+ $loc = ([string]$prog.InstallLocation).Trim().Trim('"').TrimEnd('\')
263
+ if (-not $loc -or -not (Test-DirPresent $loc)) { continue }
264
+ $full = Get-FullPath $loc
265
+ if ($seen.Add($full)) { $cand.Add([pscustomobject]@{ Path = $full; Root = (Split-Path -Parent $full); Program = $prog.DisplayName; Reason = $null; Idle = -1; Bytes = [long]0 }) }
266
+ }
267
+ foreach ($root in $Roots) {
268
+ if ($tokens.Count -eq 0 -or -not $root -or -not (Test-DirPresent $root)) { continue }
269
+ foreach ($e in (Get-ChildEntries $root)) {
270
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e) -or $never.Contains($e.Name)) { continue }
271
+ if (-not (Test-NameMatchesTokens -Name $e.Name -Tokens $tokens)) { continue }
272
+ $full = Remove-LongPrefix $e.FullName
273
+ if ($seen.Add($full)) { $cand.Add([pscustomobject]@{ Path = $full; Root = (Get-FullPath $root); Program = ''; Reason = $null; Idle = -1; Bytes = [long]0 }) }
274
+ }
275
+ }
276
+ if ($cand.Count -eq 0) { return @() }
277
+ if ($null -eq $Claims) { $Claims = Get-LeftoverClaims -IgnorePaths @($cand | ForEach-Object { $_.Path }) }
278
+ if ($Claims.RegistryCount -eq 0) { Write-Warn 'no uninstall entries could be read: no folder is offered (fail closed)'; return @() }
279
+ foreach ($c in $cand) {
280
+ $why = Get-ProtectionReason $c.Path
281
+ if (-not $why) { $why = Test-LeftoverClaimed -Path $c.Path -Claims $Claims }
282
+ $c.Reason = $why
283
+ # Sized only when offered: a registered install folder can be a protected root such as Program Files.
284
+ if (-not $why) { $c.Bytes = [long](Get-DirectoryBytes $c.Path) }
285
+ }
286
+ return $cand.ToArray() # @() around a List[object] throws "Argument types do not match" in PS 5.1
287
+ }
288
+
289
+ function Find-MachineLeftovers {
290
+ <# .SYNOPSIS Section 27: top-level folders in the leftover roots that nothing claims, idle -Days or more. Rows
291
+ carry Reason = $null to offer, else why the folder is only reported. Fails closed. #>
292
+ param([int] $Days, [string[]] $Roots = @(), [pscustomobject] $Claims = $null)
293
+ if ($Roots.Count -eq 0) { $Roots = @(Get-LeftoverRoots) }
294
+ $never = New-NeverSet
295
+ $cand = New-Object System.Collections.Generic.List[object]
296
+ foreach ($root in $Roots) {
297
+ if (-not $root -or -not (Test-DirPresent $root)) { continue }
298
+ foreach ($e in (Get-ChildEntries $root)) {
299
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e) -or $never.Contains($e.Name)) { continue }
300
+ $cand.Add([pscustomobject]@{ Path = (Remove-LongPrefix $e.FullName); Root = (Get-FullPath $root); Program = ''; Reason = $null; Idle = 0; Bytes = [long]0 })
301
+ }
302
+ }
303
+ if ($cand.Count -eq 0) { return @() }
304
+ if ($null -eq $Claims) { $Claims = Get-LeftoverClaims -IgnorePaths @($cand | ForEach-Object { $_.Path }) }
305
+ if ($Claims.RegistryCount -eq 0) { Write-Warn 'no uninstall entries could be read: no folder is offered (fail closed)'; return @() }
306
+ Write-Note "cross-checked against $($Claims.RegistryCount) installed-program entries and $(@($Claims.Paths).Count) paths still in use"
307
+ $out = @()
308
+ foreach ($c in $cand) {
309
+ if (Test-LeftoverClaimed -Path $c.Path -Claims $Claims) { continue }
310
+ $c.Idle = Get-IdleDays $c.Path
311
+ if ($c.Idle -lt $Days) { continue }
312
+ $c.Reason = Get-ProtectionReason $c.Path
313
+ $c.Bytes = [long](Get-DirectoryBytes $c.Path)
314
+ $out += $c
315
+ }
316
+ return @($out | Sort-Object Bytes -Descending | Select-Object -First 200)
317
+ }
318
+
319
+ function Invoke-LeftoverChoice {
320
+ <# .SYNOPSIS Report the rows that stay, list the rest, and let a person pick folders for the Recycle Bin.
321
+ -PathsOrConsoleOnly (section 26's second prompt): only --select-file paths or a console answer pick here - a
322
+ queued --select list belongs to a later prompt and is left for it. #>
323
+ param([object[]] $Rows, [int] $Section, [switch] $PathsOrConsoleOnly)
324
+ $ws = $Script:WS
325
+ foreach ($r in @($Rows | Where-Object { $_.Reason })) { Write-Note ("kept: {0} ({1})" -f $r.Path, $r.Reason) }
326
+ $offer = @($Rows | Where-Object { -not $_.Reason })
327
+ if ($offer.Count -eq 0) { Write-Info 'no leftover folder can be offered'; return }
328
+ Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FOLDER') 'White'
329
+ $i = 1; $total = [long]0
330
+ foreach ($r in $offer) {
331
+ $idle = '-'
332
+ if ($r.Idle -ge 0) { $idle = "$($r.Idle)d" }
333
+ Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f $i, (Format-Bytes $r.Bytes), $idle, $r.Path) 'Gray'
334
+ $label = ''
335
+ if ($Section -eq 26) { $label = 'leftover'; if ($r.Program) { $label = "leftover: $($r.Program)" } }
336
+ Add-JsonCandidate -Section $Section -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle -Label $label
337
+ $total += $r.Bytes; $i++
338
+ }
339
+ Write-Info ('total: ' + (Format-Bytes $total))
340
+ $dest = 'the Recycle Bin'
341
+ if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
342
+ Write-Note "selected folders go to $dest"
343
+ $saved = $ws.SelectQueue
344
+ if ($PathsOrConsoleOnly) { $ws.SelectQueue = @() }
345
+ try { $picks = @(Read-MultiSelect -Total $offer.Count -NoAutoYes -Candidates @($offer | ForEach-Object { $_.Path })) } finally { if ($PathsOrConsoleOnly) { $ws.SelectQueue = $saved } }
346
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
347
+ if (-not $ws.DryRun) {
348
+ if (-not (Confirm-Ui -Prompt "Move $($picks.Count) folder(s) to $dest ?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
349
+ }
350
+ foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $offer[$k - 1].Path -Within $offer[$k - 1].Root }
351
+ }
352
+
353
+ function Get-DeadPathEntries {
354
+ <# .SYNOPSIS PATH entries and path-valued environment variables (machine and user) naming something that no
355
+ longer exists. Report only. #>
356
+ $out = @()
357
+ foreach ($scope in 'Machine', 'User') {
358
+ $vars = $null
359
+ try { $vars = [Environment]::GetEnvironmentVariables($scope) } catch { continue }
360
+ foreach ($k in @($vars.Keys | Sort-Object)) {
361
+ foreach ($part in @([string]$vars[$k] -split ';' | Where-Object { $_.Trim() })) {
362
+ $v = [Environment]::ExpandEnvironmentVariables($part.Trim().Trim('"'))
363
+ if ($v -match '^[A-Za-z]:\\' -and -not (Test-Path -LiteralPath $v)) { $out += [pscustomobject]@{ Scope = $scope; Name = [string]$k; Value = $v } }
364
+ }
365
+ }
366
+ }
367
+ return $out
368
+ }
369
+
370
+ function Get-Targets27 {
371
+ $P = $Script:P
372
+ return @((New-Target 27 'Leftover program folders' "$($P.PF) | $($P.PF86) | Common Files | $($P.PD)" -Kind cmd -Note 'you pick; idle --days; nothing installed, registered or running uses them'))
373
+ }
374
+
375
+ function Invoke-Section27 {
376
+ $ws = $Script:WS
377
+ Write-SectionIntro @(
378
+ 'Uninstallers often leave folders in Program Files, Common Files and ProgramData. This lists the top-level',
379
+ 'folders there that no installed program, service, driver, PATH entry, shortcut, task or running process uses',
380
+ "and that nothing has touched for $($ws.Days)+ days. You pick what goes; it lands in the Recycle Bin."
381
+ )
382
+ Invoke-LeftoverChoice -Rows @(Find-MachineLeftovers -Days $ws.Days) -Section 27
383
+ $dead = @(Get-DeadPathEntries)
384
+ if ($dead.Count -eq 0) { return }
385
+ Write-Section 'Environment entries naming something that no longer exists (report only)'
386
+ foreach ($d in $dead) { Write-UiLine (" {0,-7} {1,-24} {2}" -f $d.Scope, $d.Name, $d.Value) 'Gray' }
387
+ Write-Note 'remove them in System Properties > Environment Variables once the tool they belonged to is gone'
388
+ }
@@ -26,14 +26,36 @@ function ConvertTo-MatchToken {
26
26
  }
27
27
 
28
28
  function Add-MatchTokens {
29
- <# .SYNOPSIS Add a display string to the token set: the whole thing, plus each word of 4+ characters. #>
29
+ <# .SYNOPSIS Add a display string to the token set: the whole thing, each word of 4+ characters, and - for a
30
+ name of three or more words - its initials ("Internet Download Manager" claims a folder named "IDM"). #>
30
31
  param([System.Collections.Generic.HashSet[string]] $Set, [string] $Text)
31
32
  $whole = ConvertTo-MatchToken $Text
32
33
  if ($whole.Length -ge 3) { $null = $Set.Add($whole) }
33
- foreach ($w in ($Text -split '[^A-Za-z0-9]+')) {
34
+ $words = @($Text -split '[^A-Za-z0-9]+' | Where-Object { $_ })
35
+ foreach ($w in $words) {
34
36
  $t = ConvertTo-MatchToken $w
35
37
  if ($t.Length -ge 4) { $null = $Set.Add($t) }
36
38
  }
39
+ if ($words.Count -ge 3) {
40
+ $initials = (($words | ForEach-Object { $_.Substring(0, 1) }) -join '').ToLowerInvariant()
41
+ if ($initials.Length -ge 3) { $null = $Set.Add($initials) }
42
+ }
43
+ }
44
+
45
+ function Add-CommandLineTokens {
46
+ <# .SYNOPSIS Claim every path segment of a running command line: a tool started as "node ...\superpowers-chrome\..."
47
+ runs under a process named node, so its own name only ever appears here. #>
48
+ param([System.Collections.Generic.HashSet[string]] $Set, [string] $CommandLine)
49
+ if ([string]::IsNullOrWhiteSpace($CommandLine)) { return }
50
+ # Only the folders of PATH arguments count. A bare word claims nothing: a shell whose command line merely
51
+ # mentioned "vega-desktop-updater" made that orphan look claimed on the P52 (2026-11-02).
52
+ foreach ($arg in ($CommandLine -split '[\s"]+')) {
53
+ $a = $arg.Trim("'")
54
+ if ($a -notmatch '^[A-Za-z]:[\\/]' -and $a -notmatch '^[\\/]{2}' -and @($a -split '[\\/]').Count -lt 3) { continue }
55
+ foreach ($seg in ($a -split '[\\/=]+')) {
56
+ if ($seg.Length -ge 4 -and -not $seg.StartsWith('-')) { Add-MatchTokens -Set $Set -Text $seg }
57
+ }
58
+ }
37
59
  }
38
60
 
39
61
  function Get-InstalledProgramRows {
@@ -56,6 +78,10 @@ function Get-InstalledProgramRows {
56
78
  Publisher = [string]$props.Publisher; InstallDate = [string]$props.InstallDate
57
79
  EstimatedSizeKb = [long]0 + $props.EstimatedSize; InstallLocation = [string]$props.InstallLocation
58
80
  UninstallString = [string]$props.UninstallString; SystemComponent = [int]0 + $props.SystemComponent
81
+ # Section 26 (P9) reads these; sections 23 and 24 ignore them.
82
+ KeyPath = [string]$k.Name; KeyName = [string]$k.PSChildName; Machine = ([string]$k.Name).StartsWith('HKEY_LOCAL_MACHINE', [StringComparison]::OrdinalIgnoreCase)
83
+ QuietUninstallString = [string]$props.QuietUninstallString; WindowsInstaller = [int]0 + $props.WindowsInstaller
84
+ ParentKeyName = [string]$props.ParentKeyName; ReleaseType = [string]$props.ReleaseType
59
85
  }
60
86
  }
61
87
  }
@@ -65,7 +91,12 @@ function Get-InstalledProgramRows {
65
91
  function Get-InstalledProgramIndex {
66
92
  <# .SYNOPSIS Everything that says "a program owns this name": uninstall entries, program folders, Store
67
93
  package names and running processes. RegistryCount drives the fail-closed gate in section 23. #>
94
+ # -IgnorePaths (P9, sections 26 and 27): folders being judged as leftovers, whose own names must not
95
+ # count as a claim on themselves.
96
+ param([string[]] $IgnorePaths = @())
68
97
  $P = $Script:P
98
+ $ignore = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
99
+ foreach ($x in $IgnorePaths) { if ($x) { $null = $ignore.Add((Get-FullPath $x)) } }
69
100
  $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
70
101
  $rows = @(Get-InstalledProgramRows)
71
102
  foreach ($r in $rows) {
@@ -75,10 +106,14 @@ function Get-InstalledProgramIndex {
75
106
  }
76
107
  foreach ($dir in @($P.PF, $P.PF86, (Join-Path $P.L 'Programs'), (Join-Path $P.L 'Microsoft\WindowsApps'), (Join-Path $P.PD 'Microsoft\Windows\Start Menu\Programs'))) {
77
108
  if (-not $dir -or -not (Test-DirPresent $dir)) { continue }
78
- foreach ($e in (Get-ChildEntries $dir)) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
109
+ foreach ($e in (Get-ChildEntries $dir)) {
110
+ if ($ignore.Count -gt 0 -and $ignore.Contains((Remove-LongPrefix $e.FullName))) { continue }
111
+ Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0])
112
+ }
79
113
  }
80
114
  foreach ($e in (Get-ChildEntries (Join-Path $P.L 'Packages'))) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
81
115
  try { foreach ($proc in (Get-Process -ErrorAction SilentlyContinue)) { Add-MatchTokens -Set $set -Text $proc.ProcessName } } catch { $null = $_ }
116
+ try { foreach ($proc in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { Add-CommandLineTokens -Set $set -CommandLine ([string]$proc.CommandLine) } } catch { $null = $_ }
82
117
  return [pscustomobject]@{ Tokens = $set; RegistryCount = $rows.Count; Rows = $rows }
83
118
  }
84
119
 
@@ -114,6 +149,15 @@ function Test-OrphanFolderName {
114
149
  foreach ($t in $Index.Tokens) {
115
150
  if ($t.Length -lt 4) { continue }
116
151
  if ($t.StartsWith($n) -or $n.StartsWith($t)) { return $false }
152
+ # A shared vendor stem claims it too: "DBeaverData" belongs to the "DBeaverCorp.DBeaverCE" Store app.
153
+ if ($n.Length -ge 6 -and $t.Length -ge 6 -and $n.Substring(0, 6) -eq $t.Substring(0, 6)) { return $false }
154
+ }
155
+ # A reverse-DNS app id ("com.vendor.app", as Tauri and Electron apps name their data folder) is claimed when
156
+ # any segment after the first is: "com.aoneahsan.windowsweep" belongs to the installed "windowsweep".
157
+ if ($Name -match '^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+){2,}$') {
158
+ foreach ($seg in @($Name.Split('.') | Select-Object -Skip 1)) {
159
+ if ((ConvertTo-MatchToken $seg).Length -ge 4 -and -not (Test-OrphanFolderName -Name $seg -Index $Index)) { return $false }
160
+ }
117
161
  }
118
162
  return $true
119
163
  }
@@ -1,4 +1,5 @@
1
- # personal.ps1 - sections 18 and 19: personal files in Downloads. Interactive only; the Recycle Bin is the destination.
1
+ # personal.ps1 - section 18 (partial downloads in Downloads) and the file walk section 19 shares (data_scanner.ps1).
2
+ # Interactive only; the Recycle Bin is the destination.
2
3
 
3
4
  $Script:WS_PARTIAL_PATTERNS = @('*.crdownload', '*.part', '*.partial', '*.fdmdownload', '*.opdownload', '*.!ut', '*.aria2', '*.download', '*.bc!', '*.tmp')
4
5
 
@@ -15,15 +16,9 @@ function Get-Targets18 {
15
16
  return $t
16
17
  }
17
18
 
18
- function Get-Targets19 {
19
- $t = @()
20
- foreach ($r in (Get-PersonalRoots)) { $t += (New-Target 19 "Files >= $($Script:WS.LargeFileMb) MB idle $($Script:WS.Days)+ days" $r -Kind cmd -Note 'moved to the Recycle Bin after you pick them') }
21
- return $t
22
- }
23
-
24
19
  function Get-FilesUnder {
25
20
  <# .SYNOPSIS Files under Root up to MaxDepth, never following links, never entering protected folders. #>
26
- param([string] $Root, [int] $MaxDepth = 4)
21
+ param([string] $Root, [int] $MaxDepth = 4, [int] $MaxFiles = 0)
27
22
  $out = New-Object System.Collections.Generic.List[object]
28
23
  $stack = New-Object System.Collections.Generic.Stack[object]
29
24
  $stack.Push(@($Root, 0))
@@ -33,6 +28,7 @@ function Get-FilesUnder {
33
28
  if (Test-ReparsePoint $e) { continue }
34
29
  if ($e -is [IO.DirectoryInfo]) { if ([int]$item[1] -lt $MaxDepth) { $stack.Push(@((Remove-LongPrefix $e.FullName), [int]$item[1] + 1)) }; continue }
35
30
  $out.Add($e)
31
+ if ($MaxFiles -gt 0 -and $out.Count -ge $MaxFiles) { return $out }
36
32
  }
37
33
  }
38
34
  return $out
@@ -41,30 +37,16 @@ function Get-FilesUnder {
41
37
  function Get-PartialDownloads {
42
38
  $list = @()
43
39
  foreach ($root in (Get-PersonalRoots)) {
40
+ $ignore = Test-LastAccessLive -Root $root
44
41
  foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
45
42
  foreach ($pat in $Script:WS_PARTIAL_PATTERNS) {
46
- if ($f.Name -like $pat) { $list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f)).TotalDays); Root = $root }; break }
43
+ if ($f.Name -like $pat) { $list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f -IgnoreAccess:$ignore)).TotalDays); Root = $root }; break }
47
44
  }
48
45
  }
49
46
  }
50
47
  return @($list | Sort-Object Bytes -Descending)
51
48
  }
52
49
 
53
- function Get-LargeStaleFiles {
54
- $ws = $Script:WS
55
- $min = [long]$ws.LargeFileMb * 1MB
56
- $list = @()
57
- foreach ($root in (Get-PersonalRoots)) {
58
- foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
59
- if ([long]$f.Length -lt $min) { continue }
60
- $idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f)).TotalDays)
61
- if ($idle -lt $ws.Days) { continue }
62
- $list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = $idle; Root = $root }
63
- }
64
- }
65
- return @($list | Sort-Object Bytes -Descending | Select-Object -First 100)
66
- }
67
-
68
50
  function Show-FileTable {
69
51
  param([object[]] $Rows)
70
52
  Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FILE') 'White'
@@ -104,23 +86,11 @@ function Invoke-Section18 {
104
86
  Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin' -Section 18
105
87
  }
106
88
 
107
- function Invoke-Section19 {
108
- $ws = $Script:WS
109
- Write-SectionIntro @(
110
- "Files of $($ws.LargeFileMb) MB or more in Downloads that nothing has touched for $($ws.Days)+ days: old installers, ISOs,",
111
- 'archives you already extracted. You pick what goes; it lands in the Recycle Bin.'
112
- )
113
- if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
114
- Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin' -Section 19
115
- }
116
-
117
89
  function Show-PersonalScan {
118
90
  <# .SYNOPSIS Read-only listing used by --scan. #>
119
91
  Write-Box 'Personal files (read-only)' 'Sections 18 and 19 are interactive only - nothing here is deleted'
120
92
  Write-Section 'Partial / orphan downloads'
121
93
  $p = @(Get-PartialDownloads)
122
94
  if ($p.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($p | Select-Object -First 30) }
123
- Write-Section "Files >= $($Script:WS.LargeFileMb) MB idle $($Script:WS.Days)+ days in Downloads"
124
- $l = @(Get-LargeStaleFiles)
125
- if ($l.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($l | Select-Object -First 30) }
95
+ Show-DataScan
126
96
  }
@@ -63,6 +63,7 @@ function Get-ProjectActivityDays {
63
63
  <# .SYNOPSIS Days since any SOURCE file in the project changed (artefact dirs and .git excluded). #>
64
64
  param([string] $ProjectDir)
65
65
  $newest = [datetime]::MinValue
66
+ $ignore = Test-LastAccessLive -Root $ProjectDir
66
67
  $stack = New-Object System.Collections.Generic.Stack[string]
67
68
  $stack.Push($ProjectDir)
68
69
  $seen = 0
@@ -75,7 +76,7 @@ function Get-ProjectActivityDays {
75
76
  if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) { continue }
76
77
  $stack.Push((Remove-LongPrefix $e.FullName)); continue
77
78
  }
78
- $t = Get-NewestTimestampUtc $e
79
+ $t = Get-NewestTimestampUtc $e -IgnoreAccess:$ignore
79
80
  if ($t -gt $newest) { $newest = $t }
80
81
  }
81
82
  }
@@ -60,9 +60,15 @@ function Write-MachineProgress {
60
60
 
61
61
  function Add-JsonCandidate {
62
62
  <# .SYNOPSIS Record one item an interactive section offered, so --json callers can present the same list. #>
63
- param([int] $Section, [int] $Index, [string] $Path, [long] $Bytes, [int] $IdleDays, [string] $Project = '')
63
+ param([int] $Section, [int] $Index, [string] $Path, [long] $Bytes, [int] $IdleDays, [string] $Project = '', [string] $Label = '',
64
+ [System.Collections.IDictionary] $Extra = $null)
64
65
  if (-not $Script:WS.JsonMode) { return }
65
- $Script:WS.Candidates += [ordered]@{ section = $Section; index = $Index; path = $Path; bytes = $Bytes; idle_days = $IdleDays; project = $Project }
66
+ $c = [ordered]@{ section = $Section; index = $Index; path = $Path; bytes = $Bytes; idle_days = $IdleDays; project = $Project }
67
+ # P9: an optional display name - section 26's candidates are programs whose path is a registry key.
68
+ if ($Label) { $c.label = $Label }
69
+ # P9: section-specific fields added after the shared ones (section 19's data scanner: reason, age basis, ...).
70
+ if ($null -ne $Extra) { foreach ($k in $Extra.Keys) { $c[$k] = $Extra[$k] } }
71
+ $Script:WS.Candidates += $c
66
72
  }
67
73
 
68
74
  function Invoke-SectionById {
@@ -113,7 +119,9 @@ function Invoke-SectionById {
113
119
  $status = 'ran'
114
120
  if ($ws.DryRun) { $status = 'dry-run' }
115
121
  try {
116
- & $sec.Fn
122
+ # A section speaks through the Write-* helpers; anything left on its output stream (a cmdlet's return value)
123
+ # would land on stdout ahead of the --json line, so it is discarded here for every section.
124
+ $null = & $sec.Fn
117
125
  } catch {
118
126
  $status = 'failed'
119
127
  Write-Err "section $Id failed: $($_.Exception.Message)"
@@ -223,7 +231,12 @@ function Get-JsonSummary {
223
231
  sections = @($ws.Report.steps | ForEach-Object { [ordered]@{ section = $_.section; status = $_.status; freed_bytes = $_.freed_bytes } })
224
232
  # candidates and targets are ALWAYS present, empty when nothing was collected: a caller can rely on the shape.
225
233
  candidates = @($ws.Candidates); targets = @($ws.ScanTargets)
226
- refusals = @($ws.Refusals); excluded = @($ws.Excluded); log_file = $ws.LogFile; report_file = $ws.ReportFile
234
+ refusals = @($ws.Refusals); excluded = @($ws.Excluded)
235
+ # D82: the volumes where idle age used write/creation time because last-access was found live.
236
+ access_live_volumes = @(Get-AccessLiveVolumes)
237
+ # P9: the folders section 19 scanned or refused ({path, accepted, reason}); empty unless it ran.
238
+ personal_roots = @($ws.PersonalRootsReport)
239
+ log_file = $ws.LogFile; report_file = $ws.ReportFile
227
240
  }
228
241
  }
229
242
 
@@ -240,7 +253,18 @@ function Get-CatalogueJson {
240
253
  }
241
254
  }
242
255
 
256
+ function Save-ElevatedSummary {
257
+ <# .SYNOPSIS Let the waiting non-admin parent relay an elevated child's JSON to its caller. #>
258
+ param([string] $Json)
259
+ if ($Script:WS.ElevatedChild -and $Script:WS.ElevatedSummaryToken) {
260
+ $path = Join-Path $Script:WS.ReportsDir ("elevated-summary-$($Script:WS.ElevatedSummaryToken).json")
261
+ [IO.File]::WriteAllText($path, $Json, (New-Object System.Text.UTF8Encoding($false)))
262
+ }
263
+ }
264
+
243
265
  function Write-JsonSummary {
244
266
  <# .SYNOPSIS --json: the only line written to stdout. #>
245
- [Console]::Out.WriteLine(((Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress))
267
+ $json = (Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress
268
+ Save-ElevatedSummary -Json $json
269
+ [Console]::Out.WriteLine($json)
246
270
  }