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
21 changes: 19 additions & 2 deletions docs/ecosystems.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
| `python` | Python | implemented |
| `go` | Go | implemented (per-project only) |
| `flutter` | Flutter/Dart | implemented |
| `android` | Android | implemented |
| `global` | Global Caches | implemented |
| `llm` | LLM Model Stores | implemented |
| `android` | Android | planned |
| `docker` | Docker | planned |

**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`).
**Dedup attribution**: project ecosystems (node, rust, ruby, python, go, flutter, android) 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 → android), 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 @@ -132,6 +132,23 @@
- **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.

## Android

**Detection**: `build.gradle` or `build.gradle.kts` in parent directory. Every Gradle module carries its own build script, so the root project and each subproject (`app/`, `feature/`, ...) is detected as its own project root.

**Artifacts**:

| Pattern | Category | Safety | Description |
|---------|----------|--------|-------------|
| `build` | build | safe | Compiled output (what `gradle clean` removes) |
| `.gradle` | cache | safe | Per-project Gradle cache (regenerates on next build) |

**Notes**:
- Because each module has its own marker, the single `build` rule reclaims every module's output (`build`, `app/build`, `feature/build`, ...) without enumerating module names.
- **Scope is per-project only.** The shared Gradle user home (`~/.gradle/caches`), AVD images, and NDK / system-images are home-rooted and handled by the Global Caches scanner, not here — no duplicate registration.
- **No SDK exclusion needed** (unlike Flutter): the Android SDK ships no `build.gradle`, so it never establishes a project context and nothing inside it is offered for deletion.
- **React Native overlap**: an RN project nests an `android/` Gradle tree. `android/build` and `android/.gradle` are matched by node's RN rules first in scanner order, so they attribute to node; the android scanner still covers the deeper module builds (`android/app/build`) the RN rules do not list.

## 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
30 changes: 30 additions & 0 deletions internal/scanner/android.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package scanner

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

// androidWalkEcosystem drives Android/Gradle scanning in the single-pass walk
// engine. Every Gradle module carries its own build.gradle(.kts), so the root
// project and each subproject (app/, feature/, ...) is detected as its own
// project root; the single `build` rule then reclaims every module's output
// without enumerating module names. `.gradle` is the per-project Gradle cache,
// regenerated on the next build.
//
// Scope is per-project only. The shared Gradle user home (~/.gradle/caches),
// AVD images, and NDK/system-images are home-rooted and belong to the Global
// Caches scanner's catalog, not here.
//
// No SDK PruneRoot is needed (unlike Flutter): the Android SDK ships no
// build.gradle, so it never establishes a project context and nothing inside it
// is ever matched. React Native projects nest an android/ Gradle tree, but
// node's RN rules match android/build first in table order, so those artifacts
// attribute to node; this scanner still covers the deeper module builds
// (android/app/build) that the RN rules do not list.
var androidWalkEcosystem = walkEcosystem{
Name: "android",
Eco: model.EcoAndroid,
Markers: []string{"build.gradle", "build.gradle.kts"},
Rules: []artifactRule{
{RelPath: "build", Category: model.CatBuild, Safety: model.SafetySafe}, // compiled output (gradle clean)
{RelPath: ".gradle", Category: model.CatCache, Safety: model.SafetySafe}, // per-project Gradle cache
},
}
150 changes: 150 additions & 0 deletions internal/scanner/android_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package scanner_test

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

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

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

projDir := filepath.Join(root, "myapp")
mustMkdir(t, filepath.Join(projDir, "build", "outputs"))
mustWriteFile(t, filepath.Join(projDir, "build", "outputs", "app.apk"), make([]byte, 8192))
mustMkdir(t, filepath.Join(projDir, ".gradle"))
mustWriteFile(t, filepath.Join(projDir, ".gradle", "state"), make([]byte, 1024))
mustWriteFile(t, filepath.Join(projDir, "build.gradle.kts"), []byte("plugins {}"))

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

if len(results) != 2 {
t.Fatalf("expected 2 results (build + .gradle), got %d", len(results))
}
for _, r := range results {
if r.Ecosystem != model.EcoAndroid {
t.Errorf("expected ecosystem=android, got %s", r.Ecosystem)
}
if r.Safety != model.SafetySafe {
t.Errorf("expected safety=safe, got %s", r.Safety)
}
}
}

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

// build/ + .gradle/ without a build.gradle marker should be ignored.
mustMkdir(t, filepath.Join(root, "random", "build"))
mustMkdir(t, filepath.Join(root, "random", ".gradle"))

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

// TestAndroidScanner_MultiModule pins the core design: each module carries its
// own build.gradle and is detected as its own project root, so a single `build`
// rule reclaims every module's output without enumerating module names.
func TestAndroidScanner_MultiModule(t *testing.T) {
root := t.TempDir()

// Root project with settings + build script, plus two subproject modules.
mustWriteFile(t, filepath.Join(root, "build.gradle"), []byte("// root"))
mustMkdir(t, filepath.Join(root, "build"))
mustWriteFile(t, filepath.Join(root, "build", "out"), make([]byte, 4096))

for _, mod := range []string{"app", "feature"} {
modDir := filepath.Join(root, mod)
mustMkdir(t, filepath.Join(modDir, "build"))
mustWriteFile(t, filepath.Join(modDir, "build.gradle"), []byte("// "+mod))
mustWriteFile(t, filepath.Join(modDir, "build", "out"), make([]byte, 4096))
}

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

want := map[string]bool{
filepath.Join(root, "build"): false,
filepath.Join(root, "app", "build"): false,
filepath.Join(root, "feature", "build"): false,
}
for _, r := range results {
if _, ok := want[r.Path]; ok {
want[r.Path] = true
}
}
for path, found := range want {
if !found {
t.Errorf("expected module build/ to be reported: %s (results=%v)", path, results)
}
}
}

// TestAndroidScanner_ReactNativeAttributedToNode pins that a React Native
// project's android/build is attributed to node (first in table order), not
// duplicated by the android scanner, while the android scanner still covers the
// deeper module build the RN rules do not list.
func TestAndroidScanner_ReactNativeAttributedToNode(t *testing.T) {
root := t.TempDir()

rn := filepath.Join(root, "rnapp")
mustMkdir(t, rn)
mustWriteFile(t, filepath.Join(rn, "package.json"), []byte("{}"))
mustWriteFile(t, filepath.Join(rn, "metro.config.js"), []byte("module.exports = {}"))

androidDir := filepath.Join(rn, "android")
mustMkdir(t, filepath.Join(androidDir, "build"))
mustWriteFile(t, filepath.Join(androidDir, "build.gradle"), []byte("// android"))
mustWriteFile(t, filepath.Join(androidDir, "build", "out"), make([]byte, 4096))

appDir := filepath.Join(androidDir, "app")
mustMkdir(t, filepath.Join(appDir, "build"))
mustWriteFile(t, filepath.Join(appDir, "build.gradle"), []byte("// app"))
mustWriteFile(t, filepath.Join(appDir, "build", "out"), make([]byte, 4096))

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

byPath := make(map[string]model.Ecosystem, len(results))
for _, r := range results {
if prev, dup := byPath[r.Path]; dup {
t.Errorf("path reported twice: %s (%s and %s)", r.Path, prev, r.Ecosystem)
}
byPath[r.Path] = r.Ecosystem
}

if got := byPath[filepath.Join(androidDir, "build")]; got != model.EcoNode {
t.Errorf("android/build should attribute to node, got %q", got)
}
if got := byPath[filepath.Join(appDir, "build")]; got != model.EcoAndroid {
t.Errorf("android/app/build should attribute to android, got %q", got)
}
}

func TestAndroidScanner_NameAndEcosystem(t *testing.T) {
for _, s := range scanner.DefaultRegistry().All() {
if s.Name() != "android" {
continue
}
if s.Ecosystem() != model.EcoAndroid {
t.Errorf("expected ecosystem=android, got %s", s.Ecosystem())
}
return
}
t.Error(`expected a registered scanner named "android"`)
}
1 change: 1 addition & 0 deletions internal/scanner/registry_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ func DefaultRegistry() *Registry {
reg.Register(newWalkScanner(pythonWalkEcosystem))
reg.Register(newWalkScanner(goWalkEcosystem))
reg.Register(newWalkScanner(flutterWalkEcosystem))
reg.Register(newWalkScanner(androidWalkEcosystem))
reg.Register(NewXcodeScanner())
reg.Register(NewGlobalScanner())
reg.Register(NewLLMScanner())
Expand Down
2 changes: 1 addition & 1 deletion internal/scanner/walk.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,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, flutterWalkEcosystem}
var walkEcosystemTable = []walkEcosystem{nodeWalkEcosystem, rustWalkEcosystem, rubyWalkEcosystem, pythonWalkEcosystem, goWalkEcosystem, flutterWalkEcosystem, androidWalkEcosystem}

// 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