From 843649998daec0210c594ab1825e49d884d86c00 Mon Sep 17 00:00:00 2001 From: Amos Elmaliah Date: Wed, 14 Oct 2026 19:13:49 -0400 Subject: [PATCH] feat: add repository artifact cleanup and live review --- README.md | 65 +- bun.lock | 1 + desktop/README.md | 13 +- desktop/src/main/controller.test.ts | 2 +- desktop/src/main/controller.ts | 3 +- desktop/src/main/engine.test.ts | 4 +- desktop/src/main/engine.ts | 68 +- desktop/src/main/repo-cleanup.test.ts | 59 ++ desktop/src/main/settings.test.ts | 12 + desktop/src/main/settings.ts | 4 +- desktop/src/renderer/app.ts | 2 +- desktop/src/shared/types.ts | 3 + .../repo-artifact-cleanup/contract_audit.yaml | 598 +++++++++++++++++ .../repo-artifact-cleanup/decisions.yaml | 204 ++++++ .../repo-artifact-cleanup/execution.yaml | 116 ++++ .../git_probe_evidence.json | 46 ++ .../repo-artifact-cleanup/performance.json | 158 +++++ .../repo_artifact_cleanup_eval_cases.json | 622 ++++++++++++++++++ .../repo_artifact_cleanup_prd.yaml | 589 +++++++++++++++++ .../plans/repo-artifact-cleanup/research.yaml | 239 +++++++ .../repo-artifact-cleanup/validate_plan.rb | 142 ++++ package.json | 6 +- scripts/benchmark-repo-artifacts.ts | 55 ++ src/commands/clean-json.test.ts | 54 ++ src/commands/clean.test.ts | 10 +- src/commands/clean.ts | 94 +-- src/commands/interactive-live.test.ts | 58 ++ src/commands/interactive.test.ts | 4 +- src/commands/interactive.ts | 79 +-- src/commands/repo-cli.test.ts | 47 ++ src/commands/scan.ts | 31 +- src/index.ts | 44 +- src/pickers/README.md | 19 + src/pickers/live-review-prompt.test.ts | 59 ++ src/pickers/live-review-state.ts | 34 + src/pickers/live-review.test.ts | 25 + src/pickers/live-review.ts | 84 +++ src/repo-artifacts/categories.test.ts | 137 ++++ src/repo-artifacts/eligibility.ts | 239 +++++++ src/repo-artifacts/git-safety.test.ts | 82 +++ src/repo-artifacts/git-transport.test.ts | 45 ++ src/repo-artifacts/repo-artifacts.test.ts | 282 ++++++++ src/repo-artifacts/rules.ts | 44 ++ src/repo-artifacts/walker.ts | 115 ++++ src/scanners/base-scanner.test.ts | 12 +- src/scanners/base-scanner.ts | 117 ++-- src/scanners/docker.test.ts | 6 +- src/scanners/docker.ts | 6 +- src/scanners/go-artifacts.ts | 10 + src/scanners/homebrew.test.ts | 6 +- src/scanners/homebrew.ts | 6 +- src/scanners/index.test.ts | 11 +- src/scanners/index.ts | 106 ++- src/scanners/node-build-artifacts.ts | 10 + src/scanners/node-modules.ts | 107 +-- src/scanners/rust-artifacts.ts | 10 + src/types.test.ts | 3 + src/types.ts | 97 ++- src/utils/backup-fallback.test.ts | 52 ++ src/utils/backup.test.ts | 3 + src/utils/backup.ts | 10 +- src/utils/cleanup.ts | 86 +++ src/utils/config.test.ts | 19 +- src/utils/config.ts | 108 +-- src/utils/fs.ts | 54 +- vitest.config.ts | 6 +- 66 files changed, 4953 insertions(+), 489 deletions(-) create mode 100644 desktop/src/main/repo-cleanup.test.ts create mode 100644 docs/plans/repo-artifact-cleanup/contract_audit.yaml create mode 100644 docs/plans/repo-artifact-cleanup/decisions.yaml create mode 100644 docs/plans/repo-artifact-cleanup/execution.yaml create mode 100644 docs/plans/repo-artifact-cleanup/git_probe_evidence.json create mode 100644 docs/plans/repo-artifact-cleanup/performance.json create mode 100644 docs/plans/repo-artifact-cleanup/repo_artifact_cleanup_eval_cases.json create mode 100644 docs/plans/repo-artifact-cleanup/repo_artifact_cleanup_prd.yaml create mode 100644 docs/plans/repo-artifact-cleanup/research.yaml create mode 100644 docs/plans/repo-artifact-cleanup/validate_plan.rb create mode 100644 scripts/benchmark-repo-artifacts.ts create mode 100644 src/commands/clean-json.test.ts create mode 100644 src/commands/interactive-live.test.ts create mode 100644 src/commands/repo-cli.test.ts create mode 100644 src/pickers/live-review-prompt.test.ts create mode 100644 src/pickers/live-review-state.ts create mode 100644 src/pickers/live-review.test.ts create mode 100644 src/pickers/live-review.ts create mode 100644 src/repo-artifacts/categories.test.ts create mode 100644 src/repo-artifacts/eligibility.ts create mode 100644 src/repo-artifacts/git-safety.test.ts create mode 100644 src/repo-artifacts/git-transport.test.ts create mode 100644 src/repo-artifacts/repo-artifacts.test.ts create mode 100644 src/repo-artifacts/rules.ts create mode 100644 src/repo-artifacts/walker.ts create mode 100644 src/scanners/go-artifacts.ts create mode 100644 src/scanners/node-build-artifacts.ts create mode 100644 src/scanners/rust-artifacts.ts create mode 100644 src/utils/backup-fallback.test.ts create mode 100644 src/utils/cleanup.ts diff --git a/README.md b/README.md index 6cff535..27318f3 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,10 @@ The `.dmg` files are also on [Releases](https://github.com/guhcostan/mac-cleaner | `system-cache` | Application caches in ~/Library/Caches | | `system-logs` | System and application logs | | `dev-cache` | npm, yarn, pip, Xcode DerivedData, CocoaPods | -| `node-modules` | Orphaned node_modules in old projects | +| `node-modules` | Stale dependencies beside `package.json` (moderate) | +| `rust-artifacts` | `target` beside `Cargo.toml` (moderate) | +| `node-build-artifacts` | Node build output beside `package.json` (risky) | +| `go-artifacts` | Ignored `bin`/`dist` beside `go.mod` (risky, explicit opt-in) | | `homebrew` | Homebrew download cache **and old formula versions** (`brew cleanup --prune=all`) | | `docker` | Unused images, stopped containers, build cache — **only the types you select** | @@ -217,6 +220,66 @@ npx mac-cleaner-cli clean --all --yes Risky categories (downloads, iOS backups, etc) are always skipped unless you pass `--unsafe`. +### Repository artifacts + +```bash +# Scan only a workspace; repeat --root for additional workspaces. +npx mac-cleaner-cli scan --category rust-artifacts --root ~/dev --json --verbose + +# Simulate validated Node build cleanup as one JSON document. +npx mac-cleaner-cli clean --categories node-build-artifacts --unsafe --root ~/dev --dry-run --json + +# Go requires explicit inclusion or an explicit Go category, plus risky enablement. +npx mac-cleaner-cli clean --categories go-artifacts --unsafe --root ~/dev --dry-run --json + +# Live review: selections start empty. A separate confirmation precedes cleanup. +npx mac-cleaner-cli --live --root ~/dev +npx mac-cleaner-cli scan --live --root ~/dev +``` + +All repository rules require a readable, regular, non-symlink marker in the **immediate parent**. Node build names are `.next`, `.turbo`, `.parcel-cache`, `coverage`, `dist`, `build`, `out`, and `.cache`. Generic Node names (`coverage`, `dist`, `build`, `out`, `.cache`) and Go `bin`/`dist` additionally require a positive Git ignore match. They are omitted in non-Git projects. A marker or ignore match does not prove that every untracked file is disposable. + +Git's current index and HEAD both protect tracked content, including staged additions/removals, conflicts, symlinks and submodules. Nested `.git` files/directories also protect candidate ancestors. Git failures, unreadable contents and incomplete inspection prevent eligibility. Candidate symlinks and traversal through symlinks below canonical roots are rejected. Node wins a Node/Go `dist` collision when both categories are requested; a Go-only request assigns it to Go. + +Default roots are `~/Projects`, `~/Developer`, `~/Code`, `~/dev`, `~/workspace`, and `~/repos`. `extraPaths.projects` adds roots for all repository categories; legacy `extraPaths.nodeModules` adds roots only for `node-modules`. A nonempty repeatable `--root` list replaces these roots for that command, without changing other scanners. Roots are canonicalized and restricted to directories below HOME, `/Users`, or `/Volumes`; broad HOME/system roots are rejected. Empty extra-path arrays add nothing. + +Add these settings to the existing `~/.maccleanerrc` (or `~/.config/mac-cleaner-cli/config.json`): + +```json +{ + "extraPaths": { "projects": ["~/work"], "nodeModules": [] }, + "excludePaths": ["**/keep.txt", "docs/build"], + "includeEcosystems": [], + "nodeModulesDaysOld": 30, + "projectScanMaxDepth": 4, + "parallelScans": true, + "concurrency": 4 +} +``` + +`--exclude ` repeats and adds to configured keep paths. A basename matches any component; slash-containing patterns match root-relative prefixes on component boundaries. `*` and `?` match within a component, and `**` as a whole component matches zero or more components. Absolute paths, traversal, empty patterns, negation, braces and brackets are invalid. Any excluded descendant protects its entire artifact directory, including across overlapping roots. Malformed repository policy fails closed. + +Node dependencies must be at least `nodeModulesDaysOld` days old (integer 0–365, default 30), using the newer of the project-directory mtime and **every candidate entry's newest mtime**, including directories and links. `projectScanMaxDepth` is 0–16 (default 4): children of parents at depth 0 through 4 are inspected. Artifact contents are fully inspected regardless of this discovery cap. Known hidden worktree containers are traversed; dependency contents and Git metadata are pruned during discovery. + +Node and Rust are enabled by default. `--include go`, config `includeEcosystems: ["go"]`, or explicitly selecting `go-artifacts` opts into Go; `--all`, `--unsafe`, and `--risky` alone do not. `defaultCategories` narrows default category selection; `excludeCategories` always wins. Risky Node/Go categories start with no preselected files in interactive review. Scripted selection requires the existing explicit risky flag. + +Selected repository items are revalidated against their original immutable scan context, current markers/config/keep rules, refreshed Git state, canonical roots and device/inode identity before cleanup. No new discoveries are added during clean. Dry-run exercises the same validation without creating backup sessions or changing files. Outside-HOME roots may be cleaned permanently, but backup mode rejects them with `BACKUP_UNSUPPORTED_PATH`; backup failure never falls back to deletion. Validated selected ancestors cover descendants once, while independently selectable scan rows remain available. + +These checks reduce stale-result risk; they are not atomic with deletion, do not detect active builds, and cannot guarantee inactivity. Byte counts are **logical estimates**, including regular-file and symlink-entry sizes; they are not physical disk-block measurements. + +### Clean JSON and scan events + +`clean --json` requires `--all` or `--categories`; actual cleanup also requires `--yes`. Dry-run does not. Stdout contains one versioned document, including empty and failed runs. Diagnostics go to stderr; JSON mode does not prompt or open a browser. Exit status is 0 for a complete scan/plan/clean (including empty), 1 for scan/config/validation/execution failure; invalid CLI usage follows Commander's nonzero convention. + +Per-filesystem-item outcomes include category, path, status, estimated size and rejection/skip reasons. Statuses are `deleted`, `backed-up`, `would-delete`, `would-back-up`, `rejected`, or `skipped`. Covered children report `SKIPPED_COVERED_BY_PARENT` with zero duplicate accounting. External tools such as Docker and Homebrew have action-level outcomes, not invented filesystem deletion records. + +Existing aggregate fields remain. Actual `totalFreedSpace`, `totalBackedUpSize`, and `totalCleanedItems` are separate from `wouldFreeSpace`, `wouldBackUpSize`, and `wouldCleanItems`; dry-run actual counters are zero. Scan JSON retains `totalSize`, `totalItems`, category fields and verbose item `path`/`size`, adding optional `id`, `fileCount`, `newestEntryModifiedAt`, and artifact evidence. IDs hash category plus NUL plus canonical path; they identify rows and do not authorize cleanup. + +Library callers can pass `onEvent` and `signal: AbortSignal` to scanner options. Events are `start`, `progress`, `candidate`, `warning`, and `done`, correlated by `runId` and category where applicable. The registry shares one repository discovery pass per request. Cancellation returns no usable cleanup plan. The Promise API remains; repository callers pass `scanResult.context` as the additive fourth argument to `clean(items, dryRun, backupDir, context)`. Bare repository cleanup without scan evidence is refused. + +Live review requires a terminal and cannot be combined with JSON. Use `s`/`m`/`p` for size/newest-mtime/path sorting, `/` for search, `n`/`b` for pages, arrows to move, space to select, and `q` to cancel. Selection survives view changes. Enter during scanning cannot submit; after completion it finishes review before the ordinary final confirmation and validation. `scan --live` is read-only. + + ### Uninstall Apps Remove applications completely with all their preferences, caches, and support files: diff --git a/bun.lock b/bun.lock index affe350..723bf8b 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@inquirer/checkbox": "^5.0.2", "@inquirer/confirm": "^6.0.2", + "@inquirer/core": "^11.0.2", "chalk": "^5.3.0", "commander": "^14.0.3", "ora": "^9.0.0", diff --git a/desktop/README.md b/desktop/README.md index a047827..1c72a91 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -25,7 +25,7 @@ category, safety rule and protected-path check of the CLI applies here too. ### Safety -- Unattended runs (daily and Clean Now) can only use categories that are **not** marked risky. This is enforced in the main process, not just in the UI. +- Unattended runs (daily and Clean Now) can only use categories that are **not** marked risky, with the new repository artifact categories additionally excluded. This is enforced in the main process, not just in the UI. - Automatic runs skip temp files touched in the last 24 hours (configurable) because running apps may still be using them. - Deep Clean only deletes exactly what a scan preview showed and you confirmed. If the preview is older than 15 minutes, it refuses and asks you to scan again instead of rescanning on its own. - All deletions go through the CLI's `removeItem`, which refuses system paths, `/` and your home folder, and never follows symlinks. @@ -33,7 +33,7 @@ category, safety rule and protected-path check of the CLI applies here too. ### Default automatic categories -`temp-files`, `browser-cache`, `system-logs`. You can add any other non-risky category (Homebrew, User Cache, Trash, Development Cache, Docker…) in **Settings**. +`temp-files`, `browser-cache`, `system-logs`. You can add other allowed non-risky categories (Homebrew, User Cache, Trash, Development Cache, Docker…) in **Settings**. ## Install @@ -153,3 +153,12 @@ Without them the build is ad-hoc signed (see the note in [Install](#install)). Bump `version` in `desktop/package.json` before a release: it is the version shown in the app and in the file names. + + +### Repository artifact cleanup + +Deep scans share the CLI repository engine and configured roots/keep rules. `rust-artifacts` joins moderate deep defaults; `node-build-artifacts` is risky and `go-artifacts` requires explicit deep-category selection. None of these three new categories can enter scheduled or quick cleanup, even through stale settings or a direct controller request. + +The controller retains immutable per-scan repository policy and identity evidence. Cleaning revalidates only the selected preview items, refreshes Git protection and rejects changed/excluded candidates; it never introduces newly discovered items. Independently selectable parent/child scan rows remain, with selected filesystem overlap removed after validation. Preview metadata preserves name/path/size and adds optional ID, regular-file count and newest-entry mtime. + +Desktop deep cleanup remains permanent deletion and does not inherit CLI `backupEnabled`. Markers/ignore rules do not prove every untracked file disposable. There remains a filesystem/Git race window after final checks, and reported sizes are logical estimates. See the root README for rules, exclusions, root precedence and the API contract. diff --git a/desktop/src/main/controller.test.ts b/desktop/src/main/controller.test.ts index 7d74292..8364cda 100644 --- a/desktop/src/main/controller.test.ts +++ b/desktop/src/main/controller.test.ts @@ -53,7 +53,7 @@ describe('CleanerController', () => { const record = await controller.tick(); expect(record?.mode).toBe('auto'); // Fresh temp files are left alone by unattended runs. - expect(registry['temp-files'].clean).toHaveBeenCalledWith([expect.objectContaining({ name: 'stale' })], false); + expect(registry['temp-files'].clean).toHaveBeenCalledWith([expect.objectContaining({ name: 'stale' })], false, undefined, undefined); expect(record?.freedSpace).toBe(110); expect(onRunFinished).toHaveBeenCalledWith(record); expect(store.lastAutoRunAt).toEqual(new Date(2026, 8, 30, 10, 5)); diff --git a/desktop/src/main/controller.ts b/desktop/src/main/controller.ts index 26da377..c92fd0d 100644 --- a/desktop/src/main/controller.ts +++ b/desktop/src/main/controller.ts @@ -17,6 +17,7 @@ import { type ProgressEvent, type ScannerRegistry, } from './engine.js'; +import { isAllowedInAutoClean } from './settings.js'; import { isAutoCleanDue, nextAutoCleanAt } from './schedule.js'; import type { Store } from './store.js'; @@ -154,7 +155,7 @@ export class CleanerController { private async runLightClean(mode: RunMode): Promise { const { settings } = this.deps.store; - const ids = this.validIds(settings.autoClean.categories); + const ids = this.validIds(settings.autoClean.categories).filter(isAllowedInAutoClean); const record = await this.exclusive(mode, async () => { const results = await scanCategories(this.deps.registry, ids, (e) => this.setProgress(mode, e)); diff --git a/desktop/src/main/engine.test.ts b/desktop/src/main/engine.test.ts index b57a7d1..69fa01f 100644 --- a/desktop/src/main/engine.test.ts +++ b/desktop/src/main/engine.test.ts @@ -39,7 +39,7 @@ describe('scanCategories', () => { it('turns a scanner crash into an error result', async () => { const registry = { trash: fakeScanner('trash', [], { failScan: true }) }; const [result] = await scanCategories(registry, ['trash']); - expect(result.error).toBe('boom'); + expect(result.error).toContain('boom'); expect(result.items).toEqual([]); }); }); @@ -57,7 +57,7 @@ describe('cleanScanResults', () => { expect(record.freedSpace).toBe(115); expect(record.cleanedItems).toBe(3); expect(record.categories.map((c) => c.id)).toEqual(['system-logs', 'trash']); - expect(trash.clean).toHaveBeenCalledWith(results[0].items, false); + expect(trash.clean).toHaveBeenCalledWith(results[0].items, false, undefined, undefined); }); it('applies the item filter and skips categories left empty', async () => { diff --git a/desktop/src/main/engine.ts b/desktop/src/main/engine.ts index 8eb43b0..bcd1612 100644 --- a/desktop/src/main/engine.ts +++ b/desktop/src/main/engine.ts @@ -1,3 +1,5 @@ +import { runScans } from '../../../src/scanners/index.js'; +import { cleanSelectedResults, scanTotals } from '../../../src/utils/cleanup.js'; import { randomUUID } from 'crypto'; import { CATEGORIES, type CategoryId, type CleanableItem, type ScanResult, type Scanner } from '../../../src/types.js'; import type { @@ -41,25 +43,9 @@ export async function scanCategories( onProgress?: (event: ProgressEvent) => void ): Promise { const ids = categoryIds.filter((id) => registry[id]); - const results: ScanResult[] = []; - - for (let i = 0; i < ids.length; i++) { - const scanner = registry[ids[i]]!; - onProgress?.({ phase: 'scanning', completed: i, total: ids.length, current: scanner.category.name }); - try { - results.push(await scanner.scan()); - } catch (error) { - results.push({ - category: scanner.category, - items: [], - totalSize: 0, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - onProgress?.({ phase: 'scanning', completed: ids.length, total: ids.length, current: '' }); - return results; + onProgress?.({ phase: 'scanning', completed: 0, total: ids.length, current: '' }); + const summary = await runScans(ids, { onProgress: (completed, total, scanner) => onProgress?.({ phase: 'scanning', completed, total, current: scanner.category.name }) }, undefined, registry); + return summary.results; } export async function cleanScanResults( @@ -77,37 +63,15 @@ export async function cleanScanResults( const categories: CategoryRunResult[] = []; const errors: string[] = []; - for (let i = 0; i < results.length; i++) { - const result = results[i]; - const scanner = registry[result.category.id]; - options.onProgress?.({ phase: 'cleaning', completed: i, total: results.length, current: result.category.name }); - - if (result.error) { - errors.push(`${result.category.name}: ${result.error}`); - } - - const items = options.itemFilter ? options.itemFilter(result) : result.items; - if (!scanner || items.length === 0) continue; - - try { - const cleaned = await scanner.clean(items, false); - categories.push({ - id: result.category.id, - name: result.category.name, - freedSpace: cleaned.freedSpace, - cleanedItems: cleaned.cleanedItems, - errors: cleaned.errors, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - categories.push({ - id: result.category.id, - name: result.category.name, - freedSpace: 0, - cleanedItems: 0, - errors: [message], - }); - } + for (const result of results) if (result.error) errors.push(`${result.category.name}: ${result.error}`); + const selected = results.filter((r) => registry[r.category.id]).map((r) => ({ ...r, items: options.itemFilter ? options.itemFilter(r) : r.items })); + const cleaned = await cleanSelectedResults(selected, (id) => registry[id]!, { + dryRun: false, + onProgress: (completed, result) => options.onProgress?.({ phase: 'cleaning', completed, total: results.length, current: result.category.name }), + }); + for (const result of cleaned.results) { + if (!selected.find((r) => r.category.id === result.category.id)?.items.length) continue; + categories.push({ id: result.category.id, name: result.category.name, freedSpace: result.freedSpace, cleanedItems: result.cleanedItems, errors: result.errors }); } options.onProgress?.({ phase: 'cleaning', completed: results.length, total: results.length, current: '' }); @@ -138,7 +102,7 @@ export function toScanPreview(results: ScanResult[], scannedAt: Date, topN = 5): topItems: [...result.items] .sort((a, b) => b.size - a.size) .slice(0, topN) - .map((item) => ({ name: item.name, path: item.path, size: item.size })), + .map((item) => ({ name: item.name, path: item.path, size: item.size, id: item.id, fileCount: item.fileCount, newestEntryModifiedAt: item.newestEntryModifiedAt?.toISOString() })), error: result.error, }; }) @@ -146,7 +110,7 @@ export function toScanPreview(results: ScanResult[], scannedAt: Date, topN = 5): return { scannedAt: scannedAt.toISOString(), - totalSize: categories.reduce((sum, c) => sum + c.totalSize, 0), + totalSize: scanTotals(results).totalSize, categories, }; } diff --git a/desktop/src/main/repo-cleanup.test.ts b/desktop/src/main/repo-cleanup.test.ts new file mode 100644 index 0000000..bfa2922 --- /dev/null +++ b/desktop/src/main/repo-cleanup.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { mkdir, readFile, rm, stat, writeFile } from 'fs/promises'; +import { join } from 'path'; +const { home } = vi.hoisted(() => ({ home: `/private/tmp/mac-cleaner-desktop-repo-${process.pid}` })); +vi.mock('os', async (original) => ({ ...await original(), homedir: () => home })); +import { RustArtifactsScanner } from '../../../src/scanners/rust-artifacts.js'; +import { clearConfigCache } from '../../../src/utils/config.js'; +import { CleanerController } from './controller.js'; +import { Store } from './store.js'; + +const root = join(home, 'projects'); +beforeEach(async () => { + clearConfigCache(); + await mkdir(root, { recursive: true }); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ extraPaths: { projects: [root] }, backupEnabled: true })); +}); +afterEach(async () => { clearConfigCache(); await rm(home, { recursive: true, force: true }); }); + +async function artifact(name: string) { + const path = join(root, name, 'target'); + await mkdir(path, { recursive: true }); + await writeFile(join(root, name, 'Cargo.toml'), ''); + await writeFile(join(path, 'output'), 'hello'); + return path; +} +async function setup() { + const store = await Store.open(join(home, 'state')); + const controller = new CleanerController({ store, registry: { 'rust-artifacts': new RustArtifactsScanner() }, version: 'fixture' }); + return { store, controller }; +} + +it('carries the deep preview through real validation and permanent fixture cleanup without discovering new items', async () => { + const valid = await artifact('valid'); + const changed = await artifact('changed'); + const { controller } = await setup(); + const preview = await controller.scanDeep(['rust-artifacts']); + expect(preview.totalSize).toBe(10); + expect(preview.categories[0].topItems[0]).toMatchObject({ id: expect.any(String), fileCount: 1, newestEntryModifiedAt: expect.any(String) }); + const unseen = await artifact('created-after-preview'); + await rm(join(root, 'changed/Cargo.toml')); + const record = await controller.cleanDeep(['rust-artifacts']); + expect(record.cleanedItems).toBe(1); + expect(record.freedSpace).toBe(5); + expect(record.categories[0].errors.join()).toContain('MARKER_MISSING'); + await expect(stat(valid)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(changed, 'output'), 'utf8')).toBe('hello'); + expect(await readFile(join(unseen, 'output'), 'utf8')).toBe('hello'); + await expect(stat(join(home, '.mac-cleaner-cli/backup'))).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +it('blocks new repository categories in quick cleanup even if settings bypass normal validation', async () => { + const path = await artifact('keep'); + const { controller, store } = await setup(); + store.settings.autoClean.categories = ['rust-artifacts', 'node-build-artifacts', 'go-artifacts']; + const record = await controller.runQuickClean(); + expect(record.cleanedItems).toBe(0); + expect(record.categories).toEqual([]); + expect(await readFile(join(path, 'output'), 'utf8')).toBe('hello'); +}); diff --git a/desktop/src/main/settings.test.ts b/desktop/src/main/settings.test.ts index f617862..0b8d89a 100644 --- a/desktop/src/main/settings.test.ts +++ b/desktop/src/main/settings.test.ts @@ -48,3 +48,15 @@ describe('settings', () => { expect(next.autoClean.categories).toEqual(['trash']); }); }); + +it('blocks new repository categories in quick/scheduled cleanup, while Rust enters deep defaults', () => { + const next = mergeSettings(DEFAULT_SETTINGS, { autoClean: { categories: ['rust-artifacts', 'node-build-artifacts', 'trash'] } }); + expect(next.autoClean.categories).toEqual(['trash']); + expect(DEFAULT_DEEP_CATEGORIES).toContain('rust-artifacts'); + expect(DEFAULT_DEEP_CATEGORIES).not.toContain('node-build-artifacts'); +}); + +it('never enables Go in default deep scans or unattended cleanup', () => { + expect(DEFAULT_DEEP_CATEGORIES).not.toContain('go-artifacts'); + expect(mergeSettings(DEFAULT_SETTINGS, { autoClean: { categories: ['go-artifacts'] } }).autoClean.categories).toEqual([]); +}); diff --git a/desktop/src/main/settings.ts b/desktop/src/main/settings.ts index 01924fe..837513a 100644 --- a/desktop/src/main/settings.ts +++ b/desktop/src/main/settings.ts @@ -19,7 +19,7 @@ export const DEFAULT_DEEP_CATEGORIES: CategoryId[] = (Object.keys(CATEGORIES) as /** Risky categories are never allowed in unattended runs. */ export function isAllowedInAutoClean(id: CategoryId): boolean { - return id in CATEGORIES && CATEGORIES[id].safetyLevel !== 'risky'; + return id in CATEGORIES && CATEGORIES[id].safetyLevel !== 'risky' && !['rust-artifacts', 'node-build-artifacts', 'go-artifacts'].includes(id); } export const DEFAULT_SETTINGS: Settings = { @@ -53,7 +53,7 @@ export function mergeSettings(current: Settings, patch: SettingsPatch | unknown) const p = (patch && typeof patch === 'object' ? patch : {}) as SettingsPatch; const auto = (p.autoClean && typeof p.autoClean === 'object' ? p.autoClean : {}) as Partial; - let categories = current.autoClean.categories; + let categories = current.autoClean.categories.filter(isAllowedInAutoClean); if (Array.isArray(auto.categories)) { categories = [...new Set(auto.categories)].filter( (id): id is CategoryId => typeof id === 'string' && isAllowedInAutoClean(id as CategoryId) diff --git a/desktop/src/renderer/app.ts b/desktop/src/renderer/app.ts index 8f3584e..1567767 100644 --- a/desktop/src/renderer/app.ts +++ b/desktop/src/renderer/app.ts @@ -555,7 +555,7 @@ function toggleRow(label: string, hint: string | null, checked: boolean, onChang function renderSettings(s: AppState): Child[] { const auto = s.settings.autoClean; - const autoCategories = s.categories.filter((c) => c.safetyLevel !== 'risky'); + const autoCategories = s.categories.filter((c) => c.safetyLevel !== 'risky' && !['rust-artifacts', 'node-build-artifacts', 'go-artifacts'].includes(c.id)); return [ errorBanner(), diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index ed9b5f6..7e943e8 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -52,6 +52,9 @@ export interface CategoryInfo { } export interface ScanPreviewItem { + id?: string; + fileCount?: number; + newestEntryModifiedAt?: string; name: string; path: string; size: number; diff --git a/docs/plans/repo-artifact-cleanup/contract_audit.yaml b/docs/plans/repo-artifact-cleanup/contract_audit.yaml new file mode 100644 index 0000000..7355bd0 --- /dev/null +++ b/docs/plans/repo-artifact-cleanup/contract_audit.yaml @@ -0,0 +1,598 @@ +--- +version: '1.1' +artifact: contract audit +metadata: + title: Audit of repository-artifact recommendations and current architecture + status: complete + updated_at: '2026-10-13' + audited_target: + host: 725f5e7a1b7fcfda226df65555b4a53a2451afa2 + reference: 2a1a7c67925ba0474f1d10a88e4f777f2f2c8ce1 + planning_revision: 1.0 -> 1.1 + method: Source inspection, official Git docs, isolated Git semantics probe and planning + consistency checks. Product code/tests and performance have not been evaluated. +acceptance_criteria: + source: repo_artifact_cleanup_prd.yaml + ids: + - AC1 + - AC2 + - AC3 + - AC4 + - AC5 + - AC6 + - AC7 + - AC8 + - AC9 + - AC10 + - AC11 + - AC12 + - AC13 + - AC14 +evidence_sources: +- id: E1 + source: src/scanners/node-modules.ts:10 + observed_at: '2026-10-13' + observation: Hardcoded six roots; depth-4 parent traversal skips hidden dirs, follows + marker access/stat, uses parent mtime; broad catch labels marker/stat/size errors + orphaned and has no age check. + confidence: high +- id: E2 + source: src/scanners/base-scanner.ts:18 + observed_at: '2026-10-13' + observation: Base clean accepts caller-supplied items and routes to removeItems/backupItems + without category eligibility/context or per-item outcomes. + confidence: high +- id: E3 + source: src/commands/clean.ts:143 + observed_at: '2026-10-13' + observation: CLI dry-run returns before scanner.clean; scan options hardcode concurrency + 4 and real cleanup loads config only after selection. + confidence: high +- id: E4 + source: src/utils/config.ts:30 + observed_at: '2026-10-13' + observation: extraPaths.projects/nodeModules, excludeCategories/concurrency are + validated but unused in repo policy. Config permits HOME,/Users,/Volumes based + on lexical paths and creates extraPaths presets. + confidence: high +- id: E5 + source: src/types.ts:1 + observed_at: '2026-10-13' + observation: Typed category union and CATEGORIES drive registry/selection; CleanableItem + already has name/type/modifiedAt/detail, but verbose scan JSON only serializes + path/size. + confidence: high +- id: E6 + source: src/commands/scan.ts:66 + observed_at: '2026-10-13' + observation: scan JSON summary is single-document with optional verbose item paths; + clean CLI currently lacks JSON output. + confidence: high +- id: E7 + source: desktop/src/main/engine.ts:38 + observed_at: '2026-10-13' + observation: Desktop calls scanner.scan with no options and scanner.clean(items,false), + returns category aggregates and name/path/size previews; it does not use stable + per-item IDs. + confidence: high +- id: E8 + source: "/Users/amoselmaliah/dev/projects/repo-cleaner-go/internal/core/rules.go:17" + observed_at: '2026-10-13' + observation: Node/Rust/Go rule names, default node+rust and Go opt-in. Include normalization + is replacement-style and unsupported values are silently omitted; do not copy + semantics without defining host policy. + confidence: high +- id: E9 + source: "/Users/amoselmaliah/dev/projects/repo-cleaner-go/internal/core/clean.go:45" + observed_at: '2026-10-13' + observation: CleanArtifacts rescans eligible paths. CleanArtifactsKnownStream explicitly + skips eligibility rescan and is used by the live CLI. + confidence: high +- id: E10 + source: "/Users/amoselmaliah/dev/projects/repo-cleaner-go/internal/core/scan.go:285" + observed_at: '2026-10-13' + observation: Internal walker prunes .git and candidate names and dedupes descendants. + The fd backend uses -u without .git subtree pruning; backend behavior differs. + confidence: high +- id: E11 + source: desktop/src/main/settings.ts:16 + observed_at: '2026-10-13' + observation: Default deep categories include every non-risky category; isAllowedInAutoClean + also admits every non-risky category. + confidence: high +- id: E12 + source: src/pickers/selection-rules.ts:23 + observed_at: '2026-10-13' + observation: Risky categories start with no item preselection; non-risky category + selection selects all its paths. CLI clean respects supportsFileSelection for + manual item review. + confidence: high +- id: E13 + source: src/utils/fs.ts:305 + observed_at: '2026-10-13' + observation: removeItemWithError checks protected paths before dry-run, but dry-run + returns before lstat/existence. Safety checks use lexical resolve; an ancestor + symlink is not checked. + confidence: high +- id: E14 + source: src/utils/backup.ts:73 + observed_at: '2026-10-13' + observation: backupPathFor only represents paths under HOME. backupItems dry-run + tests only backupPathFor and skips backupItem's validatePathSafety; real backup + rejects unsupported paths. + confidence: high +- id: E15 + source: src/commands/interactive.ts:197 + observed_at: '2026-10-13' + observation: Interactive dry-run still calls ensureBackupDir when backup is enabled, + so it can create a backup session during simulation. + confidence: high +- id: E16 + source: desktop/src/main/controller.ts:126 + observed_at: '2026-10-13' + observation: Deep clean uses only previewed selected items and refuses a preview + older than five minutes. A validation rescan must never add fresh items; desktop + deep cleanup intentionally uses permanent deletion. + confidence: high +- id: E17 + source: "/Users/amoselmaliah/dev/projects/repo-cleaner-go/internal/core/workspace.go:80" + observed_at: '2026-10-13' + observation: Workspace marker lookup walks up ancestors; a top-level marker can + justify unrelated generic descendants. + confidence: high +- id: E18 + source: "/Users/amoselmaliah/dev/projects/repo-cleaner-go/internal/core/types.go:3" + observed_at: '2026-10-13' + observation: Core Artifact scan JSON has only path/type/bytes. File-count/newest-mtime + metadata is present in live UI/clean helpers and includes directory mtimes, not + all core scan results. + confidence: high +- id: E19 + source: vitest.config.ts:24 + observed_at: '2026-10-13' + observation: Root test/typecheck scripts exclude desktop tests/types; NodeModulesScanner + is excluded from coverage and its existing scanner tests do not inspect discovery + behavior. + confidence: high +- id: E20 + source: https://git-scm.com/docs/git-ls-files + observed_at: '2026-10-13' + observation: Official docs specify --cached index contents and --stage/-z output. + One worktree inventory can check all its candidates. + confidence: high +- id: E21 + source: https://git-scm.com/docs/git-check-ignore + observed_at: '2026-10-13' + observation: Official docs describe ignore matching, tracked-query suppression by + default, NUL-separated stdin and --no-index. Ignore matching is not an inventory + of tracked children. + confidence: high +- id: E22 + source: https://git-scm.com/docs/git-ls-tree + observed_at: '2026-10-13' + observation: Official docs describe recursive HEAD tree listing and NUL output; + complements current index when tracked paths are staged for removal. + confidence: high +- id: E23 + source: git_probe_evidence.json + observed_at: '2026-10-13' + observation: 'Isolated Git 2.55.0 probe: ignored dist contains force-tracked files; + default check-ignore suppresses tracked paths, --no-index matches them. Index/HEAD + differ after staged removal; git -C reads an index from a worktree with .git file.' + confidence: high +- id: E24 + source: src/utils/fs.ts:109 + observed_at: '2026-10-13' + observation: Current size helper counts regular-file and link lstat bytes without + traversing link targets. Logical byte totals are estimates, not physical block + reclamation. + confidence: high +- id: E25 + source: src/commands/interactive.ts:268 + observed_at: '2026-10-13' + observation: CLI picker selections use item.path. Desktop uses previewed item arrays + and category IDs; the prior claim that desktop engine keys item state by path + overstated its implementation. + confidence: high +- id: E26 + source: docs/plans/repo-artifact-cleanup/research.yaml (prior revision) + observed_at: '2026-10-13' + observation: The previous tracker contained duplicate readiness keys and completed + items with unresolved dependencies; YAML parsing alone did not detect these consistency + errors. + confidence: high +- id: E27 + source: src/commands/repo-cli.test.ts + observed_at: '2026-10-14' + observation: Isolated subprocess fixtures verify global roots and dry-run before subcommands, JSON output, malformed policy, actual fixture-only cleanup and backup simulation. + confidence: high +- id: E28 + source: docs/plans/repo-artifact-cleanup/execution.yaml + observed_at: '2026-10-14' + observation: Implementation progress and executed safety, UI, root and desktop checks are recorded separately from the historical audit findings. + confidence: high +- id: E29 + source: docs/plans/repo-artifact-cleanup/performance.json + observed_at: '2026-10-14' + observation: Isolated small/medium/large scan and selected-validation measurements include wall time, sampled process RSS, Git process count and candidate inspection work, with overlap comparisons and measurement limits. + confidence: high + +findings: +- criterion_id: AC1 + status: mismatch + current_behavior: Node marker errors enter orphan handling; Rust/Go/Node build categories + absent. + gap: Immediate-parent readable marker checks and orphan fix. + evidence: + - E1 + - E8 + - E17 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC2 + status: mismatch + current_behavior: Only node_modules repo output is scanned; hidden artifact names + are skipped by the current walker. + gap: Add complete rules and deterministic collision/hidden handling. + evidence: + - E1 + - E8 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC3 + status: mismatch + current_behavior: Registry has no ecosystem opt-in and desktop selection derives + from safety level. + gap: Introduce default-enabled selection distinct from available scanner inventory. + evidence: + - E5 + - E7 + - E11 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC4 + status: mismatch + current_behavior: extraPaths validated without use; current docs originally proposed + replacement semantics and deferred CLI roots. + gap: Append configured extras; add explicit overrides and shared scan/clean policy. + evidence: + - E1 + - E4 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC5 + status: mismatch + current_behavior: Neither repository provides tracked-content protection; the previous + plan applied exclusions at directory roots only. + gap: Index/HEAD and content keep-path vetoes with Git uncertainty handling. + evidence: + - E8 + - E10 + - E17 + - E20 + - E21 + - E22 + - E23 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC6 + status: mismatch + current_behavior: Clean forwards supplied paths without repo-policy context; final + filesystem helpers do not detect replaced directories or symlink ancestors. + gap: Immutable scan evidence, selected-path revalidation and immediate checks. + evidence: + - E2 + - E13 + - E16 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC7 + status: mismatch + current_behavior: CLI dry-run bypasses scanners; interactive dry-run can create + backups; backup dry-run omits protected paths. + gap: Shared preflight for simulation/mutation and explicit outside-HOME rejection. + evidence: + - E3 + - E13 + - E14 + - E15 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC8 + status: partial + current_behavior: Node implicitly prunes node_modules; there is no selected-plan + dedupe across categories. + gap: Keep independent scan choices and normalize only the selected eligible filesystem + plan. + evidence: + - E1 + - E2 + - E7 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC9 + status: partial + current_behavior: size/type/directory mtime exist; regular counts and newest entry + time are not host item fields. + gap: One measurement pass including directory/link mtimes without following targets. + evidence: + - E5 + - E18 + - E24 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC10 + status: mismatch + current_behavior: Scan JSON exists; clean has human output and early null returns + without structured item outcomes. + gap: Explicit serialization, failure reporting and planned/actual accounting. + evidence: + - E3 + - E6 + - E2 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC11 + status: partial + current_behavior: Optional scanner arguments and category registry give adapters; + desktop receives no per-scan policy. + gap: Add optional context and update CLI/desktop callers without mutable scanner + authorization. + evidence: + - E2 + - E5 + - E7 + - E16 + - E25 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC12 + status: mismatch + current_behavior: Host has paging/selection but no stream/search/sort interface; + original PRD deferred the scope. + gap: Plan events/cancellation and opt-in live review as a concrete later slice. + evidence: + - E12 + - E18 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC13 + status: partial + current_behavior: Relevant tests exist, but discovery has minimal coverage and root + commands exclude desktop. + gap: Require focused safety fixtures and separate desktop checks. + evidence: + - E19 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +- criterion_id: AC14 + status: mismatch + current_behavior: Earlier PRD deferred roots and streaming, used incomplete evaluation + mapping and unmeasured performance claims. + gap: Restore full requested scope and make validation/performance boundaries explicit. + evidence: + - E1 + - E4 + - E18 + - E19 + - E26 + next_action: Implement the PRD slice(s) mapped to this criterion and execute its + eval cases. +recommendation_review: +- id: A1 + severity: P0 + topic: Tracked contents + previous: Recommend accepting tracked-file deletion as a documented limitation. + recommendation: Keep tracked-content protection using batched index plus HEAD inventory; + ignore matching is a separate signal for generic names. + reason: Per-candidate git check-ignore cost is not the only design available; staged + removals need HEAD protection. + evidence: + - E20 + - E21 + - E22 + - E23 + requirements: + - SR2 + - SR3 + - SR4 +- id: A2 + severity: P0 + topic: Category/risk defaults + previous: Separate category was an unresolved choice; new safety levels/default + behavior unspecified. + recommendation: Separate risky node-build-artifacts, opt-in risky go-artifacts and + moderate rust-artifacts; prevent new categories entering unattended desktop cleaning. + reason: Adding a moderate category changes default deep selection and allowed automatic + categories. + evidence: + - E11 + - E12 + requirements: + - FR3 + - FR5 + - SR8 +- id: A3 + severity: P0 + topic: Excluded descendants + previous: Exclude matched candidate directories and prune traversal. + recommendation: Veto an ancestor artifact if any descendant is excluded. + reason: Deleting dist would otherwise delete an excluded dist/keep.txt. + evidence: + - E2 + - E13 + requirements: + - FR8 + - SR10 +- id: A4 + severity: P0 + topic: Dry-run parity + previous: Call scanner.clean in dry-run and assume equivalent validation. + recommendation: Also run existence/type and protected-path checks in filesystem/backup + preflight; avoid ensureBackupDir in dry-run. + reason: Lower-level backup and remove paths have additional parity gaps. + evidence: + - E3 + - E13 + - E14 + - E15 + requirements: + - SR6 + - FR11 +- id: A5 + severity: P0 + topic: Replacement and symlink ancestry + previous: Re-scan and reject replaced paths, without a mechanism to distinguish + a new directory at the same path. + recommendation: Record dev/ino identity and root binding; lstat candidate before + realpath and check ancestors. + reason: Canonical path plus category hash cannot prove directory identity or prevent + ancestor redirection. + evidence: + - E2 + - E13 + requirements: + - SR5 + - SR9 + - FR10 +- id: A6 + severity: P1 + topic: Root config and external backup + previous: extraPaths.projects replaces defaults; one-off CLI root is deferred. + recommendation: Preserve additive extraPaths semantics, add repeatable --root override, + and reject external backup paths in both dry/real execution. + reason: Config key/init use extras; backup format only restores HOME-relative paths. + evidence: + - E1 + - E4 + - E14 + requirements: + - FR7 + - FR11 +- id: A7 + severity: P1 + topic: Age and timestamps + previous: Preserve parent-only project age; newest regular-file mtime only. + recommendation: Tighten node_modules age using newest entry mtime and include root/descendant + directory mtimes in metadata. + reason: Editing existing files does not reliably update parent-directory mtime; + Go metadata includes directories. + evidence: + - E1 + - E18 + - E24 + requirements: + - SR7 + - FR14 +- id: A8 + severity: P1 + topic: Execution context + previous: Wire validator into BaseScanner.clean without specifying how roots/opt-in + options survive to clean. + recommendation: Add optional clean context, preserve immutable selected scan policy, + and refresh current safety facts. + reason: Singleton scanners and desktop no-options calls make hidden last-scan state + unsafe. + evidence: + - E2 + - E7 + - E16 + requirements: + - FR10 + - FR12 + - FR18 +- id: A9 + severity: P1 + topic: Dedupe timing + previous: Drop descendants across candidates without category-selection semantics. + recommendation: Keep independently selectable cross-category scan rows; dedupe selected + eligible paths at execution. + reason: An unselected worktree parent must not hide selected node_modules contents. + evidence: + - E1 + - E7 + - E16 + requirements: + - FR9 +- id: A10 + severity: P1 + topic: Scope completeness + previous: Streaming/live review and positional/explicit roots were deferred outside + the core plan. + recommendation: Include explicit root flags and a bounded streaming/live-review + implementation slice. + reason: The user requested the complete implementation plan for the reported functionality. + evidence: + - E4 + - E18 + requirements: + - FR7 + - FR16 + - FR17 +- id: A11 + severity: P2 + topic: Metadata and identity accuracy + previous: Go scan results described as generally exposing file count/newest mtime; + desktop described as using path keys. + recommendation: Distinguish Go core JSON from live helpers and retain host path + selection without claiming desktop has an item-key scheme. + reason: Those details were broader than observed source evidence. + evidence: + - E18 + - E25 + requirements: + - FR14 + - FR15 +- id: A12 + severity: P1 + topic: Planning artifact validity + previous: YAML/JSON parsing treated as sufficient validation; duplicate readiness + and unresolved completed dependencies remained. + recommendation: Add duplicate-key detection, reference/coverage checks and a consistent + readiness gate. + reason: Syntactically valid artifacts can still discard state or contradict their + tracker. + evidence: + - E26 + requirements: + - OR4 +confirmed_recommendations: +- Keep the existing node_modules scanner instead of adding a duplicate. +- Immediate-parent ecosystem markers for repo artifacts. +- Go opt-in separate from risky enablement. +- Shared rules/walker/eligibility modules with existing config authority. +- Revalidate selected candidates for every clean entrypoint. +- Retain backup/restore and explicit confirmation; do not port unchecked Go TUI cleanup. +- Retain optional path-derived IDs and measure before native discovery optimization. +implementation_readiness: + status: ready + blocking_items: [] + notes: Ready for the audit-recommended design; proposals are explicit and evidence-backed. + This audit does not count as user approval or implementation verification. +residual_risks: +- Concurrent changes after final checks remain possible; no atomic deletion claim. +- Ignore rules and familiar marker/name combinations are practical signals, not proof + every untracked file is disposable. +- Non-Git generic names are deliberately omitted. +- Git/query and full candidate-inspection costs need measurement during implementation; + no speed claims are made. +- Desktop per-item deletion is not redesigned; risky-category confirmation and explicit + category choice remain the desktop controls. +validation_results: + planning_command: ruby docs/plans/repo-artifact-cleanup/validate_plan.rb + result: passed + coverage: + requirements: 32 + acceptance_criteria: 14 + planned_evaluation_cases: 26 + implementation_slices: 7 + negative_controls: + - scenario: Duplicate readiness YAML key + result: rejected + - scenario: Missing streaming evaluation/FR16 coverage + result: rejected + - scenario: Unknown evidence reference E999 + result: rejected + product_tests: Not run; this task updates planning recommendations only. + git_probe: Isolated synthetic repositories only; verified index/HEAD, ignore matching + and .git-file worktree semantics. Not a scan benchmark. diff --git a/docs/plans/repo-artifact-cleanup/decisions.yaml b/docs/plans/repo-artifact-cleanup/decisions.yaml new file mode 100644 index 0000000..2cba958 --- /dev/null +++ b/docs/plans/repo-artifact-cleanup/decisions.yaml @@ -0,0 +1,204 @@ +--- +version: '1.1' +feature: repo-artifact-cleanup +status_note: Confirmed recommendations agree with the provided report and source evidence. + Recommended choices were selected by this audit; no entry records user approval. +evidence_registry: contract_audit.yaml:evidence_sources +decisions: +- id: D1 + title: Go remains explicitly opt-in + status: confirmed + context: Go names are generic and default category inventories can activate new + scanners. + statement: Explicit include/config/category selection enables Go; --all and risk + flags do not. + based_on_evidence: + - E8 + - E11 + implements: + - FR5 + - FR12 + - SR8 + consequences: + - Explicit include/config/category selection enables Go; --all and risk flags do + not. +- id: D2 + title: Immediate-parent marker scope + status: confirmed + context: Reference root/ancestor markers can authorize unrelated descendants. + statement: Require readable regular non-symlink marker in candidate's immediate + parent. + based_on_evidence: + - E1 + - E17 + implements: + - FR4 + - SR1 + consequences: + - Require readable regular non-symlink marker in candidate's immediate parent. +- id: D3 + title: Preserve path selection and optional IDs + status: confirmed + context: Host picker uses paths; desktop uses scan item arrays. + statement: Preserve path fields; optional ID is sha256(category + NUL + canonical + path) and never authorizes cleanup. + based_on_evidence: + - E5 + - E25 + implements: + - FR14 + - FR15 + consequences: + - Preserve path fields; optional ID is sha256(category + NUL + canonical path) and + never authorizes cleanup. +- id: D4 + title: Three core repo modules + status: confirmed + context: Registry/BaseScanner adapters already exist. + statement: Use rules, walker and eligibility modules; adapt existing config/commands/types. + based_on_evidence: + - E2 + - E5 + implements: + - OR1 + - FR6 + consequences: + - Use rules, walker and eligibility modules; adapt existing config/commands/types. +- id: D5 + title: Retain host cleanup/backup UX + status: confirmed + context: Host provides explicit selection/confirmation and backup/restore; Go live + clean bypasses full revalidation. + statement: Keep host execution paths and apply shared preflight; do not port unchecked + live clean or local trash. + based_on_evidence: + - E2 + - E9 + - E14 + implements: + - FR11 + - OR2 + consequences: + - Keep host execution paths and apply shared preflight; do not port unchecked live + clean or local trash. +- id: D6 + title: Separate risky Node build category + status: recommended + context: One moderate combined category would enter --all/default deep cleanup and + affect existing category expectations. + statement: Use node-build-artifacts marked risky with per-item CLI review; Rust + target is moderate; new repo categories are barred from unattended desktop cleanup. + based_on_evidence: + - E11 + - E12 + implements: + - FR3 + - SR8 + consequences: + - Use node-build-artifacts marked risky with per-item CLI review; Rust target is + moderate; new repo categories are barred from unattended desktop cleanup. +- id: D7 + title: Retain Git tracked-content protection + status: recommended + context: The previous limitation recommendation assumed per-candidate processes + were necessary and conflated ignore matches with tracked protection. + statement: Query index and HEAD per worktree; reject tracked/gitlink overlaps, refresh + on clean, and require ignore evidence for generic names. + based_on_evidence: + - E20 + - E21 + - E22 + - E23 + implements: + - SR2 + - SR3 + - SR4 + consequences: + - Query index and HEAD per worktree; reject tracked/gitlink overlaps, refresh on + clean, and require ignore evidence for generic names. +- id: D8 + title: Keep exclusions protect descendants + status: recommended + context: Excluding a file cannot be respected by deleting its ancestor. + statement: Any excluded child vetoes whole artifact cleanup; malformed policy cannot + fall back to unrestricted scanning. + based_on_evidence: + - E2 + - E13 + implements: + - FR8 + - SR10 + consequences: + - Any excluded child vetoes whole artifact cleanup; malformed policy cannot fall + back to unrestricted scanning. +- id: D9 + title: Additive config roots and explicit overrides + status: recommended + context: extraPaths naming/init imply additions; replacement could silently lose + ~/dev coverage. + statement: Append config roots, retain Node-only legacy roots and add repeatable + --root override. Validate canonical locations and external backup limits. + based_on_evidence: + - E1 + - E4 + - E14 + implements: + - FR7 + - FR11 + consequences: + - Append config roots, retain Node-only legacy roots and add repeatable --root override. + Validate canonical locations and external backup limits. +- id: D10 + title: Dry-run preflight parity + status: recommended + context: Parity gaps exist below command dispatch and backup dry-run creates session + directories. + statement: Share path/type/protection/backup checks and make planned counters explicit; + create backup sessions only during mutation. + based_on_evidence: + - E3 + - E13 + - E14 + - E15 + implements: + - SR6 + - FR13 + consequences: + - Share path/type/protection/backup checks and make planned counters explicit; create + backup sessions only during mutation. +- id: D11 + title: Freshness and metadata include directories + status: recommended + context: Parent mtime misses changes to existing files; Go live metadata considers + directories. + statement: node_modules freshness uses newest entry plus parent time; metadata reports + newest entry, regular count and logical bytes. + based_on_evidence: + - E1 + - E18 + - E24 + implements: + - SR7 + - FR14 + consequences: + - node_modules freshness uses newest entry plus parent time; metadata reports newest + entry, regular count and logical bytes. +- id: D12 + title: Complete scope and selected-only validation + status: recommended + context: Earlier plan deferred required roots/UI and left context unspecified. + statement: Keep explicit roots and safe live-review slice in scope; revalidate selected + paths with immutable per-run context and dev/ino evidence. + based_on_evidence: + - E7 + - E16 + - E18 + implements: + - FR7 + - FR10 + - FR16 + - FR17 + - SR5 + consequences: + - Keep explicit roots and safe live-review slice in scope; revalidate selected paths + with immutable per-run context and dev/ino evidence. diff --git a/docs/plans/repo-artifact-cleanup/execution.yaml b/docs/plans/repo-artifact-cleanup/execution.yaml new file mode 100644 index 0000000..fa0e3f0 --- /dev/null +++ b/docs/plans/repo-artifact-cleanup/execution.yaml @@ -0,0 +1,116 @@ +version: 1 +status: complete +completed_at: '2026-10-14' +contract: repo_artifact_cleanup_prd.yaml +authorization: User requested implementation of audited decisions and all I1-I7 on 2026-10-13. +initial_tree: Only untracked docs/plans/ existed; preserved. +instructions: No applicable AGENTS.md found. +retention_rule: Keep each slice after focused behavior tests, typecheck, lint and diff review; revise failures before advancing. +planning_validation: Passed (32 requirements, 14 criteria, 26 cases; ready). +slices: + I1: {status: complete, decision: keep, validation: "21 focused tests; root typecheck/lint; diff review passed"} + I2: {status: complete, decision: keep, validation: "118 focused root tests plus 39 fs tests; 17 desktop tests; both typechecks and lint passed"} + I3: {status: complete, decision: keep, validation: "60 focused root tests; 24 desktop tests; both typechecks and lint passed"} + I4: {status: complete, decision: keep, validation: "42 focused root tests; 8 desktop settings tests; both typechecks and lint passed"} + I5: {status: complete, decision: keep, validation: "70 focused tests; root typecheck/lint passed after test type fix"} + I6: {status: complete, decision: keep, validation: "49 focused tests; typecheck/lint; real PTY Enter guard, selection, search, sorting and paging passed"} + I7: {status: complete, decision: keep, validation: "539 root tests with coverage; 46 desktop tests; both typechecks/builds; root lint covers src and desktop/src; plan validation and diff review passed"} + +final_checks: + root: + - {command: 'bun run test:coverage', result: "56 files, 539 tests passed; thresholds unchanged at statements/functions/lines 90%, branches 80%"} + - {command: bun run typecheck, result: passed} + - {command: bun run lint, result: "passed for src and desktop/src; desktop has no separate lint script"} + - {command: bun run build, result: "passed; built CLI scan/clean help inspected"} + desktop: + - {command: bun run test, result: "6 files, 46 tests passed"} + - {command: bun run typecheck, result: passed} + - {command: bun run build, result: "main/preload/renderer builds passed"} + coverage_percent: {statements: 90.87, branches: 83.88, functions: 92.48, lines: 92.53} + repository_core_line_coverage_percent: 98.47 + live_prompt_line_coverage_percent: 100 + terminal: Real PTY exercised Enter during discovery, explicit selection, sort/search/page changes and completed submission; no filesystem mutation. + plan: ruby docs/plans/repo-artifact-cleanup/validate_plan.rb + whitespace: git diff --check + +evidence_suites: + repo: src/repo-artifacts/repo-artifacts.test.ts + rules_events: src/repo-artifacts/categories.test.ts + git: src/repo-artifacts/git-safety.test.ts and git-transport.test.ts + backup: src/utils/backup-fallback.test.ts and backup.test.ts + cli: src/commands/repo-cli.test.ts and clean-json.test.ts + live: src/pickers/live-review.test.ts, live-review-prompt.test.ts and src/commands/interactive-live.test.ts + desktop: desktop/src/main/repo-cleanup.test.ts, engine.test.ts, controller.test.ts and settings.test.ts + regression: Existing root command/scanner/config/fs suites and complete desktop suite. +acceptance_evidence: + AC1: [repo, rules_events, git] + AC2: [rules_events] + AC3: [rules_events, cli, desktop] + AC4: [repo, rules_events, cli] + AC5: [repo, rules_events, git] + AC6: [repo, git, backup, desktop] + AC7: [repo, backup, cli, desktop] + AC8: [repo, desktop, regression] + AC9: [repo, git, cli] + AC10: [cli, regression] + AC11: [repo, cli, live, desktop, regression] + AC12: [rules_events, live] + AC13: [repo, rules_events, git, backup, cli, live, desktop, regression] + AC14: [execution.yaml, performance.json, contract_audit.yaml] +eval_evidence: + method: Named behavior tests implement the scenarios; the planning JSON is not itself an executable product test runner. + EV1: [repo] + EV2: [rules_events] + EV3: [rules_events, desktop] + EV4: [repo, rules_events, cli] + EV5: [repo] + EV6: [repo, rules_events] + EV7: [repo, git, rules_events] + EV8: [repo, git] + EV9: [rules_events] + EV10: [repo, git] + EV11: [repo, git] + EV12: [repo] + EV13: [repo, backup] + EV14: [repo, backup] + EV15: [repo] + EV16: [repo, git] + EV17: [cli] + EV18: [cli, repo] + EV19: [cli, regression] + EV20: [repo, desktop] + EV21: [repo, rules_events, cli, live, desktop] + EV22: [rules_events] + EV23: [rules_events, git] + EV24: [live] + EV25: [final_checks] + EV26: [performance.json] + +performance: + command: bun run benchmark:repo + evidence: performance.json + runtime: Bun 1.4.3 on darwin arm64 + candidates: [30, 300, 1500] + nonoverlap_scan_ms: [63.7, 549.4, 3628.8] + overlap_scan_ms: [66.7, 515.9, 3644.4] + nonoverlap_selected_validation_ms: [79.8, 578.7, 4039.7] + worktrees: [1, 5, 25] + scan_git_processes: [6, 30, 150] + selected_validation_git_processes: [11, 55, 275] + interpretation: Each candidate tree is inspected once per scan, including overlapping roots. Selected validation separately refreshes two category batches; Git queries scale per worktree/batch, not per candidate. + limits: Single ordered sample per variant; fixture setup excluded; 128-byte files; sampled process RSS includes baseline and excludes Git child RSS; warm-cache/order effects remain. These are measurements, not production throughput claims. + optimization_decision: No additional discovery backend or cross-run cache; retain measured shared walk and request-local Git batching. + +observations: + - Existing filesystem socket tests require sandbox escalation; passed with temporary fixtures. + - Existing controller freshness timeout is 15 minutes (audit text said five); preserved actual contract. + - Integrated review found newly configured roots could rebase keep patterns and depth checks; three regression cases first failed, then passed after applying current root provenance without broadening original identity authority. + - Live command tests separately verify final confirmation and exact selected context; desktop fixture tests exercise preview-to-validation and permanent deletion despite CLI backupEnabled. + - All destructive product tests used temporary fixtures; external-tool cleanup was mocked. No user repository or home cleanup was performed. + - Historical planning/audit findings are preserved; this tracker records the subsequently authorized implementation and validation. +remaining_limitations: + - Filesystem/Git validation is not atomic with mutation; there is no active-build detection. + - Size and freed-space values are logical estimates, not measured physical disk blocks. + - Ambiguous generic outputs in non-Git projects remain intentionally ineligible. + - Git queries fail closed at the documented 10-second timeout or 16-MiB combined output limit. +incomplete_acceptance_criteria: [] diff --git a/docs/plans/repo-artifact-cleanup/git_probe_evidence.json b/docs/plans/repo-artifact-cleanup/git_probe_evidence.json new file mode 100644 index 0000000..5180d9d --- /dev/null +++ b/docs/plans/repo-artifact-cleanup/git_probe_evidence.json @@ -0,0 +1,46 @@ +{ + "observed_at": "2026-10-13", + "kind": "isolated synthetic Git semantics probe; not an implementation test or performance measurement", + "git_version": "git version 2.55.0", + "fixture_root": "/private/tmp/mac-cleaner-git-audit-i20qkg_3", + "directory_ignore_exit": 1, + "directory_ignore_paths": [], + "tracked_paths_under_ignored_directory": [ + "dist/keep.txt", + "dist/line\nbreak.txt" + ], + "staged_deletion_still_on_disk": true, + "staged_deletion_in_index": false, + "staged_deletion_in_head": true, + "worktree_git_is_file": true, + "worktree_index_contains_tracked_file": true, + "conclusion": "An ignore rule can cover a directory containing force-tracked files. check-ignore suppresses tracked queries by default; --no-index reports ignore matching even for tracked files. Use explicit index/HEAD inventory for tracked-content protection; worktree .git files are supported by git -C.", + "check_ignore_variants": [ + { + "args": [ + "check-ignore", + "--stdin", + "-z" + ], + "exit_code": 0, + "matched_paths": [ + "dist/generated.js" + ] + }, + { + "args": [ + "check-ignore", + "--no-index", + "--stdin", + "-z" + ], + "exit_code": 0, + "matched_paths": [ + "dist", + "dist/", + "dist/generated.js", + "dist/keep.txt" + ] + } + ] +} diff --git a/docs/plans/repo-artifact-cleanup/performance.json b/docs/plans/repo-artifact-cleanup/performance.json new file mode 100644 index 0000000..b88ee81 --- /dev/null +++ b/docs/plans/repo-artifact-cleanup/performance.json @@ -0,0 +1,158 @@ +{ + "measuredAt": "2026-10-14T11:57:39.603Z", + "runtime": "Bun 1.4.3", + "platform": "darwin arm64", + "method": "Single sample per size/order; fixture creation excluded; no cleanup mutation; scan then selected validation; peak RSS sampled every 5ms includes process baseline. Files are logical 128-byte fixtures, not representative storage throughput. Overlap scans follow non-overlap scans, so cache/order effects remain.", + "records": [ + { + "name": "small", + "projects": 10, + "filesPerArtifact": 20, + "worktrees": 1, + "overlap": false, + "candidates": 30, + "wallMilliseconds": 63.655209, + "peakRssBytes": 16368288, + "gitProcesses": 6, + "directoriesVisited": 12, + "candidatesInspected": 30, + "entriesInspected": 630, + "gitMilliseconds": 39.035167, + "inspectionMilliseconds": 8.396708000000046, + "selectedValidation": { + "wallMilliseconds": 79.80616700000002, + "gitProcesses": 11, + "directoriesVisited": 0, + "candidatesInspected": 30, + "entriesInspected": 630, + "gitMilliseconds": 65.33246100000005, + "inspectionMilliseconds": 8.317210999999872 + } + }, + { + "name": "small", + "projects": 10, + "filesPerArtifact": 20, + "worktrees": 1, + "overlap": true, + "candidates": 30, + "wallMilliseconds": 66.67908299999999, + "peakRssBytes": 26788536, + "gitProcesses": 6, + "directoriesVisited": 23, + "candidatesInspected": 30, + "entriesInspected": 630, + "gitMilliseconds": 35.686916999999966, + "inspectionMilliseconds": 9.667711000000054, + "selectedValidation": { + "wallMilliseconds": 84.00991699999997, + "gitProcesses": 11, + "directoriesVisited": 0, + "candidatesInspected": 30, + "entriesInspected": 630, + "gitMilliseconds": 67.26637399999998, + "inspectionMilliseconds": 9.171792000000096 + } + }, + { + "name": "medium", + "projects": 100, + "filesPerArtifact": 50, + "worktrees": 5, + "overlap": false, + "candidates": 300, + "wallMilliseconds": 549.4089159999999, + "peakRssBytes": 35341008, + "gitProcesses": 30, + "directoriesVisited": 106, + "candidatesInspected": 300, + "entriesInspected": 15300, + "gitMilliseconds": 178.79579200000012, + "inspectionMilliseconds": 233.49587899999824, + "selectedValidation": { + "wallMilliseconds": 578.7314580000002, + "gitProcesses": 55, + "directoriesVisited": 0, + "candidatesInspected": 300, + "entriesInspected": 15300, + "gitMilliseconds": 317.9619530000018, + "inspectionMilliseconds": 207.7805440000011 + } + }, + { + "name": "medium", + "projects": 100, + "filesPerArtifact": 50, + "worktrees": 5, + "overlap": true, + "candidates": 300, + "wallMilliseconds": 515.9477080000001, + "peakRssBytes": 37864144, + "gitProcesses": 30, + "directoriesVisited": 127, + "candidatesInspected": 300, + "entriesInspected": 15300, + "gitMilliseconds": 173.98541699999987, + "inspectionMilliseconds": 207.36911999999847, + "selectedValidation": { + "wallMilliseconds": 588.4879170000004, + "gitProcesses": 55, + "directoriesVisited": 0, + "candidatesInspected": 300, + "entriesInspected": 15300, + "gitMilliseconds": 318.24949999999944, + "inspectionMilliseconds": 212.8177530000071 + } + }, + { + "name": "large", + "projects": 500, + "filesPerArtifact": 100, + "worktrees": 25, + "overlap": false, + "candidates": 1500, + "wallMilliseconds": 3628.8155829999996, + "peakRssBytes": 28508880, + "gitProcesses": 150, + "directoriesVisited": 526, + "candidatesInspected": 1500, + "entriesInspected": 151500, + "gitMilliseconds": 907.7380450000164, + "inspectionMilliseconds": 2045.1232630000668, + "selectedValidation": { + "wallMilliseconds": 4039.7490830000024, + "gitProcesses": 275, + "directoriesVisited": 0, + "candidatesInspected": 1500, + "entriesInspected": 151500, + "gitMilliseconds": 1689.8338319999966, + "inspectionMilliseconds": 2074.0456339999873 + } + }, + { + "name": "large", + "projects": 500, + "filesPerArtifact": 100, + "worktrees": 25, + "overlap": true, + "candidates": 1500, + "wallMilliseconds": 3644.3536250000034, + "peakRssBytes": 28426960, + "gitProcesses": 150, + "directoriesVisited": 547, + "candidatesInspected": 1500, + "entriesInspected": 151500, + "gitMilliseconds": 901.8195040000173, + "inspectionMilliseconds": 2036.2037010000386, + "selectedValidation": { + "wallMilliseconds": 3955.534416999999, + "gitProcesses": 275, + "directoriesVisited": 0, + "candidatesInspected": 1500, + "entriesInspected": 151500, + "gitMilliseconds": 1596.0540749999927, + "inspectionMilliseconds": 2083.5632889999833 + } + } + ] +} diff --git a/docs/plans/repo-artifact-cleanup/repo_artifact_cleanup_eval_cases.json b/docs/plans/repo-artifact-cleanup/repo_artifact_cleanup_eval_cases.json new file mode 100644 index 0000000..1cb75d9 --- /dev/null +++ b/docs/plans/repo-artifact-cleanup/repo_artifact_cleanup_eval_cases.json @@ -0,0 +1,622 @@ +{ + "version": "1.1", + "feature": "repo-artifact-cleanup", + "canonical_contract": "repo_artifact_cleanup_prd.yaml", + "forbidden_claims": [ + "A matching directory name or marker proves all contents disposable.", + "Ignore matching alone protects tracked contents.", + "All Go scan JSON includes the live UI metadata.", + "Dry-run is equivalent without checking backup/type/existence paths.", + "Validation eliminates all filesystem or Git races.", + "Logical size equals physical space actually freed.", + "Planning syntax checks are passing product tests or user approval." + ], + "cases": [ + { + "id": "EV1", + "story_ids": [ + "US1", + "US5" + ], + "requirement_ids": [ + "FR1", + "FR4", + "SR1" + ], + "criterion_ids": [ + "AC1" + ], + "prompt_or_scenario": "Old nonempty node_modules beside missing/unreadable/symlink package.json; repeat with marker in ancestor only.", + "expected_behavior": "Do not emit an eligible orphan candidate.", + "assertions": [ + "Missing marker omits candidate.", + "Inaccessible marker creates a diagnostic, not eligibility.", + "No catch-all error path labels it cleanable." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV2", + "story_ids": [ + "US1", + "US2", + "US3" + ], + "requirement_ids": [ + "FR2", + "FR3", + "FR5", + "FR4" + ], + "criterion_ids": [ + "AC1", + "AC2" + ], + "prompt_or_scenario": "Create every rule directory beside the required marker and identical names in unmarked/ancestor-marked locations.", + "expected_behavior": "Only matching immediate-parent rules qualify; category mapping is stable.", + "assertions": [ + "All table names covered, including .next/.turbo/.cache.", + "Rust/Node/Go marker types cannot activate each other's rules.", + "Node has precedence for enabled Node/Go dist; Go-only category scans assign Go." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV3", + "story_ids": [ + "US3" + ], + "requirement_ids": [ + "FR5", + "FR12", + "SR8" + ], + "criterion_ids": [ + "AC3" + ], + "prompt_or_scenario": "Default and explicit-Go CLI/direct/desktop scans; include risk flag or --all alone; exclude Go by config.", + "expected_behavior": "Go inclusion is explicit and independent of risk flags.", + "assertions": [ + "Default scans/deep defaults omit Go.", + "Explicit include or explicit Go category opts in.", + "excludeCategories wins.", + "No new repo category is allowed in scheduled/quick desktop cleanup." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV4", + "story_ids": [ + "US4" + ], + "requirement_ids": [ + "FR7", + "FR12", + "FR18" + ], + "criterion_ids": [ + "AC4", + "AC11" + ], + "prompt_or_scenario": "Defaults plus projects/legacy nodeModules config, empty extras, then repeated --root for a command.", + "expected_behavior": "Extras append; explicit roots override only repo roots.", + "assertions": [ + "~/dev remains scanned after config --init.", + "Legacy nodeModules extras do not enable other ecosystems.", + "Invalid canonical roots fail policy validation.", + "Non-repo scanner search paths do not change." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV5", + "story_ids": [ + "US4", + "US5" + ], + "requirement_ids": [ + "FR8", + "SR10" + ], + "criterion_ids": [ + "AC4", + "AC5" + ], + "prompt_or_scenario": "Apply basename/prefix/glob keep patterns, zero-segment ** matches, malformed patterns and overlapping roots.", + "expected_behavior": "Keep rules remain conservative and deterministic.", + "assertions": [ + "**/target matches target at root and below.", + "Prefix docs/build cannot match docs/building.", + "Malformed config/CLI patterns fail instead of being silently discarded.", + "A second root cannot bypass an exclusion." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV6", + "story_ids": [ + "US4", + "US5" + ], + "requirement_ids": [ + "FR8", + "FR10", + "SR10" + ], + "criterion_ids": [ + "AC5", + "AC6" + ], + "prompt_or_scenario": "Candidate dist contains dist/keep.txt excluded by pattern; another child contains a nested .git directory/file.", + "expected_behavior": "The whole candidate is ineligible.", + "assertions": [ + "No backup/removal of dist.", + "Rejected reason identifies excluded content or nested repository.", + "Pruning does not hide evidence required to veto the candidate." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV7", + "story_ids": [ + "US5" + ], + "requirement_ids": [ + "SR2", + "SR3" + ], + "criterion_ids": [ + "AC5" + ], + "prompt_or_scenario": "Ignore dist but force-track a descendant; add tracked symlink/conflict/staged-addition and staged-removal-still-on-disk fixtures.", + "expected_behavior": "All tracked overlaps veto candidate regardless of ignore matches.", + "assertions": [ + "Index and HEAD union protects paths.", + "NUL parsing preserves tabs/newlines.", + "Ignore match is never sufficient by itself." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV8", + "story_ids": [ + "US5" + ], + "requirement_ids": [ + "SR2", + "SR4", + "OR3" + ], + "criterion_ids": [ + "AC5" + ], + "prompt_or_scenario": "Worktree uses .git file; submodule gitlink overlaps artifact; Git executable/query/index/HEAD fails; unborn repo has staged files.", + "expected_behavior": "Correct worktree context is queried and uncertainty fails closed.", + "assertions": [ + "Different worktree indexes are not shared by common Git-dir key.", + "Gitlinks protect candidate ancestors.", + "Verified unborn state is distinguished from a broken query.", + "Query timeout/output limit/missing Git for a detected repo rejects candidates." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV9", + "story_ids": [ + "US1", + "US3" + ], + "requirement_ids": [ + "FR3", + "FR5", + "SR1", + "SR3" + ], + "criterion_ids": [ + "AC2", + "AC5" + ], + "prompt_or_scenario": "Generic output names are ignored/unignored in Git or exist in a non-Git marked project.", + "expected_behavior": "Generic names require marker plus positive ignore evidence and tracked-content veto.", + "assertions": [ + "Non-Git generic dist/build/bin are omitted.", + "Recognized non-Git node_modules/.next/target remain marker-qualified.", + "No claim that marker/ignore proves every untracked file disposable." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV10", + "story_ids": [ + "US5" + ], + "requirement_ids": [ + "FR10", + "SR5", + "SR9" + ], + "criterion_ids": [ + "AC6" + ], + "prompt_or_scenario": "After scan replace candidate with a new directory at same path, remove marker, replace a parent with a symlink or change root binding.", + "expected_behavior": "Only original selected candidates that retain identity/policy can mutate.", + "assertions": [ + "dev/ino mismatch produces rejection.", + "lstat occurs before following candidate canonicalization.", + "Ancestry/root containment is rechecked.", + "No atomic/zero-race guarantee is advertised." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV11", + "story_ids": [ + "US1", + "US5" + ], + "requirement_ids": [ + "SR7", + "FR14" + ], + "criterion_ids": [ + "AC6", + "AC9" + ], + "prompt_or_scenario": "Project directory appears old but a nested existing file, empty directory or symlink entry is recent; include exact age boundary.", + "expected_behavior": "Recent candidate activity makes node_modules ineligible.", + "assertions": [ + "Effective age uses max project/newest candidate entry time.", + "30-day default and configured threshold are honored.", + "Newest entry includes directories and root, without following symlinks." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV12", + "story_ids": [ + "US5" + ], + "requirement_ids": [ + "SR5", + "SR6", + "FR10" + ], + "criterion_ids": [ + "AC6", + "AC7" + ], + "prompt_or_scenario": "Select protected, missing, replaced and out-of-root paths in dry-run and real cleanup.", + "expected_behavior": "Both paths reject the same stable states.", + "assertions": [ + "PROTECTED/MISSING/REPLACED/out-of-root reasons are structured.", + "Dry-run performs no filesystem mutation.", + "Invalid repo item metadata/ID cannot authorize arbitrary descendants." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV13", + "story_ids": [ + "US4", + "US5" + ], + "requirement_ids": [ + "FR11", + "SR6" + ], + "criterion_ids": [ + "AC7" + ], + "prompt_or_scenario": "Backup enabled with outside-HOME project root; also a protected path and an eligible home artifact.", + "expected_behavior": "Backup feasibility is checked in simulation and actual execution.", + "assertions": [ + "Outside-HOME yields BACKUP_UNSUPPORTED_PATH and is not deleted.", + "Dry-run does not call ensureBackupDir/mkdir/rename/cp/rm.", + "Eligible HOME backup retains restore mapping.", + "Failed backup never falls back to delete." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV14", + "story_ids": [ + "US5" + ], + "requirement_ids": [ + "FR11", + "FR18" + ], + "criterion_ids": [ + "AC7", + "AC11" + ], + "prompt_or_scenario": "Normal HOME artifact backup followed by restore, including copy fallback and failed backup case.", + "expected_behavior": "Existing backup contract remains usable for validated candidates.", + "assertions": [ + "backedUpSize is separate from freedSpace.", + "Restore returns the original artifact.", + "Failure outcome identifies the affected path." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV15", + "story_ids": [ + "US1", + "US5" + ], + "requirement_ids": [ + "FR9" + ], + "criterion_ids": [ + "AC8" + ], + "prompt_or_scenario": "Select worktree parent plus node_modules child, then child alone, then invalid parent plus valid child.", + "expected_behavior": "Dedupe only valid selected ancestor overlaps.", + "assertions": [ + "Independent scan rows remain selectable.", + "Child-alone request stays cleanable.", + "A rejected ancestor does not suppress an independently selected child.", + "Covered-child result has zero duplicate byte/item accounting." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV16", + "story_ids": [ + "US2", + "US6" + ], + "requirement_ids": [ + "FR6", + "FR14" + ], + "criterion_ids": [ + "AC9" + ], + "prompt_or_scenario": "Tree with regular files, empty directories, hardlinks, symlinks and unreadable descendants.", + "expected_behavior": "Metadata follows logical-size and newest-entry semantics; incomplete inspection blocks eligibility.", + "assertions": [ + "Regular fileCount excludes symlinks.", + "Link lstat bytes count without following link target.", + "Root/dir/file/link mtimes contribute to newestEntryModifiedAt.", + "No physical freed-space guarantee follows from logical size." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV17", + "story_ids": [ + "US6" + ], + "requirement_ids": [ + "FR13", + "FR15" + ], + "criterion_ids": [ + "AC10" + ], + "prompt_or_scenario": "Serialize successful, empty, skipped, scan-error and partially failing clean --json output.", + "expected_behavior": "One parseable document carries outcomes and errors with correct exit status.", + "assertions": [ + "No progress/donation/pretty summary contaminates stdout.", + "Per-item filesystem results include category/path/status/reason.", + "Existing scan summary and verbose path/size keys remain.", + "Empty scan failures do not print an already-clean success." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV18", + "story_ids": [ + "US6" + ], + "requirement_ids": [ + "FR13", + "SR6" + ], + "criterion_ids": [ + "AC7", + "AC10" + ], + "prompt_or_scenario": "Run deterministic JSON dry-run with deletion and backup routing, then actual clean.", + "expected_behavior": "Planned and actual results are explicitly separate.", + "assertions": [ + "Dry-run actual cleanedItems/freedSpace/backedUpSize are zero.", + "would-delete/would-back-up outcomes and planned metrics are present.", + "Noninteractive actual JSON clean without --yes fails without prompting.", + "JSON mode still respects risk and ecosystem policy." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV19", + "story_ids": [ + "US6" + ], + "requirement_ids": [ + "FR13", + "OR2" + ], + "criterion_ids": [ + "AC10", + "AC11" + ], + "prompt_or_scenario": "JSON clean uses Homebrew/Docker external cleanup scanners with dry-run/partial failure.", + "expected_behavior": "External actions are represented at action/category level.", + "assertions": [ + "Do not fabricate per-path eligibility or exact file deletion for external tools.", + "supportsBackup=false behavior remains explicit.", + "No new real external cleanup runs in test fixtures." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV20", + "story_ids": [ + "US1", + "US5" + ], + "requirement_ids": [ + "FR10", + "FR12", + "FR18" + ], + "criterion_ids": [ + "AC6", + "AC11" + ], + "prompt_or_scenario": "Two overlapping concurrent scans use different roots/include/exclude policy; desktop deep preview is followed by validation.", + "expected_behavior": "Each clean uses its own selected snapshot and current safety vetoes.", + "assertions": [ + "No singleton last-scan authorization state.", + "No validation rescan adds new items not shown in preview.", + "Original first three clean arguments stay compatible.", + "Direct scan callers resolve safe config defaults; bare clean cannot opt Go in." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV21", + "story_ids": [ + "US6" + ], + "requirement_ids": [ + "FR14", + "FR15" + ], + "criterion_ids": [ + "AC9", + "AC11" + ], + "prompt_or_scenario": "Same category/canonical path appears in CLI/desktop/picker and repeated scans; change path/category.", + "expected_behavior": "Identity is stable for the same category/path while existing path selection still works.", + "assertions": [ + "Stable ID uses deterministic hash with unambiguous separator.", + "Paths and existing item fields remain.", + "IDs are not deletion credentials." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV22", + "story_ids": [ + "US1", + "US2", + "US4" + ], + "requirement_ids": [ + "FR6", + "FR18" + ], + "criterion_ids": [ + "AC2", + "AC4", + "AC13" + ], + "prompt_or_scenario": "Mixed ecosystem scan with hidden build names/worktree containers, orphan dependencies, max-depth boundaries and overlap roots.", + "expected_behavior": "Discovery respects exact depth semantics and shares work per request.", + "assertions": [ + "Artifacts are recognized before dot-directory pruning.", + "Do not descend dependency/Git metadata trees.", + "Inspect candidates beside parents at depth 0..4 with default depth.", + "Candidate contents are fully checked regardless of discovery cap." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV23", + "story_ids": [ + "US7" + ], + "requirement_ids": [ + "FR16" + ], + "criterion_ids": [ + "AC12" + ], + "prompt_or_scenario": "Collect event callbacks during mixed scan, partial root failure and cancellation.", + "expected_behavior": "Events correlate to run/category and cancelled scans never trigger cleanup.", + "assertions": [ + "One start and terminal done per run; candidate/warning counts agree with summary.", + "Aborted scan does not publish a complete clean plan.", + "Single-document JSON mode is preserved." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV24", + "story_ids": [ + "US7" + ], + "requirement_ids": [ + "FR17", + "SR8" + ], + "criterion_ids": [ + "AC12" + ], + "prompt_or_scenario": "Live review receives candidates while user changes sort/search/page and selects explicit paths.", + "expected_behavior": "View changes preserve selected identities and cannot initiate mutation.", + "assertions": [ + "Initial selections are empty.", + "Enter while scanning cannot submit deletion.", + "Sort ties use canonical path for deterministic ordering.", + "After completion, user sees selection and confirms before validator/mutation." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV25", + "story_ids": [ + "US1", + "US2", + "US3", + "US4", + "US5", + "US6", + "US7" + ], + "requirement_ids": [ + "OR1", + "OR4", + "FR18" + ], + "criterion_ids": [ + "AC11", + "AC13", + "AC14" + ], + "prompt_or_scenario": "Review integrated CLI/desktop adapter changes and execute affected fixture suites/typechecks/builds.", + "expected_behavior": "All surfaces implement the same policy contract and checks match the changed scope.", + "assertions": [ + "New IDs/config help/docs and risk defaults are consistent.", + "Node discovery/safety paths receive meaningful coverage.", + "Root checks are supplemented with desktop checks when those files change." + ], + "evidence_level": "planned implementation behavior; not yet executed" + }, + { + "id": "EV26", + "story_ids": [ + "US4", + "US7" + ], + "requirement_ids": [ + "OR3", + "OR4" + ], + "criterion_ids": [ + "AC14" + ], + "prompt_or_scenario": "Benchmark implemented shared walker plus Git checks on representative fixture sizes, recording process counts and overlap-root work.", + "expected_behavior": "Collect evidence before selecting performance changes.", + "assertions": [ + "No benchmark is claimed from this planning audit.", + "Git subprocess count scales with worktrees/batches, not each candidate.", + "Record wall time/memory/candidate inspection cost and compare only equivalent policies." + ], + "evidence_level": "planned implementation behavior; not yet executed" + } + ] +} diff --git a/docs/plans/repo-artifact-cleanup/repo_artifact_cleanup_prd.yaml b/docs/plans/repo-artifact-cleanup/repo_artifact_cleanup_prd.yaml new file mode 100644 index 0000000..adce52c --- /dev/null +++ b/docs/plans/repo-artifact-cleanup/repo_artifact_cleanup_prd.yaml @@ -0,0 +1,589 @@ +--- +version: '1.1' +artifact: feature PRD +metadata: + title: Repository Build Artifact Cleanup + status: implementation_ready_recommendation + updated_at: '2026-10-13' + canonical: true + scope: Complete repo-artifact feature set, delivered in bounded phases; this turn + is a planning audit. +summary: Extend the existing scanner architecture with marker-scoped repo artifact + cleanup, Git/keep-path protection, execution-time validation, configurable roots, + rich results and streaming live review. +authority: + requirements: This PRD is canonical; audit, tracker, decisions and eval cases derive + from it. + recommendations: Audit-selected design choices are recommendations, not recorded + user approval or authorization to begin product implementation. +user_stories: +- id: US1 + role: developer + want: review stale node_modules and Node build output separately + so_that: I can reclaim generated content with appropriate safety controls. +- id: US2 + role: Rust developer + want: clean target directories beside Cargo.toml + so_that: I can reclaim rebuildable output. +- id: US3 + role: Go developer + want: opt in to bin/dist cleanup + so_that: generic names do not enter cleanup by default. +- id: US4 + role: operator + want: use explicit/configured scan roots and keep-path patterns + so_that: cleanup reaches my workspaces and protects selected contents. +- id: US5 + role: operator + want: selected items checked again before deletion, backup or dry-run + so_that: stale scans and tracked contents cannot authorize cleanup. +- id: US6 + role: script author + want: stable item metadata and JSON cleanup outcomes + so_that: I can audit and automate cleanup. +- id: US7 + role: interactive user + want: see live discovery and sort/search/page candidates + so_that: I can review large scans before explicitly confirming cleanup. +requirements: + functional: + - id: FR1 + text: Keep the existing node-modules category and migrate its discovery onto the + shared repository engine; remove markerless orphan eligibility. + - id: FR2 + text: Add rust-artifacts for target beside Cargo.toml. + - id: FR3 + text: Add a separate node-build-artifacts category for .next, .turbo, .parcel-cache, + coverage, dist, build, out and .cache beside package.json. + - id: FR4 + text: Require a readable regular, non-symlink ecosystem marker in the immediate + parent; ancestor markers and permission errors never authorize a candidate. + - id: FR5 + text: Add go-artifacts for bin and dist beside go.mod only with explicit Go opt-in. + - id: FR6 + text: Use one request-scoped shared repository walk for the requested repo categories; + bound discovery depth, measure eligible trees once and prune dependency/Git + metadata traversal. + - id: FR7 + text: Scan built-in roots plus extraPaths.projects; support repeatable --root + overrides for repo scans and preserve extraPaths.nodeModules as a Node-only + legacy addition. + - id: FR8 + text: Add excludePaths with basename, root-relative prefix and limited glob syntax. + Exclusions apply to traversal, candidates and contents; any excluded descendant + vetoes whole-directory cleanup. + - id: FR9 + text: Canonicalize and deduplicate repo candidates across roots/categories. Deduplicate + selected filesystem cleanup across all categories after filtering/validation; + preserve independently selectable scan rows. + - id: FR10 + text: Revalidate only selected candidate paths against current marker, policy, + Git protection, identity, containment, exclusions and freshness; never introduce + newly discovered items during clean. + - id: FR11 + text: Retain existing backup/restore routing; refuse an unrepresentable external + backup path without falling back to permanent deletion. + - id: FR12 + text: Resolve category selection, includeEcosystems, excludeCategories, parallelScans + and concurrency consistently for CLI, direct scanner callers and desktop. + - id: FR13 + text: Add clean --json with per-item outcomes for filesystem cleanup, aggregate + action outcomes for external-tool scanners, scan failures, skipped categories, + truthful dry-run accounting and documented exit codes. + - id: FR14 + text: Repo items expose optional id, fileCount, newestEntryModifiedAt and artifact + evidence; keep path, size, name, isDirectory and modifiedAt compatible. + - id: FR15 + text: Preserve existing scan JSON summary keys and verbose path/size keys; add + optional metadata fields in verbose item output. + - id: FR16 + text: Add optional start/progress/candidate/warning/done scan events and AbortSignal + cancellation without changing the Promise-returning scanner API. + - id: FR17 + text: Add opt-in live terminal review with size/mtime/path sorting, search, paging + and stable selections. Scanning/Enter alone cannot trigger deletion; final confirmation + and clean validation still apply. + - id: FR18 + text: Update CLI help, config validation/init/docs, pickers, desktop settings/controller/engine, + and tests for new categories and per-run context. + safety: + - id: SR1 + text: Markers classify an ecosystem; they do not by themselves prove every child + is generated. + - id: SR2 + text: Inside a Git worktree, veto any candidate intersecting current index or + HEAD paths; protect gitlinks and nested .git metadata. Refresh this information + for the selected clean batch. + - id: SR3 + text: For generic Node coverage/dist/build/out/.cache and Go bin/dist, additionally + require a positive ignore match in Git. In non-Git projects omit these ambiguous + names; recognized node_modules/.next/.turbo/.parcel-cache and Rust target remain + marker-qualified. + - id: SR4 + text: Treat an unavailable/failing Git query for a detected repository as unknown + eligibility; skip it with a diagnostic, never silently treat it as non-Git. + - id: SR5 + text: Reject candidate symlinks and symlink traversal below approved canonical + roots. Check the lexical candidate with lstat before canonicalization; recheck + root binding, ancestry, containment and dev/ino identity before mutation. + - id: SR6 + text: Dry-run performs the same eligibility, protected-path, existence/type and + backup-representability checks without creating backup sessions or changing + files. + - id: SR7 + text: For node_modules, retain the 30-day default but use max(project-directory + mtime, newest candidate entry mtime). A recently modified child makes an old + project ineligible. + - id: SR8 + text: Node build output and Go output are risky with per-item review; Go remains + opt-in independently of risk flags. New repo categories cannot be added to unattended + desktop cleanup automatically. + - id: SR9 + text: Do not claim atomic filesystem/Git validation, guaranteed inactivity, or + actual physical bytes freed from logical size estimates. Checks reduce stale-result + risk; final rechecks still have a race window. + - id: SR10 + text: Malformed exclusion/root policy fails closed for repo cleanup; malformed + user config must not silently broaden roots or discard exclusions. + operational: + - id: OR1 + text: 'Use three core repo modules: rules, walker and eligibility. Existing config/type/registry/command + utilities provide adapters, not a parallel configuration framework.' + - id: OR2 + text: Do not copy Go shell discovery backends, unchecked streaming clean path, + auto-selected destructive TUI, repo-local trash or MCP authorization. + - id: OR3 + text: Use argument-array Git subprocesses with timeouts, bounded/streamed output + and NUL parsing. Measure process count and scan time before adding discovery + engines or caching across runs. + - id: OR4 + text: Implement one vertical slice at a time with scoped tests/checks and explicit + keep/revise discipline. +rule_table: +- category: node-modules + ecosystem: node + names: + - node_modules + marker: package.json + safetyLevel: moderate + age: max(parent mtime,newest entry mtime) >= nodeModulesDaysOld; default 30 + requiresIgnoredDirectory: false +- category: node-build-artifacts + ecosystem: node + names: + - ".next" + - ".turbo" + - ".parcel-cache" + marker: package.json + safetyLevel: risky + age: No additional age threshold + requiresIgnoredDirectory: false +- category: node-build-artifacts + ecosystem: node + names: + - coverage + - dist + - build + - out + - ".cache" + marker: package.json + safetyLevel: risky + age: No additional age threshold + requiresIgnoredDirectory: true +- category: rust-artifacts + ecosystem: rust + names: + - target + marker: Cargo.toml + safetyLevel: moderate + age: No additional age threshold + requiresIgnoredDirectory: false +- category: go-artifacts + ecosystem: go + names: + - bin + - dist + marker: go.mod + safetyLevel: risky + age: No additional age threshold + requiresIgnoredDirectory: true +policy: + classification: Require immediate-parent marker and an enabled rule. If the same + path matches enabled Node and Go rules, assign Node first; a Go-only category + selection assigns Go. Never emit the same canonical path twice. + markers: lstat must identify a readable regular file without following a marker + symlink; marker parse validity is not promised. + roots: + defaults: + - "~/Projects" + - "~/Developer" + - "~/Code" + - "~/dev" + - "~/workspace" + - "~/repos" + precedence: Nonempty explicit --root list replaces repo roots for that command. + Otherwise use defaults plus extraPaths.projects; add extraPaths.nodeModules + only for node-modules. Empty extraPaths arrays add nothing. + validation: Expand ~/ and relative CLI paths, resolve real paths, validate canonical + allowed directories against the existing HOME,/Users,/Volumes policy, reject + broad system/HOME root operations and record input-root to canonical-root binding. + overlap: Retain containing-root provenance when deduplicating candidates. A matching + exclusion from any applicable root vetoes a candidate; overlapping roots cannot + re-enable a keep path. + external_backup: External roots may scan and clean permanently when backup is + off. With backup on, outside-HOME items fail BACKUP_UNSUPPORTED_PATH; never + fall back to deletion. + exclusions: + grammar: Literal basename matches any component; patterns containing / are root-relative + prefixes; glob syntax supports *, ?, and ** as a complete path segment. ** matches + zero or more segments; no negation, braces or bracket syntax in v1. + examples: + - dist + - docs/build + - "**/target" + - "**/keep.txt" + invalid: Empty/absolute/traversal/unsupported patterns produce structured configuration + errors; repo cleanup aborts instead of silently dropping them. + descendants: Evaluate keep patterns on all candidate contents during the metadata/safety + walk. Any matching child rejects the entire candidate; repo categories offer + whole artifact roots, not arbitrary child deletion. + ecosystems: Node and Rust are enabled by default. CLI --include and config includeEcosystems + are additive; an explicit go-artifacts category selection also opts in. --all, + --unsafe and --risky never imply Go opt-in. + categories: Explicit selection or configured defaultCategories chooses categories; + excludeCategories always removes them. Registry availability is distinct from + default-enabled selection. CLI risky flags retain existing behavior; new Node/Go + output requires risky enablement. + desktop: Default deep categories must explicitly exclude Go and other risky categories. + New repo categories are not permitted for scheduled/quick cleanup; explicit deep-category + selection opts Go in and passes its policy context. Desktop deep cleanup remains + hard deletion and does not inherit CLI backupEnabled. + discovery: Inspect artifact names before hidden-directory pruning. Traverse .worktrees + and .claude/worktrees/.cursor/worktrees within the depth budget; always prune + .git metadata and node_modules dependency contents. Treat nested worktrees/submodules + as separate Git contexts. If a would-be artifact contains nested .git metadata + or a gitlink, reject whole-directory cleanup. + depth: 'projectScanMaxDepth defaults to 4 parent-directory levels, matching current + Node discovery: inspect children of parents at depth 0..4. Configurable range + 0..16. Candidate contents are fully inspected independently of discovery depth.' + metadata: size is estimated logical regular-file bytes plus link lstat bytes; fileCount + counts regular files. newestEntryModifiedAt is max of lstat mtimes for root, descendant + directories, files and links; targets are never followed. Incomplete safety/metadata + inspection is reported and cannot produce an eligible candidate. + freshness: nodeModulesDaysOld defaults to 30; current parent-only mtime is tightened + with the newest candidate entry. Other output categories use marker/Git/ignore + checks and deliberate category controls, without extra age gating. + safety_limits: No active-build/process detection, physical block-size guarantee + or atomic protection from changes after the final check. Non-Git recognized artifact + names cannot be verified against a Git index. +architecture: + core_modules: + - path: src/repo-artifacts/rules.ts + responsibility: Declarative rules, category/ecosystem precedence and immediate-parent + marker checks. + - path: src/repo-artifacts/walker.ts + responsibility: Bounded discovery, tree metrics and nested-metadata/keep-path + inspection; optional events and cancellation. + - path: src/repo-artifacts/eligibility.ts + responsibility: Policy normalization helpers, Git snapshots, exclude matching, + path containment/identity, candidate validation and structured rejection reasons. + adapters: + - 'src/types.ts: optional repo policy/evidence/event/result types and new category + metadata.' + - 'src/utils/config.ts: extend existing Config and validation; keep one config authority.' + - 'src/scanners/node-modules.ts and new rust-artifacts/node-build-artifacts/go-artifacts + scanners: thin category adapters.' + - 'src/scanners/index.ts: batch selected repo categories through one request-scoped + engine; standalone scanner.scan remains supported.' + - 'src/scanners/base-scanner.ts: optional validation hook/context for repo adapters; + non-repo scanners are not subjected to repo-marker validation.' + - 'src/utils/fs.ts and backup.ts: shared preflight and outcome reporting for filesystem + mutations.' + - 'src/commands and desktop engine/controller: preserve selected scan snapshots + and pass context to clean.' + execution_context: Extend clean(items,dryRun?,backupDir?,context?) additively. Store + immutable normalized repo policy and scan identity evidence with each repo ScanResult; + callers pass that context. Recheck current config exclusions/roots conservatively + at clean. Never store authorization/options in mutable singleton-scanner state. + selection: Only exact artifact candidates from the selected snapshot can be cleaned. + A caller-supplied ID/rule/size cannot justify a path; validators use current facts + and require repo identity evidence. Missing context uses safe default policy and + cannot enable Go. + git_design: + repository_detection: Resolve each candidate's containing worktree with Git and + recognize .git files as well as directories; an invalid .git boundary is a failure, + not non-Git. + scan_queries: + - git -C ls-files --cached --stage -z + - git -C ls-tree -r --full-tree -z + - git -C check-ignore --stdin -z (one batch for ambiguous candidate + directories) + protection: Use the union of index and HEAD paths to protect staged removals still + on disk, staged additions, conflicts, symlinks and gitlinks. Any path equal + to or beneath a candidate vetoes it. Distinguish verified unborn repositories + from broken HEAD/index queries. + process_policy: Argument arrays only; NUL-separated parsing; sanitize inherited + Git routing variables; disable optional locks; enforce timeout/output limits + and fail closed. + lifetime: Request-scoped caches keyed by worktree path/index identity, not common + Git-dir identity. Refresh index/HEAD/ignore batches for selected clean candidates; + benchmark this cost rather than assuming it negligible. + ignore_note: Ignore matching is an additional signal for generic names. It is + not a substitute for tracked-content or nested-repository checks. + validation_order: + - Normalize selected policy and reject malformed config. + - lstat lexical candidate, reject link/type change; resolve/verify approved root + and every descendant ancestor. + - Recheck immediate-parent marker and active rule/category/Go opt-in. + - Inspect candidate contents for keep paths, nested Git metadata, metrics and age. + - Apply refreshed worktree index/HEAD/ignore vetoes. + - Compare scan dev/ino identity; apply protected-path and backup representability + checks. + - Dedupe the validated selected filesystem plan; emit covered-child outcomes. + - Immediately recheck path/type/root/marker identity before calling existing remove/backup; + report mutation errors. + deduplication: Scan categories keep independently selectable parent/child rows when + they come from different scanner kinds. Global totals use a canonical path union + for overlapping filesystem candidates; external-tool estimates remain separately + identified. At clean, suppress a child only when its validated ancestor is also + selected; report SKIPPED_COVERED_BY_PARENT. If the ancestor is invalid, the independently + selected child remains eligible. + race_boundary: dev/ino catches stable path replacement, not arbitrary concurrent + child/index writes. Per-worktree refresh plus immediate filesystem checks reduce + the window but do not make rm/rename atomic. +cli_contract: + common_repo_options: + - "--root (repeatable; repo roots only)" + - "--include (comma-separated, additive)" + - "--exclude (repeatable; adds to config keep patterns)" + clean_json: Requires --all or --categories for deterministic noninteractive selection. + Actual clean also requires --yes when noninteractive; dry-run does not. Incompatible + live/JSON flags fail explicitly. + json_output: One versioned JSON document on stdout, including empty/error results. + Progress, diagnostics and config warnings go to stderr; suppress donation messages, + browser actions and interactive prompts. + statuses: + - deleted + - backed-up + - would-delete + - would-back-up + - rejected + - skipped + accounting: Expose actual freedSpace/backedUpSize/cleanedItems separately from wouldFreeSpace/wouldBackUpSize/wouldCleanItems. + Dry-run actual counters are zero; byte values are logical estimates. External-tool + scanners return action-level outcomes, without invented per-path deletion claims. + exit_codes: 0 for a complete scan/plan/clean (including empty); 1 for scan/validation/execution/config + failure; Commander usage errors follow its existing nonzero convention. + stable_id: Optional sha256(category + NUL + canonical path) hex ID; path stays the + existing picker/desktop key. IDs are identifiers, not authorization tokens. + streaming: Optional event callback plus AbortSignal; callback consumers receive + start/progress/candidate/warning/done with category/run correlation. Plain --json + stays a single summary, not implicit NDJSON. + live_ui: Root interactive --live and read-only scan --live reuse the existing picker + infrastructure. Sort/search/page view state is independent of the selected canonical + paths; nothing is preselected on entry. Enter during scanning cannot submit cleanup. + After scan completion, selected roots proceed through final confirmation and ordinary + clean validation. +configuration_additions: + excludePaths: string[] keep patterns + includeEcosystems: "('node'|'rust'|'go')[] additive opt-ins" + nodeModulesDaysOld: integer 0..365; default 30 + projectScanMaxDepth: integer 0..16; default 4 + compatibility: extraPaths keeps its additive meaning; valid roots remain canonically + validated. Add category IDs to config validation from CATEGORIES rather than a + second hardcoded inventory. +non_goals: +- Porting Go terminal/raw-stty code or unchecked TUI cleanup. +- Repo-local trash, native find/fd discovery, Go build/brew invocation or MCP authorization. +- Deleting source directories lacking sufficient classification evidence. +- Automatic unattended cleanup of the new repo categories. +- Redesigning desktop backup behavior, global retention settings, or existing non-repo + scanners' category eligibility. +acceptance_criteria: +- id: AC1 + text: Missing, unreadable, symlink or ancestor-only markers never create an eligible + repo candidate. +- id: AC2 + text: The complete rule table is classified deterministically, including hidden + artifacts and a Node/Go dist collision. +- id: AC3 + text: Go is absent from defaults; explicit inclusion/category selection enables + it subject to exclusions and risk rules across CLI and desktop. +- id: AC4 + text: Built-ins plus config additions and explicit root overrides behave identically + at scan/clean; overlapping roots cannot bypass an exclusion. +- id: AC5 + text: Excluded contents protect a candidate ancestor, tracked/index/HEAD/gitlink/nested-repository + contents veto candidates, and Git uncertainty fails closed. +- id: AC6 + text: Only selected candidates that retain their policy, identity, ancestry and + freshness can reach remove/backup; dry-run reports the same rejection codes. +- id: AC7 + text: Dry-run never creates backup files/directories; outside-HOME backup attempts + are rejected without destructive fallback; normal backup/restore remains functional. +- id: AC8 + text: Candidate and selected-plan dedupe avoid double deletion/counting without + hiding a child category when its parent category is unselected. +- id: AC9 + text: Metadata counts regular files, accounts for link bytes without following targets, + and uses directory/file/link mtimes including the candidate root. +- id: AC10 + text: JSON stdout contains one document for success, empty, partial-error and dry-run + cases; exit status and actual/planned metrics are explicit. +- id: AC11 + text: Path identity, existing aggregate result fields and scanner call compatibility + are preserved; CLI and desktop carry immutable per-run repo policy. +- id: AC12 + text: Scan events are deterministic enough for consumers, cancellation is read-only, + and live review preserves explicit selections through sort/search/paging. +- id: AC13 + text: Meaningful fixture tests cover every high-risk criterion; root and desktop + test/typecheck/build checks are scoped to touched slices. +- id: AC14 + text: Every reported gap is assigned an implementation slice; performance improvements + depend on measured data. +implementation_sequence: +- id: I1 + title: Existing Node safety slice + primary_surface: node-modules scan-to-clean flow + files: + - src/repo-artifacts/* + - src/scanners/node-modules.ts + - src/scanners/base-scanner.ts + - src/types.ts + deliverables: Rules/walker/validator contract, marker error fix, Git/content exclusions, + recent-child freshness and identity evidence. + acceptance: + - AC1 + - AC5 + - AC6 + - AC9 + - AC11 + checks: Focused repo/node/base fixtures; root typecheck/lint. +- id: I2 + title: Roots/config/dry-run execution slice + primary_surface: CLI and desktop repo request policy + files: + - src/utils/config.ts + - src/utils/fs.ts + - src/utils/backup.ts + - src/scanners/index.ts + - src/commands/scan.ts + - src/commands/clean.ts + - src/commands/interactive.ts + - src/index.ts + - desktop/src/main/engine.ts + - desktop/src/main/controller.ts + deliverables: Root/include/exclude flags, config wiring, immutable clean context, + protected/backup preflight, no dry-run mkdir, selected-plan dedupe. + acceptance: + - AC3 + - AC4 + - AC6 + - AC7 + - AC8 + - AC11 + checks: Focused config/commands/backup/fs/registry and desktop engine/controller + tests; root/desktop typecheck. +- id: I3 + title: Rust and Node build categories + primary_surface: category registration and review + files: + - src/scanners/rust-artifacts.ts + - src/scanners/node-build-artifacts.ts + - src/types.ts + - src/scanners/index.ts + - src/pickers/* + - desktop/src/main/settings.ts + - desktop/src/renderer/app.ts + deliverables: Rust target, separate risky Node output, positive ignore requirement + for generic names, hidden artifacts and defaults. + acceptance: + - AC2 + - AC3 + - AC5 + - AC11 + checks: Rule/scanner fixtures and category/picker/desktop settings tests. +- id: I4 + title: Opt-in Go category + primary_surface: Go policy and classification + files: + - src/scanners/go-artifacts.ts + - src/types.ts + - src/scanners/index.ts + - src/utils/config.ts + - desktop/src/main/settings.ts + deliverables: Go opt-in/risk boundaries and deterministic dist collision behavior. + acceptance: + - AC2 + - AC3 + - AC5 + - AC11 + checks: Go opt-in, collision, default deep/auto selection fixtures. +- id: I5 + title: Metadata and clean JSON + primary_surface: filesystem outcome serialization + files: + - src/types.ts + - src/utils/fs.ts + - src/utils/backup.ts + - src/commands/scan.ts + - src/commands/clean.ts + - src/index.ts + deliverables: Stable optional IDs, richer verbose scan JSON, per-item clean outcomes, + actual/planned accounting, external-tool action representation. + acceptance: + - AC9 + - AC10 + - AC11 + checks: Metadata/JSON/error/empty/dry-run fixtures and focused external-tool compatibility + tests. +- id: I6 + title: Streaming events and live table + primary_surface: read-only discovery and review UI + files: + - src/repo-artifacts/walker.ts + - src/scanners/index.ts + - src/pickers/file-picker.ts + - src/index.ts + - src/commands/interactive.ts + - src/commands/scan.ts + deliverables: Events, cancellation, opt-in live sorting/search/paging and selection + retention with final confirmation. + acceptance: + - AC12 + checks: Event/cancellation/view-state behavior fixtures plus terminal interaction + verification; add @inquirer/core as a direct dependency if its supported API is + used. +- id: I7 + title: Integrated checks, documentation and measurements + primary_surface: completed feature contract + files: + - README.md + - src/pickers/README.md + - desktop/README.md + - vitest.config.ts + deliverables: Document all policy/flag changes and omitted ambiguous non-Git outputs; + measure shared walker and Git batches; remove repo safety paths from coverage + exclusions. + acceptance: + - AC13 + - AC14 + checks: Affected root/desktop test suites, both typechecks, lint and builds; benchmark + small/medium/large and overlapping-root fixtures before choosing optimizations. +bounded_iteration: + primary_planning_surface: repo_artifact_cleanup_prd.yaml; other planning files are + derived and synchronized. + planning_signal: ruby docs/plans/repo-artifact-cleanup/validate_plan.rb + retention_rule: Keep an audit revision only when artifacts parse without duplicate + keys, references/criterion coverage and readiness agree, and changed safety recommendations + have source/probe evidence. + implementation_signal: For each slice run its focused behavior tests and typecheck/lint; + integrated desktop slices also run desktop checks. Repeat or broaden when changes/failures + justify it. + stop: Audit stops at design readiness; implementation is a subsequent task. +readiness: + status: ready + blocking_ids: [] + meaning: The recommended design is concrete enough to implement; this is not product-code + verification or recorded user approval. diff --git a/docs/plans/repo-artifact-cleanup/research.yaml b/docs/plans/repo-artifact-cleanup/research.yaml new file mode 100644 index 0000000..a66af25 --- /dev/null +++ b/docs/plans/repo-artifact-cleanup/research.yaml @@ -0,0 +1,239 @@ +--- +version: '1.1' +feature: repo-artifact-cleanup +canonical_contract: repo_artifact_cleanup_prd.yaml +items: +- id: R1 + title: Node category boundary + state: complete + priority: P0 + complexity: medium + depends_on: [] + next_action: Implement category metadata and picker/default-policy cases. + evidence: + - E11 + - E12 + decision: Separate risky node-build-artifacts; retain node-modules. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R2 + title: Tracked-content and ignore policy + state: complete + priority: P0 + complexity: medium + depends_on: [] + next_action: Implement worktree-scoped Git snapshots and negative cases. + evidence: + - E20 + - E21 + - E22 + - E23 + decision: Batched index/HEAD veto plus batched positive ignore signal for generic + names; Git uncertainty fails closed. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R3 + title: Nested repos, gitlinks and worktrees + state: complete + priority: P1 + complexity: low + depends_on: + - R2 + next_action: Implement boundary/metadata fixtures. + evidence: + - E10 + - E16 + - E23 + decision: Treat nested working trees as independent contexts; any .git metadata + or gitlink inside a candidate vetoes deleting its ancestor. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R4 + title: Root precedence and override + state: complete + priority: P1 + complexity: low + depends_on: [] + next_action: Implement roots and external-backup rejection cases. + evidence: + - E1 + - E4 + - E14 + decision: Defaults plus extraPaths additions; explicit repeatable --root replaces + repo roots; canonical validation and keep-rule union across overlapping roots. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R5 + title: Node age policy + state: complete + priority: P1 + complexity: low + depends_on: [] + next_action: Implement freshness boundary/recent-child cases. + evidence: + - E1 + - E18 + decision: Retain 30-day default and tighten freshness with candidate newest entry + mtime; new output categories use category/Git rules without an additional age + threshold. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R6 + title: Exclusion grammar and ancestor protection + state: complete + priority: P0 + complexity: medium + depends_on: + - R4 + next_action: Implement descendant and malformed-config cases. + evidence: + - E2 + - E13 + decision: Limited grammar, fail-closed malformed policy, matched descendants veto + ancestor cleanup, and exclusions cannot be bypassed through another root. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R7 + title: Clean JSON and metadata contract + state: complete + priority: P1 + complexity: low + depends_on: [] + next_action: Implement metadata/serialization and external-tool compatibility cases. + evidence: + - E5 + - E6 + - E18 + - E24 + - E25 + decision: One JSON document with per-item outcomes, optional stable IDs, logical + byte metrics, newest entry time, and distinct actual/planned counters. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R8 + title: Streaming/live UI scope + state: complete + priority: P1 + complexity: low + depends_on: [] + next_action: Implement events/view state after core policy is integrated. + evidence: + - E12 + - E18 + decision: Retain full requested scope as later I6 slice; optional events and cancellation + plus safe live review, sort/search/page and confirmation. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R9 + title: Dry-run and backup limits + state: complete + priority: P0 + complexity: medium + depends_on: [] + next_action: Implement CLI/interactive/backup parity cases. + evidence: + - E3 + - E13 + - E14 + - E15 + decision: Shared filesystem/backup preflight for dry-run; no session mkdir; outside-HOME + backup fails with no permanent-delete fallback. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R10 + title: Clean policy/context propagation + state: complete + priority: P0 + complexity: medium + depends_on: + - R2 + - R4 + next_action: Update root and desktop scan/clean callers. + evidence: + - E2 + - E7 + - E16 + decision: Optional context in clean API, immutable scan result policy/evidence, + refreshed current vetoes, and no mutable singleton state. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R11 + title: Cross-category dedupe + state: complete + priority: P1 + complexity: low + depends_on: + - R10 + next_action: Implement unselected/rejected-parent and overlap cases. + evidence: + - E1 + - E7 + - E16 + decision: Keep scan selections independent; normalize selected valid filesystem + ancestors and report covered descendants. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R12 + title: Hidden traversal and depth bounds + state: complete + priority: P1 + complexity: low + depends_on: [] + next_action: Implement hidden/depth/empty/nested fixtures. + evidence: + - E1 + - E10 + decision: Recognize hidden artifact names first; traverse known worktree containers; + default four parent levels; inspect candidate contents fully. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R13 + title: Scope and evaluation mapping + state: complete + priority: P1 + complexity: low + depends_on: + - R1 + - R2 + - R3 + - R4 + - R5 + - R6 + - R7 + - R8 + - R9 + - R10 + - R11 + - R12 + next_action: Execute the planning validator and retain only consistent artifact + revisions. + evidence: + - E19 + - E26 + decision: Seven implementation slices cover the report; shared validation checks + all IDs, acceptance coverage, dependencies and readiness. + resolution_kind: audit recommendation based on code/docs; not user approval +- id: R15 + title: Global repository options before a subcommand + state: complete + priority: P1 + complexity: low + depends_on: + - R4 + - R10 + next_action: Retain subprocess regression checks in integrated validation. + evidence: + - E27 + decision: Merge global and subcommand root/include/exclude options and retain a global dry-run flag; dropping them could broaden a scan or accidentally turn simulation into mutation. + resolution_kind: Implementation investigation and isolated subprocess evidence; no contract change. + +deferred_measurements: +- id: R14 + title: Measure scan and Git-query cost + state: complete + priority: P2 + complexity: medium + depends_on: + - R13 + next_action: Use recorded measurements before proposing future optimization; no new discovery backend or cross-run cache selected. + evidence: + - E29 + decision: Measurements confirm candidate trees are inspected once with overlapping roots and Git processes scale with worktree batches. Selected validation costs are separately measured; timings are single-sample fixtures, not general throughput guarantees. + +readiness: + status: ready + blocking_ids: [] + rationale: Critical decisions have evidence-backed recommendations and concrete + contracts; coding is cheaper than additional design research. + not_claimed: + - Audit readiness alone authorizes implementation; subsequent user authorization is recorded in execution.yaml. + - The planning validator executes product tests or benchmarks; implementation results are recorded in execution.yaml and performance.json. + - Fixture benchmark timings generalize to production repository throughput. diff --git a/docs/plans/repo-artifact-cleanup/validate_plan.rb b/docs/plans/repo-artifact-cleanup/validate_plan.rb new file mode 100644 index 0000000..7aed05c --- /dev/null +++ b/docs/plans/repo-artifact-cleanup/validate_plan.rb @@ -0,0 +1,142 @@ +#!/usr/bin/env ruby +# Validate the planning contract, not the product implementation. +require 'yaml' +require 'json' +require 'set' + +ROOT = File.expand_path(__dir__) + +def assert(condition, message) + raise message unless condition +end + +def check_yaml_keys(node, path) + return unless node + if node.is_a?(Psych::Nodes::Mapping) + seen = Set.new + node.children.each_slice(2) do |key, value| + assert(key.is_a?(Psych::Nodes::Scalar), "Non-scalar YAML key at #{path}") + assert(seen.add?(key.value), "Duplicate YAML key #{key.value} at #{path}") + check_yaml_keys(value, "#{path}.#{key.value}") + end + elsif node.is_a?(Psych::Nodes::Alias) + raise "YAML alias not supported at #{path}" + elsif node.respond_to?(:children) && node.children + node.children.each_with_index { |child, i| check_yaml_keys(child, "#{path}[#{i}]") } + end +end + +def yaml(name) + content = File.read(File.join(ROOT, name)) + check_yaml_keys(YAML.parse_stream(content), name) + YAML.load(content) +end + +class UniqueHash < Hash + def []=(key, value) + raise "Duplicate JSON key #{key}" if key?(key) + super + end +end + +def json(name) + JSON.parse(File.read(File.join(ROOT, name)), object_class: UniqueHash) +end + +def ids(items, field = 'id') + values = items.map { |item| item.fetch(field) } + assert(values.length == values.uniq.length, "Duplicate #{field}: #{values}") + values.to_set +end + +def references(values, known, label) + missing = values.to_set - known + assert(missing.empty?, "Unknown references in #{label}: #{missing.to_a}") +end + +prd = yaml('repo_artifact_cleanup_prd.yaml') +tracker = yaml('research.yaml') +audit = yaml('contract_audit.yaml') +decisions = yaml('decisions.yaml') +evals = json('repo_artifact_cleanup_eval_cases.json') +probe = json('git_probe_evidence.json') + +requirements = ids(prd.fetch('requirements').values.flatten) +criteria = ids(prd.fetch('acceptance_criteria')) +stories = ids(prd.fetch('user_stories')) +evidence = ids(audit.fetch('evidence_sources')) +research = tracker.fetch('items') + tracker.fetch('deferred_measurements', []) +research_ids = ids(research) +ids(decisions.fetch('decisions')) +ids(prd.fetch('implementation_sequence')) +ids(evals.fetch('cases')) + +assert(prd.dig('metadata', 'canonical') == true, 'PRD must be canonical') +references(audit.dig('acceptance_criteria', 'ids'), criteria, 'audit criteria') +assert(ids(audit.fetch('findings'), 'criterion_id') == criteria, 'Audit must cover every criterion') +audit.fetch('findings').each { |finding| references(finding.fetch('evidence'), evidence, 'audit finding') } +audit.fetch('recommendation_review').each do |review| + references(review.fetch('evidence'), evidence, review.fetch('id')) + references(review.fetch('requirements'), requirements, review.fetch('id')) +end + +research.each do |item| + %w[id title state priority complexity depends_on next_action evidence decision].each do |key| + assert(item.key?(key), "Missing research field #{key}: #{item['id']}") + end + references(item.fetch('evidence'), evidence, item.fetch('id')) + references(item.fetch('depends_on'), research_ids, item.fetch('id')) + assert(!item.fetch('depends_on').include?(item.fetch('id')), 'Research cannot depend on itself') + if item.fetch('state') == 'complete' + assert(!item.fetch('decision').empty?, "Complete research lacks decision: #{item['id']}") + assert(!item.fetch('evidence').empty?, "Complete research lacks evidence: #{item['id']}") + item.fetch('depends_on').each do |dependency| + assert(research.find { |r| r['id'] == dependency }['state'] == 'complete', "Unresolved dependency: #{item['id']} -> #{dependency}") + end + end +end + +visiting = Set.new +visited = Set.new +visit = lambda do |id| + return if visited.include?(id) + assert(!visiting.include?(id), "Research dependency cycle: #{id}") + visiting.add(id) + research.find { |r| r['id'] == id }.fetch('depends_on').each { |dep| visit.call(dep) } + visiting.delete(id) + visited.add(id) +end +research_ids.each { |id| visit.call(id) } + +decisions.fetch('decisions').each do |decision| + references(decision.fetch('based_on_evidence'), evidence, decision.fetch('id')) + references(decision.fetch('implements'), requirements, decision.fetch('id')) +end + +covered_requirements = Set.new +covered_criteria = Set.new +covered_stories = Set.new +evals.fetch('cases').each do |test_case| + %w[prompt_or_scenario expected_behavior assertions].each { |key| assert(!test_case.fetch(key).empty?, "Empty eval #{key}") } + [['requirement_ids', requirements, covered_requirements], ['criterion_ids', criteria, covered_criteria], ['story_ids', stories, covered_stories]].each do |key, known, covered| + references(test_case.fetch(key), known, test_case.fetch('id')) + covered.merge(test_case.fetch(key)) + end +end +assert(covered_requirements == requirements, "Requirements lack evals: #{(requirements - covered_requirements).to_a}") +assert(covered_criteria == criteria, "Criteria lack evals: #{(criteria - covered_criteria).to_a}") +assert(covered_stories == stories, "Stories lack evals: #{(stories - covered_stories).to_a}") +slice_criteria = prd.fetch('implementation_sequence').flat_map { |slice| slice.fetch('acceptance') }.to_set +assert(slice_criteria == criteria, 'Implementation slices must cover the criteria') + +readiness = tracker.dig('readiness', 'status') +assert(prd.dig('readiness', 'status') == readiness, 'PRD/tracker readiness disagree') +assert(audit.dig('implementation_readiness', 'status') == readiness, 'Audit/tracker readiness disagree') +if readiness == 'ready' + assert(tracker.dig('readiness', 'blocking_ids').empty?, 'Ready tracker has blockers') + assert(research.none? { |item| %w[P0 P1].include?(item['priority']) && item['state'] != 'complete' }, 'Ready tracker has incomplete critical research') +end +assert(probe.fetch('worktree_git_is_file') && probe.fetch('worktree_index_contains_tracked_file'), 'Worktree evidence is incomplete') + +puts "Planning validation passed: #{requirements.size} requirements, #{criteria.size} criteria, #{evals['cases'].size} cases; readiness=#{readiness}." +puts 'This does not execute product tests or imply implementation approval.' diff --git a/package.json b/package.json index 4062191..0eb179b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mac-cleaner-cli", "version": "1.4.0", - "description": "macOS CLI tool to free up disk space — removes caches, logs, Homebrew leftovers, Xcode DerivedData, and junk files", + "description": "macOS CLI tool to free up disk space \u2014 removes caches, logs, Homebrew leftovers, Xcode DerivedData, and junk files", "type": "module", "bin": { "mac-cleaner-cli": "./dist/index.mjs" @@ -21,7 +21,8 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "prepublishOnly": "bun run build" + "prepublishOnly": "bun run build", + "benchmark:repo": "bun scripts/benchmark-repo-artifacts.ts" }, "keywords": [ "mac", @@ -65,6 +66,7 @@ "dependencies": { "@inquirer/checkbox": "^5.0.2", "@inquirer/confirm": "^6.0.2", + "@inquirer/core": "^11.0.2", "chalk": "^5.3.0", "commander": "^14.0.3", "ora": "^9.0.0" diff --git a/scripts/benchmark-repo-artifacts.ts b/scripts/benchmark-repo-artifacts.ts new file mode 100644 index 0000000..5a1728d --- /dev/null +++ b/scripts/benchmark-repo-artifacts.ts @@ -0,0 +1,55 @@ +// Bun-only benchmark: isolated temporary HOME via module mock, never user data. +import { mock } from 'bun:test'; +import * as os from 'os'; +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; +const fixtureHome = await mkdtemp('/private/tmp/mac-cleaner-bench-'); +mock.module('os', () => ({ ...os, homedir: () => fixtureHome })); +const { CATEGORIES } = await import('../src/types.js'); +const { scanRepoArtifacts } = await import('../src/repo-artifacts/walker.js'); +const { newMetrics, validateSelected } = await import('../src/repo-artifacts/eligibility.js'); +const ids = ['node-build-artifacts', 'rust-artifacts'] as const; +const records = []; +const env = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }; +try { + for (const [name, projects, files, worktrees] of [['small', 10, 20, 1], ['medium', 100, 50, 5], ['large', 500, 100, 25]] as const) { + const root = join(fixtureHome, name); + await mkdir(root); + for (let w = 0; w < worktrees; w++) { + const worktree = join(root, `repo-${w}`); await mkdir(worktree); + execFileSync('git', ['-C', worktree, 'init', '-q'], { env }); + await writeFile(join(worktree, '.gitignore'), 'dist/\n.next/\ntarget/\n'); + } + for (let p = 0; p < projects; p++) { + const project = join(root, `repo-${p % worktrees}`, `project-${p}`); await mkdir(project); + await writeFile(join(project, 'package.json'), '{}'); await writeFile(join(project, 'Cargo.toml'), ''); + for (const artifact of ['.next', 'dist', 'target']) { + const path = join(project, artifact); await mkdir(path); + await Promise.all(Array.from({ length: files }, (_, i) => writeFile(join(path, `${i}.bin`), Buffer.alloc(128)))); + } + } + for (const overlap of [false, true]) { + const metrics = newMetrics(); + let peakRss = process.memoryUsage().rss; + const sample = setInterval(() => { peakRss = Math.max(peakRss, process.memoryUsage().rss); }, 5); + const started = performance.now(); + const results = await scanRepoArtifacts(ids.map((id) => CATEGORIES[id]), { roots: overlap ? [root, join(root, 'repo-0')] : [root] }, metrics); + const wallMilliseconds = performance.now() - started; + clearInterval(sample); peakRss = Math.max(peakRss, process.memoryUsage().rss); + if (results.some((r) => r.error) || results.reduce((n, r) => n + r.items.length, 0) !== projects * 3) throw new Error('Benchmark eligibility mismatch'); + const cleanMetrics = newMetrics(); + const refreshStart = performance.now(); + for (const result of results) { + const validated = await validateSelected(result.items, result.category.id as typeof ids[number], result.context, cleanMetrics); + if (validated.rejected.length) throw new Error('Benchmark validation rejected'); + } + const refreshMilliseconds = performance.now() - refreshStart; + records.push({ name, projects, filesPerArtifact: files, worktrees, overlap, candidates: projects * 3, wallMilliseconds, peakRssBytes: peakRss, ...metrics, selectedValidation: { wallMilliseconds: refreshMilliseconds, ...cleanMetrics } }); + console.error(`${name} overlap=${overlap}: ${wallMilliseconds.toFixed(1)} ms; ${metrics.gitProcesses} Git processes; ${metrics.candidatesInspected} candidates`); + } + await rm(root, { recursive: true, force: true }); + } + const document = { measuredAt: new Date().toISOString(), runtime: `Bun ${Bun.version}`, platform: `${os.platform()} ${os.arch()}`, method: 'Single sample per size/order; fixture creation excluded; no cleanup mutation; scan then selected validation; peak RSS sampled every 5ms includes process baseline. Files are logical 128-byte fixtures, not representative storage throughput. Overlap scans follow non-overlap scans, so cache/order effects remain.', records }; + await writeFile('docs/plans/repo-artifact-cleanup/performance.json', JSON.stringify(document, null, 2) + '\n'); +} finally { await rm(fixtureHome, { recursive: true, force: true }); } diff --git a/src/commands/clean-json.test.ts b/src/commands/clean-json.test.ts new file mode 100644 index 0000000..b054536 --- /dev/null +++ b/src/commands/clean-json.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { cleanCommand } from './clean.js'; +import { CATEGORIES, type CleanResult, type ScanSummary } from '../types.js'; +import * as scanners from '../scanners/index.js'; +import * as config from '../utils/config.js'; +vi.mock('../scanners/index.js', () => ({ runAllScans: vi.fn(), runScans: vi.fn(), getScanner: vi.fn(), getAllScanners: vi.fn(() => []) })); +vi.mock('../utils/config.js', () => ({ loadConfig: vi.fn(async () => ({ backupEnabled: false })) })); +const log = vi.spyOn(console, 'log').mockImplementation(() => {}); +beforeEach(() => { vi.clearAllMocks(); process.exitCode = undefined; }); +afterEach(() => { process.exitCode = undefined; }); +const item = { path: '/fixture/item', name: 'item', size: 10, isDirectory: true }; +function setup(extra: Partial = {}) { + const scan = { category: CATEGORIES['rust-artifacts'], items: [item], totalSize: 10, ...extra }; + vi.mocked(scanners.runAllScans).mockResolvedValue({ results: [scan], totalSize: scan.totalSize, totalItems: scan.items.length }); + vi.mocked(scanners.getScanner).mockReturnValue({ category: scan.category, scan: vi.fn(), clean: vi.fn(async (_items, dry): Promise => ({ category: scan.category, cleanedItems: dry ? 0 : 1, freedSpace: dry ? 0 : 10, wouldCleanItems: dry ? 1 : 0, wouldFreeSpace: dry ? 10 : 0, outcomes: [{ category: scan.category.id, path: item.path, scope: 'item', status: dry ? 'would-delete' : 'deleted', size: 10 }], errors: [] })) }); +} +function output() { expect(log).toHaveBeenCalledTimes(1); return JSON.parse(log.mock.calls[0][0]); } +it('requires deterministic selection and actual confirmation without prompting', async () => { + await cleanCommand({ json: true }); + expect(output().errors[0].error).toContain('JSON_SELECTION_REQUIRED'); + expect(process.exitCode).toBe(1); log.mockClear(); + await cleanCommand({ json: true, all: true }); + expect(output().errors[0].error).toContain('JSON_CONFIRMATION_REQUIRED'); + expect(scanners.runAllScans).not.toHaveBeenCalled(); +}); +it('emits truthful planned counters and per-item outcomes in one document', async () => { + setup(); await cleanCommand({ json: true, all: true, dryRun: true }); + expect(output()).toMatchObject({ version: 1, dryRun: true, totalCleanedItems: 0, totalFreedSpace: 0, wouldCleanItems: 1, wouldFreeSpace: 10, results: [{ outcomes: [{ path: item.path, status: 'would-delete' }] }] }); +}); +it('reports empty scan errors and config failures as nonzero JSON', async () => { + setup({ items: [], totalSize: 0, error: 'scan failed' }); + await cleanCommand({ json: true, all: true, dryRun: true }); + expect(output().errors).toEqual([{ category: 'rust-artifacts', error: 'scan failed' }]); + expect(process.exitCode).toBe(1); log.mockClear(); + vi.mocked(config.loadConfig).mockResolvedValueOnce({ repoPolicyError: 'INVALID_EXCLUSIONS' }); + setup({ items: [], totalSize: 0 }); + await cleanCommand({ json: true, all: true, dryRun: true }); + expect(output().errors[0].error).toBe('INVALID_EXCLUSIONS'); +}); +it('reports empty success and risky skips without donations or prompts', async () => { + setup({ items: [], totalSize: 0 }); await cleanCommand({ json: true, all: true, yes: true }); + expect(output().totalErrors).toBe(0); log.mockClear(); + setup({ category: CATEGORIES['node-build-artifacts'] }); await cleanCommand({ json: true, all: true, yes: true }); + expect(output().skippedCategories).toEqual([{ category: 'node-build-artifacts', reason: 'RISK_NOT_ENABLED' }]); +}); +it('represents external tools at action scope without invented paths', async () => { + setup({ category: CATEGORIES.docker }); + vi.mocked(scanners.getScanner).mockReturnValue({ category: CATEGORIES.docker, supportsBackup: false, scan: vi.fn(), clean: vi.fn(async () => ({ category: CATEGORIES.docker, cleanedItems: 1, freedSpace: 5, errors: ['one action failed'] })) }); + await cleanCommand({ json: true, all: true, yes: true }); + const result = output(); + expect(result.results[0].outcomes).toEqual([{ category: 'docker', scope: 'action', status: 'rejected', reason: 'one action failed', size: 5 }]); + expect(result.totalFreedSpace).toBe(5); + expect(process.exitCode).toBe(1); +}); diff --git a/src/commands/clean.test.ts b/src/commands/clean.test.ts index 3cdfe16..26a5510 100644 --- a/src/commands/clean.test.ts +++ b/src/commands/clean.test.ts @@ -1,3 +1,5 @@ +const { commandHome } = vi.hoisted(() => ({ commandHome: `/private/tmp/mac-cleaner-command-${process.pid}` })); +vi.mock('os', async (original) => ({ ...await original(), homedir: () => commandHome })); import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { cleanCommand } from './clean.js'; import * as scanners from '../scanners/index.js'; @@ -176,7 +178,9 @@ describe('clean command', () => { const result = await cleanCommand({ all: true, yes: true, dryRun: true }); - expect(result).toBeNull(); + expect(result?.totalCleanedItems).toBe(0); + expect(result?.wouldCleanItems).toBe(1); + expect(mockScanner.clean).toHaveBeenCalledWith(expect.anything(), true, undefined, undefined); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('DRY RUN')); consoleSpy.mockRestore(); @@ -610,7 +614,7 @@ describe('clean command backup handling', () => { await cleanCommand({ all: true, yes: true }); - expect(scanner.clean).toHaveBeenCalledWith(expect.anything(), undefined, undefined); + expect(scanner.clean).toHaveBeenCalledWith(expect.anything(), undefined, undefined, undefined); consoleSpy.mockRestore(); }); @@ -657,7 +661,7 @@ describe('clean command backup handling', () => { const output = consoleSpy.mock.calls.flat().join('\n'); expect(output).toContain('No backup possible'); // An external-tool scanner must never receive a backup directory. - expect(scanner.clean).toHaveBeenCalledWith(expect.anything(), undefined, undefined); + expect(scanner.clean).toHaveBeenCalledWith(expect.anything(), undefined, undefined, undefined); consoleSpy.mockRestore(); }); diff --git a/src/commands/clean.ts b/src/commands/clean.ts index b00c763..8c9865c 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -1,8 +1,9 @@ import chalk from 'chalk'; +import { cleanSelectedResults } from '../utils/cleanup.js'; import confirm from '@inquirer/confirm'; import checkbox from '@inquirer/checkbox'; import { spawn } from 'child_process'; -import type { CategoryId, CleanSummary, CleanableItem, ScanResult, SafetyLevel } from '../types.js'; +import type { CategoryId, CleanSummary, CleanableItem, ScanResult, SafetyLevel, RepoRequestOptions } from '../types.js'; import { runAllScans, runScans, getScanner, getAllScanners } from '../scanners/index.js'; import { formatSize, createScanProgress, createCleanProgress, sanitizeDisplayName, loadConfig, ensureBackupDir, getBackupDir } from '../utils/index.js'; import { printScanErrors } from './scan.js'; @@ -39,7 +40,9 @@ function openUrl(url: string): void { }).unref(); } -interface CleanCommandOptions { +interface CleanCommandOptions extends RepoRequestOptions { + json?: boolean; + live?: boolean; all?: boolean; yes?: boolean; dryRun?: boolean; @@ -63,6 +66,8 @@ interface CategoryChoice { } export async function cleanCommand(options: CleanCommandOptions): Promise { + if (options.json) return cleanJsonCommand(options); + if (options.live) throw new Error('Use root --live for interactive cleanup review'); const showProgress = !options.noProgress && process.stdout.isTTY; const categoryIds = options.categories?.length ? options.categories @@ -70,8 +75,9 @@ export async function cleanCommand(options: CleanCommandOptions): Promise { scanProgress?.update(completed, `Scanning ${scanner.category.name}...`); }, @@ -85,7 +91,9 @@ export async function cleanCommand(options: CleanCommandOptions): Promise r.error)) process.exitCode = 1; + if (summary.totalItems === 0) { + if (summary.results.some((r) => r.error)) return null; console.log(chalk.green('\n✓ Your Mac is already clean!\n')); return null; } @@ -140,17 +148,6 @@ export async function cleanCommand(options: CleanCommandOptions): Promise sum + i.size, 0); - console.log(` ${scanner.category.name}: ${items.length} items (${formatSize(size)})`); - } - console.log(chalk.cyan(`\n[DRY RUN] Would free ${formatSize(totalToClean)}\n`)); - return null; - } - // The `clean` subcommand honours `backupEnabled` exactly like the interactive // flow does. Backing up in one entry point and deleting permanently in the // other would be worse than not backing up at all: the user's expectation @@ -180,38 +177,22 @@ export async function cleanCommand(options: CleanCommandOptions): Promise getScanner(categoryId)?.supportsBackup !== false) - ? await ensureBackupDir() + ? (options.dryRun ? `${getBackupDir()}/dry-run` : await ensureBackupDir()) : undefined; const cleanProgress = showProgress ? createCleanProgress(selectedItems.length) : null; - const cleanResults: CleanSummary = { - results: [], - totalFreedSpace: 0, - totalBackedUpSize: 0, - backupDir, - totalCleanedItems: 0, - totalErrors: 0, - }; - - let cleanedCount = 0; - for (const { categoryId, items } of selectedItems) { - const scanner = getScanner(categoryId); - cleanProgress?.update(cleanedCount, `Cleaning ${scanner.category.name}...`); - - const scannerBackupDir = scanner.supportsBackup === false ? undefined : backupDir; - const result = await scanner.clean(items, options.dryRun, scannerBackupDir); - cleanResults.results.push(result); - cleanResults.totalFreedSpace += result.freedSpace; - cleanResults.totalBackedUpSize = (cleanResults.totalBackedUpSize ?? 0) + (result.backedUpSize ?? 0); - cleanResults.totalCleanedItems += result.cleanedItems; - cleanResults.totalErrors += result.errors.length; - cleanedCount++; - } + const cleanResults = await cleanSelectedResults(selectedItems.map(({ categoryId, items }) => ({ + ...summary.results.find((r) => r.category.id === categoryId)!, items, + })), getScanner, { + dryRun: options.dryRun, backupDir, + onProgress: (completed, result) => cleanProgress?.update(completed, `Cleaning ${result.category.name}...`), + }); cleanProgress?.finish(); - await printCleanResults(cleanResults); + if (options.dryRun) console.log(`[DRY RUN] Would free ${formatSize(cleanResults.wouldFreeSpace ?? 0)}; would back up ${formatSize(cleanResults.wouldBackUpSize ?? 0)}`); + else await printCleanResults(cleanResults); // Failures during deletion must be visible to callers (CI, cron, scripts) if (cleanResults.totalErrors > 0) { @@ -221,6 +202,37 @@ export async function cleanCommand(options: CleanCommandOptions): Promise { + let result: CleanSummary = { results: [], totalFreedSpace: 0, totalBackedUpSize: 0, totalCleanedItems: 0, totalErrors: 0, wouldFreeSpace: 0, wouldBackUpSize: 0, wouldCleanItems: 0 }; + const errors: { category?: CategoryId; error: string }[] = []; + const skippedCategories: { category: CategoryId; reason: string }[] = []; + let warnings: { category: CategoryId; path: string; reason: string }[] = []; + try { + if (options.live) throw new Error('--live and --json cannot be combined'); + if (!options.all && !options.categories?.length) throw new Error('JSON_SELECTION_REQUIRED: use --all or --categories'); + if (!options.dryRun && !options.yes) throw new Error('JSON_CONFIRMATION_REQUIRED: actual JSON cleanup requires --yes'); + const request = { roots: options.roots, includeEcosystems: options.includeEcosystems, excludePaths: options.excludePaths }; + const scanned = options.categories?.length ? await runScans(options.categories, request) : await runAllScans(request); + warnings = scanned.results.flatMap((r) => (r.warnings ?? []).map((w) => ({ category: r.category.id, ...w }))); + const selected = scanned.results.filter((r) => { + if (r.error) errors.push({ category: r.category.id, error: r.error }); + const reason = r.skipped ?? (!options.unsafe && r.category.safetyLevel === 'risky' ? 'RISK_NOT_ENABLED' : undefined); + if (reason) skippedCategories.push({ category: r.category.id, reason }); + return !reason && r.items.length > 0; + }); + const config = await loadConfig(); + if (config.repoPolicyError) throw new Error(config.repoPolicyError); + const backup = config.backupEnabled && selected.some((r) => getScanner(r.category.id).supportsBackup !== false); + const backupDir = backup ? (options.dryRun ? `${getBackupDir()}/dry-run` : await ensureBackupDir()) : undefined; + result = await cleanSelectedResults(selected, getScanner, { dryRun: options.dryRun, backupDir }); + } catch (error) { errors.push({ error: error instanceof Error ? error.message : String(error) }); } + result.totalErrors += errors.length; + console.log(JSON.stringify({ version: 1, dryRun: !!options.dryRun, sizeBasis: 'estimated-logical-bytes', ...result, errors, warnings, skippedCategories }, null, 2)); + if (result.totalErrors > 0) process.exitCode = 1; + return result; +} + async function selectItemsInteractively( results: ScanResult[] ): Promise<{ categoryId: CategoryId; items: CleanableItem[] }[]> { diff --git a/src/commands/interactive-live.test.ts b/src/commands/interactive-live.test.ts new file mode 100644 index 0000000..577db53 --- /dev/null +++ b/src/commands/interactive-live.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { CleanContext, ScanSummary } from '../types.js'; +import { CATEGORIES } from '../types.js'; +import { interactiveCommand } from './interactive.js'; +import { liveReviewPrompt } from '../pickers/live-review.js'; +import filePickerPrompt from '../pickers/file-picker.js'; +import { getScanner, runAllScans } from '../scanners/index.js'; +import confirm from '@inquirer/confirm'; + +vi.mock('../scanners/index.js', () => ({ runAllScans: vi.fn(), getScanner: vi.fn(), getAllScanners: vi.fn(() => []) })); +vi.mock('../pickers/live-review.js', () => ({ liveReviewPrompt: vi.fn() })); +vi.mock('../pickers/file-picker.js', () => ({ default: vi.fn() })); +vi.mock('@inquirer/confirm', () => ({ default: vi.fn() })); +vi.mock('../utils/index.js', async (original) => ({ ...await original(), hasFullDiskAccess: async () => true, loadConfig: async () => ({ backupEnabled: false }) })); + +const tty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const context: CleanContext = Object.freeze({ complete: true, evidence: Object.freeze({}), repoPolicy: Object.freeze({ roots: [], categories: ['rust-artifacts'] as const, excludePaths: [], daysOld: 30, configDaysOld: 30, maxDepth: 4, explicitRoots: false }) }); +const items = ['selected', 'unselected'].map((name) => ({ path: `/fixture/${name}/target`, name, size: 5, isDirectory: true })); +const summary: ScanSummary = { results: [{ category: CATEGORIES['rust-artifacts'], items, totalSize: 10, context }], totalSize: 10, totalItems: 2 }; +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); +}); +afterEach(() => { + vi.restoreAllMocks(); + if (tty) Object.defineProperty(process.stdin, 'isTTY', tty); + else Reflect.deleteProperty(process.stdin, 'isTTY'); +}); + +it.each([false, true])('requires a separate final confirmation after live selection (confirm: %s)', async (approved) => { + const clean = vi.fn(async () => ({ category: CATEGORIES['rust-artifacts'], cleanedItems: 1, freedSpace: 5, errors: [] })); + vi.mocked(getScanner).mockReturnValue({ category: CATEGORIES['rust-artifacts'], scan: vi.fn(), clean }); + vi.mocked(liveReviewPrompt).mockResolvedValue({ summary, selected: new Map([['rust-artifacts', new Set([items[0].path])]]), cancelled: false }); + vi.mocked(confirm).mockImplementation(async () => { + expect(clean).not.toHaveBeenCalled(); + return approved; + }); + const result = await interactiveCommand({ live: true, noProgress: true }); + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ default: false })); + expect(runAllScans).not.toHaveBeenCalled(); + expect(filePickerPrompt).not.toHaveBeenCalled(); + if (approved) { + expect(clean).toHaveBeenCalledWith([items[0]], undefined, undefined, context); + expect(result?.totalCleanedItems).toBe(1); + } else { + expect(clean).not.toHaveBeenCalled(); + expect(result).toBeNull(); + } +}); + +it.each([false, true])('never turns empty or cancelled live review into automatic selection (cancelled: %s)', async (cancelled) => { + vi.mocked(liveReviewPrompt).mockResolvedValue({ summary, selected: new Map(), cancelled }); + expect(await interactiveCommand({ live: true, noProgress: true })).toBeNull(); + expect(confirm).not.toHaveBeenCalled(); + expect(getScanner).not.toHaveBeenCalled(); + expect(runAllScans).not.toHaveBeenCalled(); +}); diff --git a/src/commands/interactive.test.ts b/src/commands/interactive.test.ts index 50d729b..35ecf56 100644 --- a/src/commands/interactive.test.ts +++ b/src/commands/interactive.test.ts @@ -436,7 +436,7 @@ describe('interactive command', () => { // Passing it down (instead of simulating here) matters: Docker and // Homebrew override clean() with external commands and have their own // dry-run branch. - expect(mockScanner.clean).toHaveBeenCalledWith(expect.anything(), true, undefined); + expect(mockScanner.clean).toHaveBeenCalledWith(expect.anything(), true, undefined, undefined); consoleSpy.mockRestore(); }); @@ -448,7 +448,7 @@ describe('interactive command', () => { await interactiveCommand({}); - expect(mockScanner.clean).toHaveBeenCalledWith(expect.anything(), undefined, undefined); + expect(mockScanner.clean).toHaveBeenCalledWith(expect.anything(), undefined, undefined, undefined); consoleSpy.mockRestore(); }); diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index ef4bf51..95e9b95 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -1,6 +1,9 @@ +import { liveReviewPrompt } from '../pickers/live-review.js'; +import type { LiveReviewResult } from '../pickers/live-review.js'; import chalk from 'chalk'; +import { cleanSelectedResults } from '../utils/cleanup.js'; import confirm from '@inquirer/confirm'; -import type { CategoryId, CleanSummary, CleanableItem, ScanResult, SafetyLevel } from '../types.js'; +import type { CategoryId, CleanSummary, CleanableItem, ScanResult, SafetyLevel, RepoRequestOptions } from '../types.js'; import { runAllScans, getScanner, getAllScanners } from '../scanners/index.js'; import { formatSize, createScanProgress, createCleanProgress, hasFullDiskAccess, FULL_DISK_ACCESS_HINT, loadConfig, ensureBackupDir, getBackupDir } from '../utils/index.js'; import filePickerPrompt from '../pickers/file-picker.js'; @@ -12,7 +15,8 @@ const SAFETY_ICONS: Record = { risky: chalk.red('●'), }; -interface InteractiveOptions { +interface InteractiveOptions extends RepoRequestOptions { + live?: boolean; includeRisky?: boolean; noProgress?: boolean; absolutePaths?: boolean; @@ -21,6 +25,7 @@ interface InteractiveOptions { } export async function interactiveCommand(options: InteractiveOptions = {}): Promise { + if (options.live && !process.stdin.isTTY) throw new Error('--live requires an interactive terminal'); console.log(); console.log(chalk.bold.cyan('🧹 Mac Cleaner CLI')); console.log(chalk.dim('─'.repeat(50))); @@ -33,25 +38,25 @@ export async function interactiveCommand(options: InteractiveOptions = {}): Prom } // Step 1: Scan - const showProgress = !options.noProgress && process.stdout.isTTY; + const showProgress = !options.noProgress && !options.live && process.stdout.isTTY; const scanners = getAllScanners(); const scanProgress = showProgress ? createScanProgress(scanners.length) : null; console.log(chalk.cyan('Scanning your Mac for cleanable files...\n')); - const summary = await runAllScans({ - parallel: true, - concurrency: 4, - onProgress: (completed, _total, scanner) => { - scanProgress?.update(completed, `Scanning ${scanner.category.name}...`); - }, - }); + let live: LiveReviewResult | undefined; + const request = { roots: options.roots, includeEcosystems: options.includeEcosystems, excludePaths: options.excludePaths }; + if (options.live) live = await liveReviewPrompt({ includeRisky: options.includeRisky, scan: (events) => runAllScans({ ...request, ...events }) }); + if (live?.cancelled) return null; + const summary = live?.summary ?? await runAllScans({ ...request, onProgress: (completed, _total, scanner) => scanProgress?.update(completed, `Scanning ${scanner.category.name}...`) }); scanProgress?.finish(); printScanErrors(summary.results); - if (summary.totalSize === 0) { + if (summary.results.some((r) => r.error)) process.exitCode = 1; + if (summary.totalItems === 0) { + if (summary.results.some((r) => r.error)) return null; console.log(chalk.green('✓ Your Mac is already clean! Nothing to remove.\n')); return null; } @@ -85,7 +90,7 @@ export async function interactiveCommand(options: InteractiveOptions = {}): Prom console.log(); // Step 4: Let user select categories - const selectedItems = await selectItemsInteractively(resultsWithItems, options.absolutePaths, options.filePicker); + const selectedItems = live ? resultsWithItems.map((r) => ({ categoryId: r.category.id, items: r.items.filter((i) => live!.selected.get(r.category.id)?.has(i.path)) })).filter((r) => r.items.length > 0) : await selectItemsInteractively(resultsWithItems, options.absolutePaths, options.filePicker); if (selectedItems.length === 0) { console.log(chalk.yellow('\nNo items selected. Nothing to clean.\n')); @@ -191,41 +196,17 @@ export async function interactiveCommand(options: InteractiveOptions = {}): Prom // folders behind. const backupDir = backupEnabled && selectedItems.some(({ categoryId }) => getScanner(categoryId)?.supportsBackup !== false) - ? await ensureBackupDir() + ? (options.dryRun ? `${getBackupDir()}/dry-run` : await ensureBackupDir()) : undefined; const cleanProgress = showProgress ? createCleanProgress(selectedItems.length) : null; - const cleanResults: CleanSummary = { - results: [], - totalFreedSpace: 0, - totalBackedUpSize: 0, - backupDir, - totalCleanedItems: 0, - totalErrors: 0, - }; - - let cleanedCount = 0; - for (const { categoryId, items } of selectedItems) { - const scanner = getScanner(categoryId); - cleanProgress?.update( - cleanedCount, - `${options.dryRun ? 'Simulating' : 'Cleaning'} ${scanner.category.name}...` - ); - - // dryRun is passed down to the scanner rather than simulated here on - // purpose: Docker and Homebrew override `clean()` with external commands and - // have their own dry-run early return. Simulating from the outside would - // produce a report that does not match what the real command would do. - const scannerBackupDir = scanner.supportsBackup === false ? undefined : backupDir; - const result = await scanner.clean(items, options.dryRun, scannerBackupDir); - cleanResults.results.push(result); - cleanResults.totalFreedSpace += result.freedSpace; - cleanResults.totalBackedUpSize = (cleanResults.totalBackedUpSize ?? 0) + (result.backedUpSize ?? 0); - cleanResults.totalCleanedItems += result.cleanedItems; - cleanResults.totalErrors += result.errors.length; - cleanedCount++; - } + const cleanResults = await cleanSelectedResults(selectedItems.map(({ categoryId, items }) => ({ + ...summary.results.find((r) => r.category.id === categoryId)!, items, + })), getScanner, { + dryRun: options.dryRun, backupDir, + onProgress: (completed, result) => cleanProgress?.update(completed, `Cleaning ${result.category.name}...`), + }); cleanProgress?.finish(); @@ -295,12 +276,12 @@ function printCleanResults(summary: CleanSummary, dryRun = false): void { console.log(chalk.dim('─'.repeat(50))); for (const result of summary.results) { - if (result.cleanedItems > 0) { - const backedUp = (result.backedUpSize ?? 0) > 0; + if ((dryRun ? result.wouldCleanItems ?? 0 : result.cleanedItems) > 0) { + const backedUp = (dryRun ? result.wouldBackUpSize ?? 0 : result.backedUpSize ?? 0) > 0; console.log( backedUp - ? ` ${result.category.name.padEnd(30)} ${chalk.cyan('↦')} ${formatSize(result.backedUpSize ?? 0)} ${dryRun ? 'would be moved' : 'moved'} to backup` - : ` ${result.category.name.padEnd(30)} ${chalk.green('✓')} ${formatSize(result.freedSpace)} ${dryRun ? 'would be freed' : 'freed'}` + ? ` ${result.category.name.padEnd(30)} ${chalk.cyan('↦')} ${formatSize(dryRun ? result.wouldBackUpSize ?? 0 : result.backedUpSize ?? 0)} ${dryRun ? 'would be moved' : 'moved'} to backup` + : ` ${result.category.name.padEnd(30)} ${chalk.green('✓')} ${formatSize(dryRun ? result.wouldFreeSpace ?? 0 : result.freedSpace)} ${dryRun ? 'would be freed' : 'freed'}` ); } for (const error of result.errors) { @@ -312,10 +293,10 @@ function printCleanResults(summary: CleanSummary, dryRun = false): void { console.log(chalk.dim('─'.repeat(50))); console.log( dryRun - ? chalk.bold.cyan(`[DRY RUN] Would free ${formatSize(summary.totalFreedSpace)} of disk space`) + ? chalk.bold.cyan(`[DRY RUN] Would free ${formatSize(summary.wouldFreeSpace ?? 0)} of disk space`) : chalk.bold(`🎉 Freed ${chalk.green(formatSize(summary.totalFreedSpace))} of disk space!`) ); - console.log(chalk.dim(` ${dryRun ? 'Would clean' : 'Cleaned'} ${summary.totalCleanedItems} items`)); + console.log(chalk.dim(` ${dryRun ? 'Would clean' : 'Cleaned'} ${dryRun ? summary.wouldCleanItems ?? 0 : summary.totalCleanedItems} items`)); if ((summary.totalBackedUpSize ?? 0) > 0) { console.log(); diff --git a/src/commands/repo-cli.test.ts b/src/commands/repo-cli.test.ts new file mode 100644 index 0000000..0848873 --- /dev/null +++ b/src/commands/repo-cli.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm, readFile } from 'fs/promises'; +import { join, resolve } from 'path'; +import { writeFileSync } from 'fs'; +import { spawnSync } from 'child_process'; +let home: string; let root: string; +beforeEach(async () => { + home = await mkdtemp('/private/tmp/mac-cleaner-cli-integration-'); root = join(home, 'projects'); + await mkdir(join(root, 'target'), { recursive: true }); await writeFile(join(root, 'Cargo.toml'), ''); await writeFile(join(root, 'target/file'), 'data'); +}); +afterEach(async () => { await rm(home, { recursive: true, force: true }); }); +function cli(args: string[]) { + // An isolated os.homedir mock avoids reading the user's config in this subprocess. + const source = `import { mock } from 'bun:test'; import * as os from 'os'; mock.module('os', () => ({ ...os, homedir: () => ${JSON.stringify(home)} })); process.argv = ['bun', 'mac-cleaner', ...${JSON.stringify(args)}]; await import(${JSON.stringify(resolve('src/index.ts'))});`; + const harness = join(home, 'invoke.ts'); writeFileSync(harness, source); + const result = spawnSync('bun', [harness], { encoding: 'utf8' }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr, json: () => { if (!result.stdout) throw new Error(result.stderr); return JSON.parse(result.stdout); } }; +} +it('keeps scan summary keys, emits metadata and honors roots before a subcommand', () => { + const scan = cli(['--root', root, 'scan', '--category', 'rust-artifacts', '--json', '--verbose']); + expect(scan.status, scan.stderr + scan.stdout).toBe(0); + expect(scan.json()).toMatchObject({ totalItems: 1, totalSize: 4, categories: [{ items: [{ path: join(root, 'target'), size: 4, fileCount: 1, id: expect.any(String), newestEntryModifiedAt: expect.any(String) }] }] }); +}); +it('honors global dry-run and performs real fixture-only JSON cleanup after --yes', async () => { + const args = ['--root', root, 'clean', '--categories', 'rust-artifacts', '--json']; + const simulated = cli(['--dry-run', ...args]); + expect(simulated.status, simulated.stderr + simulated.stdout).toBe(0); expect(simulated.json()).toMatchObject({ totalCleanedItems: 0, wouldCleanItems: 1 }); + expect(await readFile(join(root, 'target/file'), 'utf8')).toBe('data'); + const actual = cli([...args, '--yes']); + expect(actual.status).toBe(0); expect(actual.json()).toMatchObject({ totalCleanedItems: 1, totalFreedSpace: 4 }); +}); +it('emits one JSON failure for malformed keep policy and incompatible live flags', () => { + const bad = cli(['clean', '--root', root, '--categories', 'rust-artifacts', '--json', '--dry-run', '--exclude', '../bad']); + expect(bad.status).toBe(1); expect(bad.json().errors[0].error).toBe('INVALID_EXCLUSIONS'); + const live = cli(['--live', 'clean', '--all', '--json', '--dry-run']); + expect(live.status).toBe(1); expect(live.json().errors[0].error).toContain('--live and --json'); +}); + + +it('simulates configured backup without creating a session directory', async () => { + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ backupEnabled: true })); + const result = cli(['clean', '--root', root, '--categories', 'rust-artifacts', '--json', '--dry-run']); + expect(result.status).toBe(0); + expect(result.json()).toMatchObject({ totalFreedSpace: 0, totalBackedUpSize: 0, totalCleanedItems: 0, wouldBackUpSize: 4 }); + await expect(readFile(join(home, '.mac-cleaner-cli'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(root, 'target/file'), 'utf8')).toBe('data'); +}); diff --git a/src/commands/scan.ts b/src/commands/scan.ts index c0015dc..addd87e 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -1,5 +1,6 @@ +import { liveReviewPrompt } from '../pickers/live-review.js'; import chalk from 'chalk'; -import type { CategoryId, CategoryGroup, ScanSummary, ScanResult, SafetyLevel } from '../types.js'; +import type { CategoryId, CategoryGroup, ScanSummary, ScanResult, SafetyLevel, RepoRequestOptions } from '../types.js'; import { CATEGORIES } from '../types.js'; import { runAllScans, runScans, getAllScanners } from '../scanners/index.js'; import { formatRelativeAge, formatSize, createScanProgress, sanitizeDisplayName } from '../utils/index.js'; @@ -10,7 +11,8 @@ const SAFETY_ICONS: Record = { risky: chalk.red('●'), }; -interface ScanCommandOptions { +interface ScanCommandOptions extends RepoRequestOptions { + live?: boolean; category?: CategoryId; verbose?: boolean; noProgress?: boolean; @@ -18,9 +20,11 @@ interface ScanCommandOptions { } export async function scanCommand(options: ScanCommandOptions): Promise { + if (options.live && options.json) throw new Error('--live and --json cannot be combined'); + if (options.live && !process.stdin.isTTY) throw new Error('--live requires an interactive terminal'); const scanners = options.category ? [options.category] : getAllScanners().map((s) => s.category.id); const total = scanners.length; - const showProgress = !options.noProgress && !options.json && process.stdout.isTTY; + const showProgress = !options.noProgress && !options.json && !options.live && process.stdout.isTTY; const progress = showProgress ? createScanProgress(total) : null; @@ -28,18 +32,20 @@ export async function scanCommand(options: ScanCommandOptions): Promise { progress?.update(completed, `Scanning ${scanner.category.name}...`); }, }; - if (options.category) { - summary = await runScans([options.category], scanOptions); - } else { - summary = await runAllScans(scanOptions); - } + if (options.live) { + const review = await liveReviewPrompt({ readOnly: true, scan: (events) => options.category ? runScans([options.category], { ...scanOptions, ...events }) : runAllScans({ ...scanOptions, ...events }) }); + summary = review.summary; + if (review.cancelled) return { ...summary, cancelled: true }; + } else if (options.category) summary = await runScans([options.category], scanOptions); + else summary = await runAllScans(scanOptions); progress?.finish(); @@ -67,6 +73,9 @@ function toJsonSummary(summary: ScanSummary, verbose = false) { const failed = summary.results.filter((r) => r.error); return { + version: 1, + warnings: summary.results.flatMap((r) => (r.warnings ?? []).map((w) => ({ category: r.category.id, ...w }))), + skippedCategories: summary.results.filter((r) => r.skipped).map((r) => ({ category: r.category.id, reason: r.skipped })), totalSize: summary.totalSize, totalItems: summary.totalItems, ...(failed.length > 0 && { @@ -82,7 +91,7 @@ function toJsonSummary(summary: ScanSummary, verbose = false) { totalSize: r.totalSize, itemCount: r.items.length, ...(verbose && { - items: r.items.map((i) => ({ path: i.path, size: i.size })), + items: r.items.map((i) => ({ path: i.path, size: i.size, ...(i.id && { id: i.id }), ...(i.fileCount !== undefined && { fileCount: i.fileCount }), ...(i.newestEntryModifiedAt && { newestEntryModifiedAt: i.newestEntryModifiedAt }), ...(i.artifact && { artifact: i.artifact }) })), }), })), }; diff --git a/src/index.ts b/src/index.ts index 9c8a338..eb0c4e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { ExitPromptError } from '@inquirer/core'; import { appInstallCommand, cleanCommand, interactiveCommand, listCategories, maintenanceCommand, scanCommand, uninstallCommand } from './commands/index.js'; import { initConfig, configExists, listBackups, cleanOldBackups, restoreBackup, loadConfig, formatSize } from './utils/index.js'; import { formatError, isDebugEnabled } from './utils/errors.js'; -import { CATEGORIES, type CategoryId } from './types.js'; +import { CATEGORIES, type CategoryId, type Ecosystem } from './types.js'; import pkg from '../package.json' with { type: 'json' }; function parseCategoryId(value: string): CategoryId { @@ -21,6 +21,15 @@ function parseCategoryIdList(value: string): CategoryId[] { return value.split(',').map((id) => parseCategoryId(id.trim())); } +function appendOption(value: string, previous: string[] = []): string[] { return [...previous, value]; } +function parseEcosystems(value: string): Ecosystem[] { + return value.split(',').map((part) => { + const name = part.trim(); + if (!['node', 'rust', 'go'].includes(name)) throw new InvalidArgumentError(`Unknown ecosystem "${name}"`); + return name as Ecosystem; + }); +} + /** * Terminates the CLI on an unrecoverable error: a cancelled prompt is a normal * exit, anything else is reported with a non-zero status (full stack with @@ -81,6 +90,10 @@ program .name('mac-cleaner-cli') .description('Open source CLI tool to clean your Mac') .version(pkg.version) + .option('--root ', 'Repository root override (repeatable)', appendOption) + .option('--include ', 'Add ecosystems (node,rust,go)', parseEcosystems) + .option('--exclude ', 'Keep matching repository paths (repeatable)', appendOption) + .option('--live', 'Review discovery live with sorting, search and paging') .option('-r, --risky', 'Include risky categories (downloads, iOS backups, etc)') .option('-f, --file-picker', 'Force file picker for ALL categories') .option('-A, --absolute-paths', 'Show absolute paths instead of truncated notations') @@ -89,10 +102,14 @@ program .action(action(async (options) => { const config = await loadConfig(); await interactiveCommand({ + live: options.live ?? program.opts().live, + roots: [...(program.opts().root ?? []), ...(options.root ?? [])], + includeEcosystems: [...(program.opts().include ?? []), ...(options.include ?? [])], + excludePaths: [...(program.opts().exclude ?? []), ...(options.exclude ?? [])], includeRisky: options.risky, filePicker: options.filePicker ?? config.filePicker, absolutePaths: options.absolutePaths, - dryRun: options.dryRun, + dryRun: options.dryRun ?? program.opts().dryRun, noProgress: !options.progress, }); })); @@ -100,12 +117,20 @@ program program .command('scan') .description('Scan for cleanable files without deleting anything') + .option('--root ', 'Repository root override (repeatable)', appendOption) + .option('--include ', 'Add ecosystems (node,rust,go)', parseEcosystems) + .option('--exclude ', 'Keep matching repository paths (repeatable)', appendOption) + .option('--live', 'Live read-only review (interactive terminal only)') .option('-c, --category ', 'Scan a single category', parseCategoryId) .option('-v, --verbose', 'Show the largest items in each category') .option('--json', 'Output results as JSON (for scripts and integrations)') .option('--no-progress', 'Disable progress bar') .action(action(async (options) => { await scanCommand({ + live: options.live ?? program.opts().live, + roots: [...(program.opts().root ?? []), ...(options.root ?? [])], + includeEcosystems: [...(program.opts().include ?? []), ...(options.include ?? [])], + excludePaths: [...(program.opts().exclude ?? []), ...(options.exclude ?? [])], category: options.category, verbose: options.verbose, json: options.json, @@ -116,6 +141,10 @@ program program .command('clean') .description('Clean cleanable files (non-interactive with --all or --categories)') + .option('--root ', 'Repository root override (repeatable)', appendOption) + .option('--include ', 'Add ecosystems (node,rust,go)', parseEcosystems) + .option('--exclude ', 'Keep matching repository paths (repeatable)', appendOption) + .option('--json', 'Output one JSON result; requires --all/--categories and --yes for actual cleanup') .option('-a, --all', 'Clean all non-risky categories without selection') .option('-c, --categories ', 'Comma-separated category ids to clean (e.g. trash,browser-cache)', parseCategoryIdList) .option('-y, --yes', 'Skip confirmation prompt') @@ -124,10 +153,15 @@ program .option('--no-progress', 'Disable progress bar') .action(action(async (options) => { await cleanCommand({ + json: options.json, + live: options.live ?? program.opts().live, + roots: [...(program.opts().root ?? []), ...(options.root ?? [])], + includeEcosystems: [...(program.opts().include ?? []), ...(options.include ?? [])], + excludePaths: [...(program.opts().exclude ?? []), ...(options.exclude ?? [])], all: options.all, categories: options.categories, yes: options.yes, - dryRun: options.dryRun, + dryRun: options.dryRun ?? program.opts().dryRun, unsafe: options.unsafe, noProgress: !options.progress, }); @@ -142,7 +176,7 @@ program .action(action(async (options) => { await uninstallCommand({ yes: options.yes, - dryRun: options.dryRun, + dryRun: options.dryRun ?? program.opts().dryRun, noProgress: !options.progress, }); })); @@ -161,7 +195,7 @@ program purgeable: options.purgeable, timemachine: options.timemachine, yes: options.yes, - dryRun: options.dryRun, + dryRun: options.dryRun ?? program.opts().dryRun, }); })); diff --git a/src/pickers/README.md b/src/pickers/README.md index 1def40f..2079582 100644 --- a/src/pickers/README.md +++ b/src/pickers/README.md @@ -147,3 +147,22 @@ State is preserved when switching between categories. - Pagination boundary conditions - Clipboard integration (`c` key handler) - Directory grouping edge cases (empty dirs, deeply nested paths) + + +## Live review + +`live-review.ts` uses the same supported `@inquirer/core` prompt API as the file picker. `live-review-state.ts` keeps category/path selections separate from sorting, search, paging and incoming rows. All selections start empty. Size and newest-entry-mtime sorts descend; ties use canonical path, then category. + +| Key | Live action | +| --- | --- | +| `↑` / `↓` | Move within the current page | +| `space` | Toggle one row (disabled in read-only scan) | +| `s` / `m` / `p` | Sort by size / newest mtime / path | +| `/` | Edit search; Enter finishes search | +| `n` / `b`, `→` / `←` | Next / previous page | +| `q` / Escape | Cancel discovery/review without cleanup | +| Enter | Finish review only after scanning completes | + +The prompt owns a request-local AbortController. It only returns explicit selections; the command shows a final confirmation and passes the original scan context through selected-path validation. `--json` remains one document and cannot run live review. Risky Node build and Go categories have no automatic file selections. + +`live-review.test.ts` covers state behavior. `live-review-prompt.test.ts` drives the real Inquirer keypress/render cycle through streams, including Enter while scanning, cancellation, risky visibility, read-only behavior and selection retention. I6 also received a real PTY smoke check recorded in the execution tracker. diff --git a/src/pickers/live-review-prompt.test.ts b/src/pickers/live-review-prompt.test.ts new file mode 100644 index 0000000..23510b8 --- /dev/null +++ b/src/pickers/live-review-prompt.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from 'vitest'; +import { PassThrough } from 'stream'; +import { liveReviewPrompt } from './live-review.js'; +import { CATEGORIES, type ScannerOptions, type ScanSummary } from '../types.js'; +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); +const category = CATEGORIES['rust-artifacts']; +const items = Array.from({ length: 12 }, (_, i) => ({ path: `/fixture/${i}/target`, name: String(i), size: i + 1, isDirectory: true })); +const summary = { results: [{ category, items, totalSize: 78 }], totalSize: 78, totalItems: 12 }; +function terminal() { + const input = new PassThrough(); const output = new PassThrough(); let text = ''; + output.on('data', (data: Buffer) => { text += data.toString(); }); + return { input, output, text: () => text }; +} +it('handles actual keypress/render cycles while retaining only explicit selections', async () => { + const io = terminal(); let events!: ScannerOptions; let complete!: (summary: ScanSummary) => void; + const promise = liveReviewPrompt({ scan: (options) => { events = options; return new Promise((resolve) => { complete = resolve; }); } }, io); + let settled = false; void promise.then(() => { settled = true; }); + await tick(); io.input.write('\r'); await tick(); + expect(settled).toBe(false); expect(io.text()).toContain('Enter cannot submit cleanup'); + events.onEvent?.({ type: 'candidate', runId: 'test', category: category.id, item: items[0] }); + await tick(); io.input.write(' '); await tick(); + for (const item of items.slice(1)) events.onEvent?.({ type: 'candidate', runId: 'test', category: category.id, item }); + events.onEvent?.({ type: 'warning', runId: 'test', reason: 'fixture warning' }); + events.onEvent?.({ type: 'progress', runId: 'test', completed: 1 }); + await tick(); io.input.write('p/0\rnbms\u001b[B\u001b[A'); await tick(); + expect(io.text()).toContain('1 selected'); expect(io.text()).toContain('Search: 0'); + io.input.write('/\u007f\rnb'); await tick(); expect(io.text()).toContain('page 2/2'); + complete(summary); await tick(); io.input.write('\r'); + const result = await promise; + expect([...result.selected.get(category.id)!]).toEqual([items[0].path]); expect(result.cancelled).toBe(false); + io.input.end(); +}); +it('read-only review includes risky candidates but space never selects them', async () => { + const io = terminal(); + const promise = liveReviewPrompt({ readOnly: true, scan: async (options) => { + options.onEvent?.({ type: 'candidate', runId: 'test', category: 'node-build-artifacts', item: items[0] }); return summary; + } }, io); + await tick(); io.input.write(' \r'); + expect((await promise).selected.size).toBe(0); expect(io.text()).toContain('(read only)'); io.input.end(); +}); +it('hides risky rows by default and aborts on q without a selection', async () => { + const io = terminal(); let events!: ScannerOptions; + const promise = liveReviewPrompt({ scan: (options) => { events = options; options.onEvent?.({ type: 'candidate', runId: 'test', category: 'node-build-artifacts', item: items[0] }); return new Promise(() => {}); } }, io); + await tick(); expect(io.text()).toContain('0 candidates'); io.input.write('q'); + const result = await promise; expect(result.cancelled).toBe(true); expect(events.signal?.aborted).toBe(true); expect(result.selected.size).toBe(0); + events.onEvent?.({ type: 'warning', runId: 'late', reason: 'late' }); io.input.end(); +}); +it('can review opted-in risky rows and cancel a failed or aborted scan', async () => { + for (const mode of ['failed', 'aborted'] as const) { + const io = terminal(); + const promise = liveReviewPrompt({ includeRisky: true, scan: async (options) => { + options.onEvent?.({ type: 'candidate', runId: 'test', category: 'node-build-artifacts', item: items[0] }); + if (mode === 'failed') throw new Error('fixture error'); + return { ...summary, cancelled: true }; + } }, io); + await tick(); io.input.write('\r'); await tick(); io.input.write('\u001b'); + expect((await promise).cancelled).toBe(true); io.input.end(); + } +}); diff --git a/src/pickers/live-review-state.ts b/src/pickers/live-review-state.ts new file mode 100644 index 0000000..c497c2c --- /dev/null +++ b/src/pickers/live-review-state.ts @@ -0,0 +1,34 @@ +import type { CategoryId, CleanableItem } from '../types.js'; +export interface LiveRow { category: CategoryId; item: CleanableItem } +export type LiveSort = 'size' | 'mtime' | 'path'; +export interface LiveState { + rows: LiveRow[]; + selected: Set; + sort: LiveSort; + query: string; + page: number; + cursor: number; + scanning: boolean; + cancelled: boolean; +} +export const liveKey = (row: LiveRow): string => row.category + '\0' + row.item.path; +export const initialLiveState = (): LiveState => ({ rows: [], selected: new Set(), sort: 'size', query: '', page: 0, cursor: 0, scanning: true, cancelled: false }); +export function liveView(state: LiveState, pageSize = 10): { rows: LiveRow[]; page: number; pages: number } { + const rows = state.rows.filter((r) => `${r.category} ${r.item.path}`.toLowerCase().includes(state.query.toLowerCase())).sort((a, b) => { + const primary = state.sort === 'size' ? b.item.size - a.item.size : state.sort === 'mtime' ? +(b.item.newestEntryModifiedAt ?? b.item.modifiedAt ?? 0) - +(a.item.newestEntryModifiedAt ?? a.item.modifiedAt ?? 0) : 0; + return primary || a.item.path.localeCompare(b.item.path) || a.category.localeCompare(b.category); + }); + const pages = Math.max(1, Math.ceil(rows.length / pageSize)); + const page = Math.max(0, Math.min(state.page, pages - 1)); + return { rows: rows.slice(page * pageSize, (page + 1) * pageSize), page, pages }; +} +export function addLiveRow(state: LiveState, row: LiveRow): LiveState { + return { ...state, rows: [...state.rows.filter((r) => liveKey(r) !== liveKey(row)), row] }; +} +export function toggleLiveRow(state: LiveState, row: LiveRow): LiveState { + const selected = new Set(state.selected); + const key = liveKey(row); + if (selected.has(key)) selected.delete(key); else selected.add(key); + return { ...state, selected }; +} +export function canSubmitLive(state: LiveState): boolean { return !state.scanning && !state.cancelled; } diff --git a/src/pickers/live-review.test.ts b/src/pickers/live-review.test.ts new file mode 100644 index 0000000..b90f78d --- /dev/null +++ b/src/pickers/live-review.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest'; +import { addLiveRow, canSubmitLive, initialLiveState, liveKey, liveView, toggleLiveRow, type LiveRow } from './live-review-state.js'; +const row = (path: string, size: number, mtime = 1): LiveRow => ({ category: 'rust-artifacts', item: { path, name: path, size, isDirectory: true, newestEntryModifiedAt: new Date(mtime) } }); +it('starts empty and cannot submit until scanning finishes', () => { + const state = initialLiveState(); + expect(state.selected.size).toBe(0); + expect(canSubmitLive(state)).toBe(false); + expect(canSubmitLive({ ...state, scanning: false })).toBe(true); + expect(canSubmitLive({ ...state, scanning: false, cancelled: true })).toBe(false); +}); +it('preserves selected identities across arrivals, sorting, searching and paging', () => { + let state = addLiveRow(initialLiveState(), row('/b', 10)); + state = toggleLiveRow(state, state.rows[0]); + state = addLiveRow(state, row('/a', 10, 2)); + state = addLiveRow(state, row('/c', 20, 3)); + expect(liveView(state).rows.map((r) => r.item.path)).toEqual(['/c', '/a', '/b']); + expect(liveView({ ...state, sort: 'path' }).rows.map((r) => r.item.path)).toEqual(['/a', '/b', '/c']); + expect(liveView({ ...state, sort: 'mtime' }).rows.map((r) => r.item.path)).toEqual(['/c', '/a', '/b']); + expect(liveView({ ...state, query: '/a' }).rows).toHaveLength(1); + expect(liveView({ ...state, page: 1 }, 2).rows.map((r) => r.item.path)).toEqual(['/b']); + expect(state.selected).toEqual(new Set([liveKey(row('/b', 10))])); + state = addLiveRow(state, row('/b', 30)); + expect(state.rows).toHaveLength(3); + expect(state.selected.size).toBe(1); +}); diff --git a/src/pickers/live-review.ts b/src/pickers/live-review.ts new file mode 100644 index 0000000..29cd223 --- /dev/null +++ b/src/pickers/live-review.ts @@ -0,0 +1,84 @@ +import { createPrompt, isDownKey, isEnterKey, isUpKey, useEffect, useKeypress, useRef, useState } from '@inquirer/core'; +import chalk from 'chalk'; +import type { CategoryId, ScannerOptions, ScanSummary } from '../types.js'; +import { CATEGORIES } from '../types.js'; +import { formatSize, sanitizeDisplayName } from '../utils/index.js'; +import { addLiveRow, canSubmitLive, initialLiveState, liveKey, liveView, toggleLiveRow, type LiveState } from './live-review-state.js'; + +export interface LiveReviewResult { summary: ScanSummary; selected: Map>; cancelled: boolean } +interface LiveConfig { + scan: (options: ScannerOptions) => Promise; + readOnly?: boolean; + includeRisky?: boolean; +} +/** Same supported Inquirer prompt infrastructure as the ordinary file picker. */ +export const liveReviewPrompt = createPrompt((config, done) => { + const [state, setState] = useState(initialLiveState); + const stateRef = useRef(state); + stateRef.current = state; + const [summary, setSummary] = useState(); + const [editing, setEditing] = useState(false); + const [message, setMessage] = useState(''); + const [warnings, setWarnings] = useState(0); + const warningCount = useRef(0); + const abort = useRef(new AbortController()); + function update(next: LiveState) { stateRef.current = next; setState(next); } + useEffect(() => { + let disposed = false; + void config.scan({ signal: abort.current.signal, onEvent: (event) => { + if (disposed) return; + if (event.type === 'candidate' && (config.readOnly || config.includeRisky || CATEGORIES[event.category].safetyLevel !== 'risky')) update(addLiveRow(stateRef.current, { category: event.category, item: event.item })); + if (event.type === 'warning') setWarnings(++warningCount.current); + if (event.type === 'progress') setMessage('Discovering and checking candidates…'); + } }).then((scanned) => { + if (disposed) return; + setSummary(scanned); + update({ ...stateRef.current, scanning: false, cancelled: !!scanned.cancelled }); + setMessage(scanned.cancelled ? 'Scan cancelled' : 'Scan complete. Enter finishes review.'); + }).catch((error: unknown) => { + if (disposed) return; + update({ ...stateRef.current, scanning: false, cancelled: true }); + setMessage(`Scan failed: ${String(error)}`); + }); + return () => { disposed = true; abort.current.abort(); }; + }, []); + const view = liveView(state); + useKeypress((key, rl) => { + if (editing) { + if (isEnterKey(key) || key.name === 'escape') setEditing(false); + else if (!key.ctrl) update({ ...state, query: rl.line, page: 0, cursor: 0 }); + return; + } + if (key.name === 'q' || key.name === 'escape') { + abort.current.abort(); + done({ summary: summary ?? { results: [], totalSize: 0, totalItems: 0, cancelled: true }, selected: new Map(), cancelled: true }); + } else if (isEnterKey(key)) { + if (!canSubmitLive(state) || !summary) { setMessage('Still scanning — Enter cannot submit cleanup. Use q to cancel.'); return; } + const selected = new Map>(); + if (!config.readOnly) for (const row of state.rows) if (state.selected.has(liveKey(row))) { + const paths = selected.get(row.category) ?? new Set(); paths.add(row.item.path); selected.set(row.category, paths); + } + done({ summary, selected, cancelled: false }); + } else if (isDownKey(key)) update({ ...state, cursor: Math.max(0, Math.min(state.cursor + 1, view.rows.length - 1)) }); + else if (isUpKey(key)) update({ ...state, cursor: Math.max(0, state.cursor - 1) }); + else if (key.name === 'space' && !config.readOnly && view.rows[state.cursor]) update(toggleLiveRow(state, view.rows[state.cursor])); + else if (key.name === 'n' || key.name === 'right') update({ ...state, page: Math.min(view.page + 1, view.pages - 1), cursor: 0 }); + else if (key.name === 'b' || key.name === 'left') update({ ...state, page: Math.max(view.page - 1, 0), cursor: 0 }); + else if (['s', 'm', 'p'].includes(key.name ?? '')) update({ ...state, sort: key.name === 's' ? 'size' : key.name === 'm' ? 'mtime' : 'path', cursor: 0 }); + else if (rl.line === '/') { setEditing(true); rl.clearLine(0); rl.write(state.query); return; } + rl.clearLine(0); + }); + const rows = view.rows.map((row, index) => { + const label = `${index === state.cursor ? '❯' : ' '} ${state.selected.has(liveKey(row)) ? '[x]' : '[ ]'} ${formatSize(row.item.size).padStart(10)} ${sanitizeDisplayName(row.item.path)}`; + return CATEGORIES[row.category].safetyLevel === 'risky' ? chalk.yellow(label) : label; + }); + return [ + chalk.bold(`Live repository review${config.readOnly ? ' (read only)' : ''} — ${state.scanning ? 'scanning' : 'complete'}`), + `${state.rows.length} candidates • ${state.selected.size} selected • ${warnings} warnings • page ${view.page + 1}/${view.pages} • sort ${state.sort}`, + `Search${editing ? ' (type, Enter to finish)' : ''}: ${sanitizeDisplayName(state.query)}`, + ...rows, + chalk.dim('↑/↓ move • space select • s size / m modified / p path • / search • n/b pages • q cancel'), + chalk.dim('Enter finishes review after scanning. Cleanup requires a separate confirmation.'), + message, + ].join('\n'); +}); diff --git a/src/repo-artifacts/categories.test.ts b/src/repo-artifacts/categories.test.ts new file mode 100644 index 0000000..851de06 --- /dev/null +++ b/src/repo-artifacts/categories.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; +const { home } = vi.hoisted(() => ({ home: `/private/tmp/mac-cleaner-categories-${process.pid}` })); +vi.mock('os', async (original) => ({ ...await original(), homedir: () => home })); +import { clearConfigCache } from '../utils/config.js'; +import { runScans } from '../scanners/index.js'; +import { scanRepoArtifacts } from './walker.js'; +import { newMetrics } from './eligibility.js'; +import { CATEGORIES } from '../types.js'; +import { autoSelectableFilePaths } from '../pickers/selection-rules.js'; +let root: string; +beforeEach(async () => { clearConfigCache(); await mkdir(home, { recursive: true }); root = await mkdtemp(join(home, 'fixture-')); }); +afterEach(async () => { clearConfigCache(); await rm(home, { recursive: true, force: true }); }); +const git = (...args: string[]) => execFileSync('git', ['-C', root, ...args], { stdio: 'pipe' }); +async function outputs(parent: string, marker: string, names: string[]) { + await mkdir(parent, { recursive: true }); await writeFile(join(parent, marker), '{}'); + for (const name of names) { await mkdir(join(parent, name), { recursive: true }); await writeFile(join(parent, name, 'file'), 'data'); } +} +it('classifies the full Rust/Node table and omits ambiguous non-Git outputs', async () => { + await outputs(root, 'package.json', ['node_modules', '.next', '.turbo', '.parcel-cache', 'coverage', 'dist', 'build', 'out', '.cache']); + await outputs(root, 'Cargo.toml', ['target']); + const ids = ['node-modules', 'node-build-artifacts', 'rust-artifacts'] as const; + const nonGit = await runScans([...ids], { roots: [root], daysOld: 0 }); + expect(nonGit.totalItems).toBe(5); + git('init', '-q'); await writeFile(join(root, '.gitignore'), 'coverage/\ndist/\nbuild/\nout/\n.cache/\n'); + const metrics = newMetrics(); + const results = await scanRepoArtifacts(ids.map((id) => CATEGORIES[id]), { roots: [root], daysOld: 0 }, metrics); + expect(results.flatMap((r) => r.items)).toHaveLength(10); + expect(metrics.candidatesInspected).toBe(10); + expect(autoSelectableFilePaths(results, 'node-build-artifacts')).toEqual([]); + expect(CATEGORIES['node-build-artifacts'].supportsFileSelection).toBe(true); + git('add', '-f', 'dist/file'); + expect((await runScans([...ids], { roots: [root], daysOld: 0 })).totalItems).toBe(9); +}); +it('bounds parent discovery at depth 0..4 but checks all candidate descendants', async () => { + for (let depth = 0; depth <= 5; depth++) await outputs(join(root, ...Array(depth).fill('child')), 'Cargo.toml', ['target']); + const result = await runScans(['rust-artifacts'], { roots: [root] }); + expect(result.totalItems).toBe(5); + const deep = join(root, 'target', ...Array(8).fill('inside')); + await mkdir(deep, { recursive: true }); await writeFile(join(deep, 'keep.txt'), 'keep'); + const kept = await runScans(['rust-artifacts'], { roots: [root], excludePaths: ['**/keep.txt'] }); + expect(kept.totalItems).toBe(4); +}); +it('discovers hidden worktree containers, prunes dependency trees, keeps legacy roots Node-only', async () => { + await outputs(join(root, '.claude/worktrees/project'), 'Cargo.toml', ['target']); + await outputs(join(root, 'node_modules/embedded'), 'Cargo.toml', ['target']); + expect((await runScans(['rust-artifacts'], { roots: [root] })).totalItems).toBe(1); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ extraPaths: { nodeModules: [root] } })); clearConfigCache(); + expect((await runScans(['rust-artifacts'])).totalItems).toBe(0); +}); +it('shares discovery across categories and overlapping roots', async () => { + await outputs(join(root, 'project'), 'Cargo.toml', ['target']); + await outputs(join(root, 'project'), 'package.json', ['.next']); + const metrics = newMetrics(); + const results = await scanRepoArtifacts([CATEGORIES['rust-artifacts'], CATEGORIES['node-build-artifacts']], { roots: [root, join(root, 'project')] }, metrics); + expect(results.flatMap((r) => r.items)).toHaveLength(2); + expect(metrics.candidatesInspected).toBe(2); +}); + +it('assigns a Node/Go dist collision once and allows explicit Go-only selection', async () => { + await outputs(root, 'package.json', ['dist']); await outputs(root, 'go.mod', ['bin']); + git('init', '-q'); await writeFile(join(root, '.gitignore'), 'dist/\nbin/\n'); + const both = await runScans(['go-artifacts', 'node-build-artifacts'], { roots: [root] }); + expect(both.results.find((r) => r.category.id === 'node-build-artifacts')?.items.map((i) => i.path)).toEqual([join(root, 'dist')]); + expect(both.results.find((r) => r.category.id === 'go-artifacts')?.items.map((i) => i.path)).toEqual([join(root, 'bin')]); + const only = await runScans(['go-artifacts'], { roots: [root] }); + expect(only.totalItems).toBe(2); + const { getScanner } = await import('../scanners/index.js'); + expect((await getScanner('go-artifacts').clean(only.results[0].items, true)).cleanedItems).toBe(0); + expect((await getScanner('go-artifacts').clean(only.results[0].items, true, undefined, only.results[0].context)).wouldCleanItems).toBe(2); +}); + +it('omits Go from defaults, opts in by include/config/category and honors exclusions', async () => { + const { ALL_SCANNERS, runAllScans } = await import('../scanners/index.js'); + // Stub non-repository discovery so this fixture cannot scan the host. + const spies = Object.values(ALL_SCANNERS).filter((s) => !['node-modules', 'node-build-artifacts', 'rust-artifacts', 'go-artifacts'].includes(s.category.id)).map((s) => vi.spyOn(s, 'scan').mockResolvedValue({ category: s.category, items: [], totalSize: 0 })); + try { + await outputs(root, 'go.mod', ['bin', 'dist']); git('init', '-q'); await writeFile(join(root, '.gitignore'), 'dist/\nbin/\n'); + expect((await runAllScans({ roots: [root] })).results.some((r) => r.category.id === 'go-artifacts')).toBe(false); + expect((await runAllScans({ roots: [root], includeEcosystems: ['go'] })).totalItems).toBe(2); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ includeEcosystems: ['go'] })); clearConfigCache(); + expect((await runAllScans({ roots: [root] })).totalItems).toBe(2); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ excludeCategories: ['go-artifacts'] })); clearConfigCache(); + expect((await runScans(['go-artifacts'], { roots: [root] })).results[0].skipped).toBe('CATEGORY_EXCLUDED'); + } finally { spies.forEach((s) => s.mockRestore()); } +}); + +it('emits one lifecycle with matching candidates/warnings and returns a Promise summary', async () => { + await outputs(root, 'Cargo.toml', ['target']); + await outputs(join(root, 'node'), 'package.json', ['dist']); + const events: import('../types.js').ScanEvent[] = []; + const result = await runScans(['rust-artifacts', 'node-build-artifacts'], { roots: [root], onEvent: (event) => events.push(event) }); + expect(events.filter((e) => e.type === 'start')).toHaveLength(1); + expect(events.filter((e) => e.type === 'done')).toHaveLength(1); + expect(new Set(events.map((e) => e.runId)).size).toBe(1); + expect(events.filter((e) => e.type === 'candidate')).toHaveLength(result.totalItems); + expect(events.at(-1)).toMatchObject({ type: 'done', cancelled: false, candidates: 1, warnings: 1 }); + expect(result.results.flatMap((r) => r.warnings ?? [])).toHaveLength(1); + expect(events.some((e) => e.type === 'progress')).toBe(true); +}); +it('cancels during metadata emission without publishing a usable clean plan', async () => { + await outputs(root, 'Cargo.toml', ['target']); + await outputs(join(root, 'next'), 'Cargo.toml', ['target']); + const abort = new AbortController(); + const events: import('../types.js').ScanEvent[] = []; + const result = await runScans(['rust-artifacts'], { roots: [root], signal: abort.signal, onEvent: (event) => { events.push(event); if (event.type === 'candidate') abort.abort(); } }); + expect(result.cancelled).toBe(true); + expect(result.totalItems).toBe(0); + expect(result.results[0].context?.complete).toBe(false); + expect(events.at(-1)).toMatchObject({ type: 'done', cancelled: true, candidates: 0 }); + const preAborted = await runScans(['rust-artifacts'], { roots: [root], signal: abort.signal }); + expect(preAborted.totalItems).toBe(0); +}); +it('isolates candidate event observers from scan evidence and still terminates on policy errors', async () => { + await outputs(root, 'Cargo.toml', ['target']); + const result = await runScans(['rust-artifacts'], { roots: [root], onEvent: (event) => { if (event.type === 'candidate') event.item.path = '/forged'; } }); + expect(result.results[0].items[0].path).toBe(join(root, 'target')); + const events: import('../types.js').ScanEvent[] = []; + await runScans(['rust-artifacts'], { roots: [root], excludePaths: ['../bad'], onEvent: (e) => events.push(e) }); + expect(events[0].type).toBe('start'); + expect(events.at(-1)?.type).toBe('done'); + expect(events.filter((e) => e.type === 'warning')).toHaveLength(1); +}); + + +it('supports standalone scanner calls with the same safe configuration', async () => { + const { getScanner } = await import('../scanners/index.js'); + await outputs(root, 'Cargo.toml', ['target']); await outputs(root, 'package.json', ['.next']); await outputs(root, 'go.mod', ['bin']); + git('init', '-q'); await writeFile(join(root, '.gitignore'), 'bin/'); + for (const category of ['rust-artifacts', 'node-build-artifacts', 'go-artifacts'] as const) { + const events: import('../types.js').ScanEvent[] = []; + const result = await getScanner(category).scan({ roots: [root], onEvent: (event) => events.push(event) }); + expect(result.items).toHaveLength(1); expect(events.at(-1)).toMatchObject({ type: 'done', candidates: 1 }); + } +}); diff --git a/src/repo-artifacts/eligibility.ts b/src/repo-artifacts/eligibility.ts new file mode 100644 index 0000000..424149c --- /dev/null +++ b/src/repo-artifacts/eligibility.ts @@ -0,0 +1,239 @@ +import { spawn } from 'child_process'; +import { lstat, realpath } from 'fs/promises'; +import { homedir } from 'os'; +import { dirname, isAbsolute, join, relative, resolve } from 'path'; +import type { ArtifactEvidence, CleanableItem, CleanContext, RepoCategoryId, RepoPolicy, RepoRoot, ScannerOptions } from '../types.js'; +import { classify } from './rules.js'; +import { loadConfig } from '../utils/config.js'; +import { inspectTree } from './walker.js'; + +export class EligibilityError extends Error { + constructor(readonly reason: string) { super(reason); } +} +export function contains(parent: string, path: string): boolean { + return path === parent || path.startsWith(parent + '/'); +} +export function reasonOf(error: unknown): string { + if (error instanceof EligibilityError) return error.reason; + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'MISSING' : (error as NodeJS.ErrnoException).code ?? 'INSPECTION_FAILED'; +} +export function validatePatterns(patterns: unknown): asserts patterns is string[] { + if (!Array.isArray(patterns) || patterns.some((p) => typeof p !== 'string' || !p || isAbsolute(p) || /[!{}[\]\\\0]/.test(p) || p.split('/').some((s) => !s || s === '.' || s === '..' || (s.includes('**') && s !== '**')))) { + throw new EligibilityError('INVALID_EXCLUSIONS'); + } +} +function segmentMatch(pattern: string, name: string): boolean { + return new RegExp('^' + pattern.replace(/[.+^$()|\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 's').test(name); +} +export function matchesExclude(pattern: string, path: string): boolean { + const parts = path.split('/'); + if (!pattern.includes('/')) return parts.some((part) => segmentMatch(pattern, part)); + const patterns = pattern.split('/'); + const match = (i: number, j: number): boolean => { + if (i === patterns.length) return true; // prefix, on a component boundary + if (patterns[i] === '**') return match(i + 1, j) || (j < parts.length && match(i, j + 1)); + return j < parts.length && segmentMatch(patterns[i], parts[j]) && match(i + 1, j + 1); + }; + return match(0, 0); +} +export function excluded(path: string, policy: RepoPolicy): boolean { + return policy.roots.some((root) => contains(root.path, path) && policy.excludePaths.some((pattern) => matchesExclude(pattern, relative(root.path, path)))); +} +export function freezeContext(policy: RepoPolicy, items: CleanableItem[], complete = true): CleanContext { + const evidence = Object.fromEntries(items.filter((i) => i.artifact).map((i) => [i.path, Object.freeze({ ...i.artifact! })])); + return Object.freeze({ repoPolicy: Object.freeze({ ...policy, roots: Object.freeze(policy.roots.map((r) => Object.freeze({ ...r }))), categories: Object.freeze([...policy.categories]), excludePaths: Object.freeze([...policy.excludePaths]) }), evidence: Object.freeze(evidence), complete }); +} + +export async function normalizePolicy(categories: RepoCategoryId[], options: ScannerOptions = {}, refresh = false): Promise { + const config = await loadConfig(undefined, refresh); + if (config.repoPolicyError) throw new EligibilityError(config.repoPolicyError); + if (options.excludePaths !== undefined) validatePatterns(options.excludePaths); + if (options.roots !== undefined && !Array.isArray(options.roots)) throw new EligibilityError('INVALID_ROOTS'); + if (options.includeEcosystems !== undefined && !Array.isArray(options.includeEcosystems)) throw new EligibilityError('INVALID_ECOSYSTEM'); + const patterns = [...(config.excludePaths ?? []), ...(options.excludePaths ?? [])]; + validatePatterns(patterns); + if (options.includeEcosystems?.some((e) => !['node', 'rust', 'go'].includes(e))) throw new EligibilityError('INVALID_ECOSYSTEM'); + const daysOld = options.daysOld ?? config.nodeModulesDaysOld ?? 30; + if (!Number.isInteger(daysOld) || daysOld < 0 || daysOld > 365) throw new EligibilityError('INVALID_AGE'); + const explicitRoots = !!options.roots?.length; + const defaults = ['Projects', 'Developer', 'Code', 'dev', 'workspace', 'repos'].map((p) => join(homedir(), p)); + const inputs = explicitRoots ? options.roots!.map((input) => ({ input, nodeOnly: false })) : [ + ...[...defaults, ...(config.extraPaths?.projects ?? [])].map((input) => ({ input, nodeOnly: false })), + ...(categories.includes('node-modules') ? (config.extraPaths?.nodeModules ?? []).map((input) => ({ input, nodeOnly: true })) : []), + ]; + const roots: RepoRoot[] = []; + const home = await realpath(homedir()); + for (const { input, nodeOnly } of inputs) { + if (typeof input !== 'string' || !input || input.includes('\0')) throw new EligibilityError('INVALID_ROOT'); + const lexical = resolve(input.startsWith('~/') ? join(homedir(), input.slice(2)) : input); + try { + const path = await realpath(lexical); + if (![home, '/Users', '/Volumes'].some((base) => path.startsWith(base + '/')) || [home, '/Users', '/Volumes'].includes(path)) throw new EligibilityError('INVALID_ROOT'); + const stat = await lstat(path); + if (!stat.isDirectory()) throw new EligibilityError('INVALID_ROOT'); + if (!roots.some((r) => r.input === lexical && r.nodeOnly === nodeOnly)) roots.push({ input: lexical, path, nodeOnly, dev: stat.dev, ino: stat.ino }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !explicitRoots) continue; + throw error; + } + } + return { roots, categories: categories.filter((c) => !config.excludeCategories?.includes(c as import('../types.js').CategoryId)), excludePaths: patterns, daysOld, configDaysOld: config.nodeModulesDaysOld ?? 30, maxDepth: config.projectScanMaxDepth ?? 4, explicitRoots }; +} + +async function currentPolicy(context: CleanContext): Promise<{ policy: RepoPolicy; currentRoots: readonly RepoRoot[] }> { + const original = context.repoPolicy; + const current = await normalizePolicy([...original.categories], { roots: original.explicitRoots ? original.roots.map((r) => r.input) : undefined, excludePaths: [...original.excludePaths] }, true); + const policy = { ...original, maxDepth: Math.min(original.maxDepth, current.maxDepth), categories: current.categories, excludePaths: current.excludePaths, daysOld: Math.max(current.configDaysOld === original.configDaysOld ? original.daysOld : current.configDaysOld, original.daysOld), roots: original.roots.filter((r) => current.roots.some((c) => contains(c.path, r.path) && (!c.nodeOnly || r.nodeOnly))) }; + return { policy, currentRoots: current.roots }; +} + +/** Lexical checks precede realpath; no links below a bound canonical root. */ +export async function checkIdentity(path: string, policy: RepoPolicy, evidence?: ArtifactEvidence): Promise { + const stat = await lstat(path); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new EligibilityError('TYPE_CHANGED'); + const roots = policy.roots.filter((root) => path !== root.path && contains(root.path, path)); + if (!roots.length) throw new EligibilityError('OUT_OF_ROOT'); + for (const root of roots) { + if (await realpath(root.input) !== root.path) throw new EligibilityError('ROOT_CHANGED'); + const bound = await lstat(root.path); + if (bound.dev !== root.dev || bound.ino !== root.ino) throw new EligibilityError('ROOT_CHANGED'); + for (let current = dirname(path); current !== root.path; current = dirname(current)) { + const parent = await lstat(current); + if (parent.isSymbolicLink() || !parent.isDirectory()) throw new EligibilityError('SYMLINK_ANCESTOR'); + } + } + if (await realpath(path) !== path) throw new EligibilityError('OUT_OF_ROOT'); + if (evidence && (evidence.dev !== stat.dev || evidence.ino !== stat.ino)) throw new EligibilityError('REPLACED'); +} + +export async function checkMarkerIdentity(path: string, evidence: ArtifactEvidence): Promise { + const marker = await lstat(join(dirname(path), evidence.marker)); + if (marker.isSymbolicLink() || !marker.isFile() || marker.dev !== evidence.markerDev || marker.ino !== evidence.markerIno) throw new EligibilityError('MARKER_CHANGED'); +} + +export interface RepoMetrics { gitProcesses: number; directoriesVisited: number; candidatesInspected: number; entriesInspected: number; gitMilliseconds: number; inspectionMilliseconds: number } +export const newMetrics = (): RepoMetrics => ({ gitProcesses: 0, directoriesVisited: 0, candidatesInspected: 0, entriesInspected: 0, gitMilliseconds: 0, inspectionMilliseconds: 0 }); + +/** Bounded argument-array Git transport. No shell, inherited routing or optional locks. */ +export async function gitQuery(cwd: string, args: string[], input = '', metrics?: RepoMetrics, signal?: AbortSignal): Promise<{ code: number; output: string }> { + const started = performance.now(); + if (metrics) metrics.gitProcesses++; + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))); + return new Promise((resolveQuery, reject) => { + const child = spawn('git', ['-C', cwd, ...args], { env: { ...env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }, stdio: ['pipe', 'pipe', 'pipe'] }); + const chunks: Buffer[] = []; + let bytes = 0; + let failed = false; + const fail = () => { failed = true; child.kill('SIGKILL'); reject(new EligibilityError('GIT_UNKNOWN')); }; + const abort = () => { failed = true; child.kill('SIGKILL'); reject(new EligibilityError('CANCELLED')); }; + const timer = setTimeout(fail, 10000); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + child.on('error', () => { clearTimeout(timer); fail(); }); + child.stdout.on('data', (chunk: Buffer) => { bytes += chunk.length; if (bytes > 16 * 1024 * 1024) fail(); else chunks.push(chunk); }); + child.stderr.on('data', (chunk: Buffer) => { bytes += chunk.length; if (bytes > 16 * 1024 * 1024) fail(); }); + child.stdin.on('error', () => { /* exit status handles an early closed pipe */ }); + child.on('close', (code) => { if (metrics) metrics.gitMilliseconds += performance.now() - started; clearTimeout(timer); signal?.removeEventListener('abort', abort); if (!failed) { const bytes = Buffer.concat(chunks); const output = bytes.toString('utf8'); if (!Buffer.from(output).equals(bytes)) reject(new EligibilityError('GIT_UNKNOWN')); else resolveQuery({ code: code ?? -1, output }); } }); + child.stdin.end(input); + }); +} + +export class GitBatch { + private boundaries = new Map>(); + constructor(private metrics?: RepoMetrics, private signal?: AbortSignal) {} + private boundary(dir: string): Promise { + const cached = this.boundaries.get(dir); + if (cached) return cached; + const query = (async () => { + try { await lstat(join(dir, '.git')); return dir; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new EligibilityError('GIT_UNKNOWN'); } + return dirname(dir) === dir ? undefined : this.boundary(dirname(dir)); + })(); + this.boundaries.set(dir, query); + return query; + } + async check(candidates: { path: string; ignored: boolean }[]): Promise> { + const rejected = new Map(); + const groups = new Map(); + for (const candidate of candidates) { + try { + const root = await this.boundary(dirname(candidate.path)); + if (!root) { if (candidate.ignored) rejected.set(candidate.path, 'IGNORE_REQUIRED'); continue; } + groups.set(root, [...(groups.get(root) ?? []), candidate]); + } catch { rejected.set(candidate.path, 'GIT_UNKNOWN'); } + } + for (const [root, group] of groups) { + try { + const query = (args: string[], input = '') => gitQuery(root, args, input, this.metrics, this.signal); + const must = async (args: string[]) => { const r = await query(args); if (r.code !== 0) throw new EligibilityError('GIT_UNKNOWN'); return r.output; }; + const top = (await must(['rev-parse', '--show-toplevel'])).replace(/\n$/, ''); + if (await realpath(top) !== root) throw new EligibilityError('GIT_UNKNOWN'); + const index = await must(['ls-files', '--cached', '--stage', '-z']); + const head = await query(['rev-parse', '--verify', 'HEAD^{commit}']); + let tree = ''; + if (head.code === 0) tree = await must(['ls-tree', '-r', '--full-tree', '-z', head.output.trim()]); + else { + const ref = await must(['symbolic-ref', '-q', 'HEAD']); + if (!ref.startsWith('refs/heads/') || (await query(['show-ref', '--verify', '--quiet', ref.trim()])).code !== 1) throw new EligibilityError('GIT_UNKNOWN'); + } + const tracked = [...index.split('\0'), ...tree.split('\0')].filter(Boolean).map((line) => { + const tab = line.indexOf('\t'); + if (tab < 0) throw new EligibilityError('GIT_UNKNOWN'); + return join(root, line.slice(tab + 1)); + }); + const ambiguous = group.filter((c) => c.ignored).map((c) => c.path + '/'); + const ignored = new Set(); + if (ambiguous.length) { + const result = await query(['check-ignore', '--stdin', '-z'], ambiguous.join('\0') + '\0'); + if (result.code !== 0 && result.code !== 1) throw new EligibilityError('GIT_UNKNOWN'); + result.output.split('\0').filter(Boolean).forEach((p) => ignored.add(p)); + } + for (const candidate of group) { + if (tracked.some((p) => contains(candidate.path, p) || contains(p, candidate.path))) rejected.set(candidate.path, 'TRACKED_CONTENT'); + else if (candidate.ignored && !ignored.has(candidate.path + '/')) rejected.set(candidate.path, 'IGNORE_REQUIRED'); + } + } catch { for (const candidate of group) rejected.set(candidate.path, 'GIT_UNKNOWN'); } + } + return rejected; + } +} + +export async function validateSelected(items: CleanableItem[], category: RepoCategoryId, context?: CleanContext, metrics?: RepoMetrics): Promise<{ valid: CleanableItem[]; rejected: { path: string; reason: string }[] }> { + const valid: CleanableItem[] = []; + const rejected: { path: string; reason: string }[] = []; + const candidates: { item: CleanableItem; ignored: boolean }[] = []; + let policy: RepoPolicy | undefined; + let currentRoots: readonly RepoRoot[] = []; + try { if (context) ({ policy, currentRoots } = await currentPolicy(context)); } + catch (error) { return { valid, rejected: items.map((i) => ({ path: i.path, reason: reasonOf(error) })) }; } + for (const item of items) { + try { + const evidence = context?.evidence[item.path]; + if (!context?.complete || !evidence || evidence.category !== category) throw new EligibilityError('MISSING_CONTEXT'); + if (!policy) throw new EligibilityError('MISSING_CONTEXT'); + if (!policy.categories.includes(category)) throw new EligibilityError('CATEGORY_DISABLED'); + const maxDepth = policy.maxDepth; + const withinDepth = (roots: readonly RepoRoot[]) => roots.some((r) => contains(r.path, dirname(item.path)) && relative(r.path, dirname(item.path)).split('/').filter(Boolean).length <= maxDepth && (category === 'node-modules' || !r.nodeOnly)); + if (!withinDepth(policy.roots) || !withinDepth(currentRoots)) throw new EligibilityError('OUT_OF_ROOT'); + validatePatterns(policy.excludePaths); + await checkIdentity(item.path, policy, evidence); + const rule = await classify(item.path, [category]); + if (!rule || rule.marker !== evidence.marker) throw new EligibilityError('MARKER_MISSING'); + await checkMarkerIdentity(item.path, evidence); + if (metrics) metrics.candidatesInspected++; + // Keep old authority/identity bindings, but apply keep patterns relative to + // every original and currently applicable root. Root edits cannot rebase + // an exclusion away from a selected candidate or its contents. + const measured = await inspectTree(item.path, { ...policy, roots: [...policy.roots, ...currentRoots] }, metrics); + const parent = await lstat(dirname(item.path)); + if (category === 'node-modules' && Date.now() - Math.max(parent.mtimeMs, measured.newestEntryModifiedAt.getTime()) < policy.daysOld * 86400000) throw new EligibilityError('RECENT_ACTIVITY'); + candidates.push({ item: { ...item, ...measured, artifact: evidence }, ignored: rule.requiresIgnoredDirectory }); + } catch (error) { rejected.push({ path: item.path, reason: reasonOf(error) }); } + } + const gitRejected = await new GitBatch(metrics).check(candidates.map(({ item, ignored }) => ({ path: item.path, ignored }))); + for (const { item } of candidates) { + const reason = gitRejected.get(item.path); + if (reason) rejected.push({ path: item.path, reason }); else valid.push(item); + } + return { valid, rejected }; +} diff --git a/src/repo-artifacts/git-safety.test.ts b/src/repo-artifacts/git-safety.test.ts new file mode 100644 index 0000000..1e6c186 --- /dev/null +++ b/src/repo-artifacts/git-safety.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { chmod, mkdir, mkdtemp, rename, rm, symlink, utimes, writeFile } from 'fs/promises'; +import { execFileSync } from 'child_process'; +import { join } from 'path'; +const { home } = vi.hoisted(() => ({ home: `/private/tmp/mac-cleaner-git-${process.pid}` })); +vi.mock('os', async (original) => ({ ...await original(), homedir: () => home })); +import { runScans, getScanner } from '../scanners/index.js'; +import { clearConfigCache } from '../utils/config.js'; +import { GitBatch, newMetrics } from './eligibility.js'; +let root: string; +const git = (...args: string[]) => execFileSync('git', ['-C', root, ...args], { env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }, stdio: 'pipe' }).toString().trim(); +beforeEach(async () => { + clearConfigCache(); await mkdir(home, { recursive: true }); root = await mkdtemp(join(home, 'fixture-')); + await writeFile(join(root, 'Cargo.toml'), ''); await mkdir(join(root, 'target')); await writeFile(join(root, 'target/file'), 'data'); +}); +afterEach(async () => { vi.unstubAllEnvs(); vi.restoreAllMocks(); clearConfigCache(); await rm(home, { recursive: true, force: true }); }); +function init() { git('init', '-q'); git('config', 'user.name', 'Fixture'); git('config', 'user.email', 'fixture@example.test'); git('config', 'commit.gpgsign', 'false'); git('config', 'core.hooksPath', '/dev/null'); } +const scan = () => runScans(['rust-artifacts'], { roots: [root] }); +it('uses each linked worktree index, not the common Git directory', async () => { + init(); await writeFile(join(root, '.gitignore'), 'target/\n'); git('add', 'Cargo.toml', '.gitignore'); git('commit', '-qm', 'fixture'); + const linked = join(root, 'linked'); git('worktree', 'add', '-qb', 'linked', linked); + await mkdir(join(linked, 'target')); await writeFile(join(linked, 'target/file'), 'tracked'); + execFileSync('git', ['-C', linked, 'add', '-f', 'target/file']); + const results = await scan(); + expect(results.results[0].items.map((i) => i.path)).toEqual([join(root, 'target')]); + expect(results.results[0].warnings).toContainEqual({ path: join(linked, 'target'), reason: 'TRACKED_CONTENT' }); +}); +it('protects gitlinks even with no nested .git metadata on disk', async () => { + init(); git('add', 'Cargo.toml'); git('commit', '-qm', 'fixture'); + const commit = git('rev-parse', 'HEAD'); + git('update-index', '--add', '--cacheinfo', `160000,${commit},target/submodule`); + expect((await scan()).results[0].warnings).toContainEqual({ path: join(root, 'target'), reason: 'TRACKED_CONTENT' }); +}); +it('protects conflict stages and tracked symlink entries', async () => { + init(); + const blob = execFileSync('git', ['-C', root, 'hash-object', '-w', '--stdin'], { input: 'conflict' }).toString().trim(); + execFileSync('git', ['-C', root, 'update-index', '-z', '--index-info'], { input: `100644 ${blob} 1\ttarget/conflict\0` }); + expect((await scan()).totalItems).toBe(0); + git('update-index', '--force-remove', 'target/conflict'); + await symlink('/unfollowed', join(root, 'target/link')); git('add', 'target/link'); + expect((await scan()).totalItems).toBe(0); +}); +it('fails closed when Git is missing, HEAD is broken or .git is invalid', async () => { + init(); + vi.stubEnv('PATH', ''); + expect((await scan()).results[0].warnings).toContainEqual({ path: join(root, 'target'), reason: 'GIT_UNKNOWN' }); + vi.unstubAllEnvs(); + await writeFile(join(root, '.git/HEAD'), 'broken\n'); + expect((await scan()).totalItems).toBe(0); + await rm(join(root, '.git'), { recursive: true }); await writeFile(join(root, '.git'), 'gitdir: /missing'); + expect((await scan()).results[0].error).toBeDefined(); +}); +it('counts Git queries per worktree batch regardless of candidate count', async () => { + init(); + const metrics = newMetrics(); + const batch = new GitBatch(metrics); + const paths = Array.from({ length: 50 }, (_, i) => ({ path: join(root, `candidate-${i}`), ignored: false })); + expect((await batch.check(paths)).size).toBe(0); + expect(metrics.gitProcesses).toBe(5); +}); +it('vetoes unreadable descendants and reports partial root failures', async () => { + const blocked = join(root, 'target/blocked'); await mkdir(blocked); await chmod(blocked, 0); + try { + const result = await scan(); expect(result.totalItems).toBe(0); expect(result.results[0].error).toBeDefined(); + } finally { await chmod(blocked, 0o700); } +}); +it('rechecks marker identity and configured exclusions after preview', async () => { + const result = await scan(); + await rename(join(root, 'Cargo.toml'), join(root, 'old-marker')); await writeFile(join(root, 'Cargo.toml'), 'replacement'); + // Keep replacement inode distinct even on filesystems that quickly reuse inodes. + const changed = await getScanner('rust-artifacts').clean(result.results[0].items, true, undefined, result.results[0].context); + expect(changed.outcomes?.[0].reason).toBe('MARKER_CHANGED'); +}); +it('accepts the exact Node age boundary and rejects one millisecond of newer activity', async () => { + const path = join(root, 'node_modules'); await mkdir(path); await writeFile(join(root, 'package.json'), '{}'); await writeFile(join(path, 'file'), 'data'); + const now = Math.floor(Date.now() / 1000) * 1000; const boundary = new Date(now - 30 * 86400000); + await utimes(join(path, 'file'), boundary, boundary); await utimes(path, boundary, boundary); await utimes(root, boundary, boundary); + vi.spyOn(Date, 'now').mockReturnValue(now); + expect((await getScanner('node-modules').scan({ roots: [root] })).items).toHaveLength(1); + const recent = new Date(boundary.getTime() + 2); await utimes(join(path, 'file'), recent, recent); + expect((await getScanner('node-modules').scan({ roots: [root] })).items).toHaveLength(0); +}); diff --git a/src/repo-artifacts/git-transport.test.ts b/src/repo-artifacts/git-transport.test.ts new file mode 100644 index 0000000..3c09521 --- /dev/null +++ b/src/repo-artifacts/git-transport.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'events'; +import { PassThrough } from 'stream'; +import { spawn } from 'child_process'; +vi.mock('child_process', () => ({ spawn: vi.fn() })); +import { gitQuery } from './eligibility.js'; +let child: EventEmitter & { stdin: PassThrough; stdout: PassThrough; stderr: PassThrough; kill: ReturnType }; +beforeEach(() => { + child = Object.assign(new EventEmitter(), { stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn() }); + vi.mocked(spawn).mockReturnValue(child as never); +}); +afterEach(() => { child.emit('close', 1); vi.useRealTimers(); vi.unstubAllEnvs(); }); +it('uses argument arrays and removes inherited Git routing', async () => { + vi.stubEnv('GIT_INDEX_FILE', '/wrong/index'); vi.stubEnv('GIT_DIR', '/wrong/git'); + const pending = gitQuery('/fixture', ['ls-files', '-z']); + const [, args, options] = vi.mocked(spawn).mock.calls.at(-1)!; + expect(args).toEqual(['-C', '/fixture', 'ls-files', '-z']); + expect(options?.env?.GIT_INDEX_FILE).toBeUndefined(); expect(options?.env?.GIT_DIR).toBeUndefined(); + child.stdout.write('a\tb\nc\0'); child.emit('close', 0); + expect((await pending).output).toBe('a\tb\nc\0'); +}); +it('bounds subprocess output', async () => { + const pending = gitQuery('/fixture', ['ls-files', '-z']); + const rejection = expect(pending).rejects.toThrow('GIT_UNKNOWN'); + child.stdout.write(Buffer.alloc(16 * 1024 * 1024 + 1)); + await rejection; expect(child.kill).toHaveBeenCalledWith('SIGKILL'); +}); +it('fails closed on timeout without waiting in real time', async () => { + vi.useFakeTimers(); + const pending = gitQuery('/fixture', ['ls-files', '-z']); + const rejection = expect(pending).rejects.toThrow('GIT_UNKNOWN'); + await vi.advanceTimersByTimeAsync(10001); await rejection; + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); +}); +it('cancels an in-flight Git process', async () => { + const abort = new AbortController(); const pending = gitQuery('/fixture', ['ls-files'], '', undefined, abort.signal); + const rejection = expect(pending).rejects.toThrow('CANCELLED'); abort.abort(); await rejection; +}); + + +it('fails closed on undecodable path bytes', async () => { + const pending = gitQuery('/fixture', ['ls-files', '-z']); + const rejection = expect(pending).rejects.toThrow('GIT_UNKNOWN'); + child.stdout.write(Buffer.from([0xff, 0])); child.emit('close', 0); await rejection; +}); diff --git a/src/repo-artifacts/repo-artifacts.test.ts b/src/repo-artifacts/repo-artifacts.test.ts new file mode 100644 index 0000000..ebd4dff --- /dev/null +++ b/src/repo-artifacts/repo-artifacts.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { chmod, link, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, utimes, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; +const { home } = vi.hoisted(() => ({ home: `/private/tmp/mac-cleaner-repo-${process.pid}` })); +vi.mock('os', async (original) => ({ ...await original(), homedir: () => home })); +import { NodeModulesScanner } from '../scanners/node-modules.js'; +import { clearConfigCache, initConfig } from '../utils/config.js'; +import { cleanSelectedResults, scanTotals } from '../utils/cleanup.js'; +import { BaseScanner } from '../scanners/base-scanner.js'; +import { CATEGORIES } from '../types.js'; +import { restoreBackup, getBackupDir } from '../utils/backup.js'; +import { matchesExclude, validatePatterns } from './eligibility.js'; + +let root: string; +const scanner = new NodeModulesScanner(); +const old = new Date(Date.now() - 40 * 86400000); +const git = (...args: string[]) => execFileSync('git', ['-C', root, ...args], { env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }, stdio: 'pipe' }); +async function artifact(name = 'project') { + const project = join(root, name); + const path = join(project, 'node_modules'); + await mkdir(path, { recursive: true }); + await writeFile(join(project, 'package.json'), '{}'); + await writeFile(join(path, 'file'), 'hello'); + await age(project, path, join(path, 'file')); + return path; +} +async function age(...paths: string[]) { for (const path of paths) await utimes(path, old, old); } +const scan = (extra = {}) => scanner.scan({ roots: [root], ...extra }); +beforeEach(async () => { clearConfigCache(); await mkdir(home, { recursive: true }); root = await mkdtemp(join(home, 'fixture-')); }); +afterEach(async () => { clearConfigCache(); await rm(home, { recursive: true, force: true }); }); + +describe('repository safety fixtures', () => { + it('requires immediate readable regular non-symlink markers; never accepts orphans', async () => { + const path = await artifact(); + await rm(join(root, 'project/package.json')); + await writeFile(join(root, 'package.json'), '{}'); + expect((await scan()).items).toEqual([]); + await symlink(join(root, 'package.json'), join(root, 'project/package.json')); + expect((await scan()).items).toEqual([]); + await rm(join(root, 'project/package.json')); + await mkdir(join(root, 'project/package.json')); + expect((await scan()).items).toEqual([]); + await rm(join(root, 'project/package.json'), { recursive: true }); + await writeFile(join(root, 'project/package.json'), '{}'); + await chmod(join(root, 'project/package.json'), 0); + expect((await scan()).items).toEqual([]); + await chmod(join(root, 'project/package.json'), 0o600); + await age(join(root, 'project'), path); + expect((await scan()).items).toHaveLength(1); + }); + it('measures logical file/link bytes, hardlinks, directories and newest entry', async () => { + const path = await artifact(); + await link(join(path, 'file'), join(path, 'hard')); + await symlink('/nonexistent/external', join(path, 'link')); + await mkdir(join(path, 'empty')); + expect((await scan()).items).toHaveLength(0); + const result = await scan({ daysOld: 0 }); + expect(result.items[0].size).toBe(10 + (await lstat(join(path, 'link'))).size); + expect(result.items[0].fileCount).toBe(2); + expect(result.items[0].newestEntryModifiedAt!.getTime()).toBeGreaterThan(old.getTime()); + expect(Object.isFrozen(result.context?.evidence)).toBe(true); + expect((await scan({ daysOld: 0 })).items[0].id).toBe(result.items[0].id); + }); + it('protects excluded descendants, overlapping roots and nested .git files', async () => { + const path = await artifact(); + expect((await scan({ excludePaths: ['**/file'] })).items).toEqual([]); + expect((await scan({ roots: [join(root, 'project'), root], excludePaths: ['project/node_modules/file'] })).items).toEqual([]); + await writeFile(join(path, '.git'), 'gitdir: /missing'); + expect((await scan({ daysOld: 0 })).warnings).toContainEqual({ path, reason: 'NESTED_REPOSITORY' }); + }); + it('protects index and HEAD union including staged removals and newline paths', async () => { + const path = await artifact(); + git('init', '-q'); + git('config', 'user.name', 'Fixture'); git('config', 'user.email', 'fixture@example.test'); + const tracked = join(path, 'tab\tline\nfile'); + await writeFile(tracked, 'tracked'); + git('add', '.'); git('commit', '-qm', 'fixture'); + git('rm', '--cached', tracked); + await writeFile(join(root, '.gitignore'), 'node_modules/\n'); + expect((await scan({ daysOld: 0 })).warnings).toContainEqual({ path, reason: 'TRACKED_CONTENT' }); + }); + it('handles unborn index and refuses a broken detected repository', async () => { + const path = await artifact(); + git('init', '-q'); + expect((await scan()).items).toHaveLength(1); + git('add', 'project/node_modules/file'); + expect((await scan()).items).toHaveLength(0); + await writeFile(join(root, '.git/index'), 'broken'); + expect((await scan()).warnings).toContainEqual({ path, reason: 'GIT_UNKNOWN' }); + }); + it('refreshes tracked protection at clean, validates selected identities and marker removal', async () => { + const path = await artifact(); + git('init', '-q'); + const result = await scan(); + git('add', 'project/node_modules/file'); + expect((await scanner.clean(result.items, true, undefined, result.context)).errors.join()).toContain('TRACKED_CONTENT'); + git('rm', '--cached', 'project/node_modules/file'); + await rename(path, path + '-old'); + await mkdir(path); await age(path, join(root, 'project')); + for (const dryRun of [true, false]) expect((await scanner.clean(result.items, dryRun, undefined, result.context)).errors.join()).toContain('REPLACED'); + await rm(path, { recursive: true }); await rename(path + '-old', path); + await rm(join(root, 'project/package.json')); + expect((await scanner.clean(result.items, true, undefined, result.context)).errors.join()).toContain('MARKER_MISSING'); + }); + it('rejects parent symlink swaps, forged paths, absent context and newly recent children', async () => { + const path = await artifact(); + const result = await scan(); + expect((await scanner.clean(result.items, true)).errors.join()).toContain('MISSING_CONTEXT'); + expect((await scanner.clean([{ ...result.items[0], path: join(path, 'file') }], true, undefined, result.context)).errors.join()).toContain('MISSING_CONTEXT'); + await writeFile(join(path, 'file'), 'recent'); + expect((await scanner.clean(result.items, true, undefined, result.context)).errors.join()).toContain('RECENT_ACTIVITY'); + await rename(join(root, 'project'), join(root, 'original')); + await symlink(join(root, 'original'), join(root, 'project')); + expect((await scanner.clean(result.items, true, undefined, result.context)).errors.join()).toContain('SYMLINK_ANCESTOR'); + }); + it('removes only an unchanged selected candidate', async () => { + const first = await artifact('a'); await artifact('b'); + const result = await scan(); + const clean = await scanner.clean(result.items.filter((i) => i.path === first), false, undefined, result.context); + expect(clean.cleanedItems).toBe(1); + await expect(lstat(first)).rejects.toMatchObject({ code: 'ENOENT' }); + expect((await scan()).items).toHaveLength(1); + }); +}); + +describe('keep pattern grammar', () => { + it('uses component prefixes and zero-or-more globstar segments', () => { + expect(matchesExclude('**/target', 'target')).toBe(true); + expect(matchesExclude('**/target', 'a/b/target/file')).toBe(true); + expect(matchesExclude('docs/build', 'docs/building')).toBe(false); + expect(matchesExclude('a/*/b?.txt', 'a/x/b1.txt')).toBe(true); + for (const pattern of ['', '/abs', '../x', 'a/../b', '!keep', '{a,b}', '[x]', 'x**', 'a//b']) expect(() => validatePatterns([pattern])).toThrow(); + }); +}); + + +describe('request policy and execution', () => { + it('appends config roots, keeps defaults after init, and overrides roots explicitly', async () => { + await artifact(); + await initConfig(); + const dev = join(home, 'dev'); + await mkdir(dev); + const defaultRoot = root; + root = dev; + await artifact('default'); + root = defaultRoot; + expect((await scanner.scan()).items).toHaveLength(1); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ extraPaths: { projects: [root] } })); + clearConfigCache(); + expect((await scanner.scan()).items).toHaveLength(2); + expect((await scan()).items).toHaveLength(1); + const invalid = await scanner.scan({ roots: [home] }); + expect(invalid.error).toBe('INVALID_ROOT'); + }); + it('rechecks config exclusions and fails closed for malformed config', async () => { + await artifact(); + const result = await scan(); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ excludePaths: ['**/file'] })); + expect((await scanner.clean(result.items, true, undefined, result.context)).outcomes?.[0].reason).toBe('EXCLUDED_CONTENT'); + await writeFile(join(home, '.maccleanerrc'), '{bad'); + expect((await scanner.clean(result.items, true, undefined, result.context)).errors.join()).toContain('INVALID_CONFIG'); + clearConfigCache(); + expect((await scan()).items).toEqual([]); + }); + it('isolates concurrent root/exclusion contexts and does not clean new discoveries', async () => { + await artifact('one'); await artifact('two'); + const [one, two] = await Promise.all([scan({ roots: [join(root, 'one')] }), scan({ roots: [join(root, 'two')], excludePaths: ['keep'] })]); + await artifact('new'); + const cleaned = await scanner.clean(one.items, false, undefined, one.context); + expect(cleaned.cleanedItems).toBe(1); + expect((await scanner.clean(two.items, true, undefined, two.context)).wouldCleanItems).toBe(1); + expect((await scan()).items).toHaveLength(2); + }); + it('dry-run and real mode reject missing/protected/unsupported backup paths alike', async () => { + class Files extends BaseScanner { category = CATEGORIES.trash; async scan() { return this.createResult([]); } } + const files = new Files(); + const outside = await mkdtemp('/private/tmp/mac-cleaner-external-'); + try { + await writeFile(join(outside, 'file'), 'data'); + const items = ['/System/test', join(root, 'missing'), join(outside, 'file')].map((path) => ({ path, size: 4, name: path, isDirectory: false })); + for (const dry of [true, false]) { + const result = await files.clean(items, dry, join(root, 'backup')); + expect(result.outcomes?.map((o) => o.reason)).toEqual(['PROTECTED', 'ENOENT', 'BACKUP_UNSUPPORTED_PATH']); + expect(result.cleanedItems).toBe(0); + } + expect(await readFile(join(outside, 'file'), 'utf8')).toBe('data'); + } finally { await rm(outside, { recursive: true, force: true }); } + }); + it('simulates backup without mutations and preserves restore mapping', async () => { + const path = await artifact(); + const result = await scan(); + const backup = join(getBackupDir(), 'fixture-session'); + const before = await readdir(root); + const simulated = await scanner.clean(result.items, true, backup, result.context); + expect(simulated).toMatchObject({ cleanedItems: 0, freedSpace: 0, backedUpSize: 0, wouldBackUpSize: 5 }); + expect(await readdir(root)).toEqual(before); + expect((await scanner.clean(result.items, false, backup, result.context)).backedUpSize).toBe(5); + expect((await restoreBackup(backup)).success).toBeGreaterThan(0); + expect(await readFile(join(path, 'file'), 'utf8')).toBe('hello'); + }); + it('deduplicates only validated selected ancestors; rejected parent does not suppress child', async () => { + class Files extends BaseScanner { category = CATEGORIES['git-worktrees']; async scan() { return this.createResult([]); } } + const files = new Files(); + const path = await artifact(); + const child = await scan(); + const parentItem = { path: join(root, 'project'), size: 7, name: 'parent', isDirectory: true }; + const parent = { category: files.category, items: [parentItem], totalSize: 7 }; + const lookup = (id: string) => id === 'node-modules' ? scanner : files; + expect(scanTotals([child, parent])).toEqual({ totalSize: 7, totalItems: 1 }); + const both = await cleanSelectedResults([child, parent], lookup, { dryRun: true }); + expect(both.wouldCleanItems).toBe(1); + expect(both.results[0].outcomes?.[0].reason).toBe('SKIPPED_COVERED_BY_PARENT'); + expect((await cleanSelectedResults([child], lookup, { dryRun: true })).wouldCleanItems).toBe(1); + const invalid = { ...parent, items: [{ ...parentItem, isDirectory: false }] }; + const independent = await cleanSelectedResults([invalid, child], lookup, { dryRun: true }); + expect(independent.wouldCleanItems).toBe(1); + expect(independent.results[1].outcomes?.[0].path).toBe(path); + }); +}); + + +it('canonicalizes overlapping total estimates while preserving scan rows', async () => { + const path = await artifact(); + const alias = join(root, 'alias'); await symlink(join(root, 'project'), alias); + const result = await scan(); + const duplicate = { ...result, items: [{ ...result.items[0], path: join(alias, 'node_modules') }] }; + expect(scanTotals([result, duplicate])).toEqual({ totalSize: 5, totalItems: 1 }); + expect(result.items[0].path).toBe(path); expect(duplicate.items[0].path).toBe(join(alias, 'node_modules')); +}); + +it('rejects a rebound root and a newly restrictive discovery depth', async () => { + await artifact(); + const alias = join(home, 'root-alias'); await symlink(root, alias); + const result = await scan({ roots: [alias] }); + const other = join(home, 'other'); await mkdir(other); + await rm(alias); await symlink(other, alias); + expect((await scanner.clean(result.items, true, undefined, result.context)).cleanedItems).toBe(0); + const original = await scan(); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ projectScanMaxDepth: 0 })); + expect((await scanner.clean(original.items, true, undefined, original.context)).outcomes?.[0].reason).toBe('OUT_OF_ROOT'); +}); + + +it('rejects malformed direct-caller root and exclusion types instead of broadening defaults', async () => { + await artifact(); + expect((await scan({ roots: '' as unknown as string[] })).error).toBe('INVALID_ROOTS'); + expect((await scan({ excludePaths: 'keep' as unknown as string[] })).error).toBe('INVALID_EXCLUSIONS'); +}); + + +it('reports a missing selected repository candidate consistently in dry-run and mutation', async () => { + const path = await artifact(); const result = await scan(); await rm(path, { recursive: true }); + for (const dryRun of [true, false]) { + const clean = await scanner.clean(result.items, dryRun, undefined, result.context); + expect(clean.outcomes?.[0]).toMatchObject({ status: 'rejected', reason: 'MISSING' }); + } +}); + +it.each([false, true])('applies current keep patterns from a newly applicable root (retain original: %s)', async (retainOriginal) => { + const path = await artifact(); + const project = join(root, 'project'); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ extraPaths: { projects: [project] } })); + const result = await scanner.scan(); + expect(result.items).toHaveLength(1); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ extraPaths: { projects: retainOriginal ? [project, root] : [root] }, excludePaths: ['project/node_modules/file'] })); + for (const dryRun of [true, false]) { + const clean = await scanner.clean(result.items, dryRun, undefined, result.context); + expect(clean.outcomes?.[0]).toMatchObject({ status: 'rejected', reason: 'EXCLUDED_CONTENT' }); + } + expect(await readFile(join(path, 'file'), 'utf8')).toBe('hello'); +}); + +it('enforces the current depth budget relative to a changed configured root', async () => { + await artifact(); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ extraPaths: { projects: [join(root, 'project')] }, projectScanMaxDepth: 0 })); + const result = await scanner.scan(); + expect(result.items).toHaveLength(1); + await writeFile(join(home, '.maccleanerrc'), JSON.stringify({ extraPaths: { projects: [root] }, projectScanMaxDepth: 0 })); + const clean = await scanner.clean(result.items, true, undefined, result.context); + expect(clean.outcomes?.[0]).toMatchObject({ status: 'rejected', reason: 'OUT_OF_ROOT' }); +}); diff --git a/src/repo-artifacts/rules.ts b/src/repo-artifacts/rules.ts new file mode 100644 index 0000000..c8eaa16 --- /dev/null +++ b/src/repo-artifacts/rules.ts @@ -0,0 +1,44 @@ +import { constants } from 'fs'; +import { access, lstat } from 'fs/promises'; +import { basename, dirname, join } from 'path'; +import type { Ecosystem, RepoCategoryId } from '../types.js'; + +export interface ArtifactRule { + category: RepoCategoryId; + ecosystem: Ecosystem; + names: readonly string[]; + marker: string; + requiresIgnoredDirectory: boolean; +} + +export const RULES: readonly ArtifactRule[] = [ + { category: 'node-modules', ecosystem: 'node', names: ['node_modules'], marker: 'package.json', requiresIgnoredDirectory: false }, + { category: 'node-build-artifacts', ecosystem: 'node', names: ['.next', '.turbo', '.parcel-cache'], marker: 'package.json', requiresIgnoredDirectory: false }, + { category: 'node-build-artifacts', ecosystem: 'node', names: ['coverage', 'dist', 'build', 'out', '.cache'], marker: 'package.json', requiresIgnoredDirectory: true }, + { category: 'rust-artifacts', ecosystem: 'rust', names: ['target'], marker: 'Cargo.toml', requiresIgnoredDirectory: false }, + { category: 'go-artifacts', ecosystem: 'go', names: ['bin', 'dist'], marker: 'go.mod', requiresIgnoredDirectory: true }, +]; + +export function isRepoCategory(id: string): id is RepoCategoryId { + return RULES.some((rule) => rule.category === id); +} + +export async function hasMarker(parent: string, marker: string): Promise { + const path = join(parent, marker); + try { + const stats = await lstat(path); + if (!stats.isFile() || stats.isSymbolicLink()) return false; + await access(path, constants.R_OK); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +export async function classify(path: string, categories: readonly RepoCategoryId[]): Promise { + for (const rule of RULES) { + if (categories.includes(rule.category) && rule.names.includes(basename(path)) && await hasMarker(dirname(path), rule.marker)) return rule; + } + return undefined; +} diff --git a/src/repo-artifacts/walker.ts b/src/repo-artifacts/walker.ts new file mode 100644 index 0000000..210b1e1 --- /dev/null +++ b/src/repo-artifacts/walker.ts @@ -0,0 +1,115 @@ +import { createHash, randomUUID } from 'crypto'; +import { lstat, readdir } from 'fs/promises'; +import { basename, dirname, join } from 'path'; +import type { CleanableItem, RepoCategoryId, RepoPolicy, ScanResult, ScannerOptions, Category, ScanEvent } from '../types.js'; +import { classify, type ArtifactRule } from './rules.js'; +import { checkIdentity, contains, EligibilityError, excluded, freezeContext, GitBatch, newMetrics, normalizePolicy, reasonOf, type RepoMetrics } from './eligibility.js'; + +function checkAbort(signal?: AbortSignal): void { if (signal?.aborted) throw new EligibilityError('CANCELLED'); } +/** Observer failures cannot alter scanner authorization or lose the terminal event. */ +export function emitEvent(options: ScannerOptions, event: ScanEvent): void { + try { options.onEvent?.(event.type === 'candidate' ? { ...event, item: { ...event.item, artifact: event.item.artifact ? { ...event.item.artifact } : undefined } } : event); } catch { /* An observer does not control eligibility. */ } +} + +export async function inspectTree(path: string, policy: RepoPolicy, metrics?: RepoMetrics, signal?: AbortSignal): Promise<{ size: number; fileCount: number; newestEntryModifiedAt: Date }> { + const started = performance.now(); + let size = 0; + let fileCount = 0; + let newest = 0; + const pending = [path]; + while (pending.length) { + checkAbort(signal); + const current = pending.pop()!; + if (metrics) metrics.entriesInspected++; + if (excluded(current, policy)) throw new EligibilityError('EXCLUDED_CONTENT'); + if (basename(current) === '.git') throw new EligibilityError('NESTED_REPOSITORY'); + const stats = await lstat(current); + newest = Math.max(newest, stats.mtimeMs); + if (stats.isFile()) { size += stats.size; fileCount++; } + else if (stats.isSymbolicLink()) size += stats.size; + else if (stats.isDirectory()) { + for (const name of await readdir(current)) pending.push(join(current, name)); + } else throw new EligibilityError('UNSUPPORTED_ENTRY'); + } + if (metrics) metrics.inspectionMilliseconds += performance.now() - started; + return { size, fileCount, newestEntryModifiedAt: new Date(newest) }; +} + +export async function scanRepoArtifacts(categories: Category[], options: ScannerOptions = {}, metrics = newMetrics(), lifecycle = true): Promise { + const runId = options.runId ?? randomUUID(); + const warnings: { path: string; reason: string; category?: Category['id'] }[] = []; + const items: CleanableItem[] = []; + const warn = (path: string, reason: string, category?: Category['id']) => { + warnings.push({ path, reason, category }); emitEvent(options, { type: 'warning', runId, category, path, reason }); + }; + if (lifecycle) emitEvent(options, { type: 'start', runId, categories: categories.map((c) => c.id) }); + let policy: RepoPolicy | undefined; + let failure: string | undefined; + try { + checkAbort(options.signal); + policy = await normalizePolicy(categories.map((c) => c.id as RepoCategoryId), options); + const activePolicy = policy; + const candidates: { path: string; rule: ArtifactRule }[] = []; + const visited = new Map(); + const seen = new Set(); + async function walk(dir: string, depth: number): Promise { + checkAbort(options.signal); + const budget = activePolicy.maxDepth - depth; + if (budget < 0 || (visited.get(dir) ?? -1) >= budget || excluded(dir, activePolicy)) return; + visited.set(dir, budget); + metrics.directoriesVisited++; + emitEvent(options, { type: 'progress', runId, path: dir, completed: metrics.directoriesVisited }); + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + checkAbort(options.signal); + if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === '.git') continue; + const path = join(dir, entry.name); + if (excluded(path, activePolicy)) continue; + const enabled = activePolicy.categories.filter((category) => category === 'node-modules' || activePolicy.roots.some((r) => !r.nodeOnly && contains(r.path, path))); + let rule: ArtifactRule | undefined; + try { + rule = await classify(path, enabled); + if (rule && !seen.has(path)) { seen.add(path); await checkIdentity(path, activePolicy); candidates.push({ path, rule }); } + } catch (error) { warn(path, reasonOf(error)); } + if (rule || entry.name === 'node_modules') continue; + if (entry.name.startsWith('.') && !['.worktrees', '.claude', '.cursor'].includes(entry.name)) continue; + await walk(path, depth + 1); + } + } catch (error) { if (options.signal?.aborted) throw error; warn(dir, reasonOf(error)); } + } + for (const root of policy.roots) await walk(root.path, 0); + const rejected = await new GitBatch(metrics, options.signal).check(candidates.map(({ path, rule }) => ({ path, ignored: rule.requiresIgnoredDirectory }))); + checkAbort(options.signal); + for (const { path, rule } of candidates) { + checkAbort(options.signal); + const category = categories.find((c) => c.id === rule.category)!.id; + const rejection = rejected.get(path); + if (rejection) { warn(path, rejection, category); continue; } + try { + await checkIdentity(path, policy); + metrics.candidatesInspected++; + const measured = await inspectTree(path, policy, metrics, options.signal); + const parent = await lstat(dirname(path)); + if (rule.category === 'node-modules' && Date.now() - Math.max(parent.mtimeMs, measured.newestEntryModifiedAt.getTime()) < policy.daysOld * 86400000) continue; + await checkIdentity(path, policy); + if (!await classify(path, [rule.category])) throw new EligibilityError('MARKER_MISSING'); + const stats = await lstat(path); + const markerStats = await lstat(join(dirname(path), rule.marker)); + const item: CleanableItem = { path, name: `${basename(dirname(path))}/${basename(path)}`, ...measured, isDirectory: true, modifiedAt: stats.mtime, id: createHash('sha256').update(rule.category + '\0' + path).digest('hex'), artifact: { category: rule.category, marker: rule.marker, markerDev: markerStats.dev, markerIno: markerStats.ino, dev: stats.dev, ino: stats.ino } }; + items.push(item); + emitEvent(options, { type: 'candidate', runId, category, item }); + } catch (error) { if (options.signal?.aborted) throw error; warn(path, reasonOf(error), category); } + } + } catch (error) { failure = reasonOf(error); if (failure !== 'CANCELLED') warn('', failure); } + const cancelled = options.signal?.aborted ?? false; + if (cancelled) items.length = 0; + const context = policy ? freezeContext(policy, items, !cancelled) : undefined; + const error = cancelled ? 'CANCELLED' : failure ?? (warnings.some((w) => !['TRACKED_CONTENT', 'IGNORE_REQUIRED', 'EXCLUDED_CONTENT', 'NESTED_REPOSITORY'].includes(w.reason)) ? 'Repository inspection incomplete; see warnings' : undefined); + const results = categories.map((category) => { + const selected = items.filter((i) => i.artifact?.category === category.id).sort((a, b) => b.size - a.size || a.path.localeCompare(b.path)); + return { category, items: selected, totalSize: selected.reduce((sum, i) => sum + i.size, 0), context, warnings: warnings.filter((w) => w.category ? w.category === category.id : category.id === categories[0]?.id).map(({ path, reason }) => ({ path, reason })), error }; + }); + if (lifecycle) emitEvent(options, { type: 'done', runId, cancelled, candidates: items.length, warnings: warnings.length }); + return results; +} diff --git a/src/scanners/base-scanner.test.ts b/src/scanners/base-scanner.test.ts index 4220e55..bb0dc8c 100644 --- a/src/scanners/base-scanner.test.ts +++ b/src/scanners/base-scanner.test.ts @@ -1,3 +1,6 @@ +// All backup/restore fixtures are isolated from the real home directory. +const { fixtureHome } = vi.hoisted(() => ({ fixtureHome: `/private/tmp/mac-cleaner-base-${process.pid}` })); +vi.mock('os', async (original) => ({ ...await original(), homedir: () => fixtureHome })); import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtemp, writeFile, rm, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; @@ -98,8 +101,10 @@ describe('BaseScanner', () => { const result = await scanner.clean(items, true); - expect(result.cleanedItems).toBe(1); - expect(result.freedSpace).toBe(7); + expect(result.cleanedItems).toBe(0); + expect(result.freedSpace).toBe(0); + expect(result.wouldFreeSpace).toBe(7); + expect(result.wouldCleanItems).toBe(1); }); it('should summarize failures by error code', async () => { @@ -115,7 +120,8 @@ describe('BaseScanner', () => { const result = await scanner.clean(items); expect(result.cleanedItems).toBe(1); - expect(result.errors).toEqual(['Failed to remove 2 items (2 ENOENT)']); + expect(result.errors).toHaveLength(2); + expect(result.errors.every((e) => e.includes('ENOENT'))).toBe(true); }); }); }); diff --git a/src/scanners/base-scanner.ts b/src/scanners/base-scanner.ts index cee92d0..c412702 100644 --- a/src/scanners/base-scanner.ts +++ b/src/scanners/base-scanner.ts @@ -1,67 +1,76 @@ -import type { Scanner, Category, ScanResult, CleanResult, CleanableItem, ScannerOptions } from '../types.js'; -import { removeItems } from '../utils/fs.js'; -import { backupItems } from '../utils/backup.js'; +import type { Scanner, Category, ScanResult, CleanResult, CleanableItem, ScannerOptions, CleanContext, PreparedCleanup } from '../types.js'; +import { preflightItem, removeItemWithError } from '../utils/fs.js'; +import { backupItem, backupPathFor } from '../utils/backup.js'; +import { isRepoCategory, classify } from '../repo-artifacts/rules.js'; +import { checkIdentity, checkMarkerIdentity, EligibilityError, reasonOf, validateSelected } from '../repo-artifacts/eligibility.js'; + +export function emptyCleanResult(category: Category): CleanResult { + return { category, cleanedItems: 0, freedSpace: 0, errors: [], outcomes: [], wouldCleanItems: 0, wouldFreeSpace: 0, wouldBackUpSize: 0 }; +} +export function mergeCleanResult(total: CleanResult, part: CleanResult): void { + total.cleanedItems += part.cleanedItems; + total.freedSpace += part.freedSpace; + if (part.backedUpSize !== undefined) total.backedUpSize = (total.backedUpSize ?? 0) + part.backedUpSize; + total.wouldCleanItems = (total.wouldCleanItems ?? 0) + (part.wouldCleanItems ?? 0); + total.wouldFreeSpace = (total.wouldFreeSpace ?? 0) + (part.wouldFreeSpace ?? 0); + total.wouldBackUpSize = (total.wouldBackUpSize ?? 0) + (part.wouldBackUpSize ?? 0); + total.errors.push(...part.errors); + total.outcomes = [...(total.outcomes ?? []), ...(part.outcomes ?? [])]; +} export abstract class BaseScanner implements Scanner { abstract category: Category; - - /** - * Scanners that delete files can back them up first. Scanners that delegate - * cleanup to an external tool (Docker, Homebrew) override this with `false`: - * there is no file of ours to move, and promising a backup there would repeat - * the very problem this module exists to fix. - */ readonly supportsBackup: boolean = true; - abstract scan(options?: ScannerOptions): Promise; - async clean(items: CleanableItem[], dryRun = false, backupDir?: string): Promise { - if (backupDir) { - const result = await backupItems(items, backupDir, dryRun); - - return { - category: this.category, - cleanedItems: result.success, - // Nothing was freed: the files are still on disk, inside the backup. - freedSpace: 0, - backedUpSize: result.backedUpSize, - backupDir, - errors: result.failed > 0 ? [`Failed to back up ${result.failed} items (nothing was deleted for those)`] : [], + /** A request-local plan of closures; no singleton authorization state. */ + async prepareClean(items: CleanableItem[], backupDir?: string, context?: CleanContext): Promise { + const checked = isRepoCategory(this.category.id) ? await validateSelected(items, this.category.id, context) : { valid: items, rejected: [] }; + const valid = new Map(checked.valid.map((item) => [item.path, item])); + const rejected = new Map(checked.rejected.map((item) => [item.path, item.reason])); + return Promise.all(items.map(async (selected) => { + const item = valid.get(selected.path) ?? selected; + const reason = rejected.get(item.path) ?? await preflightItem(item) ?? (backupDir && !backupPathFor(item.path, backupDir) ? 'BACKUP_UNSUPPORTED_PATH' : undefined); + const recheck = async () => { + if (isRepoCategory(this.category.id)) { + await checkIdentity(item.path, context!.repoPolicy, context!.evidence[item.path]); + if (!await classify(item.path, [this.category.id])) throw new EligibilityError('MARKER_MISSING'); + await checkMarkerIdentity(item.path, context!.evidence[item.path]); + } }; - } - - const result = await removeItems(items, dryRun); - - const errors: string[] = []; - if (result.failed > 0) { - // Summarize failures by error code (e.g. "32 EPERM, 3 EACCES") so users can - // tell permission issues apart from real failures without flooding the output - const countsByCode = new Map(); - for (const failure of result.failures) { - countsByCode.set(failure.error, (countsByCode.get(failure.error) ?? 0) + 1); - } - const breakdown = [...countsByCode.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([code, count]) => `${count} ${code}`) - .join(', '); - errors.push(`Failed to remove ${result.failed} items (${breakdown})`); - } + const execute = async (dryRun: boolean): Promise => { + const result = emptyCleanResult(this.category); + if (backupDir) { result.backupDir = backupDir; result.backedUpSize = 0; } + let failure = reason; + if (!failure) { + try { + await recheck(); + failure = await preflightItem(item) ?? undefined; + if (!failure && !dryRun) failure = backupDir ? (await backupItem(item, backupDir, recheck) ? undefined : 'BACKUP_FAILED') : await removeItemWithError(item.path, false, recheck) ?? undefined; + } catch (error) { failure = reasonOf(error); } + } + if (failure) { + result.errors.push(`${item.path}: ${failure}${backupDir ? ' (nothing was deleted for this item)' : ''}`); + result.outcomes!.push({ category: this.category.id, path: item.path, scope: 'item', status: 'rejected', reason: failure, size: 0 }); + } else { + result.outcomes!.push({ category: this.category.id, path: item.path, scope: 'item', status: dryRun ? (backupDir ? 'would-back-up' : 'would-delete') : (backupDir ? 'backed-up' : 'deleted'), size: item.size }); + if (dryRun) { result.wouldCleanItems = 1; if (backupDir) result.wouldBackUpSize = item.size; else result.wouldFreeSpace = item.size; } + else { result.cleanedItems = 1; if (backupDir) result.backedUpSize = item.size; else result.freedSpace = item.size; } + } + return result; + }; + return { item, reason, execute }; + })); + } - return { - category: this.category, - cleanedItems: result.success, - freedSpace: result.freedSpace, - errors, - }; + async clean(items: CleanableItem[], dryRun = false, backupDir?: string, context?: CleanContext): Promise { + const result = emptyCleanResult(this.category); + if (backupDir) { result.backupDir = backupDir; result.backedUpSize = 0; } + for (const action of await this.prepareClean(items, backupDir, context)) mergeCleanResult(result, await action.execute(dryRun)); + return result; } protected createResult(items: CleanableItem[], error?: string): ScanResult { - const totalSize = items.reduce((sum, item) => sum + item.size, 0); - return { - category: this.category, - items, - totalSize, - error, - }; + return { category: this.category, items, totalSize: items.reduce((sum, item) => sum + item.size, 0), error }; } } diff --git a/src/scanners/docker.test.ts b/src/scanners/docker.test.ts index 8cea73e..f7f0ef3 100644 --- a/src/scanners/docker.test.ts +++ b/src/scanners/docker.test.ts @@ -69,8 +69,10 @@ describe('DockerScanner', () => { const result = await scanner.clean(items, true); - expect(result.cleanedItems).toBe(2); - expect(result.freedSpace).toBe(1500000000); + expect(result.cleanedItems).toBe(0); + expect(result.wouldCleanItems).toBe(2); + expect(result.freedSpace).toBe(0); + expect(result.wouldFreeSpace).toBe(1500000000); expect(result.errors).toHaveLength(0); // spawn should not be called in dry run expect(mockSpawn).not.toHaveBeenCalled(); diff --git a/src/scanners/docker.ts b/src/scanners/docker.ts index 7e18403..5f38044 100644 --- a/src/scanners/docker.ts +++ b/src/scanners/docker.ts @@ -166,8 +166,10 @@ export class DockerScanner extends BaseScanner { if (dryRun) { return { category: this.category, - cleanedItems: items.length, - freedSpace: items.reduce((sum, item) => sum + item.size, 0), + cleanedItems: 0, + freedSpace: 0, + wouldCleanItems: items.length, + wouldFreeSpace: items.reduce((sum, item) => sum + item.size, 0), errors: [], }; } diff --git a/src/scanners/go-artifacts.ts b/src/scanners/go-artifacts.ts new file mode 100644 index 0000000..f3abe99 --- /dev/null +++ b/src/scanners/go-artifacts.ts @@ -0,0 +1,10 @@ +import { BaseScanner } from './base-scanner.js'; +import { CATEGORIES, type ScanResult, type ScannerOptions } from '../types.js'; +import { scanRepoArtifacts } from '../repo-artifacts/walker.js'; + +export class GoArtifactsScanner extends BaseScanner { + category = CATEGORIES['go-artifacts']; + async scan(options?: ScannerOptions): Promise { + return (await scanRepoArtifacts([this.category], options))[0]; + } +} diff --git a/src/scanners/homebrew.test.ts b/src/scanners/homebrew.test.ts index 5512b65..ae76a97 100644 --- a/src/scanners/homebrew.test.ts +++ b/src/scanners/homebrew.test.ts @@ -181,8 +181,10 @@ describe('HomebrewScanner', () => { it('should not touch anything during a dry run', async () => { const result = await scanner.clean([cacheItem], true); - expect(result.cleanedItems).toBe(1); - expect(result.freedSpace).toBe(1000); + expect(result.cleanedItems).toBe(0); + expect(result.wouldCleanItems).toBe(1); + expect(result.freedSpace).toBe(0); + expect(result.wouldFreeSpace).toBe(1000); expect(result.errors).toHaveLength(0); expect(spawnCalls).toHaveLength(0); }); diff --git a/src/scanners/homebrew.ts b/src/scanners/homebrew.ts index f586f2f..075ee9d 100644 --- a/src/scanners/homebrew.ts +++ b/src/scanners/homebrew.ts @@ -132,8 +132,10 @@ export class HomebrewScanner extends BaseScanner { if (dryRun) { return { category: this.category, - cleanedItems: items.length, - freedSpace: items.reduce((sum, item) => sum + item.size, 0), + cleanedItems: 0, + freedSpace: 0, + wouldCleanItems: items.length, + wouldFreeSpace: items.reduce((sum, item) => sum + item.size, 0), errors: [], }; } diff --git a/src/scanners/index.test.ts b/src/scanners/index.test.ts index 9af1afa..5e6cbd2 100644 --- a/src/scanners/index.test.ts +++ b/src/scanners/index.test.ts @@ -1,8 +1,12 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { ALL_SCANNERS, getScanner, getAllScanners, runScans } from "./index.js"; import type { CategoryId } from "../types.js"; describe("scanners index", () => { + beforeEach(() => { + for (const id of ['trash', 'temp-files'] as const) vi.spyOn(getScanner(id), 'scan').mockResolvedValue({ category: getScanner(id).category, items: [], totalSize: 0 }); + }); + afterEach(() => vi.restoreAllMocks()); it("should export all scanners", () => { const expectedIds: CategoryId[] = [ "system-cache", @@ -19,6 +23,9 @@ describe("scanners index", () => { "language-files", "large-files", "node-modules", + "rust-artifacts", + "go-artifacts", + "node-build-artifacts", "git-worktrees", "duplicates", "launch-agents", @@ -37,7 +44,7 @@ describe("scanners index", () => { it("should get all scanners", () => { const scanners = getAllScanners(); - expect(scanners).toHaveLength(17); + expect(scanners).toHaveLength(20); for (const scanner of scanners) { expect(scanner.category).toBeDefined(); expect(scanner.scan).toBeDefined(); diff --git a/src/scanners/index.ts b/src/scanners/index.ts index 06a8efc..85edbe6 100644 --- a/src/scanners/index.ts +++ b/src/scanners/index.ts @@ -1,4 +1,10 @@ +import { randomUUID } from 'crypto'; import type { Scanner, CategoryId, ScanResult, ScannerOptions, ScanSummary } from '../types.js'; +import { loadConfig } from '../utils/config.js'; +import { scanTotals } from '../utils/cleanup.js'; +import { isRepoCategory } from '../repo-artifacts/rules.js'; +import { scanRepoArtifacts, emitEvent } from '../repo-artifacts/walker.js'; +import { BaseScanner } from './base-scanner.js'; import { debugError, formatError } from '../utils/errors.js'; import { SystemCacheScanner } from './system-cache.js'; import { SystemLogsScanner } from './system-logs.js'; @@ -13,6 +19,9 @@ import { IosBackupsScanner } from './ios-backups.js'; import { MailAttachmentsScanner } from './mail-attachments.js'; import { LanguageFilesScanner } from './language-files.js'; import { LargeFilesScanner } from './large-files.js'; +import { GoArtifactsScanner } from './go-artifacts.js'; +import { RustArtifactsScanner } from './rust-artifacts.js'; +import { NodeBuildArtifactsScanner } from './node-build-artifacts.js'; import { NodeModulesScanner } from './node-modules.js'; import { GitWorktreesScanner } from './git-worktrees.js'; import { DuplicatesScanner } from './duplicates.js'; @@ -33,6 +42,9 @@ export const ALL_SCANNERS: Record = { 'language-files': new LanguageFilesScanner(), 'large-files': new LargeFilesScanner(), 'node-modules': new NodeModulesScanner(), + 'rust-artifacts': new RustArtifactsScanner(), + 'go-artifacts': new GoArtifactsScanner(), + 'node-build-artifacts': new NodeBuildArtifactsScanner(), 'git-worktrees': new GitWorktreesScanner(), 'duplicates': new DuplicatesScanner(), 'launch-agents': new LaunchAgentsScanner(), @@ -97,49 +109,68 @@ export async function runAllScans( options?: ParallelScanOptions, onProgress?: (scanner: Scanner, result: ScanResult) => void ): Promise { - return runScans(getAllScanners().map((s) => s.category.id), options, onProgress); + const config = await loadConfig(); + const includeGo = [...(config.includeEcosystems ?? []), ...(options?.includeEcosystems ?? [])].includes('go'); + const ids = config.defaultCategories ?? getAllScanners().map((s) => s.category.id).filter((id) => String(id) !== 'go-artifacts' || includeGo); + return runScans(ids, options, onProgress); } export async function runScans( categoryIds: CategoryId[], options?: ParallelScanOptions, - onProgress?: (scanner: Scanner, result: ScanResult) => void + onProgress?: (scanner: Scanner, result: ScanResult) => void, + registry: Partial> = ALL_SCANNERS, ): Promise { - const scanners = categoryIds.map((id) => getScanner(id)); - const parallel = options?.parallel ?? true; - const concurrency = options?.concurrency ?? 4; - - let completed = 0; - const total = scanners.length; - let results: ScanResult[]; - - const runScanner = async (scanner: Scanner): Promise => { - const result = await safeScan(scanner, options); - completed++; - options?.onProgress?.(completed, total, scanner, result); - onProgress?.(scanner, result); - return result; - }; - - if (parallel) { - const tasks = scanners.map((scanner) => ({ - label: scanner.category.id, - fn: () => runScanner(scanner), - })); - - results = await runWithConcurrency(tasks, concurrency); - } else { - results = []; - - for (const scanner of scanners) { - results.push(await runScanner(scanner)); - } + const runId = options?.runId ?? randomUUID(); + let candidateCount = 0; + let warningCount = 0; + const request: ParallelScanOptions = { ...options, runId, onEvent: (event) => { + if (event.type === 'candidate') candidateCount++; + if (event.type === 'warning') warningCount++; + emitEvent(options ?? {}, event); + } }; + emitEvent(request, { type: 'start', runId, categories: categoryIds }); + let ended = false; + try { + const config = await loadConfig(); + const scanners = [...new Set(categoryIds)].map((id) => { + const scanner = registry[id]; + if (!scanner) throw new Error(`Unknown scanner category: ${id}`); + return scanner; + }); + const parallel = options?.parallel ?? config.parallelScans ?? true; + const concurrency = options?.concurrency ?? config.concurrency ?? 4; + let completed = 0; + const report = (scanner: Scanner, result: ScanResult) => { + options?.onProgress?.(++completed, scanners.length, scanner, result); + emitEvent(request, { type: 'progress', runId, category: scanner.category.id, completed }); + onProgress?.(scanner, result); + return result; + }; + const enabled = scanners.filter((s) => !config.excludeCategories?.includes(s.category.id)); + const repo = enabled.filter((s) => isRepoCategory(s.category.id) && s instanceof BaseScanner); + const tasks = enabled.filter((s) => !repo.includes(s)).map((scanner) => ({ label: scanner.category.id, fn: async () => { + const result = options?.signal?.aborted ? { category: scanner.category, items: [], totalSize: 0, error: 'CANCELLED' } : await safeScan(scanner, request); + for (const item of result.items) emitEvent(request, { type: 'candidate', runId, category: scanner.category.id, item }); + if (result.error) emitEvent(request, { type: 'warning', runId, category: scanner.category.id, reason: result.error }); + return [report(scanner, result)]; + } })); + if (repo.length) tasks.push({ label: 'repositories' as CategoryId, fn: async () => { + const results = await scanRepoArtifacts(repo.map((s) => s.category), request, undefined, false); + return results.map((r) => report(registry[r.category.id]!, r)); + } }); + const skipped = scanners.filter((s) => !enabled.includes(s)).map((s) => report(s, { category: s.category, items: [], totalSize: 0, skipped: 'CATEGORY_EXCLUDED' })); + const batches = parallel ? await runWithConcurrency(tasks, concurrency) : []; + if (!parallel) for (const task of tasks) batches.push(await task.fn()); + const results = [...batches.flat(), ...skipped].sort((a, b) => categoryIds.indexOf(a.category.id) - categoryIds.indexOf(b.category.id)); + const cancelled = options?.signal?.aborted ?? false; + if (cancelled) for (const result of results) { result.items = []; result.totalSize = 0; result.error = 'CANCELLED'; if (result.context) result.context = Object.freeze({ ...result.context, complete: false, evidence: Object.freeze({}) }); } + ended = true; + emitEvent(request, { type: 'done', runId, cancelled, candidates: cancelled ? 0 : candidateCount, warnings: warningCount }); + return { results, ...scanTotals(results), ...(cancelled && { cancelled }) }; + } finally { + if (!ended) emitEvent(request, { type: 'done', runId, cancelled: !!options?.signal?.aborted, candidates: 0, warnings: warningCount }); } - - const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0); - const totalItems = results.reduce((sum, r) => sum + r.items.length, 0); - - return { results, totalSize, totalItems }; } /** @@ -175,6 +206,9 @@ export { LanguageFilesScanner, LargeFilesScanner, NodeModulesScanner, + RustArtifactsScanner, + GoArtifactsScanner, + NodeBuildArtifactsScanner, GitWorktreesScanner, DuplicatesScanner, LaunchAgentsScanner, diff --git a/src/scanners/node-build-artifacts.ts b/src/scanners/node-build-artifacts.ts new file mode 100644 index 0000000..590885b --- /dev/null +++ b/src/scanners/node-build-artifacts.ts @@ -0,0 +1,10 @@ +import { BaseScanner } from './base-scanner.js'; +import { CATEGORIES, type ScanResult, type ScannerOptions } from '../types.js'; +import { scanRepoArtifacts } from '../repo-artifacts/walker.js'; + +export class NodeBuildArtifactsScanner extends BaseScanner { + category = CATEGORIES['node-build-artifacts']; + async scan(options?: ScannerOptions): Promise { + return (await scanRepoArtifacts([this.category], options))[0]; + } +} diff --git a/src/scanners/node-modules.ts b/src/scanners/node-modules.ts index ff7eb36..5b8587e 100644 --- a/src/scanners/node-modules.ts +++ b/src/scanners/node-modules.ts @@ -1,111 +1,10 @@ import { BaseScanner } from './base-scanner.js'; -import { CATEGORIES, type ScanResult, type ScannerOptions, type CleanableItem } from '../types.js'; -import { exists, getSize, debugError } from '../utils/index.js'; -import { readdir, stat, access } from 'fs/promises'; -import { join } from 'path'; -import { homedir } from 'os'; - -const DEFAULT_SEARCH_PATHS = [ - join(homedir(), 'Projects'), - join(homedir(), 'Developer'), - join(homedir(), 'Code'), - join(homedir(), 'dev'), - join(homedir(), 'workspace'), - join(homedir(), 'repos'), -]; - -const DEFAULT_DAYS_OLD = 30; +import { CATEGORIES, type ScanResult, type ScannerOptions } from '../types.js'; +import { scanRepoArtifacts } from '../repo-artifacts/walker.js'; export class NodeModulesScanner extends BaseScanner { category = CATEGORIES['node-modules']; - async scan(options?: ScannerOptions): Promise { - const items: CleanableItem[] = []; - const daysOld = options?.daysOld ?? DEFAULT_DAYS_OLD; - - for (const searchPath of DEFAULT_SEARCH_PATHS) { - if (await exists(searchPath)) { - const found = await this.findNodeModules(searchPath, daysOld, 4); - items.push(...found); - } - } - - items.sort((a, b) => b.size - a.size); - - return this.createResult(items); - } - - private async findNodeModules( - dir: string, - daysOld: number, - maxDepth: number, - currentDepth = 0 - ): Promise { - const items: CleanableItem[] = []; - - if (currentDepth > maxDepth) return items; - - try { - const entries = await readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name.startsWith('.')) continue; - - const fullPath = join(dir, entry.name); - - if (entry.name === 'node_modules') { - const parentDir = dir; - const packageJsonPath = join(parentDir, 'package.json'); - - try { - await access(packageJsonPath); - const parentStats = await stat(parentDir); - const daysSinceModified = (Date.now() - parentStats.mtime.getTime()) / (1000 * 60 * 60 * 24); - - if (daysSinceModified >= daysOld) { - const size = await getSize(fullPath); - const stats = await stat(fullPath); - - items.push({ - path: fullPath, - size, - name: `${this.getProjectName(parentDir)} (${Math.floor(daysSinceModified)}d old)`, - isDirectory: true, - modifiedAt: stats.mtime, - }); - } - } catch (error) { - // No package.json or orphaned node_modules - debugError(`node-modules package.json (${fullPath})`, error); - const size = await getSize(fullPath); - if (size > 0) { - const stats = await stat(fullPath); - items.push({ - path: fullPath, - size, - name: `${this.getProjectName(parentDir)} (orphaned)`, - isDirectory: true, - modifiedAt: stats.mtime, - }); - } - } - } else { - const subItems = await this.findNodeModules(fullPath, daysOld, maxDepth, currentDepth + 1); - items.push(...subItems); - } - } - } catch (error) { - // Permission errors are expected outside the user's own projects - debugError(`node-modules scan (${dir})`, error); - } - - return items; - } - - private getProjectName(projectPath: string): string { - const parts = projectPath.split('/'); - return parts[parts.length - 1] || projectPath; + return (await scanRepoArtifacts([this.category], options))[0]; } } - diff --git a/src/scanners/rust-artifacts.ts b/src/scanners/rust-artifacts.ts new file mode 100644 index 0000000..f1cf9f8 --- /dev/null +++ b/src/scanners/rust-artifacts.ts @@ -0,0 +1,10 @@ +import { BaseScanner } from './base-scanner.js'; +import { CATEGORIES, type ScanResult, type ScannerOptions } from '../types.js'; +import { scanRepoArtifacts } from '../repo-artifacts/walker.js'; + +export class RustArtifactsScanner extends BaseScanner { + category = CATEGORIES['rust-artifacts']; + async scan(options?: ScannerOptions): Promise { + return (await scanRepoArtifacts([this.category], options))[0]; + } +} diff --git a/src/types.test.ts b/src/types.test.ts index f025173..05c37ef 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -18,6 +18,9 @@ describe('CATEGORIES', () => { 'language-files', 'large-files', 'node-modules', + 'rust-artifacts', + 'go-artifacts', + 'node-build-artifacts', 'git-worktrees', 'duplicates', 'launch-agents', diff --git a/src/types.ts b/src/types.ts index eaae517..d87e6e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,9 @@ export type CategoryId = | 'language-files' | 'large-files' | 'node-modules' + | 'rust-artifacts' + | 'go-artifacts' + | 'node-build-artifacts' | 'git-worktrees' | 'duplicates' | 'launch-agents'; @@ -31,6 +34,37 @@ export interface Category { supportsFileSelection?: boolean; } +export type Ecosystem = 'node' | 'rust' | 'go'; +export type RepoCategoryId = 'node-modules' | 'node-build-artifacts' | 'rust-artifacts' | 'go-artifacts'; +export interface RepoRoot { readonly input: string; readonly path: string; readonly nodeOnly: boolean; readonly dev: number; readonly ino: number } +export interface RepoPolicy { + readonly roots: readonly RepoRoot[]; + readonly categories: readonly RepoCategoryId[]; + readonly excludePaths: readonly string[]; + readonly daysOld: number; + readonly configDaysOld: number; + readonly maxDepth: number; + readonly explicitRoots: boolean; +} +export interface ArtifactEvidence { + readonly category: RepoCategoryId; + readonly marker: string; + readonly markerDev: number; + readonly markerIno: number; + readonly dev: number; + readonly ino: number; +} +export interface CleanContext { + readonly repoPolicy: RepoPolicy; + readonly evidence: Readonly>; + readonly complete: boolean; +} +export interface RepoRequestOptions { + roots?: string[]; + includeEcosystems?: Ecosystem[]; + excludePaths?: string[]; +} + export interface CleanableItem { path: string; size: number; @@ -38,6 +72,10 @@ export interface CleanableItem { isDirectory: boolean; modifiedAt?: Date; detail?: string; + id?: string; + fileCount?: number; + newestEntryModifiedAt?: Date; + artifact?: ArtifactEvidence; } export interface ScanResult { @@ -45,18 +83,39 @@ export interface ScanResult { items: CleanableItem[]; totalSize: number; error?: string; + skipped?: string; + warnings?: { path: string; reason: string }[]; + context?: CleanContext; } export interface ScanSummary { results: ScanResult[]; totalSize: number; totalItems: number; + cancelled?: boolean; } +export interface CleanupOutcome { + category: CategoryId; + path?: string; + scope: 'item' | 'action'; + status: 'deleted' | 'backed-up' | 'would-delete' | 'would-back-up' | 'rejected' | 'skipped'; + reason?: string; + size: number; +} +export interface PreparedCleanup { + item: CleanableItem; + reason?: string; + execute(dryRun: boolean): Promise; +} export interface CleanResult { category: Category; cleanedItems: number; - /** Space actually returned to the disk. Zero when items went to the backup. */ + outcomes?: CleanupOutcome[]; + wouldFreeSpace?: number; + wouldBackUpSize?: number; + wouldCleanItems?: number; + /** Estimated logical bytes removed. Zero when items went to the backup. */ freedSpace: number; /** Size moved into the backup. Still occupying disk until `backup --clean`. */ backedUpSize?: number; @@ -71,9 +130,23 @@ export interface CleanSummary { backupDir?: string; totalCleanedItems: number; totalErrors: number; + wouldFreeSpace?: number; + wouldBackUpSize?: number; + wouldCleanItems?: number; } -export interface ScannerOptions { +export type ScanEvent = + | { type: 'start'; runId: string; categories: CategoryId[] } + | { type: 'progress'; runId: string; category?: CategoryId; path?: string; completed: number } + | { type: 'candidate'; runId: string; category: CategoryId; item: CleanableItem } + | { type: 'warning'; runId: string; category?: CategoryId; path?: string; reason: string } + | { type: 'done'; runId: string; cancelled: boolean; candidates: number; warnings: number }; + +export interface ScannerOptions extends RepoRequestOptions { + onEvent?: (event: ScanEvent) => void; + signal?: AbortSignal; + /** Assigned by the registry; standalone callers may omit it. */ + runId?: string; verbose?: boolean; daysOld?: number; minSize?: number; @@ -84,7 +157,8 @@ export interface Scanner { /** false when cleanup is delegated to an external tool and cannot be backed up. */ readonly supportsBackup?: boolean; scan(options?: ScannerOptions): Promise; - clean(items: CleanableItem[], dryRun?: boolean, backupDir?: string): Promise; + prepareClean?(items: CleanableItem[], backupDir?: string, context?: CleanContext): Promise; + clean(items: CleanableItem[], dryRun?: boolean, backupDir?: string, context?: CleanContext): Promise; } export const CATEGORIES: Record = { @@ -209,10 +283,25 @@ export const CATEGORIES: Record = { id: 'node-modules', name: 'Node Modules', group: 'Development', - description: 'Orphaned node_modules in old projects', + description: 'Stale dependencies beside package.json', safetyLevel: 'moderate', safetyNote: 'Projects will need npm install to restore', }, + 'go-artifacts': { + id: 'go-artifacts', name: 'Go Artifacts', group: 'Development', + description: 'Opt-in ignored bin/dist beside go.mod', safetyLevel: 'risky', + safetyNote: 'May contain untracked files you want to keep. Explicit Go opt-in and review are required.', supportsFileSelection: true, + }, + 'rust-artifacts': { + id: 'rust-artifacts', name: 'Rust Artifacts', group: 'Development', + description: 'target directories beside Cargo.toml', safetyLevel: 'moderate', + safetyNote: 'Rust projects will need to rebuild', supportsFileSelection: true, + }, + 'node-build-artifacts': { + id: 'node-build-artifacts', name: 'Node Build Artifacts', group: 'Development', + description: 'Build output beside package.json', safetyLevel: 'risky', + safetyNote: 'May contain untracked files you want to keep. Review every output directory.', supportsFileSelection: true, + }, 'git-worktrees': { id: 'git-worktrees', name: 'Git Worktrees', diff --git a/src/utils/backup-fallback.test.ts b/src/utils/backup-fallback.test.ts new file mode 100644 index 0000000..966a087 --- /dev/null +++ b/src/utils/backup-fallback.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +const { home } = vi.hoisted(() => ({ home: `/private/tmp/mac-cleaner-copy-${process.pid}` })); +vi.mock('os', async (original) => ({ ...await original(), homedir: () => home })); +vi.mock('fs/promises', async (original) => { + const fs = await original(); + return { ...fs, rename: vi.fn(fs.rename), cp: vi.fn(fs.cp), rm: vi.fn(fs.rm) }; +}); +import { mkdir, readFile, writeFile, rm, rename, cp, symlink } from 'fs/promises'; +import { join } from 'path'; +import { backupItem, ensureBackupDir, restoreBackup, listBackups, cleanOldBackups } from './backup.js'; +let path: string; let backup: string; +beforeEach(async () => { vi.clearAllMocks(); await mkdir(home, { recursive: true }); path = join(home, 'artifact'); await mkdir(path); await writeFile(join(path, 'file'), 'data'); backup = await ensureBackupDir(); }); +afterEach(async () => { await rm(home, { recursive: true, force: true }); }); +const item = () => ({ path, size: 4, name: 'artifact', isDirectory: true }); +it('copies on EXDEV, preserves links, then restores the artifact', async () => { + await symlink('/unfollowed', join(path, 'link')); + vi.mocked(rename).mockRejectedValueOnce(Object.assign(new Error('cross-device'), { code: 'EXDEV' })); + expect(await backupItem(item(), backup)).toBe(true); + expect(cp).toHaveBeenCalledWith(path, expect.any(String), expect.objectContaining({ verbatimSymlinks: true })); + expect((await listBackups())[0].size).toBeGreaterThan(0); + expect((await restoreBackup(backup)).success).toBe(2); + expect(await readFile(join(path, 'file'), 'utf8')).toBe('data'); +}); +it('never deletes the source when copying fails', async () => { + vi.mocked(rename).mockRejectedValueOnce(Object.assign(new Error('cross-device'), { code: 'EXDEV' })); + vi.mocked(cp).mockRejectedValueOnce(new Error('copy failed')); + expect(await backupItem(item(), backup)).toBe(false); + expect(await readFile(join(path, 'file'), 'utf8')).toBe('data'); + expect(rm).not.toHaveBeenCalled(); +}); +it('rechecks identity after a long copy before removing the source', async () => { + vi.mocked(rename).mockRejectedValueOnce(Object.assign(new Error('cross-device'), { code: 'EXDEV' })); + const recheck = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('REPLACED')); + expect(await backupItem(item(), backup, recheck)).toBe(false); + expect(recheck).toHaveBeenCalledTimes(2); + expect(await readFile(join(path, 'file'), 'utf8')).toBe('data'); + expect(rm).not.toHaveBeenCalled(); +}); +it('reports restore failure without losing backed-up files', async () => { + expect(await backupItem(item(), backup)).toBe(true); + vi.mocked(rename).mockRejectedValueOnce(new Error('permission denied')); + const result = await restoreBackup(backup); + expect(result.failed).toBe(1); expect(result.errors[0]).toContain('Failed to restore'); + expect((await listBackups()).length).toBe(1); +}); +it('rejects restores outside the backup root and unsafe mappings', async () => { + expect((await restoreBackup('/outside')).failed).toBe(1); + await mkdir(join(backup, 'HOME', 'a..b'), { recursive: true }); + await writeFile(join(backup, 'HOME', 'a..b', 'file'), 'data'); + expect((await restoreBackup(backup)).errors.join()).toContain('Suspicious'); + expect(await cleanOldBackups()).toBe(0); +}); diff --git a/src/utils/backup.test.ts b/src/utils/backup.test.ts index f0df7f9..61db088 100644 --- a/src/utils/backup.test.ts +++ b/src/utils/backup.test.ts @@ -1,3 +1,6 @@ +// All backup/restore fixtures are isolated from the real home directory. +const { fixtureHome } = vi.hoisted(() => ({ fixtureHome: `/private/tmp/mac-cleaner-backup-${process.pid}` })); +vi.mock('os', async (original) => ({ ...await original(), homedir: () => fixtureHome })); import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdir, mkdtemp, rm, writeFile, readFile } from 'fs/promises'; import { existsSync } from 'fs'; diff --git a/src/utils/backup.ts b/src/utils/backup.ts index 97af6fa..07058d8 100644 --- a/src/utils/backup.ts +++ b/src/utils/backup.ts @@ -2,7 +2,7 @@ import { mkdir, rename, readdir, stat, rm, cp } from 'fs/promises'; import { join, dirname, resolve, relative } from 'path'; import { homedir } from 'os'; import type { CleanableItem } from '../types.js'; -import { validatePathSafety } from './fs.js'; +import { preflightItem } from './fs.js'; import { debugError } from './errors.js'; const BACKUP_DIR = join(homedir(), '.mac-cleaner-cli', 'backup'); @@ -96,8 +96,8 @@ export function backupPathFor(originalPath: string, backupDir: string): string | * Safety contract: if the backup fails, the item is NOT deleted. Callers count * that as a failure, never as a silent success. */ -export async function backupItem(item: CleanableItem, backupDir: string): Promise { - const safetyError = validatePathSafety(item.path); +export async function backupItem(item: CleanableItem, backupDir: string, beforeMutation?: () => Promise): Promise { + const safetyError = await preflightItem(item); if (safetyError) { console.error(safetyError); return false; @@ -111,6 +111,7 @@ export async function backupItem(item: CleanableItem, backupDir: string): Promis try { await mkdir(dirname(backupPath), { recursive: true }); + await beforeMutation?.(); await rename(item.path, backupPath); return true; } catch (error) { @@ -129,6 +130,7 @@ export async function backupItem(item: CleanableItem, backupDir: string): Promis errorOnExist: false, force: true, }); + await beforeMutation?.(); await rm(item.path, { recursive: true, force: true }); return true; } catch (error) { @@ -160,7 +162,7 @@ export async function backupItems( onProgress?.(i + 1, items.length, item); const backed = dryRun - ? backupPathFor(item.path, backupDir) !== null + ? (await preflightItem(item)) === null && backupPathFor(item.path, backupDir) !== null : await backupItem(item, backupDir); if (backed) { diff --git a/src/utils/cleanup.ts b/src/utils/cleanup.ts new file mode 100644 index 0000000..91f3c4b --- /dev/null +++ b/src/utils/cleanup.ts @@ -0,0 +1,86 @@ +import { realpath } from 'fs/promises'; +import { realpathSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import type { CategoryId, CleanResult, CleanSummary, PreparedCleanup, Scanner, ScanResult } from '../types.js'; +import { contains } from '../repo-artifacts/eligibility.js'; +import { emptyCleanResult, mergeCleanResult } from '../scanners/base-scanner.js'; + +/** Validate all selected filesystem rows before ancestor suppression. */ +export async function cleanSelectedResults( + selected: ScanResult[], + scannerFor: (id: CategoryId) => Scanner, + options: { dryRun?: boolean; backupDir?: string; onProgress?: (completed: number, result: ScanResult) => void } = {}, +): Promise { + const results = new Map(); + const actions: { scan: ScanResult; action: PreparedCleanup; path: string }[] = []; + const external: { scan: ScanResult; scanner: Scanner }[] = []; + for (const scan of selected) { + const scanner = scannerFor(scan.category.id); + results.set(scan, emptyCleanResult(scan.category)); + if (!scan.items.length) continue; + if (scanner.supportsBackup === false || !scanner.prepareClean) { external.push({ scan, scanner }); continue; } + try { + const prepared = await scanner.prepareClean(scan.items, options.backupDir, scan.context); + for (const action of prepared) { + // Resolve the parent only: a selected symlink is itself an item. + const path = action.reason ? resolve(action.item.path) : join(await realpath(dirname(action.item.path)).catch(() => resolve(dirname(action.item.path))), action.item.path.split('/').pop()!); + actions.push({ scan, action, path }); + } + } catch (error) { + const result = results.get(scan)!; + result.errors.push((error instanceof Error ? error.message : String(error))); + result.outcomes = scan.items.map((i) => ({ category: scan.category.id, path: i.path, scope: 'item', status: 'rejected', reason: (error instanceof Error ? error.message : String(error)), size: 0 })); + } + } + actions.sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path)); + const completed: string[] = []; + for (const { scan, action, path } of actions) { + const result = results.get(scan)!; + if (!action.reason && completed.some((parent) => contains(parent, path))) { + result.outcomes!.push({ category: scan.category.id, path: action.item.path, scope: 'item', status: 'skipped', reason: 'SKIPPED_COVERED_BY_PARENT', size: 0 }); + continue; + } + const one = await action.execute(options.dryRun ?? false); + mergeCleanResult(result, one); + if (!one.errors.length && (one.cleanedItems || one.wouldCleanItems)) completed.push(path); + } + for (const { scan, scanner } of external) { + try { + const result = await scanner.clean(scan.items, options.dryRun, scanner.supportsBackup === false ? undefined : options.backupDir, scan.context); + if (options.dryRun) { + result.wouldCleanItems ??= result.cleanedItems; + result.wouldFreeSpace ??= result.freedSpace; + result.wouldBackUpSize ??= result.backedUpSize ?? 0; + result.cleanedItems = 0; result.freedSpace = 0; result.backedUpSize = 0; + } + if (scanner.supportsBackup === false) result.outcomes = [{ category: scan.category.id, scope: 'action', status: result.errors.length ? 'rejected' : options.dryRun ? 'would-delete' : 'deleted', reason: result.errors.join('; ') || undefined, size: options.dryRun ? result.wouldFreeSpace ?? 0 : result.freedSpace }]; + results.set(scan, result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + results.get(scan)!.errors.push(message); + results.get(scan)!.outcomes = [{ category: scan.category.id, scope: 'action', status: 'rejected', reason: message, size: 0 }]; + } + } + selected.forEach((scan, i) => options.onProgress?.(i + 1, scan)); + const cleaned = [...results.values()]; + return { results: cleaned, backupDir: options.backupDir, totalFreedSpace: cleaned.reduce((n, r) => n + r.freedSpace, 0), totalBackedUpSize: cleaned.reduce((n, r) => n + (r.backedUpSize ?? 0), 0), totalCleanedItems: cleaned.reduce((n, r) => n + r.cleanedItems, 0), totalErrors: cleaned.reduce((n, r) => n + r.errors.length, 0), wouldFreeSpace: cleaned.reduce((n, r) => n + (r.wouldFreeSpace ?? 0), 0), wouldBackUpSize: cleaned.reduce((n, r) => n + (r.wouldBackUpSize ?? 0), 0), wouldCleanItems: cleaned.reduce((n, r) => n + (r.wouldCleanItems ?? 0), 0) }; +} + +/** Keep scan rows independent; count their filesystem path union once. */ +export function scanTotals(results: ScanResult[]): { totalSize: number; totalItems: number } { + const parents = new Map(); + const canonical = (path: string) => { + const parent = dirname(resolve(path)); + if (!parents.has(parent)) { + try { parents.set(parent, realpathSync(parent)); } catch { parents.set(parent, parent); } + } + return join(parents.get(parent)!, path.split('/').pop()!); + }; + const files = results.filter((r) => !['docker', 'homebrew'].includes(r.category.id)).flatMap((r) => r.items).map((item) => ({ ...item, path: canonical(item.path) })).sort((a, b) => a.path.length - b.path.length); + const paths: string[] = []; + let totalSize = 0; + let totalItems = 0; + for (const item of files) if (!paths.some((p) => contains(p, item.path))) { paths.push(item.path); totalSize += item.size; totalItems++; } + for (const result of results.filter((r) => ['docker', 'homebrew'].includes(r.category.id))) { totalSize += result.totalSize; totalItems += result.items.length; } + return { totalSize, totalItems }; +} diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts index 6b6d08b..d428e23 100644 --- a/src/utils/config.test.ts +++ b/src/utils/config.test.ts @@ -96,7 +96,7 @@ describe('config utilities', () => { expect(loaded.downloadsDaysOld).toBe(30); }); - it('should ignore a config file larger than 100KB', async () => { + it('should block repository policy for a config file larger than 100KB', async () => { const configPath = join(testConfigDir, 'huge.json'); await writeFile(configPath, JSON.stringify({ concurrency: 8, padding: 'x'.repeat(200 * 1024) })); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); @@ -104,7 +104,7 @@ describe('config utilities', () => { const loaded = await config.loadConfig(configPath); expect(loaded.concurrency).toBe(4); - expect(warn).toHaveBeenCalledWith('Config file too large, using defaults'); + expect(loaded.repoPolicyError).toContain('CONFIG_TOO_LARGE'); warn.mockRestore(); }); @@ -162,7 +162,7 @@ describe('config utilities', () => { expect(loaded.filePicker).toBe(true); }); - it('should filter invalid category IDs', async () => { + it('should reject malformed excluded categories', async () => { const configPath = join(testConfigDir, 'invalid-categories.json'); await writeFile(configPath, JSON.stringify({ defaultCategories: ['trash', 'invalid-category', 'system-cache'], @@ -170,8 +170,7 @@ describe('config utilities', () => { })); const loaded = await config.loadConfig(configPath); - expect(loaded.defaultCategories).toEqual(['trash', 'system-cache']); - expect(loaded.excludeCategories).toEqual([]); + expect(loaded.repoPolicyError).toMatch(/INVALID_.*CATEGORIES/); }); it('should ignore category fields that are not arrays', async () => { @@ -216,13 +215,11 @@ describe('config utilities', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const loaded = await config.loadConfig(configPath); - expect(loaded.extraPaths?.nodeModules).toEqual([join(FAKE_HOME, 'Projects'), '/Users/other']); - expect(loaded.extraPaths?.projects).toEqual([join(FAKE_HOME, 'Developer')]); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('Skipping path outside allowed directories')); + expect(loaded.repoPolicyError).toContain('INVALID_ROOT'); warn.mockRestore(); }); - it('should drop non-string extra paths and cap the list at 50 entries', async () => { + it('should fail closed for malformed or excessive extra paths', async () => { const configPath = join(testConfigDir, 'too-many-paths.json'); await writeFile(configPath, JSON.stringify({ extraPaths: { @@ -233,9 +230,7 @@ describe('config utilities', () => { const loaded = await config.loadConfig(configPath); - expect(loaded.extraPaths?.nodeModules).toHaveLength(50); - expect(loaded.extraPaths?.nodeModules?.[0]).toBe(join(FAKE_HOME, 'Projects', 'p0')); - expect(loaded.extraPaths?.projects).toEqual([]); + expect(loaded.repoPolicyError).toContain('INVALID_ROOTS'); }); it('should ignore extraPaths keys that are not set', async () => { diff --git a/src/utils/config.ts b/src/utils/config.ts index f1eda1f..373bc2e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,7 +1,7 @@ import { readFile, writeFile, access } from 'fs/promises'; import { join, resolve } from 'path'; import { homedir } from 'os'; -import type { CategoryId } from '../types.js'; +import { CATEGORIES, type CategoryId, type Ecosystem } from '../types.js'; import { debugError, errorCode, formatError } from './errors.js'; const CONFIG_PATHS = [ @@ -10,14 +10,15 @@ const CONFIG_PATHS = [ ]; // Valid category IDs for validation -const VALID_CATEGORIES: CategoryId[] = [ - 'system-cache', 'system-logs', 'browser-cache', 'dev-cache', - 'node-modules', 'git-worktrees', 'downloads', 'trash', 'temp-files', - 'ios-backups', 'mail-attachments', 'large-files', 'duplicates', - 'docker', 'homebrew', 'language-files', 'launch-agents', -]; +const VALID_CATEGORIES = Object.keys(CATEGORIES) as CategoryId[]; export interface Config { + /** A bad user config may not silently broaden repository cleanup. */ + repoPolicyError?: string; + excludePaths?: string[]; + includeEcosystems?: Ecosystem[]; + nodeModulesDaysOld?: number; + projectScanMaxDepth?: number; defaultCategories?: CategoryId[]; excludeCategories?: CategoryId[]; downloadsDaysOld?: number; @@ -51,6 +52,23 @@ const CONFIG_CONSTRAINTS = { function validateConfig(config: Partial): Config { const validated: Config = {}; const home = homedir(); + if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('INVALID_CONFIG'); + for (const [key, max] of [['nodeModulesDaysOld', 365], ['projectScanMaxDepth', 16]] as const) { + const value = config[key]; + if (value !== undefined) { + if (!Number.isInteger(value) || value < 0 || value > max) throw new Error(`INVALID_CONFIG: ${key}`); + validated[key] = value; + } + } + if (config.excludePaths !== undefined) { + // The full grammar is also validated by the repository policy normalizer. + if (!Array.isArray(config.excludePaths) || config.excludePaths.some((p) => typeof p !== 'string')) throw new Error('INVALID_EXCLUSIONS'); + validated.excludePaths = [...config.excludePaths]; + } + if (config.includeEcosystems !== undefined) { + if (!Array.isArray(config.includeEcosystems) || config.includeEcosystems.some((e) => !['node', 'rust', 'go'].includes(e))) throw new Error('INVALID_ECOSYSTEM'); + validated.includeEcosystems = [...config.includeEcosystems]; + } // Validate numeric fields with constraints if (config.downloadsDaysOld !== undefined) { @@ -104,6 +122,7 @@ function validateConfig(config: Partial): Config { // Validate category arrays if (config.defaultCategories !== undefined) { + if (!Array.isArray(config.defaultCategories) || config.defaultCategories.some((cat) => !VALID_CATEGORIES.includes(cat))) throw new Error('INVALID_DEFAULT_CATEGORIES'); if (Array.isArray(config.defaultCategories)) { validated.defaultCategories = config.defaultCategories.filter( (cat): cat is CategoryId => VALID_CATEGORIES.includes(cat as CategoryId) @@ -112,6 +131,7 @@ function validateConfig(config: Partial): Config { } if (config.excludeCategories !== undefined) { + if (!Array.isArray(config.excludeCategories) || config.excludeCategories.some((cat) => !VALID_CATEGORIES.includes(cat))) throw new Error('INVALID_EXCLUDE_CATEGORIES'); if (Array.isArray(config.excludeCategories)) { validated.excludeCategories = config.excludeCategories.filter( (cat): cat is CategoryId => VALID_CATEGORIES.includes(cat as CategoryId) @@ -119,47 +139,18 @@ function validateConfig(config: Partial): Config { } } - // Validate extraPaths with security checks - if (config.extraPaths !== undefined && typeof config.extraPaths === 'object') { + if (config.extraPaths !== undefined) { + if (!config.extraPaths || typeof config.extraPaths !== 'object' || Array.isArray(config.extraPaths)) throw new Error('INVALID_ROOTS'); validated.extraPaths = {}; - - const validatePaths = (paths: unknown): string[] => { - if (!Array.isArray(paths)) return []; - - return paths - .filter((p): p is string => typeof p === 'string') - .slice(0, CONFIG_CONSTRAINTS.maxExtraPaths) // Limit number of paths - .map(p => { - // Expand ~ to home directory - if (p.startsWith('~/')) { - return join(home, p.slice(2)); - } - return p; - }) - .filter(p => { - // Security: ensure paths are within home directory or common dev locations - const resolved = resolve(p); - const allowedPrefixes = [ - home, - '/Users', - '/Volumes', - ]; - const isAllowed = allowedPrefixes.some(prefix => - resolved.startsWith(prefix + '/') || resolved === prefix - ); - if (!isAllowed) { - console.warn(`Skipping path outside allowed directories: ${p}`); - } - return isAllowed; - }); - }; - - if (config.extraPaths.nodeModules) { - validated.extraPaths.nodeModules = validatePaths(config.extraPaths.nodeModules); - } - - if (config.extraPaths.projects) { - validated.extraPaths.projects = validatePaths(config.extraPaths.projects); + for (const key of ['nodeModules', 'projects'] as const) { + const paths = config.extraPaths[key]; + if (paths === undefined) continue; + if (!Array.isArray(paths) || paths.length > CONFIG_CONSTRAINTS.maxExtraPaths || paths.some((p) => typeof p !== 'string' || !p || p.includes('\0'))) throw new Error('INVALID_ROOTS'); + validated.extraPaths[key] = paths.map((p) => { + const expanded = resolve(p.startsWith('~/') ? join(home, p.slice(2)) : p); + if (![home, '/Users', '/Volumes'].some((base) => expanded.startsWith(base + '/')) || [home, '/Users', '/Volumes'].includes(expanded)) throw new Error('INVALID_ROOT'); + return expanded; + }); } } @@ -167,6 +158,10 @@ function validateConfig(config: Partial): Config { } const DEFAULT_CONFIG: Config = { + nodeModulesDaysOld: 30, + projectScanMaxDepth: 4, + excludePaths: [], + includeEcosystems: [], downloadsDaysOld: 30, largeFilesMinSize: 500 * 1024 * 1024, backupEnabled: false, @@ -177,8 +172,8 @@ const DEFAULT_CONFIG: Config = { let cachedConfig: Config | null = null; -export async function loadConfig(configPath?: string): Promise { - if (cachedConfig && !configPath) { +export async function loadConfig(configPath?: string, refresh = false): Promise { + if (cachedConfig && !configPath && !refresh) { return cachedConfig; } @@ -190,7 +185,7 @@ export async function loadConfig(configPath?: string): Promise { if (!allowedDirs.some(dir => resolved.startsWith(dir + '/') || resolved === dir)) { console.warn('Config path must be within home directory, using defaults'); - cachedConfig = DEFAULT_CONFIG; + cachedConfig = { ...DEFAULT_CONFIG, repoPolicyError: 'INVALID_CONFIG_PATH' }; return cachedConfig; } } @@ -203,8 +198,7 @@ export async function loadConfig(configPath?: string): Promise { // Limit config file size to prevent DoS if (content.length > 100 * 1024) { // 100KB max - console.warn('Config file too large, using defaults'); - continue; + throw new Error('CONFIG_TOO_LARGE'); } const parsed = JSON.parse(content) as Partial; @@ -223,6 +217,10 @@ export async function loadConfig(configPath?: string): Promise { console.warn(`Could not read config file ${path}: ${formatError(error)}`); } debugError(`loadConfig(${path})`, error); + if (errorCode(error) !== 'ENOENT') { + cachedConfig = { ...DEFAULT_CONFIG, repoPolicyError: `INVALID_CONFIG: ${formatError(error)}` }; + return cachedConfig; + } continue; } } @@ -261,7 +259,11 @@ export function clearConfigCache(): void { export async function initConfig(): Promise { const configPath = CONFIG_PATHS[0]; const defaultConfig: Config = { - downloadsDaysOld: 30, + nodeModulesDaysOld: 30, + projectScanMaxDepth: 4, + excludePaths: [], + includeEcosystems: [], + downloadsDaysOld: 30, largeFilesMinSize: 500 * 1024 * 1024, backupEnabled: false, backupRetentionDays: 7, diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 0b6a2b0..41abc9b 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -1,5 +1,5 @@ -import { lstat, readdir, rm, access, unlink } from 'fs/promises'; -import { join, resolve } from 'path'; +import { lstat, readdir, rm, access, unlink, realpath } from 'fs/promises'; +import { dirname, join, resolve } from 'path'; import { homedir } from 'os'; import type { CleanableItem } from '../types.js'; import { sanitizeDisplayName } from './display.js'; @@ -302,42 +302,32 @@ export async function removeItem(path: string, dryRun = false): Promise * Same as removeItem, but returns the failure reason (an errno code like * 'EPERM', or a safety-check message) instead of a boolean. Returns null on success. */ -export async function removeItemWithError(path: string, dryRun = false): Promise { - // The safety check deliberately runs BEFORE the dry-run early return. - // A dry run that reports success for a path the real execution would refuse - // is worse than no dry run at all: it promises free space that will never - // appear. Concrete case: the logs scanner offers items under /var/log, which - // is in PROTECTED_PATHS — the dry run claimed the full total while the real - // run returned a failure for every one of them. - const safetyError = validatePathSafety(path); - if (safetyError) { - console.error(safetyError); - return 'PROTECTED'; - } - - if (dryRun) { +export async function preflightItem(item: Pick & Partial>): Promise { + if (validatePathSafety(item.path)) return 'PROTECTED'; + try { + const stats = await lstat(item.path); + // System aliases above the parent (e.g. /tmp) are allowed; a protected + // canonical parent never becomes safe through an alternate spelling. + const canonicalParent = await realpath(dirname(item.path)); + if (validatePathSafety(join(canonicalParent, item.path.split('/').pop()!))) return 'PROTECTED'; + if (item.isDirectory !== undefined && !stats.isSymbolicLink() && stats.isDirectory() !== item.isDirectory) return 'TYPE_CHANGED'; return null; - } + } catch (error) { return errorCode(error); } +} +export async function removeItemWithError(path: string, dryRun = false, beforeMutation?: () => Promise): Promise { + const failure = await preflightItem({ path }); + if (failure) return failure; + if (dryRun) return null; try { - // Re-check file type immediately before deletion to prevent TOCTOU attacks - // An attacker could replace a file with a symlink between scan and delete const stats = await lstat(path); - - if (stats.isSymbolicLink()) { - // For symlinks, only remove the symlink itself, never follow it - await unlink(path); - } else { - await rm(path, { recursive: true, force: true }); - } + await beforeMutation?.(); + if (stats.isSymbolicLink()) await unlink(path); + else await rm(path, { recursive: true, force: true }); return null; } catch (error) { - // Log the error for debugging but don't expose details to potential attackers - if (isExpectedFsError(error)) { - debugError(`removeItem(${path})`, error); - } else { - console.error(`Failed to remove ${sanitizeDisplayName(path)}: ${errorCode(error)}`); - } + if (isExpectedFsError(error)) debugError(`removeItem(${path})`, error); + else console.error(`Failed to remove ${sanitizeDisplayName(path)}: ${errorCode(error)}`); return errorCode(error); } } diff --git a/vitest.config.ts b/vitest.config.ts index bda93fe..3e28708 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,9 +13,11 @@ export default defineConfig({ 'node_modules/**', 'dist/**', '**/*.test.ts', - '**/index.ts', + 'src/index.ts', + 'src/commands/index.ts', + 'src/utils/index.ts', + 'src/maintenance/index.ts', 'src/scanners/duplicates.ts', - 'src/scanners/node-modules.ts', // src/utils/backup.ts left this list: it is no longer dead code and now // runs on the deletion path. A module that can lose a user's file does // not sit outside the coverage count.