Module boundaries and the contracts several modules keep together. User-visible behavior is in SPEC.md; package responsibilities are in each package's doc comment (go doc ./internal/<pkg>), type definitions in the code.
Scan → Classify → Filter/Sort → Output/Clean
devclean is a monolithic Go CLI binary: every ecosystem scanner is built in, with no external scanner plugins.
- Scan: Registry batches all project (walk-based) scanners into a single filesystem pass, then runs stat scanners (fixed paths) sequentially. Real-time progress via spinner.
- Classify: activity status + gitignore-aware protection (SPEC)
- Filter/Sort: CLI flags filter by ecosystem, category, status; sort by size/time/name; top N projects
- Output: colored table or JSON (
--json) - Clean: selection → trash/force choice → per-artifact deletion (SPEC)
Two scanner families share the Scanner interface:
- Walk ecosystems (Node, Rust, Ruby, Python, Go, Flutter, Android): declarative rule tables (
walkEcosystemininternal/scanner/walk.go) executed by a single-pass walk engine. The registry partitions walk-based scanners out of every scan and batches them into one recursive filesystem traversal, dispatching each directory against all active tables — one pass regardless of how many project ecosystems are active. The engine reads each directory once (os.ReadDir) and reuses those entries for both marker detection and recursion. - Stat scanners (Xcode, Docker, Global Caches, LLM Model Stores): implement
Scannerdirectly and check fixed, known paths instead of walking a tree.
Scan root: the registry resolves the root to an absolute path once, before any scanner runs; every scanner receives that absolute root and needs no resolution of its own.
Scanners report progress via context-attached callbacks: the walk batch reports under a single "projects" label, stat scanners under their own names.
Sizing collects these figures per artifact through an in-process walk (scanner.Measure):
- disk — allocated blocks (
st_blocks×512), sparse-accurate and matchingdu. Directories contribute their own blocks (real on ext4, ~0 on APFS). - reclaim — what deleting the artifact frees;
Size, used by sorting,--min-sizeand totals. Equal to disk unless the result isCloneAware, which nets out APFS clone sharing and keeps disk asAllocatedSize(decision). - apparent — sum of logical file sizes. Surfaces only when a file is materially sparse.
Three invariants keep the figures honest:
- Hard links (
Nlink>1) are counted once per artifact, keyed by(dev, ino), so shared blocks net out across artifacts. - APFS clones in a
CloneAwareartifact count once; clone groups split across artifacts go toCloneSharessoDedupedTotalcounts them once. - Hugging Face shared blobs referenced by several models go to
BlobSharessoDedupedTotalcounts them once. - Shared traversal: sizing walks with
fstree, so it counts what finding reports and deletion removes.
Neither scanner family sizes inline. Each collects its artifacts first — the walk during its single pass, stat scanners via stat — then sizes them through one shared bounded worker pool (sizePending, min(NumCPU, 8)) so the tree-walk I/O overlaps.
Finding, sizing and the pre-delete check walk through one package, so they cover the same files:
- Read each directory once.
- Never follow symlinks. Only the root is read through, and only for
--path— decision - Stay on the root's filesystem (like
du -x); a directory on another device, of unknown device, or unreadable is returned as skipped — decision
A walkEcosystem table declares how one ecosystem participates in the shared walk: marker file names that identify a project root (package.json, Cargo.toml, any of six Python markers, …) and artifact rules that apply beneath those roots. Rules come in three match forms, each carrying its own category and safety:
| Form | Example | Semantics |
|---|---|---|
| exact relative path | node_modules, vendor/bundle, ios/Pods |
matches exactly that path under the nearest project root |
| any-depth name | __pycache__, .venv |
matches the directory name anywhere under the nearest project root |
| any-depth suffix | *.egg-info |
matches a directory-name suffix anywhere under the nearest project root |
Per directory, the engine matches artifact rules against the nearest enclosing project root of each active ecosystem (table order, first match wins), emits the artifact and skips its subtree on a match, and otherwise reads the directory once to detect new project roots (pushed onto a recursion-scoped context stack) before descending into its child directories. Artifact matching runs before the hidden-directory check so compound rules ending in a hidden segment (android/.gradle) still match; unmatched hidden directories below the scan root are descended into only when an active ecosystem lists the name as an artifact.
Tables can also:
- contribute per-project extra rules on root detection — the Node table adds React Native compound artifacts (
ios/Pods,android/.gradle, …) when the project carriesios/Podfileormetro.config.{js,ts,cjs,mjs}; - opt into
ScanResult.ProjectRootattribution — the Python table sets the matched root on every result because its artifacts sit at arbitrary depth and output grouping needs explicit attribution (nested project roots win over parents); - declare a
PruneRoothook that skips the ecosystem's own SDK checkout.
Pruned trees are skipped whole — no artifact matching, no project context, no descent — so nothing inside is ever a deletion target: installed-package trees (node_modules outside a project, site-packages, package stores, app bundles) engine-wide, and an ecosystem's SDK checkout via PruneRoot — decision.
Deduplication: a directory matching rules of several active ecosystems is reported once, attributed to the first table in order (node → rust → ruby → python → go → flutter → android), and no scanner descends into another's matched artifact (__pycache__ inside node_modules is not reported separately). Attribution can therefore differ between a full scan and an --eco subset scan.
Results are sorted by (table order, path) before returning, keeping output order stable.
ScanResult carries two optional display fields populated by scanners after detection:
Label— human-readable display name when the path basename is opaque (e.g. simulator UUID →iPhone 17 Pro · iOS 26.3). Every view that lists artifacts builds its row cells from the sameinternal/uifunctions.Recommendation— actionable hint shown as a trailing tag (e.g.superseded by newer build). Lets the user decide what to delete without external lookup.
Scanners derive these from peer comparison (DeviceSupport build ages), vendor APIs (xcrun simctl list devices --json for simulator names), known-name maps (DerivedData shared subdirectories), or vendor metadata files (DerivedData info.plist → source workspace path). Enrichment is best-effort — if the vendor command fails or returns malformed data, the scan still works with raw basenames.
Use this pattern when a single ecosystem produces directories whose names alone don't tell the user what they are.
Reclaiming is not always os.RemoveAll on a path — Docker images or simulator devices are reclaimed through vendor commands or API calls. Each ScanResult carries an optional Delete *model.DeleteMethod (kind path/command/api + display + Run); nil means path removal.
The cleaner applies its policy gates (protected refusal, dry-run) uniformly, then executes: Delete.Run(ctx) when a method is attached, otherwise path removal (trash or permanent). A method with a nil Run is refused rather than falling back to path removal — a misconfigured item must never delete a path its method didn't intend. Trash/permanent choice only applies to path removal. Path removal (including dry-run) is refused when fstree skips any directory in the item: a recursive delete does not stop at mounts. Example: the global scanner's Homebrew item (brew cleanup).
After path removal the cleaner runs the item's Release, if any, with the same trash/permanent removal; dry-run skips it.
In JSON output, non-path items serialize as "delete": {"kind": "command", "display": "..."} (Run never serializes), so agents can tell strategies apart; absence of the key means path removal.
Scanners may implement the optional VendorCleaner interface to register ecosystem-native cleanup commands. VendorCleanup embeds model.DeleteMethod: it is the ecosystem-level bulk counterpart of a per-item ScanResult.Delete, sharing one execution contract. Bulk actions (e.g. pnpm store prune) register here; per-item non-path reclaims (e.g. docker rmi <id>) attach a DeleteMethod to their ScanResult.
devclean clean --vendor-cleanup collects cleanups from selected ecosystems and runs them alongside path-based deletion. Vendor commands keep the ecosystem's internal state consistent (e.g. xcrun simctl delete unavailable removes simulator devices and updates CoreSimulator's database in one step). Dry-run prints the command without executing.
Implemented commands: SPEC. Natural future fits: Docker system prune (behind a future --include-destructive gate), Gradle --stop.
Classification forks git per repo (git rev-parse to resolve roots, git status + git log for protection and last-commit time, git check-ignore per dirty root). These run across a bounded worker pool (min(NumCPU, 8)) — root resolution over distinct project dirs, then git info over distinct roots, then check-ignore over dirty roots — instead of serially. As with sizing, the win is overlapping the forks, not replacing them; results are applied serially afterwards so output stays deterministic regardless of scheduling.
plugins/devclean/ is a Claude Code plugin holding one skill; the repository root's .claude-plugin/marketplace.json lists it (decision). The skill drives the installed binary through scan --json and clean --item … --yes, so it depends on the flags, JSON fields and safety gates in SPEC.
Pick the scanner family by where the artifacts live.
No Scanner implementation needed — add a rule table.
- Define the table:
internal/scanner/<lang>.gowith awalkEcosystemvalue — marker file names plus artifact rules (exact rel-path, any-depth name, or suffix; each with category + safety). References: Rust (rust.go) smallest, Ruby (ruby.go) compound path, Python (python.go) any-depth + suffix rules withSetProjectRoot, Node (node.go) per-project extra rules, Flutter (flutter.go)PruneRoot. - Register: append the table to
walkEcosystemTableininternal/scanner/walk.go(position = dedup attribution priority) and addreg.Register(newWalkScanner(<lang>WalkEcosystem))at the same position ininternal/scanner/registry_default.go. - Tests:
internal/scanner/<lang>_test.gocallingscanner.WalkScan(ctx, root, model.EcoYourLang). Cover detection, artifact discovery, edge cases (no marker, false-positive siblings, monorepo nesting if applicable). Uset.TempDir()for fixtures.
- Define the scanner:
internal/scanner/<name>.goimplementingScanner. Xcode (xcode.go) is the reference — a fixed path catalog, scope-gated by the scan root. - Register: add
reg.Register(NewYourScanner())ininternal/scanner/registry_default.go. - Tests:
internal/scanner/<name>_test.goexercisingScandirectly.
- Ecosystem id:
EcoYourLangconstant ininternal/model/types.go, appended toAllEcosystems(). - SDK checkout check: scan the tool's real SDK/toolchain checkout and verify nothing inside is flagged; fix any false positive in the same PR — decision.
- Real-world run: build and run
./devclean scan --path <real-project> --eco <yourlang>against an actual project before declaring done. A wrong marker file or path-walk regression passes unit tests but fails in the wild. - Docs: add a section and status row in SPEC.