6 changed files with 553 additions and 31 deletions
@ -0,0 +1,140 @@ |
|||||
|
--- |
||||
|
name: "scratchpad-cleanup" |
||||
|
description: "Use when tidying, auditing, or freeing space in the Scratchpad working folder at D:\\Repos\\Scratchpad - the weekly sweep or an ad-hoc pass. Also use when reviewing or restoring files quarantined under _trash, or deciding whether a scratch artifact should be promoted into the Obsidian vault. Triggers: /scratchpad-cleanup, clean up scratchpad, tidy scratchpad, scratchpad is a mess, what can I delete in scratchpad, restore from _trash." |
||||
|
--- |
||||
|
|
||||
|
## Scratchpad Cleanup |
||||
|
|
||||
|
Keeps `D:\Repos\Scratchpad` from silting up, without ever destroying something |
||||
|
that mattered. |
||||
|
|
||||
|
**Core principle: Scratchpad is NOT a git repo.** There is no history and no |
||||
|
undo. Every irreversible action is therefore either provably safe or routed |
||||
|
through a `_trash\<date>\` quarantine with a grace period. The quarantine *is* |
||||
|
the undo. |
||||
|
|
||||
|
### When to Use |
||||
|
|
||||
|
- The weekly automated sweep. |
||||
|
- Ad-hoc: "scratchpad is a mess", "what can I delete", "what's stale in there". |
||||
|
- Reviewing what got quarantined, or restoring something binned by mistake. |
||||
|
- Deciding whether a scratch artifact is actually a durable finding that belongs |
||||
|
in the vault or a repo. |
||||
|
|
||||
|
### Division of labour |
||||
|
|
||||
|
The mechanical decisions live in scripts so they are reproducible and identical |
||||
|
every run. The skill only makes the calls a script *shouldn't*. |
||||
|
|
||||
|
| Script | Does | Writes? | |
||||
|
|---|---|---| |
||||
|
| `scan.ps1` | Classifies every file into buckets, emits JSON | No - read-only | |
||||
|
| `apply.ps1` | Executes the safe actions | Only with `-Execute` | |
||||
|
|
||||
|
```pwsh |
||||
|
# classify (read-only); this is always step 1 |
||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\scan.ps1" | ConvertFrom-Json |
||||
|
|
||||
|
# preview actions, then commit them |
||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\apply.ps1" |
||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\apply.ps1" -Execute |
||||
|
|
||||
|
# undo a batch |
||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\apply.ps1" -Mode Restore -RestoreDate 2026-09-14 -Execute |
||||
|
|
||||
|
# before ever changing the age threshold, look at the cliff |
||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\scan.ps1" -ReportOnlyAges |
||||
|
``` |
||||
|
|
||||
|
**Always invoke the scripts with `powershell -File` and an absolute path.** They |
||||
|
are saved UTF-8 *with BOM* deliberately: Windows PowerShell 5.1 reads a BOM-less |
||||
|
file as Windows-1252, which corrupts any non-ASCII byte and silently changes |
||||
|
control flow. Keep both scripts ASCII-only; do not paste em dashes or smart |
||||
|
quotes into them. |
||||
|
|
||||
|
### Buckets, and who owns each |
||||
|
|
||||
|
| Bucket | Owner | Action | |
||||
|
|---|---|---| |
||||
|
| `autoPurgeFiles` | script | delete - `.log` / `.err` | |
||||
|
| `emptyDirs` | script | delete | |
||||
|
| `quarantine` | script | move to `_trash\<date>\` | |
||||
|
| `trashExpired` | script | delete batches past grace | |
||||
|
| `protected` | nobody | never touched | |
||||
|
| `largeFiles` | **you** | report only | |
||||
|
| `supersession` | **you** | report only | |
||||
|
| `promoteCandidates` | **you** | report only | |
||||
|
|
||||
|
### The judgment calls |
||||
|
|
||||
|
**1. Promotion candidates.** Markdown >= 2 KB and older than 14 days is held |
||||
|
*out* of quarantine, because an age rule must never silently bin a findings |
||||
|
doc. For each, decide: does this belong in the Obsidian vault (a durable |
||||
|
finding, design note, or decision record), in a real repo (a script worth |
||||
|
keeping), or is it genuinely spent? Recommend a destination; never move it |
||||
|
yourself without the user agreeing. |
||||
|
|
||||
|
**2. Supersession groups.** Numbered siblings - `parse.js` / `parse2.js` / |
||||
|
`parse3.js`, `render_v2.py` / `render_v4.py`, `smap-check.ps1` / |
||||
|
`smap-check2.ps1`. Newest-wins is the usual answer but not always right; the |
||||
|
older one occasionally holds the approach that actually worked. Present the |
||||
|
group with dates and sizes and let the user choose. |
||||
|
|
||||
|
**3. Large files.** Anything >= 5 MB is reported and never auto-actioned. In |
||||
|
this folder the space story is almost entirely a handful of `.db` snapshots, so |
||||
|
"reclaim space" and "reduce clutter" are different jobs with different targets. |
||||
|
Note that a file whose name contains `backup`, `prenuke`, `restore`, `snapshot` |
||||
|
or `.bak` is protected outright - those exist precisely to save someone. |
||||
|
|
||||
|
### Protection rules (never overridden) |
||||
|
|
||||
|
- Any path containing a `.git` segment - repo internals. |
||||
|
- Any top-level folder carrying `.git`, `node_modules`, `package.json`, `*.dll`, |
||||
|
`*.psd1`, `*.csproj`, or `requirements.txt` - a vendored tree or checked-out |
||||
|
repo, protected wholesale. Partial deletion of a module tree is worse than |
||||
|
leaving it. |
||||
|
- Names matching `backup|prenuke|restore|snapshot|\.bak$`. |
||||
|
- Everything already under `_trash\`. |
||||
|
|
||||
|
### Run report |
||||
|
|
||||
|
Lead with what changed, then what needs a decision: |
||||
|
|
||||
|
``` |
||||
|
Scratchpad cleanup - 2026-09-14 |
||||
|
Reclaimed: 5 log files, 2 empty dirs |
||||
|
Quarantined: 14 files -> _trash\2026-09-14 (restorable until 2026-10-14) |
||||
|
|
||||
|
Needs your call: |
||||
|
Promote? 5 findings docs, 34d idle - e.g. "SMAP-Kusto-Findings.md" |
||||
|
Superseded? parse.js/parse2.js/parse3.js - keep parse3 only? |
||||
|
Large: bellwether-prenuke-*.db (20.8 MB) - protected, but is it still needed? |
||||
|
``` |
||||
|
|
||||
|
Silence is fine. If nothing was reclaimed and nothing needs a decision, say so |
||||
|
in one line rather than padding the report. |
||||
|
|
||||
|
### Guardrails |
||||
|
|
||||
|
- Never act outside `D:\Repos\Scratchpad`. |
||||
|
- Never delete anything that has not been through quarantine first, except |
||||
|
`.log` / `.err` files and empty directories. |
||||
|
- `apply.ps1` is dry-run by default. Do not pass `-Execute` to a mode you have |
||||
|
not previewed in the same run. |
||||
|
- `MaxQuarantinePerRun` (60) is a deliberate circuit breaker. If it trips, do |
||||
|
NOT raise it to get past it - it means the age threshold changed and is about |
||||
|
to sweep the folder. Check `scan.ps1 -ReportOnlyAges` and report to the user |
||||
|
instead. |
||||
|
- Never move a promotion candidate into the vault unprompted; the vault has its |
||||
|
own schema rules (see `D:\Repos\Obsidian\AGENTS.md`) and an unannounced write |
||||
|
there will fail lint. |
||||
|
|
||||
|
### Common Mistakes |
||||
|
|
||||
|
- Lowering the age threshold without checking `-ReportOnlyAges` first. The |
||||
|
distribution is lumpy: a single day's work can put 100+ files in one band, so |
||||
|
a small threshold change can be the difference between 14 files and 147. |
||||
|
- Treating `.db` / `.tm7` / `.excalidraw` as junk because they are large or |
||||
|
binary. They are usually the most expensive artifacts in the folder. |
||||
|
- Deleting an empty directory inside `.git` - it corrupts the repo. The scan |
||||
|
already excludes these; do not re-add them by hand. |
||||
@ -0,0 +1,154 @@ |
|||||
|
<# |
||||
|
.SYNOPSIS |
||||
|
Executes the safe half of Scratchpad cleanup. Dry-run unless -Execute. |
||||
|
|
||||
|
.DESCRIPTION |
||||
|
Consumes scan.ps1's classification and performs only the reversible or |
||||
|
provably-safe actions: |
||||
|
|
||||
|
AutoPurge delete .log/.err files and empty directories |
||||
|
Quarantine MOVE stale files to _trash\<date>\ (never delete) |
||||
|
PurgeTrash delete _trash\<date>\ folders past the grace period |
||||
|
Restore move a quarantine batch back where it came from |
||||
|
|
||||
|
It deliberately does NOT act on the protected, promoteCandidates, |
||||
|
supersession, or largeFiles buckets. Those require judgment and belong to |
||||
|
the scratchpad-cleanup skill's report. |
||||
|
|
||||
|
Scratchpad is not a git repo, so quarantine is the only undo that exists. |
||||
|
Every batch writes a manifest.json recording original paths. |
||||
|
|
||||
|
.EXAMPLE |
||||
|
.\apply.ps1 # dry run, all safe modes |
||||
|
.\apply.ps1 -Execute |
||||
|
.\apply.ps1 -Mode Quarantine -Execute |
||||
|
.\apply.ps1 -Mode Restore -RestoreDate 2026-09-14 -Execute |
||||
|
#> |
||||
|
[CmdletBinding()] |
||||
|
param( |
||||
|
[string] $Root = 'D:\Repos\Scratchpad', |
||||
|
|
||||
|
[ValidateSet('All', 'AutoPurge', 'Quarantine', 'PurgeTrash', 'Restore')] |
||||
|
[string] $Mode = 'All', |
||||
|
|
||||
|
# Nothing is written unless this is present. Default is a dry run on purpose. |
||||
|
[switch] $Execute, |
||||
|
|
||||
|
[int] $QuarantineAfterDays = 45, |
||||
|
[int] $TrashGraceDays = 30, |
||||
|
|
||||
|
# Safety valve: refuse to quarantine more than this many files in one run. |
||||
|
# Guards against a threshold change silently sweeping the whole folder. |
||||
|
[int] $MaxQuarantinePerRun = 60, |
||||
|
|
||||
|
[string] $RestoreDate |
||||
|
) |
||||
|
|
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
$Root = (Resolve-Path -LiteralPath $Root).Path |
||||
|
$trashRoot = Join-Path $Root '_trash' |
||||
|
$stamp = (Get-Date).ToString('yyyy-MM-dd') |
||||
|
$batchDir = Join-Path $trashRoot $stamp |
||||
|
$scanScript = Join-Path $PSScriptRoot 'scan.ps1' |
||||
|
if (-not (Test-Path -LiteralPath $scanScript)) { throw "scan.ps1 not found next to apply.ps1 ($scanScript)" } |
||||
|
|
||||
|
$actions = [System.Collections.ArrayList]::new() |
||||
|
function Add-Action($kind, $from, $to, $note) { |
||||
|
[void]$actions.Add([PSCustomObject]@{ kind = $kind; from = $from; to = $to; note = $note }) |
||||
|
} |
||||
|
|
||||
|
# --- Restore is standalone; it does not need a scan --------------------------- |
||||
|
if ($Mode -eq 'Restore') { |
||||
|
if (-not $RestoreDate) { throw 'Restore requires -RestoreDate yyyy-MM-dd.' } |
||||
|
$src = Join-Path $trashRoot $RestoreDate |
||||
|
if (-not (Test-Path -LiteralPath $src)) { throw "No quarantine batch at $src" } |
||||
|
$manifestPath = Join-Path $src 'manifest.json' |
||||
|
if (-not (Test-Path -LiteralPath $manifestPath)) { throw "Batch has no manifest.json; refusing to guess original paths." } |
||||
|
|
||||
|
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json |
||||
|
foreach ($item in $manifest.items) { |
||||
|
$from = Join-Path $src $item.rel |
||||
|
$to = Join-Path $Root $item.rel |
||||
|
if (-not (Test-Path -LiteralPath $from)) { Add-Action 'restore-skip' $from $to 'missing in batch'; continue } |
||||
|
if (Test-Path -LiteralPath $to) { Add-Action 'restore-skip' $from $to 'destination already exists'; continue } |
||||
|
Add-Action 'restore' $from $to '' |
||||
|
if ($Execute) { |
||||
|
$parent = Split-Path -Parent $to |
||||
|
if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } |
||||
|
Move-Item -LiteralPath $from -Destination $to |
||||
|
} |
||||
|
} |
||||
|
$actions | Format-Table -AutoSize |
||||
|
if (-not $Execute) { Write-Host 'DRY RUN - nothing moved. Re-run with -Execute.' -ForegroundColor Yellow } |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
# --- everything else is scan-driven ------------------------------------------- |
||||
|
$scanRaw = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scanScript -Root $Root -QuarantineAfterDays $QuarantineAfterDays -TrashGraceDays $TrashGraceDays |
||||
|
$scan = ($scanRaw -join "`n") | ConvertFrom-Json |
||||
|
|
||||
|
if ($Mode -in 'All', 'AutoPurge') { |
||||
|
foreach ($e in @($scan.buckets.autoPurgeFiles)) { |
||||
|
$p = Join-Path $Root $e.rel |
||||
|
Add-Action 'delete-file' $p '' $e.reason |
||||
|
if ($Execute -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force } |
||||
|
} |
||||
|
# Deepest-first so nested empties collapse correctly. |
||||
|
foreach ($e in (@($scan.buckets.emptyDirs) | Sort-Object { ($_.rel -split '\\').Count } -Descending)) { |
||||
|
$p = Join-Path $Root $e.rel |
||||
|
Add-Action 'delete-dir' $p '' $e.reason |
||||
|
if ($Execute -and (Test-Path -LiteralPath $p)) { |
||||
|
# Re-check emptiness at execution time; scan data may be seconds stale. |
||||
|
if (-not (Get-ChildItem -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) { |
||||
|
Remove-Item -LiteralPath $p -Force |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if ($Mode -in 'All', 'Quarantine') { |
||||
|
$q = @($scan.buckets.quarantine) |
||||
|
if ($q.Count -gt $MaxQuarantinePerRun) { |
||||
|
throw ("Refusing to quarantine {0} files in one run (cap {1}). This usually means QuarantineAfterDays was lowered. Review with scan.ps1 -ReportOnlyAges, then raise -MaxQuarantinePerRun deliberately." -f $q.Count, $MaxQuarantinePerRun) |
||||
|
} |
||||
|
$manifestItems = [System.Collections.ArrayList]::new() |
||||
|
foreach ($e in $q) { |
||||
|
$from = Join-Path $Root $e.rel |
||||
|
$to = Join-Path $batchDir $e.rel |
||||
|
Add-Action 'quarantine' $from $to $e.reason |
||||
|
[void]$manifestItems.Add([PSCustomObject]@{ rel = $e.rel; ageDays = $e.ageDays; kb = $e.kb; reason = $e.reason }) |
||||
|
if ($Execute -and (Test-Path -LiteralPath $from)) { |
||||
|
$parent = Split-Path -Parent $to |
||||
|
if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } |
||||
|
Move-Item -LiteralPath $from -Destination $to -Force |
||||
|
} |
||||
|
} |
||||
|
if ($Execute -and $manifestItems.Count -gt 0) { |
||||
|
if (-not (Test-Path -LiteralPath $batchDir)) { New-Item -ItemType Directory -Path $batchDir -Force | Out-Null } |
||||
|
[PSCustomObject]@{ |
||||
|
batch = $stamp |
||||
|
createdAt = (Get-Date).ToString('o') |
||||
|
root = $Root |
||||
|
purgeEligibleOn = (Get-Date).AddDays($TrashGraceDays).ToString('yyyy-MM-dd') |
||||
|
items = @($manifestItems) |
||||
|
} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $batchDir 'manifest.json') -Encoding UTF8 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if ($Mode -in 'All', 'PurgeTrash') { |
||||
|
foreach ($e in @($scan.buckets.trashExpired)) { |
||||
|
$p = Join-Path $Root $e.rel |
||||
|
Add-Action 'purge-trash' $p '' ("{0}d old, grace {1}d, {2} files" -f $e.ageDays, $TrashGraceDays, $e.files) |
||||
|
if ($Execute -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Recurse -Force } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if ($actions.Count -eq 0) { |
||||
|
Write-Host 'Nothing to do.' -ForegroundColor Green |
||||
|
} |
||||
|
else { |
||||
|
$actions | Group-Object kind | Sort-Object Name | ForEach-Object { '{0,-14} {1}' -f $_.Name, $_.Count } |
||||
|
Write-Host '' |
||||
|
$actions | Format-Table -AutoSize kind, from, note |
||||
|
} |
||||
|
if (-not $Execute) { Write-Host 'DRY RUN - nothing changed. Re-run with -Execute.' -ForegroundColor Yellow } |
||||
@ -0,0 +1,244 @@ |
|||||
|
<# |
||||
|
.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 |
||||
Loading…
Reference in new issue