Closes #26.
Orphan detection reached five roots — Caches, Logs, Saved Application State, WebKit, HTTPStorages — and matched entries only against /^(com|org|net|io)\./. Caches and logs are where an uninstalled app leaves megabytes. Application Support is where it leaves gigabytes, and it wasn't scanned.
Found by uninstalling Cursor and watching 5.6 GB survive — 4.1 GB of it in a directory the scan never opened, under a name the pattern would have rejected anyway.
What's added
Application Support, scanned twice — once for reverse-DNS directories, once for plain-named ones.Containers— always bundle-ID named, so unambiguous.Group Containersdeliberately omitted. They carry a team-identifier prefix (9BNSXJN65R.com.example.app) and are shared between several apps of one vendor, so "is the owner gone?" isn't a question a single directory name can answer.holdsUserDataonOrphanedItem, plumbed through/orphaned-data, so the UI can refuse to pre-select these the way it can a cache.- A 90-day threshold for the user-data roots instead of 30.
The part that needs review
The issue proposed "flag any directory whose name matches no installed app." I didn't implement that — it reports live data as orphaned.
On the machine this was developed against:
| Directory | Size | What it actually is |
|---|---|---|
Google | 7.7 GB | live Chrome profile — history, bookmarks, saved passwords |
Mozilla | — | Firefox |
BraveSoftware | — | Brave |
MobileSync | — | every iPhone backup on the machine, no owning .app at all |
None of those names matches an installed app. All four would have been offered for deletion.
So plain-name orphans come from three guards instead:
KNOWN_APP_DIRSmaps a directory to the apps that own it, and is the only source of plain-name orphans. An unrecognised directory is never flagged — false negatives cost disk space, false positives cost the user their data.SYSTEM_DIR_NAMESexcludes macOS's own plain-named directories outright.- An empty app index abandons plain-name matching entirely. If Spotlight is disabled,
mdfindreturns nothing and every mapped app reads as uninstalled; the bundle-ID roots still haveNEVER_DELETEand thecom.apple.rule to fall back on, this path has nothing but the index.
The tradeoff: plain-name coverage is only as good as KNOWN_APP_DIRS, which starts at 19 entries. Expanding it is cheap, but each entry is a judgement about what's safe to attribute — so it's a deliberate opt-in list rather than a heuristic.
Verification
Run against a working developer Mac: 63 orphans, 1.19 GB.
Two largest are true positives, confirmed by hand:
Steam— 1.09 GB, app since removedMicrosoft— 4 KB stub, no Office installed
It also correctly caught com.todesktop.230313mzl4w4u92, Cursor's leftover HTTPStorages entry.
And the ones that matter: Google, Mozilla, MobileSync, AddressBook, CloudDocs, Knowledge — all left alone.
tests/orphans.test.ts adds 6 tests covering the attribution map, the system-directory exclusions, the invariant that no directory appears in both tables, and three live-scan assertions — that nothing with an installed owner is reported, that no macOS-owned directory is reported, and that the holdsUserData flag matches the root type.
Checks
bun run test— 515 pass, 0 fail, across 57 filesbun run typecheck— cleanbun run lint— 0 errors
Follow-up, not in this PR
~/.cursor (1.0 GB) and ~/.local/share/cursor-agent (355 MB) are still invisible. The issue proposed dotfile and XDG roots, but the installed-app check is structurally wrong there: those directories belong to CLI tools, which aren't .app bundles, so everything under them reads as orphaned. ~/.ssh, ~/.aws and ~/.gnupg live in the same place. That needs a different mechanism and should be its own issue.
🤖 Generated with Claude Code