Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Pipeline: **Scan → Classify → Filter/Sort → Output/Clean**
- **Metadata enrichment**: scanners populate `ScanResult.Label` / `Recommendation` so users can decide without decoding paths (UUID → "iPhone 17 Pro · iOS 26.3", "superseded by newer build", "runtime unavailable"). First applied in xcode; reusable for any ecosystem with opaque identifiers
- **Vendor cleanup**: scanners may implement the optional `VendorCleaner` interface to register ecosystem-native cleanup commands. `devclean clean --vendor-cleanup` runs them alongside path-based deletion so vendor internal state stays consistent. Implemented: `xcode` (`simctl delete unavailable`) and `global` (brew/npm/yarn/pnpm/pip/uv cache prunes, skipping tools absent from PATH). Destructive commands (Docker `system prune`) are deferred to a future `--include-destructive` gate.
- **Deletion strategy**: `ScanResult.Delete` (`model.DeleteMethod`: kind path/command/api + display + Run closure) expresses non-path reclaims per item; nil = path removal. Cleaner applies protected/dry-run gates uniformly, then delegates to the method or falls back to trash/force. `VendorCleanup` embeds the same `DeleteMethod` — bulk (ecosystem-level) vs per-item are two uses of one execution contract
- **Name-based match false positives (SDK/toolchain source)**: a directory name like `build`/`dist` is not always regenerable output — inside a tool's own SDK or toolchain checkout it can be committed *source* (Flutter SDK's `engine/src/build` holds GN build-system source, tracked in git). gitignore-aware protection does NOT catch this: committed-clean files are not `protected`, so they classify as `safe` and become deletion targets. When adding any ecosystem/rule, scan that tool's real SDK checkout and verify what gets flagged; exclude the SDK subtree via the walk engine's `PruneRoot` hook, detecting the SDK root by its invariant bootstrap layout (never a hardcoded install path). A scanner's own false positive is a safety requirement of that scanner's PR — never deferred as a separate concern.

## Documentation Rules

Expand Down
21 changes: 19 additions & 2 deletions docs/ecosystems.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
| `xcode` | iOS/Xcode (macOS only) | implemented |
| `python` | Python | implemented |
| `go` | Go | implemented (per-project only) |
| `flutter` | Flutter/Dart | implemented |
| `global` | Global Caches | implemented |
| `llm` | LLM Model Stores | implemented |
| `android` | Android | planned |
| `flutter` | Flutter/Dart | planned |
| `docker` | Docker | planned |

**Dedup attribution**: project ecosystems (node, rust, ruby, python, go) share a single-pass scan — a directory matching artifact rules of several active ecosystems is reported once, attributed to the first in scanner order (node → rust → ruby → python → go), so `--eco` subsets can shift attribution (a shared `coverage/` goes to node in a full scan, to ruby under `--eco ruby`).
**Dedup attribution**: project ecosystems (node, rust, ruby, python, go, flutter) share a single-pass scan — a directory matching artifact rules of several active ecosystems is reported once, attributed to the first in scanner order (node → rust → ruby → python → go → flutter), so `--eco` subsets can shift attribution (a shared `coverage/` goes to node in a full scan, to ruby under `--eco ruby`).

## Node.js

Expand Down Expand Up @@ -116,6 +116,22 @@
- `vendor/` is `caution` because `go mod vendor` is an opt-in choice — devs who vendor often do so for offline builds, reproducibility, or supply-chain pinning. Regeneration requires network access plus the original `go.sum`.
- Go's two big disk hogs — `~/.cache/go-build` (build cache) and `~/go/pkg/mod` (module cache) — are global, not per-project. They will be handled by the Global Caches scanner so they aren't double-attributed to every Go project on the machine.

## Flutter/Dart

**Detection**: `pubspec.yaml` in parent directory.

**Artifacts**:

| Pattern | Category | Safety | Description |
|---------|----------|--------|-------------|
| `build` | build | safe | Compiled output (what `flutter clean` removes) |
| `.dart_tool` | build | safe | Build-runner / tooling state (regenerates on next build) |

