windowsweep 1.1.0 → 1.3.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.
Files changed (43) hide show
  1. package/AI-INTEGRATION-GUIDE.md +16 -12
  2. package/CHANGELOG.md +82 -0
  3. package/LICENSE +21 -21
  4. package/README.md +104 -69
  5. package/VERSION +1 -1
  6. package/lib/actions.ps1 +212 -212
  7. package/lib/config.ps1 +108 -99
  8. package/lib/constants.ps1 +103 -93
  9. package/lib/external.ps1 +87 -0
  10. package/lib/fs.ps1 +189 -180
  11. package/lib/log.ps1 +140 -140
  12. package/lib/safety.ps1 +467 -493
  13. package/lib/scan.ps1 +109 -100
  14. package/lib/ui.ps1 +275 -275
  15. package/modules/android_avd.ps1 +36 -36
  16. package/modules/app_caches.ps1 +58 -58
  17. package/modules/browsers.ps1 +33 -33
  18. package/modules/build_tools.ps1 +35 -35
  19. package/modules/crash_trap.ps1 +89 -89
  20. package/modules/disk_usage.ps1 +49 -49
  21. package/modules/docker.ps1 +93 -93
  22. package/modules/editors.ps1 +150 -150
  23. package/modules/globals_audit.ps1 +236 -236
  24. package/modules/health.ps1 +101 -101
  25. package/modules/installed_programs.ps1 +118 -118
  26. package/modules/menu.ps1 +63 -63
  27. package/modules/notify.ps1 +60 -60
  28. package/modules/orphaned_appdata.ps1 +189 -189
  29. package/modules/personal.ps1 +126 -126
  30. package/modules/pkg_managers.ps1 +73 -73
  31. package/modules/projects.ps1 +160 -160
  32. package/modules/release_helpers.ps1 +438 -438
  33. package/modules/reports.ps1 +212 -212
  34. package/modules/runner.ps1 +246 -239
  35. package/modules/self_test_contract.ps1 +195 -0
  36. package/modules/self_test_extra.ps1 +366 -360
  37. package/modules/startup_audit.ps1 +126 -126
  38. package/modules/system_admin.ps1 +220 -220
  39. package/modules/test_runners.ps1 +24 -24
  40. package/modules/walkthrough.ps1 +80 -79
  41. package/modules/windows_user.ps1 +118 -118
  42. package/package.json +3 -3
  43. package/windowsweep.ps1 +322 -316
