From 24cb9b73db3b27ef9f73b6f384323be8345d76dc Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Sat, 18 Jul 2026 15:09:21 +0900 Subject: [PATCH 1/3] feat(scanner): add Flutter/Dart ecosystem support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan Flutter projects for reclaimable local build artifacts and register the global pub package cache. - local (safe): build/ and .dart_tool/ under pubspec.yaml roots — the two directories flutter clean removes, both regenerate on next build - global (caution): ~/.pub-cache, shared by every Flutter project and re-downloaded on next 'flutter pub get' — mirrors cargo/go/maven cache classification - docs/ecosystems.md updated in the same commit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013SX3vRs78zFYA2kTRZAtg6 --- docs/ecosystems.md | 20 +++++++- internal/scanner/flutter.go | 18 +++++++ internal/scanner/flutter_test.go | 71 ++++++++++++++++++++++++++++ internal/scanner/global.go | 1 + internal/scanner/registry_default.go | 1 + internal/scanner/walk.go | 2 +- 6 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 internal/scanner/flutter.go create mode 100644 internal/scanner/flutter_test.go diff --git a/docs/ecosystems.md b/docs/ecosystems.md index b11a2dd..3e625f4 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -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 @@ -116,6 +116,21 @@ - `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 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. @@ -169,6 +184,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) | diff --git a/internal/scanner/flutter.go b/internal/scanner/flutter.go new file mode 100644 index 0000000..ef96adf --- /dev/null +++ b/internal/scanner/flutter.go @@ -0,0 +1,18 @@ +package scanner + +import "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). +var flutterWalkEcosystem = walkEcosystem{ + Name: "flutter", + Eco: model.EcoFlutter, + Markers: []string{"pubspec.yaml"}, + 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 + }, +} diff --git a/internal/scanner/flutter_test.go b/internal/scanner/flutter_test.go new file mode 100644 index 0000000..173467c --- /dev/null +++ b/internal/scanner/flutter_test.go @@ -0,0 +1,71 @@ +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_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"`) +} diff --git a/internal/scanner/global.go b/internal/scanner/global.go index 58cfb56..9c4d117 100644 --- a/internal/scanner/global.go +++ b/internal/scanner/global.go @@ -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'"}, diff --git a/internal/scanner/registry_default.go b/internal/scanner/registry_default.go index f482346..e952258 100644 --- a/internal/scanner/registry_default.go +++ b/internal/scanner/registry_default.go @@ -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()) diff --git a/internal/scanner/walk.go b/internal/scanner/walk.go index 54b98ed..e4d4708 100644 --- a/internal/scanner/walk.go +++ b/internal/scanner/walk.go @@ -67,7 +67,7 @@ type walkEcosystem struct { // 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. From 8c850b0a45c64c6b77586b5bf86a597ab310651b Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Sat, 18 Jul 2026 15:31:37 +0900 Subject: [PATCH 2/3] fix(scanner): exclude the Flutter SDK checkout from scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Flutter SDK is a git repo whose pubspec.yaml roots match the flutter scanner. Its engine/src/build and engine/src/flutter/build are committed GN build-system source trees, not build output — matching `build` by name offered real SDK source for deletion, and gitignore-aware protection missed it because committed-clean files are not classified `protected`. Add a PruneRoot hook to the walk engine: a directory an ecosystem claims as its own toolchain/SDK checkout is skipped entirely — no artifact match, no project context, no descent. The flutter scanner detects the SDK root by its invariant bootstrap layout (bin/flutter + bin/internal/engine.version, location-independent) and prunes the whole subtree. Verified on a real SDK checkout: 74 falsely-reported artifacts drop to 0, while app projects elsewhere are unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013SX3vRs78zFYA2kTRZAtg6 --- docs/ecosystems.md | 1 + internal/scanner/flutter.go | 38 +++++++++++++++++++++--- internal/scanner/flutter_test.go | 50 ++++++++++++++++++++++++++++++++ internal/scanner/walk.go | 16 ++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 3e625f4..5eb8e3b 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -129,6 +129,7 @@ **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) diff --git a/internal/scanner/flutter.go b/internal/scanner/flutter.go index ef96adf..62d5c11 100644 --- a/internal/scanner/flutter.go +++ b/internal/scanner/flutter.go @@ -1,18 +1,48 @@ package scanner -import "github.com/ohing504/devclean/internal/model" +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"}, + 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 +} diff --git a/internal/scanner/flutter_test.go b/internal/scanner/flutter_test.go index 173467c..d4ed527 100644 --- a/internal/scanner/flutter_test.go +++ b/internal/scanner/flutter_test.go @@ -57,6 +57,56 @@ func TestFlutterScanner_IgnoresWithoutPubspec(t *testing.T) { } } +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" { diff --git a/internal/scanner/walk.go b/internal/scanner/walk.go index e4d4708..d18cafc 100644 --- a/internal/scanner/walk.go +++ b/internal/scanner/walk.go @@ -61,6 +61,13 @@ 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 @@ -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 { From 38af1031d2cb93c0ad7310e49078ac83a32de0d6 Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Sat, 18 Jul 2026 15:42:21 +0900 Subject: [PATCH 3/3] docs: record name-match false-positive rule in CLAUDE.md Capture the Flutter SDK lesson as a general guardrail: name-based artifact matches can hit committed SDK/toolchain source, gitignore protection misses it, and excluding it is the adding scanner's own safety requirement. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013SX3vRs78zFYA2kTRZAtg6 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 53c343a..1a5e367 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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