**Notes**:
- `build/` and `.dart_tool/` are exactly the two directories `flutter clean` deletes; both regenerate on the next build.
- **The Flutter SDK checkout is excluded.** The SDK is itself a git repo full of `pubspec.yaml` roots, and its `engine/src/build` / `engine/src/flutter/build` are committed GN build-system *source* trees — not build output. Matching `build` by name would offer real SDK source for deletion, and gitignore-aware protection misses it (committed-clean files are not `protected`). The scanner detects the SDK root by its invariant bootstrap layout (`bin/flutter` + `bin/internal/engine.version`, location-independent — never a hardcoded path) and skips the whole subtree.
- The global pub package cache `~/.pub-cache` (macOS/Linux default) is home-rooted and handled by the Global Caches scanner as `caution` — it is shared by every Flutter project and re-downloads on the next `flutter pub get`. The `PUB_CACHE` env override is not tracked; only the default location is scanned.

## Xcode (macOS only)

**Detection**: fixed paths under `~/Library/Developer/...` and `~/Library/Logs/...`. The scanner does not walk a project tree — it checks a known set of Xcode/CoreSimulator directories and reports the ones that exist. On non-darwin platforms, the scanner is a no-op.
Expand Down Expand Up @@ -169,6 +185,7 @@ Each entry that is `caution` carries a consequence-of-deletion note in `recommen
| `~/.gradle/caches`, `~/.gradle/wrapper/dists` | cache | caution |
| `~/.cargo/registry`, `~/.cargo/git` | cache | caution |
| `~/go/pkg/mod` | deps | caution (read-only files) |
| `~/.pub-cache` | deps | caution (shared by all Flutter projects; re-downloads on next `flutter pub get`) |
| `~/Library/Caches/ms-playwright`, `~/.cache/ms-playwright` | cache | caution |
| `~/.rustup/toolchains` | runtime | caution (toolchains must be reinstalled) |
| `~/.nvm/versions`, `~/.pyenv/versions`, `~/.rbenv/versions` | runtime | caution (installed runtimes deleted, not caches) |
Expand Down
48 changes: 48 additions & 0 deletions internal/scanner/flutter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package scanner

import (
"os"
"path/filepath"

"github.com/ohing504/devclean/internal/model"
)

// flutterWalkEcosystem drives Flutter/Dart scanning in the single-pass walk
// engine. `build/` and `.dart_tool/` are exactly what `flutter clean` removes;
// both regenerate on the next build. The global ~/.pub-cache lives in the
// global scanner's catalog, not here (per-project scanners handle local
// artifacts only).
//
// PruneRoot excludes the Flutter SDK checkout itself. The SDK is a git repo
// whose `pubspec.yaml` roots would otherwise match: its `.dart_tool` dirs are
// regenerable noise, but critically its `engine/src/build` and
// `engine/src/flutter/build` are committed GN build-system *source* trees, not
// build output — matching `build` by name would offer real source for deletion
// (and gitignore-aware protection misses it, since committed-clean files are
// not "protected"). Skipping the whole SDK subtree is the only safe answer.
var flutterWalkEcosystem = walkEcosystem{
Name: "flutter",
Eco: model.EcoFlutter,
Markers: []string{"pubspec.yaml"},
PruneRoot: isFlutterSDKRoot,
Rules: []artifactRule{
{RelPath: "build", Category: model.CatBuild, Safety: model.SafetySafe}, // compiled output
{RelPath: ".dart_tool", Category: model.CatBuild, Safety: model.SafetySafe}, // build_runner / tooling state
},
}

// isFlutterSDKRoot reports whether dir is a Flutter SDK checkout root. The
// signature is the SDK's own invariant, location-independent bootstrap layout —
// the `flutter` launcher plus the pinned engine version file that the tool
// reads to locate itself — never a hardcoded install path. The names gate keeps
// the stat off every directory that has no `bin/` child.
func isFlutterSDKRoot(dir string, names map[string]bool) bool {
if !names["bin"] {
return false
}
if _, err := os.Stat(filepath.Join(dir, "bin", "flutter")); err != nil {
return false
}
_, err := os.Stat(filepath.Join(dir, "bin", "internal", "engine.version"))
return err == nil
}
121 changes: 121 additions & 0 deletions internal/scanner/flutter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package scanner_test

import (
"context"
"path/filepath"
"testing"

"github.com/ohing504/devclean/internal/model"
"github.com/ohing504/devclean/internal/scanner"
)

func TestFlutterScanner_FindsBuildAndDartTool(t *testing.T) {
root := t.TempDir()

projDir := filepath.Join(root, "myapp")
mustMkdir(t, filepath.Join(projDir, "build", "app"))
mustWriteFile(t, filepath.Join(projDir, "build", "app", "out"), make([]byte, 8192))
mustMkdir(t, filepath.Join(projDir, ".dart_tool"))
mustWriteFile(t, filepath.Join(projDir, ".dart_tool", "package_config.json"), make([]byte, 1024))
mustWriteFile(t, filepath.Join(projDir, "pubspec.yaml"), []byte("name: myapp"))

results, err := scanner.WalkScan(context.Background(), root, model.EcoFlutter)
if err != nil {
t.Fatalf("Scan error: %v", err)
}

if len(results) != 2 {
t.Fatalf("expected 2 results (build + .dart_tool), got %d", len(results))
}

for _, r := range results {
if r.Ecosystem != model.EcoFlutter {
t.Errorf("expected ecosystem=flutter, got %s", r.Ecosystem)
}
if r.Category != model.CatBuild {
t.Errorf("expected category=build, got %s", r.Category)
}
if r.Safety != model.SafetySafe {
t.Errorf("expected safety=safe, got %s", r.Safety)
}
}
}

func TestFlutterScanner_IgnoresWithoutPubspec(t *testing.T) {
root := t.TempDir()

// build/ + .dart_tool/ without pubspec.yaml should be ignored
mustMkdir(t, filepath.Join(root, "random", "build"))
mustMkdir(t, filepath.Join(root, "random", ".dart_tool"))

results, err := scanner.WalkScan(context.Background(), root, model.EcoFlutter)
if err != nil {
t.Fatalf("Scan error: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results without pubspec.yaml, got %d", len(results))
}
}

func TestFlutterScanner_ExcludesSDKCheckout(t *testing.T) {
root := t.TempDir()

// A Flutter SDK checkout: bin/flutter + bin/internal/engine.version, with a
// root pubspec.yaml and internal build/.dart_tool that must NOT be reported.
sdk := filepath.Join(root, "flutter")
mustMkdir(t, filepath.Join(sdk, "bin", "internal"))
mustWriteFile(t, filepath.Join(sdk, "bin", "flutter"), []byte("#!/bin/sh"))
mustWriteFile(t, filepath.Join(sdk, "bin", "internal", "engine.version"), []byte("abc123"))
mustWriteFile(t, filepath.Join(sdk, "pubspec.yaml"), []byte("name: _flutter_packages"))
// engine source tree named "build" (committed source, not build output)
mustMkdir(t, filepath.Join(sdk, "engine", "src", "build"))
mustWriteFile(t, filepath.Join(sdk, "engine", "src", "build", "BUILD.gn"), make([]byte, 4096))
// internal package .dart_tool
mustMkdir(t, filepath.Join(sdk, "packages", "flutter_tools", ".dart_tool"))
mustWriteFile(t, filepath.Join(sdk, "packages", "flutter_tools", ".dart_tool", "x"), make([]byte, 4096))

// A real app project alongside the SDK, which MUST still be reported.
app := filepath.Join(root, "myapp")
mustMkdir(t, filepath.Join(app, "build"))
mustWriteFile(t, filepath.Join(app, "build", "out"), make([]byte, 4096))
mustWriteFile(t, filepath.Join(app, "pubspec.yaml"), []byte("name: myapp"))

results, err := scanner.WalkScan(context.Background(), root, model.EcoFlutter)
if err != nil {
t.Fatalf("Scan error: %v", err)
}

for _, r := range results {
if filepath.Base(filepath.Dir(r.Path)) == "src" || // engine/src/build
r.Path == filepath.Join(sdk, "engine", "src", "build") {
t.Errorf("SDK engine source tree must be excluded, got %s", r.Path)
}
if want := sdk; len(r.Path) >= len(want) && r.Path[:len(want)] == want {
t.Errorf("no artifact under the SDK checkout may be reported, got %s", r.Path)
}
}

// The app's build/ must survive the SDK exclusion.
var foundApp bool
for _, r := range results {
if r.Path == filepath.Join(app, "build") {
foundApp = true
}
}
if !foundApp {
t.Errorf("app build/ should still be reported alongside an excluded SDK; results=%v", results)
}
}

func TestFlutterScanner_NameAndEcosystem(t *testing.T) {
for _, s := range scanner.DefaultRegistry().All() {
if s.Name() != "flutter" {
continue
}
if s.Ecosystem() != model.EcoFlutter {
t.Errorf("expected ecosystem=flutter, got %s", s.Ecosystem())
}
return
}
t.Error(`expected a registered scanner named "flutter"`)
}
1 change: 1 addition & 0 deletions internal/scanner/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ var globalCaches = []globalCache{
{".cargo/registry", model.CatCache, model.SafetyCaution, "Cargo registry cache", "Cargo re-downloads crate sources on next build"},
{".cargo/git", model.CatCache, model.SafetyCaution, "Cargo git dependency cache", "Cargo re-clones git dependencies on next build"},
{"go/pkg/mod", model.CatDeps, model.SafetyCaution, "Go module cache", "use 'go clean -modcache' — read-only files make --force fail; Go re-downloads on next build"},
{".pub-cache", model.CatDeps, model.SafetyCaution, "Dart/Flutter pub package cache", "shared by every Flutter project; packages re-download on next 'flutter pub get'"},
{".rustup/toolchains", model.CatRuntime, model.SafetyCaution, "Rust toolchains", "installed toolchains must be reinstalled with 'rustup toolchain install'"},
{".nvm/versions", model.CatRuntime, model.SafetyCaution, "nvm-installed Node.js runtimes", "deletes installed Node.js versions, not a cache — reinstall with 'nvm install'"},
{".pyenv/versions", model.CatRuntime, model.SafetyCaution, "pyenv-installed Python runtimes", "deletes installed Python versions, not a cache — reinstall with 'pyenv install'"},
Expand Down
1 change: 1 addition & 0 deletions internal/scanner/registry_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ func DefaultRegistry() *Registry {
reg.Register(newWalkScanner(rubyWalkEcosystem))
reg.Register(newWalkScanner(pythonWalkEcosystem))
reg.Register(newWalkScanner(goWalkEcosystem))
reg.Register(newWalkScanner(flutterWalkEcosystem))
reg.Register(NewXcodeScanner())
reg.Register(NewGlobalScanner())
reg.Register(NewLLMScanner())
Expand Down
18 changes: 17 additions & 1 deletion internal/scanner/walk.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,20 @@ type walkEcosystem struct {
// arbitrary depth (python), where output grouping needs explicit
// attribution.
SetProjectRoot bool
// PruneRoot reports whether dir is a tree that must be skipped entirely —
// no artifact matching, no project context, no descent. Used to exclude an
// ecosystem's own toolchain/SDK checkout (e.g. the Flutter SDK), whose
// internal build/.dart_tool dirs are managed by the tool itself and must
// never be offered for deletion. names holds dir's direct entries so cheap
// gate checks avoid a stat on every directory.
PruneRoot func(dir string, names map[string]bool) bool
}

// walkEcosystemTable is the canonical, ordered table of walk-based
// ecosystems. The order decides attribution when a directory matches rules
// of several ecosystems (first match wins) and the result ordering; it
// mirrors the registry order.
var walkEcosystemTable = []walkEcosystem{nodeWalkEcosystem, rustWalkEcosystem, rubyWalkEcosystem, pythonWalkEcosystem, goWalkEcosystem}
var walkEcosystemTable = []walkEcosystem{nodeWalkEcosystem, rustWalkEcosystem, rubyWalkEcosystem, pythonWalkEcosystem, goWalkEcosystem, flutterWalkEcosystem}

// WalkScan runs the single-pass walk engine over root with the tables of the
// given ecosystems activated. Ecosystems without a walk table are ignored.
Expand Down Expand Up @@ -188,6 +195,15 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model.
names[e.Name()] = true
}

// Prune check: an ecosystem may claim this whole subtree as its own
// toolchain/SDK checkout and exclude it — skip before establishing any
// context or descending, so nothing inside is ever a deletion target.
for i := range tables {
if p := tables[i].PruneRoot; p != nil && p(dir, names) {
return nil
}
}

// Marker check: establish the project contexts rooted at this directory.
pushed := 0
for i := range tables {
Expand Down