<# .SYNOPSIS Deterministic classifier for the Scratchpad folder. Read-only. .DESCRIPTION Emits JSON describing what cleanup *would* touch. Writes nothing, moves nothing, deletes nothing. apply.ps1 consumes this; the scratchpad-cleanup skill reads it to make the judgment calls the script deliberately avoids. Every mechanical decision (age, size, vendored-tree detection, supersession grouping, trash expiry) lives here so it is reproducible. Anything requiring taste - "is this findings doc worth promoting?" - is only ever *flagged*. .EXAMPLE .\scan.ps1 | ConvertFrom-Json .\scan.ps1 -Root D:\Repos\Scratchpad -QuarantineAfterDays 45 #> [CmdletBinding()] param( [string] $Root = 'D:\Repos\Scratchpad', # Files older than this (and otherwise eligible) become quarantine candidates. # Default 45: Daniel's folder has a dense cluster at ~33 days, so 30 would # sweep 147 files in one run while 45 takes 14. Re-check with -ReportOnlyAges # before lowering this. [int] $QuarantineAfterDays = 45, # How long a _trash\ folder survives before apply.ps1 will purge it. [int] $TrashGraceDays = 30, # Files at or above this size are always reported, never auto-actioned. [int] $LargeFileMB = 5, # Markdown at or above this size, older than PromoteMinAgeDays, is treated as # a possible durable finding and held back from quarantine. [int] $PromoteMinKB = 2, [int] $PromoteMinAgeDays = 14, # Print an age-threshold histogram instead of the full scan. Use this before # changing QuarantineAfterDays. [switch] $ReportOnlyAges ) $ErrorActionPreference = 'Stop' if (-not (Test-Path -LiteralPath $Root)) { throw "Scratchpad root not found: $Root" } $Root = (Resolve-Path -LiteralPath $Root).Path $now = Get-Date $trashRoot = Join-Path $Root '_trash' # --- name patterns that mean "this file exists to protect something" ---------- # bellwether-prenuke-*.db is literally a pre-destructive-operation snapshot. # Never let an age rule reach these. $protectedNamePattern = 'backup|prenuke|restore|snapshot|\.bak$' # A top-level folder is a vendored dependency tree or a checked-out git repo # (not scratch output) if it carries any of these markers. Protect it wholesale - # partial deletion of a module tree or a repo is worse than leaving it. $vendorMarkers = @('.git', 'node_modules', 'package.json', '*.dll', '*.psd1', '*.csproj', 'requirements.txt') $purgeExtensions = @('.log', '.err') function Get-Rel([string] $FullName) { $FullName.Substring($Root.Length).TrimStart('\') } function Get-Age($file) { [int]((New-TimeSpan -Start $file.LastWriteTime -End $now).TotalDays) } $allFiles = @(Get-ChildItem -LiteralPath $Root -Recurse -File -Force -ErrorAction SilentlyContinue) $allDirs = @(Get-ChildItem -LiteralPath $Root -Recurse -Directory -Force -ErrorAction SilentlyContinue) if ($ReportOnlyAges) { foreach ($t in 15, 30, 45, 60, 90, 120) { $hit = @($allFiles | Where-Object { (Get-Age $_) -gt $t }) '{0,4}d -> {1,4} files {2,8} MB' -f $t, $hit.Count, [math]::Round((($hit | Measure-Object Length -Sum).Sum) / 1MB, 2) } return } # --- identify vendored top-level dirs ---------------------------------------- $vendorDirs = [System.Collections.Generic.HashSet[string]]::new() foreach ($d in (Get-ChildItem -LiteralPath $Root -Directory -Force -ErrorAction SilentlyContinue)) { if ($d.Name -eq '_trash') { continue } foreach ($m in $vendorMarkers) { if (Get-ChildItem -LiteralPath $d.FullName -Recurse -Force -Filter $m -ErrorAction SilentlyContinue | Select-Object -First 1) { [void]$vendorDirs.Add($d.Name) break } } } function Test-Protected($file) { $rel = Get-Rel $file.FullName $segments = $rel -split '\\' $top = $segments[0] if ($top -eq '_trash') { return 'trash' } # Never touch anything inside a git repo's metadata, at any depth. if ($segments -contains '.git') { return 'git-internals' } if ($vendorDirs.Contains($top)) { return 'vendored' } if ($file.Name -match $protectedNamePattern) { return 'protective-name' } return $null } $buckets = @{ protected = [System.Collections.ArrayList]::new() autoPurgeFiles = [System.Collections.ArrayList]::new() emptyDirs = [System.Collections.ArrayList]::new() quarantine = [System.Collections.ArrayList]::new() promoteCandidates = [System.Collections.ArrayList]::new() largeFiles = [System.Collections.ArrayList]::new() supersession = [System.Collections.ArrayList]::new() trashExpired = [System.Collections.ArrayList]::new() keep = [System.Collections.ArrayList]::new() } function New-Entry($file, $reason) { [PSCustomObject]@{ rel = Get-Rel $file.FullName name = $file.Name ageDays = Get-Age $file kb = [math]::Round($file.Length / 1KB, 1) reason = $reason } } foreach ($f in $allFiles) { $rel = Get-Rel $f.FullName $top = ($rel -split '\\')[0] # _trash is handled separately by folder, not per-file. if ($top -eq '_trash') { continue } $age = Get-Age $f if ($f.Length -ge ($LargeFileMB * 1MB)) { [void]$buckets.largeFiles.Add((New-Entry $f ">= $LargeFileMB MB - report only, never auto-actioned")) } $prot = Test-Protected $f if ($prot) { [void]$buckets.protected.Add((New-Entry $f $prot)); continue } if ($purgeExtensions -contains $f.Extension.ToLowerInvariant()) { [void]$buckets.autoPurgeFiles.Add((New-Entry $f "disposable byproduct ($($f.Extension))")) continue } # Durable-looking markdown is held back from quarantine so findings are never # silently binned by an age rule. The skill decides promote vs drop. if ($f.Extension -ieq '.md' -and $f.Length -ge ($PromoteMinKB * 1KB) -and $age -gt $PromoteMinAgeDays) { [void]$buckets.promoteCandidates.Add((New-Entry $f "markdown >= $PromoteMinKB KB, ${age}d old - judgment required")) continue } if ($age -gt $QuarantineAfterDays) { [void]$buckets.quarantine.Add((New-Entry $f "stale: ${age}d > ${QuarantineAfterDays}d")) continue } [void]$buckets.keep.Add((New-Entry $f 'active')) } # --- empty directories -------------------------------------------------------- foreach ($d in $allDirs) { $rel = Get-Rel $d.FullName $segments = $rel -split '\\' if ($segments[0] -eq '_trash') { continue } if ($segments -contains '.git') { continue } if ($vendorDirs.Contains($segments[0])) { continue } if (-not (Get-ChildItem -LiteralPath $d.FullName -Recurse -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) { [void]$buckets.emptyDirs.Add([PSCustomObject]@{ rel = $rel; reason = 'empty directory' }) } } # --- supersession groups ------------------------------------------------------ # Group numbered iterations with their unnumbered base: parse.js / parse2.js / # parse3.js, render_v2.py / render_v4.py, smap-check.ps1 / smap-check2.ps1. # Reported only - "newest wins" is usually right but not always, so a human calls it. $groups = @{} foreach ($f in $allFiles) { $rel = Get-Rel $f.FullName $top = ($rel -split '\\')[0] if ($top -eq '_trash' -or $vendorDirs.Contains($top)) { continue } $stem = $f.BaseName -replace '[ _-]?[vV]?\d+$', '' if ([string]::IsNullOrWhiteSpace($stem)) { continue } $key = '{0}|{1}|{2}' -f (Split-Path -Parent $rel), $stem, $f.Extension.ToLowerInvariant() if (-not $groups.ContainsKey($key)) { $groups[$key] = [System.Collections.ArrayList]::new() } [void]$groups[$key].Add($f) } foreach ($k in $groups.Keys) { $members = @($groups[$k]) if ($members.Count -lt 2) { continue } $sorted = @($members | Sort-Object LastWriteTime -Descending) [void]$buckets.supersession.Add([PSCustomObject]@{ stem = ($k -split '\|')[1] ext = ($k -split '\|')[2] newest = (Get-Rel $sorted[0].FullName) members = @($sorted | ForEach-Object { [PSCustomObject]@{ rel = Get-Rel $_.FullName ageDays = Get-Age $_ kb = [math]::Round($_.Length / 1KB, 1) isNewest = ($_.FullName -eq $sorted[0].FullName) } }) }) } # --- expired trash ------------------------------------------------------------ if (Test-Path -LiteralPath $trashRoot) { foreach ($d in (Get-ChildItem -LiteralPath $trashRoot -Directory -ErrorAction SilentlyContinue)) { $stamp = [datetime]::MinValue if ([datetime]::TryParseExact($d.Name, 'yyyy-MM-dd', $null, [Globalization.DateTimeStyles]::None, [ref]$stamp)) { $ageDays = [int]((New-TimeSpan -Start $stamp -End $now).TotalDays) if ($ageDays -gt $TrashGraceDays) { $n = @(Get-ChildItem -LiteralPath $d.FullName -Recurse -File -ErrorAction SilentlyContinue).Count [void]$buckets.trashExpired.Add([PSCustomObject]@{ rel = "_trash\$($d.Name)"; ageDays = $ageDays; files = $n }) } } } } [PSCustomObject]@{ root = $Root scannedAt = $now.ToString('o') config = [PSCustomObject]@{ quarantineAfterDays = $QuarantineAfterDays trashGraceDays = $TrashGraceDays largeFileMB = $LargeFileMB vendoredDirs = @($vendorDirs) } totals = [PSCustomObject]@{ files = $allFiles.Count mb = [math]::Round((($allFiles | Measure-Object Length -Sum).Sum) / 1MB, 2) } counts = [PSCustomObject]@{ protected = $buckets.protected.Count autoPurgeFiles = $buckets.autoPurgeFiles.Count emptyDirs = $buckets.emptyDirs.Count quarantine = $buckets.quarantine.Count promoteCandidates = $buckets.promoteCandidates.Count largeFiles = $buckets.largeFiles.Count supersession = $buckets.supersession.Count trashExpired = $buckets.trashExpired.Count keep = $buckets.keep.Count } buckets = [PSCustomObject]$buckets } | ConvertTo-Json -Depth 8