windowsweep 1.0.1 → 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.
- package/AI-INTEGRATION-GUIDE.md +199 -0
- package/CHANGELOG.md +68 -0
- package/README.md +22 -11
- package/VERSION +1 -1
- package/lib/actions.ps1 +8 -5
- package/lib/constants.ps1 +7 -3
- package/lib/scan.ps1 +10 -4
- package/lib/ui.ps1 +47 -7
- package/modules/globals_audit.ps1 +236 -0
- package/modules/health.ps1 +1 -1
- package/modules/installed_programs.ps1 +118 -0
- package/modules/menu.ps1 +3 -1
- package/modules/notify.ps1 +60 -0
- package/modules/orphaned_appdata.ps1 +189 -0
- package/modules/personal.ps1 +9 -7
- package/modules/pkg_managers.ps1 +4 -0
- package/modules/projects.ps1 +25 -8
- package/modules/release_helpers.ps1 +5 -1
- package/modules/runner.ps1 +55 -6
- package/modules/self_test_extra.ps1 +276 -2
- package/modules/startup_audit.ps1 +126 -0
- package/modules/windows_user.ps1 +6 -0
- package/package.json +3 -2
- package/windowsweep.ps1 +21 -1
package/modules/personal.ps1
CHANGED
|
@@ -73,22 +73,24 @@ function Show-FileTable {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
function Invoke-PersonalPicker {
|
|
76
|
-
param([object[]] $Rows, [string] $Prompt)
|
|
76
|
+
param([object[]] $Rows, [string] $Prompt, [int] $Section = 0)
|
|
77
77
|
$ws = $Script:WS
|
|
78
78
|
$Rows = @($Rows)
|
|
79
79
|
if ($Rows.Count -eq 0) { Write-Info 'none found'; return }
|
|
80
80
|
Show-FileTable $Rows
|
|
81
81
|
$total = [long]0
|
|
82
|
-
|
|
82
|
+
$i = 1
|
|
83
|
+
foreach ($r in $Rows) { $total += $r.Bytes; Add-JsonCandidate -Section $Section -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle; $i++ }
|
|
83
84
|
Write-Info ("total: " + (Format-Bytes $total))
|
|
84
85
|
$dest = 'the Recycle Bin'
|
|
85
86
|
if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
|
|
86
87
|
Write-Note "selected files go to $dest"
|
|
87
|
-
$picks = @(Read-MultiSelect -Total $Rows.Count -NoAutoYes)
|
|
88
|
+
$picks = @(Read-MultiSelect -Total $Rows.Count -NoAutoYes -Candidates @($Rows | ForEach-Object { $_.Path }))
|
|
88
89
|
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
89
90
|
if (-not $ws.DryRun) {
|
|
90
|
-
# Personal files never auto-confirm: --yes does not apply here.
|
|
91
|
-
|
|
91
|
+
# Personal files never auto-confirm: --yes does not apply here. An explicit --select / --select-file
|
|
92
|
+
# choice does, because it is a person naming these exact files in advance.
|
|
93
|
+
if (-not (Confirm-Ui -Prompt "$Prompt ($($picks.Count) file(s))?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
92
94
|
}
|
|
93
95
|
foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $Rows[$k - 1].Path -Within $Rows[$k - 1].Root }
|
|
94
96
|
}
|
|
@@ -99,7 +101,7 @@ function Invoke-Section18 {
|
|
|
99
101
|
'You pick what goes; it lands in the Recycle Bin. Desktop and cloud-synced folders are never scanned.'
|
|
100
102
|
)
|
|
101
103
|
if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
|
|
102
|
-
Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin'
|
|
104
|
+
Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin' -Section 18
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
function Invoke-Section19 {
|
|
@@ -109,7 +111,7 @@ function Invoke-Section19 {
|
|
|
109
111
|
'archives you already extracted. You pick what goes; it lands in the Recycle Bin.'
|
|
110
112
|
)
|
|
111
113
|
if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
|
|
112
|
-
Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin'
|
|
114
|
+
Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin' -Section 19
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
function Show-PersonalScan {
|
package/modules/pkg_managers.ps1
CHANGED
|
@@ -37,6 +37,10 @@ function Get-Targets01 {
|
|
|
37
37
|
(New-Target 1 'electron-builder cache' "$($P.L)\electron-builder\Cache" -Mode prune -Dev $true)
|
|
38
38
|
(New-Target 1 'node-gyp headers cache' "$($P.L)\node-gyp\Cache" -Mode prune -Dev $true)
|
|
39
39
|
(New-Target 1 'Scoop installer cache' "$($P.U)\scoop\cache" -Mode prune -Dev $true)
|
|
40
|
+
# Hugging Face keeps revisions as snapshots\<rev> linking into blobs, so removing a whole snapshot orphans
|
|
41
|
+
# blobs: this prunes by the idle gate instead of keeping "the newest per model". AI agent state under
|
|
42
|
+
# ~\.cache (claude, codex, gemini, copilot) stays protected by lib/safety.ps1.
|
|
43
|
+
(New-Target 1 'Hugging Face model cache' "$($P.U)\.cache\huggingface\hub" -Mode prune -Dev $true -Note 'models are re-downloaded on next use; the idle gate keeps recent ones')
|
|
40
44
|
)
|
|
41
45
|
}
|
|
42
46
|
|
package/modules/projects.ps1
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
|
|
2
2
|
|
|
3
|
-
$Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular')
|
|
3
|
+
$Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular', '.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
|
|
4
|
+
# Never here: .venv / venv (nobody expects an environment to vanish) and .terraform (provider binaries + lock state).
|
|
5
|
+
# A name in this table is an artefact ONLY when the same folder carries one of the listed markers: a bare
|
|
6
|
+
# .cache belongs to whoever made it, but beside a Gatsby or Parcel config it is a build cache.
|
|
7
|
+
$Script:WS_ARTEFACT_DIRS_MARKED = @{ '.cache' = @('gatsby-config.js', 'gatsby-config.ts', 'gatsby-config.mjs', '.parcelrc', 'parcel.config.js') }
|
|
4
8
|
$Script:WS_PROJECT_MARKERS = @('package.json', 'pubspec.yaml', 'composer.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'angular.json', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'vite.config.ts', 'vite.config.js')
|
|
5
9
|
$Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
|
|
6
10
|
|
|
@@ -37,6 +41,15 @@ function Get-Targets17 {
|
|
|
37
41
|
return $t
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
function Test-MarkedArtefactDir {
|
|
45
|
+
<# .SYNOPSIS True when Name is a marker-gated artefact folder AND Dir carries one of its markers. #>
|
|
46
|
+
param([string] $Dir, [string] $Name)
|
|
47
|
+
if (-not $Script:WS_ARTEFACT_DIRS_MARKED.ContainsKey($Name)) { return $false }
|
|
48
|
+
$names = @(Get-ChildEntries $Dir | ForEach-Object { $_.Name })
|
|
49
|
+
foreach ($m in $Script:WS_ARTEFACT_DIRS_MARKED[$Name]) { if ($names -contains $m) { return $true } }
|
|
50
|
+
return $false
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
function Test-ProjectMarker {
|
|
41
54
|
param([string] $Dir, [string] $ArtefactName)
|
|
42
55
|
$entries = Get-ChildEntries $Dir
|
|
@@ -59,7 +72,7 @@ function Get-ProjectActivityDays {
|
|
|
59
72
|
$seen++
|
|
60
73
|
if (Test-ReparsePoint $e) { continue }
|
|
61
74
|
if ($e -is [IO.DirectoryInfo]) {
|
|
62
|
-
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name) { continue }
|
|
75
|
+
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 }
|
|
63
76
|
$stack.Push((Remove-LongPrefix $e.FullName)); continue
|
|
64
77
|
}
|
|
65
78
|
$t = Get-NewestTimestampUtc $e
|
|
@@ -88,7 +101,7 @@ function Find-StaleArtefacts {
|
|
|
88
101
|
$skip = $false
|
|
89
102
|
foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
|
|
90
103
|
if ($skip) { continue }
|
|
91
|
-
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj')) {
|
|
104
|
+
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj') -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) {
|
|
92
105
|
if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
|
|
93
106
|
if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
|
|
94
107
|
$age = $projectAge[$dir]
|
|
@@ -116,27 +129,31 @@ function Invoke-Section17 {
|
|
|
116
129
|
$found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
|
|
117
130
|
if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
|
|
118
131
|
$reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
|
|
132
|
+
# One offered list drives the table, the JSON candidates and the selection indexes: they cannot disagree.
|
|
133
|
+
$offered = @($found | Select-Object -First 200)
|
|
134
|
+
if ($found.Count -gt $offered.Count) { Write-Warn 'list truncated to 200 entries' }
|
|
119
135
|
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
|
|
120
136
|
$i = 1; $total = [long]0
|
|
121
|
-
foreach ($f in $
|
|
137
|
+
foreach ($f in $offered) {
|
|
122
138
|
Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
|
|
123
139
|
$reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
|
|
140
|
+
Add-JsonCandidate -Section 17 -Index $i -Path $f.Path -Bytes $f.Bytes -IdleDays $f.Age -Project $f.Project
|
|
124
141
|
$total += $f.Bytes; $i++
|
|
125
|
-
if ($i -gt 200) { Write-Warn 'list truncated to 200 entries'; break }
|
|
126
142
|
}
|
|
127
143
|
Write-Info ("total: " + (Format-Bytes $total))
|
|
128
144
|
if (-not $ws.NoReport) {
|
|
129
145
|
$out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
|
|
130
146
|
try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
|
|
131
147
|
}
|
|
132
|
-
$picks = @(Read-MultiSelect -Total
|
|
148
|
+
$picks = @(Read-MultiSelect -Total $offered.Count -NoAutoYes -Candidates @($offered | ForEach-Object { $_.Path }))
|
|
133
149
|
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
134
150
|
if (-not $ws.DryRun) {
|
|
135
151
|
# Project artefacts never auto-confirm: --yes does not apply, and the walkthrough's step answer is not a selection.
|
|
136
|
-
|
|
152
|
+
# An explicit --select / --select-file choice does answer it - that is a person choosing, in advance.
|
|
153
|
+
if (-not (Confirm-Ui -Prompt "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
137
154
|
}
|
|
138
155
|
foreach ($k in $picks) {
|
|
139
|
-
$f = $
|
|
156
|
+
$f = $offered[$k - 1]
|
|
140
157
|
$r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
|
|
141
158
|
if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
|
|
142
159
|
}
|
|
@@ -18,6 +18,9 @@ function Show-Version {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function Show-SectionList {
|
|
21
|
+
# --list --json prints the catalogue and nothing else: the human table below uses Write-Host, which would
|
|
22
|
+
# break the one-line-on-stdout promise.
|
|
23
|
+
if ($Script:WS.JsonMode) { [Console]::Out.WriteLine(((Get-CatalogueJson) | ConvertTo-Json -Depth 6 -Compress)); return }
|
|
21
24
|
Write-Host ''
|
|
22
25
|
Write-Host (" {0,3} {1,-84} {2,-10} {3,-5} {4}" -f '#', 'SECTION', 'TIER', 'ADMIN', 'BATCH')
|
|
23
26
|
foreach ($s in $Script:WS_SECTIONS) {
|
|
@@ -340,7 +343,8 @@ function Install-WeeklyTask {
|
|
|
340
343
|
if (Test-NpxInstallerRefusal '--install-task') { return }
|
|
341
344
|
if (Get-ScheduledTask -TaskName $Script:WS_TASK_NAME -ErrorAction SilentlyContinue) { Write-Info 'task already exists'; return }
|
|
342
345
|
$lc = Get-LaunchCommand
|
|
343
|
-
|
|
346
|
+
# --notify is part of the task action: an unattended Sunday run that says nothing is a run nobody sees.
|
|
347
|
+
$argLine = (@($lc.Args) + @('--all', '--yes', '--quiet', '--no-color', '--notify')) -join ' '
|
|
344
348
|
Write-Info "action: $($lc.Exe) $argLine"
|
|
345
349
|
if ($Script:WS.DryRun) { Write-DryRun 'would register the task'; return }
|
|
346
350
|
if (-not (Confirm-Ui -Prompt 'Register this task for your user account?' -Default 'y')) { Write-Info 'skipped'; return }
|
package/modules/runner.ps1
CHANGED
|
@@ -42,6 +42,29 @@ function Get-RequestedSections {
|
|
|
42
42
|
return @($ids | Sort-Object -Unique)
|
|
43
43
|
}
|
|
44
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
|
+
|
|
45
68
|
function Invoke-SectionById {
|
|
46
69
|
<# .SYNOPSIS Run one section under the batch, admin and developer policies; records the report step. #>
|
|
47
70
|
param([int] $Id)
|
|
@@ -50,18 +73,24 @@ function Invoke-SectionById {
|
|
|
50
73
|
if (-not $sec) { Write-Warn "no section $Id"; return }
|
|
51
74
|
$title = "[{0:00}] {1}" -f $sec.Id, $sec.Title
|
|
52
75
|
Write-Box $title
|
|
76
|
+
Write-MachineProgress -Section $Id -Stage 'start'
|
|
53
77
|
# Batch policy: deep and interactive-only sections never run unattended without an explicit opt-in.
|
|
54
78
|
if ($ws.BatchMode -and -not $ws.DryRun) {
|
|
55
|
-
if ($sec.Batch -eq 'interactive') {
|
|
56
|
-
Write-Warn "section $Id is interactive-only: it needs a person at the keyboard
|
|
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."
|
|
57
81
|
$ws.Refusals += "section $Id (interactive-only)"
|
|
58
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'
|
|
59
84
|
return
|
|
60
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
|
+
}
|
|
61
89
|
if ($sec.Batch -eq 'deep' -and -not $ws.Deep) {
|
|
62
90
|
Write-Warn "section $Id is deep (irreversible or system-changing): refused in batch mode without --i-understand-deep."
|
|
63
91
|
$ws.Refusals += "section $Id (deep, add --i-understand-deep)"
|
|
64
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'
|
|
65
94
|
return
|
|
66
95
|
}
|
|
67
96
|
}
|
|
@@ -70,11 +99,13 @@ function Invoke-SectionById {
|
|
|
70
99
|
Write-Note "run it elevated: windowsweep --only $Id --yes --elevate"
|
|
71
100
|
$ws.Hints += "Admin section $Id skipped: windowsweep --only $Id --yes --elevate"
|
|
72
101
|
Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'needs Administrator'
|
|
102
|
+
Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
|
|
73
103
|
return
|
|
74
104
|
}
|
|
75
105
|
if ($sec.Dev -and $ws.Developer -eq $false -and $Id -in @(4, 17, 20)) {
|
|
76
106
|
Write-Info "developer mode is off - section $Id (developer-only data) skipped."
|
|
77
107
|
Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'developer mode off'
|
|
108
|
+
Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
|
|
78
109
|
return
|
|
79
110
|
}
|
|
80
111
|
$ws.SectionFreed = [long]0
|
|
@@ -101,6 +132,7 @@ function Invoke-SectionById {
|
|
|
101
132
|
Write-Note 'no measurable change in this section'
|
|
102
133
|
}
|
|
103
134
|
Add-ReportStep -Section $Id -Title $sec.Title -Status $status -Freed $freed
|
|
135
|
+
Write-MachineProgress -Section $Id -Stage 'end' -Status $status -Freed $freed
|
|
104
136
|
}
|
|
105
137
|
|
|
106
138
|
function Invoke-BatchMode {
|
|
@@ -173,18 +205,35 @@ function Show-SessionSummary {
|
|
|
173
205
|
}
|
|
174
206
|
Write-UiLine '' 'Gray'
|
|
175
207
|
Write-Note "$Script:WS_NAME v$(Get-ToolVersion) by $Script:WS_AUTHOR - $Script:WS_REPO"
|
|
208
|
+
Send-RunNotification
|
|
176
209
|
if ($ws.JsonMode) { Write-JsonSummary }
|
|
177
210
|
}
|
|
178
211
|
|
|
179
|
-
function
|
|
180
|
-
<# .SYNOPSIS --json
|
|
212
|
+
function Get-JsonSummary {
|
|
213
|
+
<# .SYNOPSIS The --json document (ordered), kept apart from the printer so the self-test can parse it. #>
|
|
181
214
|
$ws = $Script:WS
|
|
182
|
-
|
|
215
|
+
return [ordered]@{
|
|
183
216
|
tool = $Script:WS_NAME; version = (Get-ToolVersion); mode = $ws.Mode; dry_run = [bool]$ws.DryRun
|
|
184
217
|
elevated = [bool]$ws.IsAdmin; developer = $ws.Developer
|
|
185
218
|
freed_bytes = [long]$ws.TotalFreed; estimated_bytes = [long]$ws.TotalEstimated
|
|
186
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)
|
|
187
222
|
refusals = @($ws.Refusals); log_file = $ws.LogFile; report_file = $ws.ReportFile
|
|
188
223
|
}
|
|
189
|
-
|
|
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))
|
|
190
239
|
}
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
# and fixtures for logic that is invisible when wrong (RW-030). Called by Invoke-SelfTest; verdict lines only.
|
|
3
3
|
|
|
4
4
|
function Invoke-SelfTestExtra {
|
|
5
|
-
<# .SYNOPSIS Run groups [12]-[
|
|
5
|
+
<# .SYNOPSIS Run groups [12]-[17]. Returns an object with Checks and Fails counts. #>
|
|
6
6
|
$ws = $Script:WS
|
|
7
7
|
$checks = 0; $fails = 0
|
|
8
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
|
|
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 }
|
|
10
13
|
$fx = Join-Path $ws.Home ('selftest-extra-' + [guid]::NewGuid().ToString('N'))
|
|
11
14
|
$mute = { $Script:WS.Mute = $true }
|
|
12
15
|
$unmute = { $Script:WS.Mute = $false }
|
|
@@ -74,6 +77,277 @@ function Invoke-SelfTestExtra {
|
|
|
74
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++ }
|
|
75
78
|
$ws.Yes = $saved.Yes; $ws.Interactive = $saved.Interactive; $ws.ScanRoots = $saved.ScanRoots
|
|
76
79
|
|
|
80
|
+
Write-Section '[13] Pure helpers: arguments, section lists, sizes, cache leaves, JSON summary'
|
|
81
|
+
# 13a - argument parser
|
|
82
|
+
$checks++
|
|
83
|
+
$keep = @{}
|
|
84
|
+
foreach ($k in 'Mode', 'BatchMode', 'OnlyList', 'ExportFmt', 'ExportId', 'Days', 'ScanRoots', 'ExcludePaths', 'RawArgs') { $keep[$k] = $ws[$k] }
|
|
85
|
+
$ok = $false
|
|
86
|
+
try {
|
|
87
|
+
$ws.RawArgs = @('--only=1,3', '--export', 'html', '2', '--days=30', '--scan-roots', 'a;b', '--exclude-path', 'x', '--exclude-path', 'y')
|
|
88
|
+
$ws.ExcludePaths = @()
|
|
89
|
+
Read-Arguments
|
|
90
|
+
$ok = ($ws.OnlyList -eq '1,3') -and ($ws.ExportFmt -eq 'html') -and ($ws.ExportId -eq '2') -and ($ws.Days -eq 30) -and (($ws.ScanRoots -join ',') -eq 'a,b') -and (($ws.ExcludePaths -join ',') -eq 'x,y')
|
|
91
|
+
} catch { $ok = $false }
|
|
92
|
+
$threw = $false
|
|
93
|
+
try { $ws.RawArgs = @('--days', 'x'); Read-Arguments } catch { $threw = $true }
|
|
94
|
+
foreach ($k in @($keep.Keys)) { $ws[$k] = $keep[$k] }
|
|
95
|
+
if ($ok -and $threw) { Write-Ok 'Read-Arguments: --only=, --export F ID, --days=, --scan-roots, repeated --exclude-path; --days x throws' } else { Write-Err "Read-Arguments wrong (values ok=$ok, bad --days threw=$threw)"; $fails++ }
|
|
96
|
+
# 13b - section id list
|
|
97
|
+
$checks++
|
|
98
|
+
& $mute
|
|
99
|
+
$ids = @(Get-SectionIdList '1,3,5-7,9-8,99,x')
|
|
100
|
+
& $unmute
|
|
101
|
+
if (($ids -join ',') -eq '1,3,5,6,7,8,9') { Write-Ok "Get-SectionIdList '1,3,5-7,9-8,99,x' -> $($ids -join ',')" } else { Write-Err "Get-SectionIdList wrong: $($ids -join ',')"; $fails++ }
|
|
102
|
+
# 13c - docker size text (decimal units)
|
|
103
|
+
$checks++
|
|
104
|
+
$sizes = @(@('2.891GB', 2891000000), @('12.5 MB', 12500000), @('20.48kB', 20480), @('0B', 0))
|
|
105
|
+
$badSizes = @($sizes | Where-Object { [math]::Abs((ConvertFrom-SizeText $_[0]) - [long]$_[1]) -gt 1 })
|
|
106
|
+
if ($badSizes.Count -eq 0) { Write-Ok 'ConvertFrom-SizeText: 2.891GB, 12.5 MB, 20.48kB, 0B' } else { Write-Err "ConvertFrom-SizeText wrong for: $(($badSizes | ForEach-Object { $_[0] }) -join ', ')"; $fails++ }
|
|
107
|
+
# 13d - cache leaf guard
|
|
108
|
+
$checks++
|
|
109
|
+
if ((Test-KnownCacheLeaf "$fx\User Data\Default\Cache") -and -not (Test-KnownCacheLeaf "$fx\User Data\Default\Local Storage") -and -not (Test-KnownCacheLeaf "$fx\User Data\Default")) { Write-Ok 'Test-KnownCacheLeaf: Cache yes; Local Storage and a profile root no' } else { Write-Err 'Test-KnownCacheLeaf is wrong'; $fails++ }
|
|
110
|
+
# 13e - JSON summary shape
|
|
111
|
+
$checks++
|
|
112
|
+
$json = (Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress
|
|
113
|
+
$back = $null
|
|
114
|
+
try { $back = $json | ConvertFrom-Json } catch { $back = $null }
|
|
115
|
+
$need = @('tool', 'version', 'mode', 'dry_run', 'freed_bytes', 'sections', 'refusals')
|
|
116
|
+
$missing = @($need | Where-Object { ($null -eq $back) -or ($null -eq $back.PSObject.Properties[$_]) })
|
|
117
|
+
if ($back -and $missing.Count -eq 0 -and -not $json.Contains("`n")) { Write-Ok 'Get-JsonSummary: one line, parses back, required keys present' } else { Write-Err "Get-JsonSummary wrong (missing: $($missing -join ','))"; $fails++ }
|
|
118
|
+
|
|
119
|
+
Write-Section '[14] Fixtures: superseded versions, Chromium layout, workspace storage, stale artefacts, report export'
|
|
120
|
+
# 14a - superseded Squirrel versions (version order, not string order)
|
|
121
|
+
$checks++
|
|
122
|
+
$sq = Join-Path $fx 'squirrel'
|
|
123
|
+
foreach ($v in 'app-1.0.0', 'app-1.9.0', 'app-1.10.0') { New-Item -ItemType Directory -Force -Path (Join-Path $sq $v) | Out-Null; Set-Content -LiteralPath (Join-Path $sq "$v\f.txt") -Value $v }
|
|
124
|
+
& $mute
|
|
125
|
+
$null = Remove-SupersededVersions -Root $sq -Prefix 'app-' -Label 'fixture'
|
|
126
|
+
& $unmute
|
|
127
|
+
if ((Test-Path -LiteralPath (Join-Path $sq 'app-1.10.0')) -and -not (Test-Path -LiteralPath (Join-Path $sq 'app-1.9.0')) -and -not (Test-Path -LiteralPath (Join-Path $sq 'app-1.0.0'))) { Write-Ok 'Remove-SupersededVersions keeps app-1.10.0 and removes 1.9.0 and 1.0.0' } else { Write-Err 'Remove-SupersededVersions kept the wrong folder'; $fails++ }
|
|
128
|
+
# 14b - Chromium layout: cache folders only, never profile data
|
|
129
|
+
$checks++
|
|
130
|
+
$ud = Join-Path $fx 'User Data'
|
|
131
|
+
foreach ($d in 'Default\Cache', 'Default\Local Storage', 'Profile 3\Code Cache') { New-Item -ItemType Directory -Force -Path (Join-Path $ud $d) | Out-Null }
|
|
132
|
+
$leaves = @(Get-ChromiumCacheDirs $ud | ForEach-Object { [IO.Path]::GetFileName($_) } | Sort-Object)
|
|
133
|
+
if (($leaves -join ',') -eq 'Cache,Code Cache') { Write-Ok 'Get-ChromiumCacheDirs returns the two cache folders and never Local Storage' } else { Write-Err "Get-ChromiumCacheDirs wrong: [$($leaves -join ',')]"; $fails++ }
|
|
134
|
+
# 14c - stale workspace storage: dry-run keeps both, real run removes only the dead entry
|
|
135
|
+
$checks++
|
|
136
|
+
$wsRoot = Join-Path $fx 'workspaceStorage'
|
|
137
|
+
$exists = Join-Path $fx 'exists'; New-Item -ItemType Directory -Force -Path $exists | Out-Null
|
|
138
|
+
foreach ($pair in @(@('a', $exists), @('b', (Join-Path $fx 'missing')))) {
|
|
139
|
+
$d = Join-Path $wsRoot $pair[0]; New-Item -ItemType Directory -Force -Path $d | Out-Null
|
|
140
|
+
$uri = 'file:///' + ([string]$pair[1] -replace '\\', '/')
|
|
141
|
+
Set-Content -LiteralPath (Join-Path $d 'workspace.json') -Value ('{"folder":"' + $uri + '"}')
|
|
142
|
+
}
|
|
143
|
+
& $mute
|
|
144
|
+
$ws.DryRun = $true
|
|
145
|
+
Remove-StaleWorkspaceStorage -Root $wsRoot -Label 'fixture'
|
|
146
|
+
$dryKept = (Test-Path -LiteralPath (Join-Path $wsRoot 'a')) -and (Test-Path -LiteralPath (Join-Path $wsRoot 'b'))
|
|
147
|
+
$ws.DryRun = $false
|
|
148
|
+
Remove-StaleWorkspaceStorage -Root $wsRoot -Label 'fixture'
|
|
149
|
+
& $unmute
|
|
150
|
+
if ($dryKept -and (Test-Path -LiteralPath (Join-Path $wsRoot 'a')) -and -not (Test-Path -LiteralPath (Join-Path $wsRoot 'b'))) { Write-Ok 'Remove-StaleWorkspaceStorage: dry-run keeps both; real run removes only the entry whose folder is gone' } else { Write-Err 'Remove-StaleWorkspaceStorage behaved wrongly'; $fails++ }
|
|
151
|
+
# 14d - stale artefacts: three orientations, so a missing gate in either direction shows up.
|
|
152
|
+
# A marker + source idle 400 days -> listed (the finder works)
|
|
153
|
+
# B marker + fresh source -> not listed (the idle gate works)
|
|
154
|
+
# C NO marker + source idle 400 days -> not listed (the project-marker test works)
|
|
155
|
+
# C must be OLD, or the marker test is never what excludes it and the check is half-oriented.
|
|
156
|
+
$checks++
|
|
157
|
+
$pr = Join-Path $fx 'projects'
|
|
158
|
+
foreach ($n in 'A', 'B', 'C') { New-Item -ItemType Directory -Force -Path (Join-Path $pr "$n\node_modules") | Out-Null; Set-Content -LiteralPath (Join-Path $pr "$n\node_modules\x.txt") -Value 'x' }
|
|
159
|
+
foreach ($n in 'A', 'B') { Set-Content -LiteralPath (Join-Path $pr "$n\package.json") -Value '{}' }
|
|
160
|
+
Set-Content -LiteralPath (Join-Path $pr 'A\main.js') -Value 'x'
|
|
161
|
+
Set-Content -LiteralPath (Join-Path $pr 'C\main.js') -Value 'x'
|
|
162
|
+
foreach ($f in (Join-Path $pr 'A\package.json'), (Join-Path $pr 'A\main.js'), (Join-Path $pr 'C\main.js')) {
|
|
163
|
+
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath $f -Name $ts -Value $ancient }
|
|
164
|
+
}
|
|
165
|
+
$found = @(Find-StaleArtefacts -Roots @($pr) -Days 100)
|
|
166
|
+
if ($found.Count -eq 1 -and $found[0].Path -like '*\A\node_modules' -and $found[0].Age -ge 100) { Write-Ok 'Find-StaleArtefacts lists the idle project with a marker, and neither the fresh one nor the idle one without a marker' } else { Write-Err "Find-StaleArtefacts wrong: $($found.Count) result(s) [$(($found | ForEach-Object { $_.Path }) -join '; ')]"; $fails++ }
|
|
167
|
+
# 14e - report export smoke (schema-1 fixture; HTML must escape the title)
|
|
168
|
+
$checks++
|
|
169
|
+
$rep = Join-Path $fx 'report-fixture.json'
|
|
170
|
+
$fixture = '{"schema_version":1,"credits":{"tool":"windowsweep","tool_version":"0.0.0","tool_homepage":"https://github.com/aoneahsan/windowsweep","tool_license":"MIT License","author":{"name":"Ahsan Mahmood","email":"e","website":"w","linkedin":"l"}},"meta":{"started_at":"2026-09-03T10:00:00+05:00","finished_at":"2026-09-03T10:01:00+05:00","duration_seconds":60,"host":"h","user":"u","os":"Windows","powershell":"5.1","mode":"all","dry_run":false,"elevated":false,"developer_mode":true,"idle_days":100,"temp_days":3,"log_file":"C:\\x.log","launcher":"node","via_npx":false,"tool_version":"0.0.0"},"disk":{"before":[{"drive":"C:","size_bytes":100,"free_bytes":10}],"after":[{"drive":"C:","size_bytes":100,"free_bytes":20}]},"steps":[{"n":1,"section":1,"title":"Package <caches>","status":"ran","freed_bytes":4096,"note":""}],"totals":{"total_reclaimed_bytes":4096,"total_reclaimed_human":"4.0 KB","total_estimated_bytes":0,"total_estimated_human":"0 B","steps_run":1,"steps_skipped":0}}'
|
|
171
|
+
[IO.File]::WriteAllText($rep, $fixture, (New-Object System.Text.UTF8Encoding($false)))
|
|
172
|
+
& $mute
|
|
173
|
+
$md = Convert-ReportToMarkdown -Json $rep
|
|
174
|
+
$html = Convert-ReportToHtml -Json $rep
|
|
175
|
+
& $unmute
|
|
176
|
+
$mdText = ''; $htmlText = ''
|
|
177
|
+
if ($md -and (Test-Path -LiteralPath $md)) { $mdText = Get-Content -LiteralPath $md -Raw }
|
|
178
|
+
if ($html -and (Test-Path -LiteralPath $html)) { $htmlText = Get-Content -LiteralPath $html -Raw }
|
|
179
|
+
if ($mdText.Contains('4.0 KB') -and $mdText.Contains('windowsweep') -and $htmlText.Contains('4.0 KB') -and $htmlText.Contains('Package <caches>') -and -not $htmlText.Contains('Package <caches>')) { Write-Ok 'report export: Markdown and HTML written, totals present, HTML escapes the title' } else { Write-Err 'report export smoke failed'; $fails++ }
|
|
180
|
+
|
|
181
|
+
Write-Section '[15] Catalogue: every section is reachable, and the new ones declare what they promise'
|
|
182
|
+
# 15a - Get-AllTargets is driven by the catalogue, not by a literal 0..21 range
|
|
183
|
+
$checks++
|
|
184
|
+
# The ids each Get-TargetsNN really RETURNS - sections 0 and 21 own a function that yields no rows.
|
|
185
|
+
$declared = @()
|
|
186
|
+
foreach ($s in $Script:WS_SECTIONS) {
|
|
187
|
+
$fn = 'Get-Targets{0:00}' -f $s.Id
|
|
188
|
+
if (-not (Get-Command $fn -ErrorAction SilentlyContinue)) { continue }
|
|
189
|
+
if (@(& $fn).Count -gt 0) { $declared += $s.Id }
|
|
190
|
+
}
|
|
191
|
+
$reached = @(Get-AllTargets | ForEach-Object { $_.Section } | Sort-Object -Unique)
|
|
192
|
+
$missed = @($declared | Where-Object { $reached -notcontains $_ })
|
|
193
|
+
if ($declared.Count -gt 0 -and $missed.Count -eq 0) { Write-Ok "Get-AllTargets reaches all $($declared.Count) section(s) whose Get-TargetsNN returns rows" } else { Write-Err "Get-AllTargets misses section(s): $($missed -join ',')"; $fails++ }
|
|
194
|
+
# 15b - every catalogue row is well formed
|
|
195
|
+
$checks++
|
|
196
|
+
$bad = @()
|
|
197
|
+
$ids = New-Object System.Collections.Generic.HashSet[int]
|
|
198
|
+
foreach ($s in $Script:WS_SECTIONS) {
|
|
199
|
+
if (-not $ids.Add([int]$s.Id)) { $bad += "duplicate id $($s.Id)" }
|
|
200
|
+
if (-not (Get-Command $s.Fn -ErrorAction SilentlyContinue)) { $bad += "section $($s.Id): no function $($s.Fn)" }
|
|
201
|
+
if ($s.Tier -notin 'report', 'rebuilds', 'slow', 'recycle', 'permanent', 'config') { $bad += "section $($s.Id): unknown tier $($s.Tier)" }
|
|
202
|
+
if ($s.Batch -notin 'safe', 'optin', 'deep', 'interactive') { $bad += "section $($s.Id): unknown batch $($s.Batch)" }
|
|
203
|
+
}
|
|
204
|
+
if ($bad.Count -eq 0) { Write-Ok "$($Script:WS_SECTIONS.Count) catalogue rows: unique ids, existing functions, known tier and batch" } else { Write-Err ("catalogue is wrong: " + ($bad -join '; ')); $fails++ }
|
|
205
|
+
# 15c - section 23 derives its exclusions from the declared targets, so the list cannot drift
|
|
206
|
+
$checks++
|
|
207
|
+
$ex = Get-OrphanExclusions
|
|
208
|
+
$wantSegments = @()
|
|
209
|
+
foreach ($t in (Get-AllTargets)) {
|
|
210
|
+
foreach ($pre in @("$($Script:P.A)\", "$($Script:P.L)\")) {
|
|
211
|
+
if ([string]$t.Path -match [regex]::Escape($pre)) { $seg = (([string]$t.Path).Substring(([string]$t.Path).IndexOf($pre) + $pre.Length) -split '\\')[0]; if ($seg) { $wantSegments += $seg } }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
$wantSegments = @($wantSegments | Sort-Object -Unique)
|
|
215
|
+
$lost = @($wantSegments | Where-Object { -not $ex.Contains($_) })
|
|
216
|
+
if ($wantSegments.Count -gt 0 -and $lost.Count -eq 0) { Write-Ok "section 23 excludes all $($wantSegments.Count) AppData folder(s) other sections already clean" } else { Write-Err "section 23 would offer a folder we clean elsewhere: $($lost -join ', ')"; $fails++ }
|
|
217
|
+
# 15d - the fail-closed gate, in two halves: an empty index WOULD call everything orphaned, and does not
|
|
218
|
+
$checks++
|
|
219
|
+
$emptyTokens = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
|
|
220
|
+
$emptyIndex = [pscustomobject]@{ Tokens = $emptyTokens; RegistryCount = 0; Rows = @() }
|
|
221
|
+
$wouldOrphan = Test-OrphanFolderName -Name 'Slack' -Index $emptyIndex
|
|
222
|
+
& $mute
|
|
223
|
+
$gated = @(Find-OrphanedAppData -Days 100 -Index $emptyIndex)
|
|
224
|
+
& $unmute
|
|
225
|
+
if ($wouldOrphan -and $gated.Count -eq 0) { Write-Ok 'section 23 fails closed: an unreadable registry means zero candidates, not everything' } else { Write-Err "section 23 fail-closed gate broken (predicate=$wouldOrphan candidates=$($gated.Count))"; $fails++ }
|
|
226
|
+
# 15e - section 22 declares nothing deletable: its roots are protected subtrees
|
|
227
|
+
$checks++
|
|
228
|
+
$notCmd = @(Get-Targets22 | Where-Object { $_.Kind -ne 'cmd' })
|
|
229
|
+
if ($notCmd.Count -eq 0) { Write-Ok 'section 22 declares no deletable target (audit only)' } else { Write-Err "section 22 declares a deletable target: $(($notCmd | ForEach-Object { $_.Path }) -join ', ')"; $fails++ }
|
|
230
|
+
# 15f - PowerShell variable names are case-insensitive: $p silently destroys the $P roots table
|
|
231
|
+
$checks++
|
|
232
|
+
$clash = @()
|
|
233
|
+
foreach ($f in (Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' -File)) {
|
|
234
|
+
$tk = $null; $er = $null
|
|
235
|
+
$ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tk, [ref]$er)
|
|
236
|
+
foreach ($fn in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
|
|
237
|
+
$sets = @($fn.FindAll({ param($n) ($n -is [System.Management.Automation.Language.AssignmentStatementAst]) -and ($n.Left -is [System.Management.Automation.Language.VariableExpressionAst]) -and ($n.Left.VariablePath.UserPath -eq 'P') }, $true))
|
|
238
|
+
$loops = @($fn.FindAll({ param($n) ($n -is [System.Management.Automation.Language.ForEachStatementAst]) -and ($n.Variable.VariablePath.UserPath -eq 'P') }, $true))
|
|
239
|
+
if (@($sets | Where-Object { $_.Right.Extent.Text -match '\$Script:P\b' }).Count -eq 0) { continue }
|
|
240
|
+
if ($sets.Count -gt 1 -or $loops.Count -gt 0) { $clash += "$($f.Name):$($fn.Name)" }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if ($clash.Count -eq 0) { Write-Ok 'no function reuses $p after taking $P from $Script:P (they are the same variable)' } else { Write-Err "case-insensitive `$P clash in: $($clash -join ', ')"; $fails++ }
|
|
244
|
+
|
|
245
|
+
Write-Section '[16] Scripted selection and the machine-readable contract'
|
|
246
|
+
$ws.SelectPaths = @(); $ws.SelectQueue = @(); $ws.Yes = $false; $ws.Interactive = $false
|
|
247
|
+
# 16a - --select answers one prompt, in order
|
|
248
|
+
$checks++
|
|
249
|
+
$ws.SelectQueue = @('1,3')
|
|
250
|
+
& $mute
|
|
251
|
+
$sel = @(Read-MultiSelect -Total 5 -NoAutoYes)
|
|
252
|
+
& $unmute
|
|
253
|
+
if (($sel -join ',') -eq '1,3' -and $ws.SelectQueue.Count -eq 0 -and $ws.LastSelectionScripted) { Write-Ok "--select '1,3' of 5 -> 1,3, consumed once, marked as a scripted choice" } else { Write-Err "--select wrong: [$($sel -join ',')] queueLeft=$($ws.SelectQueue.Count) scripted=$($ws.LastSelectionScripted)"; $fails++ }
|
|
254
|
+
# 16b - --select-file matches paths, and warns about a line that matches nothing here
|
|
255
|
+
$checks++
|
|
256
|
+
$cands = @("$fx\one.txt", "$fx\two.txt")
|
|
257
|
+
$ws.SelectPaths = @("$fx\TWO.TXT", "$fx\not-offered.txt")
|
|
258
|
+
& $mute
|
|
259
|
+
$sel2 = @(Read-MultiSelect -Total 2 -NoAutoYes -Candidates $cands)
|
|
260
|
+
& $unmute
|
|
261
|
+
$ws.SelectPaths = @()
|
|
262
|
+
if (($sel2 -join ',') -eq '2' -and $ws.LastSelectionScripted) { Write-Ok '--select-file matches case-insensitively and ignores a line no prompt offers' } else { Write-Err "--select-file wrong: [$($sel2 -join ',')]"; $fails++ }
|
|
263
|
+
# 16c - RW-002 still holds under the new code: --yes alone selects nothing and is not a scripted choice
|
|
264
|
+
$checks++
|
|
265
|
+
$ws.Yes = $true; $ws.SelectQueue = @(); $ws.SelectPaths = @()
|
|
266
|
+
& $mute
|
|
267
|
+
$sel3 = @(Read-MultiSelect -Total 4 -NoAutoYes -Candidates @('a', 'b', 'c', 'd'))
|
|
268
|
+
& $unmute
|
|
269
|
+
$stillOff = (-not $ws.LastSelectionScripted)
|
|
270
|
+
$ws.Yes = $false
|
|
271
|
+
if ($sel3.Count -eq 0 -and $stillOff) { Write-Ok '--yes alone still selects nothing and never counts as a scripted choice' } else { Write-Err "DANGEROUS: --yes selected [$($sel3 -join ',')] scripted=$(-not $stillOff)"; $fails++ }
|
|
272
|
+
# 16d - the --json document keeps a stable shape even when nothing was collected
|
|
273
|
+
$checks++
|
|
274
|
+
$ws.JsonMode = $true; $ws.Candidates = @(); $ws.ScanTargets = @()
|
|
275
|
+
$doc = (Get-JsonSummary) | ConvertTo-Json -Depth 6 -Compress
|
|
276
|
+
$back = $null
|
|
277
|
+
try { $back = $doc | ConvertFrom-Json } catch { $back = $null }
|
|
278
|
+
$ws.JsonMode = $false
|
|
279
|
+
$hasBoth = ($back -and $null -ne $back.PSObject.Properties['candidates'] -and $null -ne $back.PSObject.Properties['targets'])
|
|
280
|
+
if ($hasBoth -and -not $doc.Contains("`n")) { Write-Ok '--json carries candidates and targets as keys even when empty, on one line' } else { Write-Err 'the --json shape is missing candidates/targets or spans lines'; $fails++ }
|
|
281
|
+
# 16e - --list --json covers the whole catalogue
|
|
282
|
+
$checks++
|
|
283
|
+
$cat = (Get-CatalogueJson) | ConvertTo-Json -Depth 6 -Compress | ConvertFrom-Json
|
|
284
|
+
$catIds = @($cat.sections | ForEach-Object { [int]$_.id } | Sort-Object)
|
|
285
|
+
$realIds = @($Script:WS_SECTIONS | ForEach-Object { [int]$_.Id } | Sort-Object)
|
|
286
|
+
if (($catIds -join ',') -eq ($realIds -join ',') -and $cat.safe_batch.Count -gt 0 -and $cat.walkthrough.Count -gt 0) { Write-Ok "--list --json covers all $($catIds.Count) sections plus the batch and profile lists" } else { Write-Err '--list --json does not match the catalogue'; $fails++ }
|
|
287
|
+
# 16f - the progress line a GUI parses round-trips
|
|
288
|
+
$checks++
|
|
289
|
+
$pl = Get-MachineProgressLine -Section 7 -Stage 'end' -Status 'ran' -Freed 4096
|
|
290
|
+
$ok16f = ($pl -match "^##$Script:WS_NAME section=(\d+) event=(\w+) status=([\w-]+) freed_bytes=(\d+)$") -and ([int]$Matches[1] -eq 7) -and ($Matches[2] -eq 'end') -and ($Matches[3] -eq 'ran') -and ([long]$Matches[4] -eq 4096)
|
|
291
|
+
$plStart = Get-MachineProgressLine -Section 7 -Stage 'start'
|
|
292
|
+
if ($ok16f -and $plStart -eq "##$Script:WS_NAME section=7 event=start") { Write-Ok 'the ##windowsweep progress line parses back into section, event, status and freed_bytes' } else { Write-Err "progress line format wrong: $pl"; $fails++ }
|
|
293
|
+
|
|
294
|
+
Write-Section '[17] New helpers: global packages, startup state, artefact list'
|
|
295
|
+
# 17a - only roots that exist are reported
|
|
296
|
+
$checks++
|
|
297
|
+
$roots = @(Get-GlobalPackageRoots)
|
|
298
|
+
$ghost = @($roots | Where-Object { -not (Test-DirPresent $_.Root) })
|
|
299
|
+
if ($ghost.Count -eq 0) { Write-Ok "Get-GlobalPackageRoots returned $($roots.Count) root(s), all of them present" } else { Write-Err "Get-GlobalPackageRoots returned a missing root: $(($ghost | ForEach-Object { $_.Root }) -join ', ')"; $fails++ }
|
|
300
|
+
# 17b - three orientations: A the finder works, B the reference test works, C the idle gate works
|
|
301
|
+
$checks++
|
|
302
|
+
$names = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
303
|
+
$null = $names.Add('kept-because-referenced')
|
|
304
|
+
$idx = [pscustomobject]@{ Names = $names; Bins = (New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)); ScriptText = '' }
|
|
305
|
+
$pkgA = [pscustomobject]@{ Manager = 'npm'; Name = 'gone-stale'; Version = '1.0.0'; Path = ''; Bytes = [long]1; Idle = 400; Bins = @() }
|
|
306
|
+
$pkgB = [pscustomobject]@{ Manager = 'npm'; Name = 'kept-because-referenced'; Version = '1.0.0'; Path = ''; Bytes = [long]1; Idle = 400; Bins = @() }
|
|
307
|
+
$pkgC = [pscustomobject]@{ Manager = 'npm'; Name = 'kept-because-fresh'; Version = '1.0.0'; Path = ''; Bytes = [long]1; Idle = 1; Bins = @() }
|
|
308
|
+
$vA = (Get-GlobalPackageVerdict -Package $pkgA -Index $idx -Days 100).Verdict
|
|
309
|
+
$vB = (Get-GlobalPackageVerdict -Package $pkgB -Index $idx -Days 100).Verdict
|
|
310
|
+
$vC = (Get-GlobalPackageVerdict -Package $pkgC -Index $idx -Days 100).Verdict
|
|
311
|
+
if ($vA -eq 'candidate' -and $vB -eq 'keep' -and $vC -eq 'keep') { Write-Ok 'global verdict: idle+unreferenced is a candidate; referenced and fresh are both kept' } else { Write-Err "global verdict wrong: A=$vA B=$vB C=$vC"; $fails++ }
|
|
312
|
+
# 17c - startup state comes from Explorer's disable timestamp, not from a guessed first byte
|
|
313
|
+
$checks++
|
|
314
|
+
$disabledVal = [byte[]](1, 0, 0, 0, 39, 190, 225, 241, 9, 168, 220, 1)
|
|
315
|
+
$enabledVal = [byte[]](1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
|
316
|
+
$markerVal = [byte[]](3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
|
317
|
+
$shortVal = [byte[]](1, 0, 0, 0)
|
|
318
|
+
$s1 = Get-StartupApprovedState -Value $disabledVal
|
|
319
|
+
$s2 = Get-StartupApprovedState -Value $enabledVal
|
|
320
|
+
$s3 = Get-StartupApprovedState -Value $markerVal
|
|
321
|
+
$s4 = Get-StartupApprovedState -Value $shortVal
|
|
322
|
+
if ($s1 -eq 'disabled' -and $s2 -eq 'enabled' -and $s3 -eq 'disabled' -and $s4 -eq 'unknown') { Write-Ok 'startup state: a disable timestamp means disabled, a zero tail means enabled, 03 still counts, a short value is unknown' } else { Write-Err "startup state wrong: stamped=$s1 zeroTail=$s2 marker03=$s3 short=$s4"; $fails++ }
|
|
323
|
+
# 17d - the artefact list gained the seven additions and still refuses the three that hold real work
|
|
324
|
+
$checks++
|
|
325
|
+
$added = @('.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
|
|
326
|
+
$forbidden = @('.venv', 'venv', '.terraform')
|
|
327
|
+
$missingAdd = @($added | Where-Object { $Script:WS_ARTEFACT_DIRS -notcontains $_ })
|
|
328
|
+
$present = @($forbidden | Where-Object { $Script:WS_ARTEFACT_DIRS -contains $_ })
|
|
329
|
+
if ($missingAdd.Count -eq 0 -and $present.Count -eq 0) { Write-Ok 'artefact list has the seven additions and none of .venv, venv, .terraform' } else { Write-Err "artefact list wrong (missing: $($missingAdd -join ',') / forbidden present: $($present -join ','))"; $fails++ }
|
|
330
|
+
# 17e - .cache is an artefact only beside a Gatsby or Parcel marker; the normal rule must still work
|
|
331
|
+
$checks++
|
|
332
|
+
$mk = Join-Path $fx 'marked'
|
|
333
|
+
foreach ($n in 'gatsby', 'plain', 'normal') { New-Item -ItemType Directory -Force -Path (Join-Path $mk "$n\.cache") | Out-Null; Set-Content -LiteralPath (Join-Path $mk "$n\.cache\f.txt") -Value 'x' }
|
|
334
|
+
New-Item -ItemType Directory -Force -Path (Join-Path $mk 'normal\node_modules') | Out-Null
|
|
335
|
+
Set-Content -LiteralPath (Join-Path $mk 'normal\node_modules\f.txt') -Value 'x'
|
|
336
|
+
Set-Content -LiteralPath (Join-Path $mk 'gatsby\gatsby-config.js') -Value 'module.exports = {}'
|
|
337
|
+
foreach ($n in 'gatsby', 'plain', 'normal') { Set-Content -LiteralPath (Join-Path $mk "$n\package.json") -Value '{}' }
|
|
338
|
+
# EVERY file ages, not just the markers: an unmarked .cache counts as source, so a fresh file inside
|
|
339
|
+
# plain\.cache or normal\.cache would make its project look active and the check would prove nothing.
|
|
340
|
+
foreach ($f in (Get-ChildItem -LiteralPath $mk -Recurse -File -Force)) {
|
|
341
|
+
foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath $f.FullName -Name $ts -Value $ancient }
|
|
342
|
+
}
|
|
343
|
+
& $mute
|
|
344
|
+
$hits = @(Find-StaleArtefacts -Roots @($mk) -Days 100 | ForEach-Object { $_.Path })
|
|
345
|
+
& $unmute
|
|
346
|
+
$gotGatsbyCache = @($hits | Where-Object { $_ -like "*\gatsby\.cache" }).Count -eq 1
|
|
347
|
+
$noPlainCache = @($hits | Where-Object { $_ -like "*\plain\.cache" }).Count -eq 0
|
|
348
|
+
$gotNodeModules = @($hits | Where-Object { $_ -like "*\normal\node_modules" }).Count -eq 1
|
|
349
|
+
if ($gotGatsbyCache -and $noPlainCache -and $gotNodeModules) { Write-Ok '.cache is an artefact only beside a Gatsby/Parcel marker, and node_modules is unaffected' } else { Write-Err "marked-artefact rule wrong: gatsby=$gotGatsbyCache plainExcluded=$noPlainCache nodeModules=$gotNodeModules"; $fails++ }
|
|
350
|
+
|
|
77
351
|
} catch {
|
|
78
352
|
& $unmute
|
|
79
353
|
Write-Err "extra self-test crashed: $($_.Exception.Message)"; $fails++
|