@@ -1,239 +1,246 @@
1
- # runner.ps1 - section dispatch, batch policy enforcement, the read-only scan mode and the summary.
2
-
3
- function Get-SectionIdList {
4
- <# .SYNOPSIS Parse "1,3,5-7" into section ids; validates against the catalogue. #>
5
- param([string] $Text)
6
- $ids = New-Object System.Collections.Generic.SortedSet[int]
7
- foreach ($part in ($Text -split ',')) {
8
- $part = $part.Trim()
9
- if (-not $part) { continue }
10
- if ($part -match '^(\d+)-(\d+)$') {
11
- $a = [int]$Matches[1]; $b = [int]$Matches[2]
12
- if ($a -gt $b) { $t = $a; $a = $b; $b = $t }
13
- for ($i = $a; $i -le $b; $i++) { if (Get-Section $i) { $null = $ids.Add($i) } else { Write-Warn "no section $i - ignored" } }
14
- } elseif ($part -match '^\d+$') {
15
- $i = [int]$part
16
- if (Get-Section $i) { $null = $ids.Add($i) } else { Write-Warn "no section $i - ignored" }
17
- } else {
18
- Write-Warn "cannot parse section '$part' - ignored"
19
- }
20
- }
21
- return @($ids)
22
- }
23
-
24
- function Get-RequestedSections {
25
- <# .SYNOPSIS Resolve --all / --only / --profile / --exclude into the ordered list to run. #>
26
- $ws = $Script:WS
27
- $ids = @()
28
- if ($ws.Mode -eq 'all') {
29
- $ids = @($Script:WS_SAFE_BATCH)
30
- if ($ws.IsAdmin) { $ids += $Script:WS_SAFE_BATCH_ADMIN }
31
- } elseif ($ws.OnlyList) {
32
- $ids = Get-SectionIdList $ws.OnlyList
33
- } elseif ($ws.Profile) {
34
- $ids = @($Script:WS_PROFILES[$ws.Profile])
35
- Write-Info "profile '$($ws.Profile)' = sections $($ids -join ',')"
36
- }
37
- if ($ws.Exclude) {
38
- $ex = Get-SectionIdList $ws.Exclude
39
- $ids = @($ids | Where-Object { $ex -notcontains $_ })
40
- Write-Info "after --exclude: sections $($ids -join ',')"
41
- }
42
- return @($ids | Sort-Object -Unique)
43
- }
44
-
45
- function Get-MachineProgressLine {
46
- <# .SYNOPSIS The progress-line format a --json caller parses. Kept pure so the self-test can round-trip it. #>
47
- param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
48
- $line = "##$Script:WS_NAME section=$Section event=$Stage"
49
- if ($Stage -eq 'end') { $line += " status=$Status freed_bytes=$Freed" }
50
- return $line
51
- }
52
-
53
- function Write-MachineProgress {
54
- <# .SYNOPSIS One machine-readable progress line on stderr, --json mode only, so a GUI need not parse the log.
55
- Never on stdout: --json promises exactly one stdout line. #>
56
- param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
57
- if (-not $Script:WS.JsonMode) { return }
58
- [Console]::Error.WriteLine((Get-MachineProgressLine -Section $Section -Stage $Stage -Status $Status -Freed $Freed))
59
- }
60
-
61
- function Add-JsonCandidate {
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 = '')
64
- 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
- }
67
-
68
- function Invoke-SectionById {
69
- <# .SYNOPSIS Run one section under the batch, admin and developer policies; records the report step. #>
70
- param([int] $Id)
71
- $ws = $Script:WS
72
- $sec = Get-Section $Id
73
- if (-not $sec) { Write-Warn "no section $Id"; return }
74
- $title = "[{0:00}] {1}" -f $sec.Id, $sec.Title
75
- Write-Box $title
76
- Write-MachineProgress -Section $Id -Stage 'start'
77
- # Batch policy: deep and interactive-only sections never run unattended without an explicit opt-in.
78
- if ($ws.BatchMode -and -not $ws.DryRun) {
79
- if ($sec.Batch -eq 'interactive' -and -not $ws.SelectActive) {
80
- Write-Warn "section $Id is interactive-only: it needs a person at the keyboard, or a selection passed with --select / --select-file."
81
- $ws.Refusals += "section $Id (interactive-only)"
82
- Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'interactive-only section in batch mode'
83
- Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
84
- return
85
- }
86
- if ($sec.Batch -eq 'interactive') {
87
- Write-Note "section $Id runs unattended because a selection was supplied (--select / --select-file): a person chose these."
88
- }
89
- if ($sec.Batch -eq 'deep' -and -not $ws.Deep) {
90
- Write-Warn "section $Id is deep (irreversible or system-changing): refused in batch mode without --i-understand-deep."
91
- $ws.Refusals += "section $Id (deep, add --i-understand-deep)"
92
- Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'deep section without --i-understand-deep'
93
- Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
94
- return
95
- }
96
- }
97
- if ($sec.Admin -and -not $ws.IsAdmin) {
98
- Write-Warn "section $Id needs Administrator rights - skipped."
99
- Write-Note "run it elevated: windowsweep --only $Id --yes --elevate"
100
- $ws.Hints += "Admin section $Id skipped: windowsweep --only $Id --yes --elevate"
101
- Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'needs Administrator'
102
- Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
103
- return
104
- }
105
- if ($sec.Dev -and $ws.Developer -eq $false -and $Id -in @(4, 17, 20)) {
106
- Write-Info "developer mode is off - section $Id (developer-only data) skipped."
107
- Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'developer mode off'
108
- Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
109
- return
110
- }
111
- $ws.SectionFreed = [long]0
112
- $before = Get-SystemDriveFree
113
- $status = 'ran'
114
- if ($ws.DryRun) { $status = 'dry-run' }
115
- try {
116
- & $sec.Fn
117
- } catch {
118
- $status = 'failed'
119
- Write-Err "section $Id failed: $($_.Exception.Message)"
120
- Write-LogLine ("section $Id error: " + $_.ScriptStackTrace)
121
- $ws.ExitCode = $Script:WS_EXIT_FAIL
122
- }
123
- $after = Get-SystemDriveFree
124
- $delta = $after - $before
125
- if ($delta -lt 0) { $delta = 0 }
126
- $freed = $ws.SectionFreed
127
- if ($ws.DryRun) {
128
- if ($freed -gt 0) { Write-UiLine (" [dry-run] this section would free about {0}" -f (Format-Bytes $freed)) 'Magenta' }
129
- } elseif ($freed -gt 0 -or $delta -gt 0) {
130
- Write-UiLine (" {0} this section freed {1} (system drive gained {2}) running total: {3}" -f $ws.Glyph.ok, (Format-Bytes $freed), (Format-Bytes $delta), (Format-Bytes $ws.TotalFreed)) 'Green'
131
- } else {
132
- Write-Note 'no measurable change in this section'
133
- }
134
- Add-ReportStep -Section $Id -Title $sec.Title -Status $status -Freed $freed
135
- Write-MachineProgress -Section $Id -Stage 'end' -Status $status -Freed $freed
136
- }
137
-
138
- function Invoke-BatchMode {
139
- <# .SYNOPSIS --all / --only / --profile #>
140
- $ws = $Script:WS
141
- $ids = @(Get-RequestedSections)
142
- if ($ids.Count -eq 0) { Write-Err 'no sections to run'; $ws.ExitCode = $Script:WS_EXIT_USAGE; return }
143
- Write-Banner
144
- Show-RunHeader
145
- Write-Info "running sections: $($ids -join ',')"
146
- foreach ($id in $ids) { Invoke-SectionById -Id $id }
147
- Show-SessionSummary
148
- if ($ws.Refusals.Count -gt 0 -and $ws.Mode -eq 'only' -and $ws.ExitCode -eq 0) { $ws.ExitCode = $Script:WS_EXIT_REFUSED }
149
- }
150
-
151
- function Show-RunHeader {
152
- $ws = $Script:WS
153
- $strategy = "prune files idle $($ws.Days)+ days (temp: $($ws.TempDays)+ days)"
154
- if ($ws.PurgeAll) { $strategy = 'FULL PURGE of cache targets' }
155
- $dev = 'off'
156
- if ($ws.Developer) { $dev = "on ($($ws.DeveloperSource))" }
157
- $adm = 'no'
158
- if ($ws.IsAdmin) { $adm = 'yes' }
159
- Write-Info "mode: $($ws.Mode) strategy: $strategy developer: $dev elevated: $adm auto-yes: $($ws.Yes)"
160
- if ($ws.DryRun) { Write-UiLine ' DRY-RUN: nothing will be deleted; sizes below are estimates of what a real run would remove.' 'Magenta' }
161
- Write-Info "log: $($ws.LogFile)"
162
- }
163
-
164
- function Invoke-ScanMode {
165
- <# .SYNOPSIS --scan: health report + every target's size + the read-only personal scanners. Deletes nothing. #>
166
- $ws = $Script:WS
167
- Write-Banner
168
- Write-Info 'read-only scan - nothing is deleted'
169
- if (Get-Command Invoke-Section00 -ErrorAction SilentlyContinue) { Invoke-Section00 }
170
- Write-Box 'Targets on disk' 'What each section can reach, and how much it currently holds'
171
- $null = Show-ScanTable
172
- if (Get-Command Show-PersonalScan -ErrorAction SilentlyContinue) { Show-PersonalScan }
173
- Add-ReportStep -Section -1 -Title 'Read-only scan' -Status 'ran'
174
- Show-SessionSummary
175
- }
176
-
177
- function Show-SessionSummary {
178
- $ws = $Script:WS
179
- Write-Box 'Session summary'
180
- if ($ws.DryRun) {
181
- Write-Kv 'Mode:' 'DRY-RUN - nothing was deleted'
182
- Write-Kv 'Would free (estimate):' (Format-Bytes $ws.TotalEstimated)
183
- } else {
184
- Write-Kv 'Reclaimed:' (Format-Bytes $ws.TotalFreed)
185
- }
186
- $secs = [int][math]::Round(((Get-Date).Ticks - $ws.Report.startedTicks) / 10000000)
187
- Write-Kv 'Duration:' (Format-Duration $secs)
188
- $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
189
- $skipped = @($ws.Report.steps | Where-Object { $_.status -notin 'ran', 'dry-run' }).Count
190
- Write-Kv 'Sections run / skipped:' "$ran / $skipped"
191
- Write-Kv 'Log:' $ws.LogFile
192
- $rep = Save-Report
193
- if ($rep) { Write-Kv 'Report (JSON):' $rep } elseif ($ws.NoReport) { Write-Kv 'Report:' '(disabled via --no-report)' }
194
- Write-UiLine '' 'Gray'
195
- Show-DriveTable
196
- if ($ws.Refusals.Count -gt 0) {
197
- Write-UiLine '' 'Gray'
198
- Write-Warn 'refused in batch mode:'
199
- foreach ($r in $ws.Refusals) { Write-Note $r }
200
- }
201
- if ($ws.Hints.Count -gt 0) {
202
- Write-UiLine '' 'Gray'
203
- Write-Info 'next steps:'
204
- foreach ($h in ($ws.Hints | Sort-Object -Unique)) { Write-Note $h }
205
- }
206
- Write-UiLine '' 'Gray'
207
- Write-Note "$Script:WS_NAME v$(Get-ToolVersion) by $Script:WS_AUTHOR - $Script:WS_REPO"
208
- Send-RunNotification
209
- if ($ws.JsonMode) { Write-JsonSummary }
210
- }
211
-
212
- function Get-JsonSummary {
213
- <# .SYNOPSIS The --json document (ordered), kept apart from the printer so the self-test can parse it. #>
214
- $ws = $Script:WS
215
- return [ordered]@{
216
- tool = $Script:WS_NAME; version = (Get-ToolVersion); mode = $ws.Mode; dry_run = [bool]$ws.DryRun
217
- elevated = [bool]$ws.IsAdmin; developer = $ws.Developer
218
- freed_bytes = [long]$ws.TotalFreed; estimated_bytes = [long]$ws.TotalEstimated
219
- sections = @($ws.Report.steps | ForEach-Object { [ordered]@{ section = $_.section; status = $_.status; freed_bytes = $_.freed_bytes } })
220
- # candidates and targets are ALWAYS present, empty when nothing was collected: a caller can rely on the shape.
221
- candidates = @($ws.Candidates); targets = @($ws.ScanTargets)
222
- refusals = @($ws.Refusals); log_file = $ws.LogFile; report_file = $ws.ReportFile
223
- }
224
- }
225
-
226
- function Get-CatalogueJson {
227
- <# .SYNOPSIS --list --json: the section catalogue, so a front end reads it instead of hard-coding it. #>
228
- return [ordered]@{
229
- tool = $Script:WS_NAME; version = (Get-ToolVersion)
230
- sections = @($Script:WS_SECTIONS | ForEach-Object { [ordered]@{ id = $_.Id; key = $_.Key; title = $_.Title; tier = $_.Tier; admin = [bool]$_.Admin; batch = $_.Batch; dev = [bool]$_.Dev } })
231
- safe_batch = @($Script:WS_SAFE_BATCH); safe_batch_admin = @($Script:WS_SAFE_BATCH_ADMIN)
232
- profiles = ([ordered]@{} + $Script:WS_PROFILES); walkthrough = @($Script:WS_WALKTHROUGH); walkthrough_admin = @($Script:WS_WALKTHROUGH_ADMIN)
233
- }
234
- }
235
-
236
- function Write-JsonSummary {
237
- <# .SYNOPSIS --json: the only line written to stdout. #>
238
- [Console]::Out.WriteLine(((Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress))
239
- }
1
+ # runner.ps1 - section dispatch, batch policy enforcement, the read-only scan mode and the summary.
2
+
3
+ function Get-SectionIdList {
4
+ <# .SYNOPSIS Parse "1,3,5-7" into section ids; validates against the catalogue. #>
5
+ param([string] $Text)
6
+ $ids = New-Object System.Collections.Generic.SortedSet[int]
7
+ foreach ($part in ($Text -split ',')) {
8
+ $part = $part.Trim()
9
+ if (-not $part) { continue }
10
+ if ($part -match '^(\d+)-(\d+)$') {
11
+ $a = [int]$Matches[1]; $b = [int]$Matches[2]
12
+ if ($a -gt $b) { $t = $a; $a = $b; $b = $t }
13
+ for ($i = $a; $i -le $b; $i++) { if (Get-Section $i) { $null = $ids.Add($i) } else { Write-Warn "no section $i - ignored" } }
14
+ } elseif ($part -match '^\d+$') {
15
+ $i = [int]$part
16
+ if (Get-Section $i) { $null = $ids.Add($i) } else { Write-Warn "no section $i - ignored" }
17
+ } else {
18
+ Write-Warn "cannot parse section '$part' - ignored"
19
+ }
20
+ }
21
+ return @($ids)
22
+ }
23
+
24
+ function Get-RequestedSections {
25
+ <# .SYNOPSIS Resolve --all / --only / --profile / --exclude into the ordered list to run. #>
26
+ $ws = $Script:WS
27
+ $ids = @()
28
+ if ($ws.Mode -eq 'all') {
29
+ $ids = @($Script:WS_SAFE_BATCH)
30
+ if ($ws.IsAdmin) { $ids += $Script:WS_SAFE_BATCH_ADMIN }
31
+ } elseif ($ws.OnlyList) {
32
+ $ids = Get-SectionIdList $ws.OnlyList
33
+ } elseif ($ws.Profile) {
34
+ $ids = @($Script:WS_PROFILES[$ws.Profile])
35
+ Write-Info "profile '$($ws.Profile)' = sections $($ids -join ',')"
36
+ }
37
+ if ($ws.Exclude) {
38
+ $ex = Get-SectionIdList $ws.Exclude
39
+ $ids = @($ids | Where-Object { $ex -notcontains $_ })
40
+ Write-Info "after --exclude: sections $($ids -join ',')"
41
+ }
42
+ return @($ids | Sort-Object -Unique)
43
+ }
44
+
45
+ function Get-MachineProgressLine {
46
+ <# .SYNOPSIS The progress-line format a --json caller parses. Kept pure so the self-test can round-trip it. #>
47
+ param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
48
+ $line = "##$Script:WS_NAME section=$Section event=$Stage"
49
+ if ($Stage -eq 'end') { $line += " status=$Status freed_bytes=$Freed" }
50
+ return $line
51
+ }
52
+
53
+ function Write-MachineProgress {
54
+ <# .SYNOPSIS One machine-readable progress line on stderr, --json mode only, so a GUI need not parse the log.
55
+ Never on stdout: --json promises exactly one stdout line. #>
56
+ param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
57
+ if (-not $Script:WS.JsonMode) { return }
58
+ [Console]::Error.WriteLine((Get-MachineProgressLine -Section $Section -Stage $Stage -Status $Status -Freed $Freed))
59
+ }
60
+
61
+ function Add-JsonCandidate {
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 = '')
64
+ 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
+ }
67
+
68
+ function Invoke-SectionById {
69
+ <# .SYNOPSIS Run one section under the batch, admin and developer policies; records the report step. #>
70
+ param([int] $Id)
71
+ $ws = $Script:WS
72
+ $sec = Get-Section $Id
73
+ if (-not $sec) { Write-Warn "no section $Id"; return }
74
+ $title = "[{0:00}] {1}" -f $sec.Id, $sec.Title
75
+ Write-Box $title
76
+ Write-MachineProgress -Section $Id -Stage 'start'
77
+ # Batch policy: deep and interactive-only sections never run unattended without an explicit opt-in.
78
+ if ($ws.BatchMode -and -not $ws.DryRun) {
79
+ if ($sec.Batch -eq 'interactive' -and -not $ws.SelectActive) {
80
+ Write-Warn "section $Id is interactive-only: it needs a person at the keyboard, or a selection passed with --select / --select-file."
81
+ $ws.Refusals += "section $Id (interactive-only)"
82
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'interactive-only section in batch mode'
83
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
84
+ return
85
+ }
86
+ if ($sec.Batch -eq 'interactive') {
87
+ Write-Note "section $Id runs unattended because a selection was supplied (--select / --select-file): a person chose these."
88
+ }
89
+ if ($sec.Batch -eq 'deep' -and -not $ws.Deep) {
90
+ Write-Warn "section $Id is deep (irreversible or system-changing): refused in batch mode without --i-understand-deep."
91
+ $ws.Refusals += "section $Id (deep, add --i-understand-deep)"
92
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'deep section without --i-understand-deep'
93
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
94
+ return
95
+ }
96
+ }
97
+ if ($sec.Admin -and -not $ws.IsAdmin) {
98
+ Write-Warn "section $Id needs Administrator rights - skipped."
99
+ Write-Note "run it elevated: windowsweep --only $Id --yes --elevate"
100
+ $ws.Hints += "Admin section $Id skipped: windowsweep --only $Id --yes --elevate"
101
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'needs Administrator'
102
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
103
+ return
104
+ }
105
+ if ($sec.Dev -and $ws.Developer -eq $false -and $Id -in @(4, 17, 20)) {
106
+ Write-Info "developer mode is off - section $Id (developer-only data) skipped."
107
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'developer mode off'
108
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
109
+ return
110
+ }
111
+ $ws.SectionFreed = [long]0
112
+ $before = Get-SystemDriveFree
113
+ $status = 'ran'
114
+ if ($ws.DryRun) { $status = 'dry-run' }
115
+ try {
116
+ & $sec.Fn
117
+ } catch {
118
+ $status = 'failed'
119
+ Write-Err "section $Id failed: $($_.Exception.Message)"
120
+ Write-LogLine ("section $Id error: " + $_.ScriptStackTrace)
121
+ $ws.ExitCode = $Script:WS_EXIT_FAIL
122
+ }
123
+ $after = Get-SystemDriveFree
124
+ $delta = $after - $before
125
+ if ($delta -lt 0) { $delta = 0 }
126
+ $freed = $ws.SectionFreed
127
+ if ($ws.DryRun) {
128
+ if ($freed -gt 0) { Write-UiLine (" [dry-run] this section would reclaim about {0}" -f (Format-Bytes $freed)) 'Magenta' }
129
+ } elseif ($freed -gt 0 -or $delta -gt 0) {
130
+ Write-UiLine (" {0} this section reclaimed {1} (system drive gained {2}) running total: {3}" -f $ws.Glyph.ok, (Format-Bytes $freed), (Format-Bytes $delta), (Format-Bytes $ws.TotalFreed)) 'Green'
131
+ } else {
132
+ Write-Note 'no measurable change in this section'
133
+ }
134
+ Add-ReportStep -Section $Id -Title $sec.Title -Status $status -Freed $freed
135
+ Write-MachineProgress -Section $Id -Stage 'end' -Status $status -Freed $freed
136
+ }
137
+
138
+ function Invoke-BatchMode {
139
+ <# .SYNOPSIS --all / --only / --profile #>
140
+ $ws = $Script:WS
141
+ $ids = @(Get-RequestedSections)
142
+ if ($ids.Count -eq 0) { Write-Err 'no sections to run - check --only / --profile against windowsweep --list'; $ws.ExitCode = $Script:WS_EXIT_USAGE; return }
143
+ Write-Banner
144
+ Show-RunHeader
145
+ Write-Info "running sections: $($ids -join ',')"
146
+ foreach ($id in $ids) { Invoke-SectionById -Id $id }
147
+ Show-SessionSummary
148
+ if ($ws.Refusals.Count -gt 0 -and $ws.Mode -eq 'only' -and $ws.ExitCode -eq 0) { $ws.ExitCode = $Script:WS_EXIT_REFUSED }
149
+ }
150
+
151
+ function Show-RunHeader {
152
+ $ws = $Script:WS
153
+ $strategy = "prune files idle $($ws.Days)+ days (temp: $($ws.TempDays)+ days)"
154
+ if ($ws.PurgeAll) { $strategy = 'FULL PURGE of cache targets' }
155
+ $dev = 'off'
156
+ if ($ws.Developer) { $dev = "on ($($ws.DeveloperSource))" }
157
+ $adm = 'no'
158
+ if ($ws.IsAdmin) { $adm = 'yes' }
159
+ Write-Info "mode: $($ws.Mode) strategy: $strategy developer: $dev elevated: $adm auto-yes: $($ws.Yes)"
160
+ if ($ws.DryRun) { Write-UiLine ' DRY-RUN: nothing will be deleted; sizes below are estimates of what a real run would remove.' 'Magenta' }
161
+ Write-Info "log: $($ws.LogFile)"
162
+ }
163
+
164
+ function Invoke-ScanMode {
165
+ <# .SYNOPSIS --scan: health report + every target's size + the read-only personal scanners. Deletes nothing. #>
166
+ $ws = $Script:WS
167
+ Write-Banner
168
+ Write-Info 'scan only - nothing is deleted; a log and a report are still written'
169
+ if (Get-Command Invoke-Section00 -ErrorAction SilentlyContinue) { Invoke-Section00 }
170
+ Write-Box 'Targets on disk' 'What each section can reach, and how much it currently holds'
171
+ $null = Show-ScanTable
172
+ if (Get-Command Show-PersonalScan -ErrorAction SilentlyContinue) { Show-PersonalScan }
173
+ Add-ReportStep -Section -1 -Title 'Scan' -Status 'ran'
174
+ Show-SessionSummary
175
+ }
176
+
177
+ function Show-SessionSummary {
178
+ $ws = $Script:WS
179
+ Write-Box 'Session summary'
180
+ if ($ws.DryRun) {
181
+ Write-Kv 'Mode:' 'DRY-RUN - nothing was deleted'
182
+ Write-Kv 'Would reclaim (est.):' (Format-Bytes $ws.TotalEstimated)
183
+ } else {
184
+ Write-Kv 'Reclaimed:' (Format-Bytes $ws.TotalFreed)
185
+ }
186
+ $secs = [int][math]::Round(((Get-Date).Ticks - $ws.Report.startedTicks) / 10000000)
187
+ Write-Kv 'Duration:' (Format-Duration $secs)
188
+ $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
189
+ $skipped = @($ws.Report.steps | Where-Object { $_.status -notin 'ran', 'dry-run' }).Count
190
+ # C-091: 'skipped' is every step that did not run, so it folds in REFUSED and FAILED as well as skipped.
191
+ # The label is kept deliberately - the exported report was aligned to this exact wording on purpose, and
192
+ # renaming both costs two surfaces and a stored artefact's vocabulary to buy a word. The fact lives here,
193
+ # beside the slot, so a reader of `9 / 2` can find out which.
194
+ Write-Kv 'Sections run / skipped:' "$ran / $skipped"
195
+ Write-Kv 'Log:' $ws.LogFile
196
+ $rep = Save-Report
197
+ if ($rep) { Write-Kv 'Report (JSON):' $rep } elseif ($ws.NoReport) { Write-Kv 'Report:' '(disabled via --no-report)' }
198
+ Write-UiLine '' 'Gray'
199
+ Show-DriveTable
200
+ if ($ws.Refusals.Count -gt 0) {
201
+ Write-UiLine '' 'Gray'
202
+ Write-Warn 'refused in batch mode:'
203
+ foreach ($r in $ws.Refusals) { Write-Note $r }
204
+ }
205
+ if ($ws.Hints.Count -gt 0) {
206
+ Write-UiLine '' 'Gray'
207
+ Write-Info 'next steps:'
208
+ foreach ($h in ($ws.Hints | Sort-Object -Unique)) { Write-Note $h }
209
+ }
210
+ Write-UiLine '' 'Gray'
211
+ Write-Note "$Script:WS_NAME v$(Get-ToolVersion) by $Script:WS_AUTHOR - $Script:WS_REPO"
212
+ Send-RunNotification
213
+ if ($ws.JsonMode) { Write-JsonSummary }
214
+ }
215
+
216
+ function Get-JsonSummary {
217
+ <# .SYNOPSIS The --json document (ordered), kept apart from the printer so the self-test can parse it. #>
218
+ $ws = $Script:WS
219
+ return [ordered]@{
220
+ tool = $Script:WS_NAME; version = (Get-ToolVersion); mode = $ws.Mode; dry_run = [bool]$ws.DryRun
221
+ elevated = [bool]$ws.IsAdmin; developer = $ws.Developer
222
+ freed_bytes = [long]$ws.TotalFreed; estimated_bytes = [long]$ws.TotalEstimated
223
+ sections = @($ws.Report.steps | ForEach-Object { [ordered]@{ section = $_.section; status = $_.status; freed_bytes = $_.freed_bytes } })
224
+ # candidates and targets are ALWAYS present, empty when nothing was collected: a caller can rely on the shape.
225
+ candidates = @($ws.Candidates); targets = @($ws.ScanTargets)
226
+ refusals = @($ws.Refusals); excluded = @($ws.Excluded); log_file = $ws.LogFile; report_file = $ws.ReportFile
227
+ }
228
+ }
229
+
230
+ function Get-CatalogueJson {
231
+ <# .SYNOPSIS --list --json: the section catalogue, so a front end reads it instead of hard-coding it. #>
232
+ return [ordered]@{
233
+ tool = $Script:WS_NAME; version = (Get-ToolVersion)
234
+ sections = @($Script:WS_SECTIONS | ForEach-Object { [ordered]@{ id = $_.Id; key = $_.Key; title = $_.Title; tier = $_.Tier; admin = [bool]$_.Admin; batch = $_.Batch; dev = [bool]$_.Dev } })
235
+ safe_batch = @($Script:WS_SAFE_BATCH); safe_batch_admin = @($Script:WS_SAFE_BATCH_ADMIN)
236
+ # The same two lists --list-targets prints, so a front end shows the engine's own promise rather than
237
+ # a copy of it that can age. Subtrees is built by Initialize-Safety from this machine's real folders.
238
+ protected = [ordered]@{ subtrees = @($Script:WS_PROTECT.Subtrees); categories = @($Script:WS_PROTECT_CATEGORIES) }
239
+ profiles = ([ordered]@{} + $Script:WS_PROFILES); walkthrough = @($Script:WS_WALKTHROUGH); walkthrough_admin = @($Script:WS_WALKTHROUGH_ADMIN)
240
+ }
241
+ }
242
+
243
+ function Write-JsonSummary {
244
+ <# .SYNOPSIS --json: the only line written to stdout. #>
245
+ [Console]::Out.WriteLine(((Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress))
246
+ }