You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
244 lines
10 KiB
244 lines
10 KiB
<#
|
|
.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\<date> 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
|
|
|