From e60245c6885c7886625b8fc3061033732b276b9b Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:58:40 +0800 Subject: [PATCH 01/22] Document generic residual cleanup design --- ...6-07-14-generic-residual-cleanup-design.md | 525 ++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-generic-residual-cleanup-design.md diff --git a/docs/superpowers/specs/2026-07-14-generic-residual-cleanup-design.md b/docs/superpowers/specs/2026-07-14-generic-residual-cleanup-design.md new file mode 100644 index 0000000..210e67a --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-generic-residual-cleanup-design.md @@ -0,0 +1,525 @@ +# Generic Residual Cleanup Design + +**Date:** 2026-07-14 + +**Status:** Approved design; implementation has not started + +## Problem + +CleanApp currently removes only paths discovered by installation providers or bundled YAML rules. For an application without a deep-clean rule, the Applications provider records only the `.app` bundle. After deletion, `CleanupResult.remaining_paths` checks only those original paths, so `Remaining: no paths` means “all planned paths are gone,” not “a fresh residual scan found nothing.” + +The Poe uninstall demonstrated the gap: `/Applications/Poe.app` was deleted successfully, while standard data under `~/Library/Application Support`, `Caches`, `HTTPStorages`, and `Preferences` remained undiscovered. + +## Goal + +Apply a generic three-stage cleanup workflow to every software source already supported by CleanApp: + +- macOS Applications +- Homebrew formula and cask +- npm and pnpm global packages +- pipx and uv tools +- Cargo and Go binaries +- software identified by bundled YAML rules + +The workflow must: + +1. Perform the existing primary uninstall after its current preview and confirmation. +2. Run a fresh residual scan in approved application-data locations. +3. Show every confirmed deletable residual and ask for a second `y/Y` confirmation. +4. Delete only the confirmed residual paths. +5. Run a fresh verification scan and report remaining, review-only, and unverified items accurately. + +## Non-goals + +- Do not recursively search the entire home directory or filesystem. +- Do not enter Chrome, Safari, Firefox, Edge, VS Code, or any other unrelated application's data directory. +- Do not delete user documents, projects, downloads, cloud files, browser website data, Keychain items, Mail, or Messages. +- Do not treat shared package-manager stores and caches as one package's residuals. +- Do not automatically invoke `sudo` or weaken the existing protected-path policy. +- Do not claim that every possible file on disk has been attributed to the removed software. +- Do not add per-application rules as the primary solution to this problem. + +The accurate product promise is: + +> CleanApp performs an identity-linked scan of supported user, XDG, package-tool, and `/Library` residual locations. + +## Considered Approaches + +### Chosen: approved roots plus identity matching + +Enumerate only direct children of approved residual roots. Match candidates using captured bundle identifiers, package identifiers, executable names, exact application names, and source-owned paths. + +This approach has bounded runtime, deterministic coverage, and a testable safety boundary. It finds standard application and command-line-tool residuals without entering unrelated application directories. + +### Rejected: Spotlight or metadata-wide search + +Spotlight can cover more locations quickly, but its index can be incomplete or stale. It also returns nested browser origins, user documents, and unrelated same-name files, making the result unsuitable for a single `y/Y` deletion prompt. + +### Rejected: recursive filesystem name search + +A recursive `find` or `rglob` maximizes superficial coverage but violates the confirmed safety requirements. It can match source repositories, user documents, browser profiles, shared caches, and short generic names such as `go`. + +## Architecture + +### End-to-end data flow + +```text +Capture identity before deletion + ↓ +Primary analyze → preview → confirm → uninstall + ↓ +Residual scan #2 using the saved identity + ↓ +Display confirmed, review-only, and unverified results + ↓ +Second y/N prompt for confirmed candidates only + ↓ +Delete confirmed residual paths without rerunning package managers + ↓ +Fresh verification scan #3 + ↓ +Report confirmed residuals, review-only matches, inaccessible roots, +original planned paths that still exist, and related running processes +``` + +Interactive selection and `cleanapp remove NAME` must share this workflow. + +### Data models + +Add the following models to `cleanapp/models.py`: + +```python +@dataclass(frozen=True, slots=True) +class ResidualIdentity: + names: frozenset[str] + bundle_ids: frozenset[str] + package_ids: frozenset[str] + executables: frozenset[str] + sources: frozenset[Source] + + +@dataclass(frozen=True, slots=True) +class ResidualCandidate: + path: Path + evidence: str + system_level: bool + device: int + inode: int + file_type: int + + +@dataclass(slots=True) +class ResidualScan: + confirmed: list[ResidualCandidate] = field(default_factory=list) + review_only: list[ResidualCandidate] = field(default_factory=list) + inaccessible_roots: list[Path] = field(default_factory=list) + running_pids: list[int] = field(default_factory=list) +``` + +Residual identity is deliberately separate from `Software.identifiers`. The existing identifiers can become package-manager uninstall arguments; inserting bundle IDs there could cause commands such as `brew uninstall --cask com.vendor.app`. + +### Residual scanner + +Create `cleanapp/residual_scanner.py` with two public interfaces: + +```python +def capture_identity(software: Software, rule: Rule | None) -> ResidualIdentity: + ... + + +class ResidualScanner: + def scan(self, identity: ResidualIdentity) -> ResidualScan: + ... +``` + +The module owns identity capture, root discovery, direct-child enumeration, candidate classification, parent-child deduplication, and inaccessible-root reporting. It never deletes files. + +### Residual remover + +Keep `Remover.remove()` as the primary uninstall operation. Add a separate method: + +```python +def remove_residuals( + self, + name: str, + candidates: list[ResidualCandidate], +) -> CleanupResult: + ... +``` + +The method deletes only the confirmed paths passed to it. It must not rerun Homebrew, npm, pnpm, pipx, uv, Cargo, or any other provider action. + +### CLI orchestration + +Extend `_confirm_and_remove()` in `cleanapp/cli.py` to: + +1. Capture identity before any `.app`, package, executable, or rule path is removed. +2. Preserve the existing primary preview and confirmation. +3. Execute the existing primary removal. +4. Run residual scan #2 even if primary removal completed with warnings. +5. Display the complete residual result. +6. Ask a second question only when confirmed candidates exist. +7. Delete confirmed candidates only after `y/Y`. +8. Always run fresh verification scan #3 for a real primary removal. +9. Combine scan #3 with original primary paths that still exist and the latest related-process check. +10. Render a truthful final status. + +## Identity Capture + +Identity must be captured before primary deletion. + +### Applications and Homebrew casks + +For each `.app` path, read available `Info.plist` files and collect: + +- `CFBundleIdentifier` +- `CFBundleName` +- `CFBundleDisplayName` +- `CFBundleExecutable` +- bundle IDs for embedded helpers, XPC services, app extensions, and login items + +Bundle IDs remain byte-exact. Human-readable names may use Unicode normalization and case-insensitive comparison. + +### Homebrew formula and cask + +Capture the exact formula/cask token, the full package name already reported by the provider, and any paths already present on `Software`. The first implementation does not add extra Homebrew metadata subprocesses. + +The normal Homebrew uninstall remains the primary action. Generic cleanup does not replace it and does not automatically invoke cask `zap`. + +### npm and pnpm + +Capture the exact scoped package name and its unscoped leaf name as a weaker signal. Exported command names that are not already represented by `Software` or a rule are not guessed. Shared npm and pnpm stores are excluded. + +### pipx and uv + +Capture the tool/package name already represented by `Software`. Exported executable names and symlink targets that are not already represented are not guessed. Shared uv caches are excluded. + +### Cargo and Go + +For Cargo and Go, the current providers represent installed binaries as `Software` items. Capture that exact binary name and its recorded path. Module identities that are not already represented are not guessed. Shared Cargo registry/git caches, Go build cache, and module cache are excluded. + +### YAML rules + +Capture rule name, slug, aliases, kill identities, and package identifiers. Rules remain the authoritative first layer; the generic scanner is a second-layer safety net. + +All supported sources enter the residual workflow. A source with insufficient identity signals may produce review-only matches or no candidates; it must not broaden into substring or full-disk searching. + +## Approved Scan Roots + +The scanner enumerates direct children only. An unmatched child is never traversed. + +### User-level macOS roots + +```text +~/Library/Application Support +~/Library/Caches +~/Library/Preferences +~/Library/Preferences/ByHost +~/Library/Logs +~/Library/HTTPStorages +~/Library/Cookies +~/Library/WebKit +~/Library/Saved Application State +~/Library/Containers +~/Library/Group Containers +~/Library/Application Scripts +~/Library/LaunchAgents +~/Library/Services +~/Library/PreferencePanes +~/Library/QuickLook +``` + +### XDG and command-line roots + +Resolve environment variables first, using the standard defaults only when unset: + +```text +$XDG_CONFIG_HOME or ~/.config +$XDG_CACHE_HOME or ~/.cache +$XDG_DATA_HOME or ~/.local/share +$XDG_STATE_HOME or ~/.local/state +``` + +Treat `~/.local/bin` as a separate user executable root. Only direct entries are considered, and the binary root is limited to executable files and symlinks associated with captured executable names. + +### Home-directory direct entries + +The scanner may enumerate direct hidden children of `$HOME`, but never recurse from `$HOME`. + +An entry such as `~/.tool-name` or `~/.tool-namerc` is confirmed only with an exact, sufficiently distinctive name and a second identity signal. Short or generic names remain review-only. + +The following are never deleted as whole files or directories: + +```text +~/.zshrc +~/.bashrc +~/.bash_profile +~/.profile +~/.gitconfig +~/.ssh +~/.gnupg +~/.aws +~/.kube +``` + +Shell configuration cleanup is outside this feature unless an exact installer-owned marker can be proven in a future design. + +### System-level roots + +```text +/Library/Application Support +/Library/Caches +/Library/Preferences +/Library/Logs +/Library/LaunchAgents +/Library/LaunchDaemons +/Library/PrivilegedHelperTools +/Library/Frameworks +/Library/PreferencePanes +/Library/QuickLook +/Library/Spotlight +/Library/Internet Plug-Ins +/Library/Audio/Plug-Ins +/Library/Extensions +``` + +`/System` is never scanned or modified. Package receipts may be queried as metadata but are not directly deleted. + +System candidates require stronger evidence than user-level candidates. Lack of read permission makes a root unverified; lack of write permission marks a candidate as requiring administrator access. The feature never invokes `sudo` automatically. + +## Candidate Classification + +### Confirmed deletable + +A path may enter the second `y/Y` deletion list when at least one strong ownership condition is met: + +- the source/provider records the exact owned path; +- the basename exactly equals a captured bundle, helper, signing, package, or executable identifier; +- the basename begins with a captured bundle ID followed by a defined boundary such as `.`, `-`, or `_`; +- the path is a standard suffix of an exact bundle ID, such as `.plist`, `.savedState`, or `.binarycookies`; +- a standard application-data root contains a direct child exactly equal to the full application name, and the target also has a bundle or exact package identity; +- a launchd plist has an exact associated label and a target-linked program path; +- an executable or symlink exactly matches a captured executable and points into the removed installation root. + +### Review only + +The scanner displays but never deletes these after a single `y/Y`: + +- matches based only on a short or generic command name; +- direct home dotfiles with no second identity signal; +- display-name-only matches without a bundle or exact package identity; +- vendor-prefix or substring-only matches; +- shared App Group containers unless no other installed application claims the group; +- legacy paths whose ownership cannot be established. + +For example, the name `go` alone cannot make `~/go` deletable. + +### Excluded by policy + +The scanner does not enter or delete: + +- browser profiles or website-origin data; +- unrelated application-support or container directories; +- Documents, Desktop, Downloads, project repositories, cloud storage, or backups; +- Keychain, Mail, Messages, and user-created content; +- shared Homebrew, npm, pnpm, Cargo, Go, or uv caches and stores; +- system-managed extensions or login-item state that requires a platform API or vendor uninstaller. + +## Other-application Isolation + +When scanning `~/Library/Application Support`, the scanner sees direct entries such as `Poe` and `Google`. + +- `Poe` can match the target identity and become a candidate. +- `Google` does not match, so the scanner never enters it. + +Consequently, Chrome data such as `Google/Chrome/Default/IndexedDB/https_poe.com_0...` is not discovered or deleted. The same rule isolates Safari, Firefox, Edge, Brave, VS Code, and every other unrelated application directory. + +## Deletion Safety + +Every confirmed candidate must pass the existing protected-path validation and these additional checks: + +1. Use `lstat`; never follow a symlink to delete its destination. +2. Confirm the candidate is still below an approved root and is not the root itself. +3. Record device, inode, and file type during scan #2. +4. Recheck device, inode, and file type immediately before deletion. +5. Refuse deletion if the object changed after preview. +6. Collapse child candidates when a confirmed parent directory already covers them. +7. Continue after individual failures and record each failure. +8. Do not retry any package-manager action during residual deletion. +9. Do not automatically elevate privileges. + +System extensions, modern login items, and other system-managed components must use their platform API or vendor uninstaller. Raw file deletion is not used when it would leave registered system state behind. + +## CLI Behavior + +### Primary confirmation + +The existing first preview and `Confirm deletion? (Y/N)` remain. Only `y/Y` performs the primary uninstall. A declined primary uninstall ends the workflow. + +### Residual scan #2 + +After a real primary uninstall, scan #2 always runs and prints complete groups: + +```text +Residual scan #2 + +Confirmed deletable: + [user] 37 MB ~/Library/Application Support/Poe + [admin] 8 KB /Library/PrivilegedHelperTools/... + +Review only (will NOT be deleted by Y): + ~/.short-name + +Unverified: + /Library/... — permission denied +``` + +Each confirmed item includes its path, scope, size, file/directory count, evidence, and permission status. + +If confirmed candidates exist, prompt: + +```text +Delete N confirmed residual paths? (y/N) +``` + +Only `y/Y` deletes. Every other response declines residual deletion. + +### Verification scan #3 + +For every real primary uninstall, scan #3 runs from the approved roots regardless of whether: + +- scan #2 found nothing; +- the user accepted or declined residual deletion; +- some deletions failed; +- a root was inaccessible. + +The verification report includes fresh scan results, original planned paths that still exist, and related running processes. + +### Dry run + +`--dry-run` remains zero-mutation: + +- no process termination; +- no package-manager command; +- no file deletion; +- no second deletion confirmation. + +It displays the primary plan plus currently detectable additional candidates, excluding duplicate primary paths, and states: + +```text +Dry run only. Post-removal verification scan was not performed because no changes were made. +``` + +## Result Semantics + +The final output must report these independent dimensions: + +- confirmed residual paths; +- review-only matches; +- inaccessible/unverified roots; +- original planned paths that still exist; +- related running processes; +- primary and residual deletion failures. + +The success statement is allowed only when: + +```text +confirmed residuals = 0 +unverified roots = 0 +original planned paths remaining = 0 +running processes = 0 +``` + +If review-only items remain, say that all confirmed residuals were removed but manual-review items remain. If roots were inaccessible, say the scan was incomplete. Never use `Remaining: no paths` as a claim of comprehensive cleanup. + +The accurate success wording is: + +> No confirmed residuals remain in the supported, successfully scanned locations. + +## Error Handling + +- A provider failure continues to behave as it does today and is logged. +- Failure to read one residual root records that root as inaccessible and does not stop other roots. +- Failure to measure a candidate displays unknown size but does not broaden matching. +- A path that changes between scan and deletion is skipped as a safety failure. +- Permission-denied deletion continues with other candidates and appears again in scan #3. +- Primary package-manager failures are reported separately and are never silently retried by residual deletion. +- Scan #3 is not skipped because scan #2 or residual deletion encountered errors. + +## Compatibility + +- Existing `Remover.remove()` behavior and call sites remain unchanged. +- Existing providers still perform the primary uninstall through their current package commands. +- Interactive automation that previously supplied one `y` may now receive a second prompt; the second prompt defaults to `N`. +- `--dry-run` continues to make no changes. +- Rules remain supported and are supplemented, not replaced, by generic scanning. +- Runtime is bounded by direct-child enumeration of approved roots plus recursive measurement of matched candidates only. + +## File Changes + +- Create `cleanapp/residual_scanner.py` for identity capture and residual scanning. +- Modify `cleanapp/models.py` for residual identity, candidate, and scan result models. +- Modify `cleanapp/cli.py` for the three-stage workflow. +- Modify `cleanapp/remover.py` for confirmed residual path removal. +- Modify `cleanapp/reporter.py` for scan #2 and scan #3 reporting. +- Keep source providers unchanged in the first implementation; identity capture uses their existing `Software` fields, rule metadata, and `.app` Info.plist data. +- Create `tests/test_residual_scanner.py`. +- Extend `tests/test_cli.py`, `tests/test_analyzer_remover.py`, and provider tests. +- Update `README.md` and `README.zh-CN.md`. + +## TDD Acceptance Matrix + +Implementation must follow red-green-refactor. Each new behavior needs a test that is observed failing for the expected missing-feature reason before production code is written. + +1. Capture `com.quora.poe.electron` before Poe.app is deleted. +2. Generate a residual identity for every existing `Source` enum value. +3. Discover the seven demonstrated Poe residual path groups without a Poe-specific rule. +4. Preserve Chrome's nested `https_poe.com` IndexedDB data. +5. Never recurse through Documents, projects, the full home directory, or unrelated application directories. +6. Prefer custom XDG environment roots over default paths. +7. Classify short-name matches such as `go` as review-only and never delete `~/go` from the name alone. +8. Exclude shared Homebrew, npm, pnpm, Cargo, Go, and uv caches. +9. Collapse child paths when a parent candidate is selected. +10. Unlink symlinks without deleting their targets. +11. Refuse deletion when inode, device, or file type changes after scan #2. +12. Mark system-level candidates as administrator-scoped. +13. Record unreadable roots as inaccessible and never report a complete scan. +14. Accept both `y` and `Y` at the residual confirmation. +15. Preserve every residual when the second answer is not `y/Y`. +16. Run a fresh scan #3 after acceptance, refusal, no candidates, and partial failure. +17. Avoid a second prompt when there are no confirmed candidates. +18. Never rerun a package-manager command during residual deletion. +19. Continue after one residual deletion fails and rediscover the remaining path in scan #3. +20. Keep `--dry-run` zero-mutation and free of a residual deletion prompt. +21. Avoid successful-cleanup wording when residuals, processes, or unverified roots remain. +22. Preserve all existing tests, wheel construction, and `uvx --from . cleanapp --version` startup verification. + +## Acceptance Criteria + +- Every currently supported installation source enters the same three-stage workflow. +- Poe's demonstrated standard residuals are discovered without adding `poe.yaml`. +- Chrome's Poe website data remains untouched. +- Only confirmed candidates are deletable after the second `y/Y` prompt. +- Weak and ambiguous matches are visible but not deleted. +- Shared package stores and other application directories are not traversed. +- System-level candidates are listed, never silently elevated, and accurately reported if permission blocks deletion. +- A fresh third scan determines the final result. +- Final messaging distinguishes verified clean scope, remaining confirmed paths, review-only matches, and incomplete scans. + +## References + +- [Apple Info.plist Core Foundation keys](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html) +- [Apple macOS directory conventions](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/MacOSXDirectories/MacOSXDirectories.html) +- [Apple App Sandbox containers](https://developer.apple.com/documentation/security/accessing-files-from-the-macos-app-sandbox) +- [Apple App Groups entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.application-groups) +- [Apple code-signing requirements](https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements) +- [Apple launchd job locations](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html) +- [Apple System Integrity Protection](https://support.apple.com/en-us/102149) +- [XDG Base Directory specification](https://specifications.freedesktop.org/basedir/) +- [Homebrew Cask uninstall and zap behavior](https://docs.brew.sh/Cask-Cookbook) +- [npm uninstall](https://docs.npmjs.com/uninstalling-packages-and-dependencies/) +- [pnpm remove](https://pnpm.io/cli/remove) +- [pipx CLI reference](https://pipx.pypa.io/stable/reference/cli/) +- [uv CLI reference](https://docs.astral.sh/uv/reference/cli/) +- [Cargo uninstall](https://doc.rust-lang.org/cargo/commands/cargo-uninstall.html) +- [Go installation management](https://go.dev/doc/manage-install) From cbcd25d03e6888a7f0a1bbd54d0952a7dbb8af97 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:12:08 +0800 Subject: [PATCH 02/22] Plan generic residual cleanup implementation --- .../2026-07-14-generic-residual-cleanup.md | 1047 +++++++++++++++++ 1 file changed, 1047 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md diff --git a/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md b/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md new file mode 100644 index 0000000..2f70940 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md @@ -0,0 +1,1047 @@ +# Generic Residual Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a generic, bounded, identity-linked residual cleanup pass to every existing CleanApp uninstall source, with a second confirmation and a fresh third verification scan. + +**Architecture:** Capture a target identity before the primary uninstall, then enumerate only direct children of approved user, XDG, and `/Library` roots. Keep discovery, deletion, presentation, and CLI orchestration separate: `ResidualScanner` never mutates, `Remover.remove_residuals()` never invokes package managers, and scan #3 alone determines the final residual status. + +**Tech Stack:** Python 3.11+, pathlib/plistlib/stat, pytest 8, Typer, Rich, Hatchling, uv. + +## Global Constraints + +- Apply the workflow to every current `Source`: Application, Homebrew formula/cask, npm/pnpm, pipx/uv, Cargo/Go, and YAML rules. +- Enumerate direct children of approved roots only; never recursively search `$HOME`, Documents, projects, browser profiles, or unrelated application directories. +- Preserve Chrome's `Google/Chrome/.../https_poe.com...` website data by never entering the unmatched `Google` child. +- Delete only `ResidualScan.confirmed` candidates after an explicit second `y/Y`; review-only items are display-only. +- Never invoke `sudo`, Homebrew, npm, pnpm, pipx, uv, Cargo, Go, or another provider action from residual deletion. +- Record and recheck device, inode, and file type; unlink symlinks without following their targets. +- Always run a fresh scan #3 after a real primary uninstall, including rejection, zero candidates, and partial failure. +- `--dry-run` performs no mutation, has no residual deletion prompt, and does not claim post-removal verification. +- Keep providers, YAML rules, and existing `Remover.remove()` behavior unchanged in the first implementation. +- Keep dependencies unchanged and preserve Python 3.11–3.14, wheel, and `uvx --from . cleanapp --version` compatibility. +- Commit author and committer email must be `253661133+BruceL017@users.noreply.github.com`; verify after every commit. + +## File Map + +- `cleanapp/models.py`: residual identity, candidate, and scan result contracts. +- `cleanapp/residual_scanner.py`: pre-delete identity capture, approved-root resolution, direct-child discovery, matching, classification, deduplication, and process/inaccessible-root reporting. +- `cleanapp/remover.py`: stat-verified deletion of already-confirmed residual candidates only. +- `cleanapp/reporter.py`: scan #2 groups and truthful scan #3 final status. +- `cleanapp/cli.py`: dry-run and real three-stage orchestration shared by interactive and named removal. +- `tests/test_residual_scanner.py`: identity, bounded traversal, Poe, Chrome isolation, XDG, classification, system scope, and inaccessible-root behavior. +- `tests/test_analyzer_remover.py`: residual deletion, symlink, TOCTOU, continuation, and no-package-manager behavior. +- `tests/test_reporter.py`: grouped scan and success-wording gates. +- `tests/test_cli.py`: confirmation and scan sequencing. +- `README.md`, `README.zh-CN.md`: user-facing workflow and safety boundary. + +--- + +### Task 1: Residual Models and Pre-delete Identity Capture + +**Files:** +- Modify: `cleanapp/models.py` +- Create: `cleanapp/residual_scanner.py` +- Create: `tests/test_residual_scanner.py` + +**Interfaces:** +- Consumes: `Software`, `Rule`, and all `Source` values. +- Produces: `ResidualIdentity`, `ResidualCandidate`, `ResidualScan`, and `capture_identity(software: Software, rule: Rule | None) -> ResidualIdentity`. + +- [ ] **Step 1: Write failing identity tests** + +Add fixtures that create an application bundle and embedded helper before deletion: + +```python +def write_bundle(path: Path, **values: str) -> None: + import plistlib + info = path / "Contents/Info.plist" + info.parent.mkdir(parents=True) + info.write_bytes(plistlib.dumps(values)) + + +def test_capture_identity_reads_main_and_embedded_bundle_metadata(tmp_path: Path) -> None: + app = tmp_path / "Poe.app" + write_bundle( + app, + CFBundleIdentifier="com.quora.poe.electron", + CFBundleName="Poe", + CFBundleExecutable="Poe", + ) + helper = app / "Contents/Frameworks/Poe Helper.app" + write_bundle( + helper, + CFBundleIdentifier="com.quora.poe.electron.helper", + CFBundleExecutable="Poe Helper", + ) + + identity = capture_identity( + Software("Poe", {Source.APPLICATION}, {"Poe"}, {app}), + None, + ) + + assert identity.bundle_ids == frozenset({ + "com.quora.poe.electron", + "com.quora.poe.electron.helper", + }) + assert {"Poe", "Poe Helper"} <= identity.executables + + +@pytest.mark.parametrize("source", list(Source)) +def test_capture_identity_supports_every_source(source: Source) -> None: + identity = capture_identity(Software("Example Tool", {source}, {"example-tool"}), None) + assert identity.sources == frozenset({source}) + assert "Example Tool" in identity.names + + +def test_capture_identity_includes_rule_metadata() -> None: + rule = Rule( + "example", + "Example Desktop", + aliases=["Example"], + kill=["example-helper"], + packages={"npm": ["@vendor/example"]}, + ) + identity = capture_identity(Software("Example", {Source.RULE}, {"example"}), rule) + assert {"Example Desktop", "Example", "example"} <= identity.names + assert "example-helper" in identity.executables + assert "@vendor/example" in identity.package_ids +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +uv run --python 3.11 --extra dev pytest tests/test_residual_scanner.py -q +``` + +Expected: collection fails because `ResidualIdentity` and `cleanapp.residual_scanner` do not exist. + +- [ ] **Step 3: Add the residual data contracts** + +Add exactly these models to `cleanapp/models.py`: + +```python +@dataclass(frozen=True, slots=True) +class ResidualIdentity: + names: frozenset[str] + bundle_ids: frozenset[str] + package_ids: frozenset[str] + executables: frozenset[str] + sources: frozenset[Source] + + +@dataclass(frozen=True, slots=True) +class ResidualCandidate: + path: Path + evidence: str + system_level: bool + device: int + inode: int + file_type: int + + +@dataclass(slots=True) +class ResidualScan: + confirmed: list[ResidualCandidate] = field(default_factory=list) + review_only: list[ResidualCandidate] = field(default_factory=list) + inaccessible_roots: list[Path] = field(default_factory=list) + running_pids: list[int] = field(default_factory=list) +``` + +- [ ] **Step 4: Implement minimal identity capture** + +Create `cleanapp/residual_scanner.py` with these helpers and behavior: + +```python +from __future__ import annotations + +from pathlib import Path +import plistlib + +from .analyzer import expand_pattern +from .models import ResidualIdentity, Rule, Software, Source + + +PACKAGE_SOURCES = { + Source.HOMEBREW_FORMULA, + Source.HOMEBREW_CASK, + Source.NPM, + Source.PNPM, + Source.PIPX, + Source.UV, + Source.CARGO, +} + + +def _bundle_paths(software: Software, rule: Rule | None) -> set[Path]: + paths = set(software.paths) + if rule: + for pattern in rule.path_patterns: + paths.update(expand_pattern(pattern)) + return {path for path in paths if path.suffix.casefold() == ".app" and path.is_dir()} + + +def capture_identity(software: Software, rule: Rule | None) -> ResidualIdentity: + names = {software.name, *software.identifiers} + package_ids = set(software.identifiers) if software.sources & PACKAGE_SOURCES else set() + executables = {path.name for path in software.paths if path.is_file() or path.is_symlink()} + bundle_ids: set[str] = set() + + if rule: + names.update((rule.name, rule.slug, *rule.aliases)) + executables.update(rule.kill) + package_ids.update(package for values in rule.packages.values() for package in values) + + for app in _bundle_paths(software, rule): + for info_path in (app / "Contents").rglob("Info.plist"): + try: + payload = plistlib.loads(info_path.read_bytes()) + except (OSError, plistlib.InvalidFileException): + continue + bundle_id = payload.get("CFBundleIdentifier") + if isinstance(bundle_id, str) and bundle_id: + bundle_ids.add(bundle_id) + for key in ("CFBundleName", "CFBundleDisplayName"): + value = payload.get(key) + if isinstance(value, str) and value: + names.add(value) + executable = payload.get("CFBundleExecutable") + if isinstance(executable, str) and executable: + executables.add(executable) + + return ResidualIdentity( + frozenset(value for value in names if value), + frozenset(bundle_ids), + frozenset(value for value in package_ids if value), + frozenset(value for value in executables if value), + frozenset(software.sources), + ) +``` + +The `rglob` is allowed only inside an already-known `.app` bundle during identity capture; it is never used on `$HOME` or a residual root. + +- [ ] **Step 5: Verify GREEN and regressions** + +Run: + +```bash +uv run --python 3.11 --extra dev pytest tests/test_residual_scanner.py tests/test_analyzer_remover.py tests/test_providers.py -q +``` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit the identity module** + +```bash +git config user.email "253661133+BruceL017@users.noreply.github.com" +git add cleanapp/models.py cleanapp/residual_scanner.py tests/test_residual_scanner.py +git commit -m "Add residual identity capture" +test "$(git log -1 --format='%ae')" = "253661133+BruceL017@users.noreply.github.com" +``` + +--- + +### Task 2: Approved-root Discovery and Strong Matching + +**Files:** +- Modify: `cleanapp/residual_scanner.py` +- Modify: `tests/test_residual_scanner.py` + +**Interfaces:** +- Consumes: `ResidualIdentity` from Task 1. +- Produces: `ResidualScanner(home: Path | None = None, system_roots: tuple[Path, ...] | None = None)` and `scan(identity: ResidualIdentity) -> ResidualScan` with confirmed Poe candidates. + +- [ ] **Step 1: Write failing bounded-discovery tests** + +Add one fixture containing the seven observed Poe groups: + +```python +POE_RELATIVE_PATHS = ( + ("Library/Application Support/Poe", True), + ("Library/Caches/com.quora.poe.electron", True), + ("Library/Caches/com.quora.poe.electron.ShipIt", True), + ("Library/HTTPStorages/com.quora.poe.electron", True), + ("Library/Cookies/com.quora.poe.electron.binarycookies", False), + ("Library/Preferences/com.quora.poe.electron.plist", False), + ("Library/Preferences/ByHost/com.quora.poe.electron.ShipIt.TEST.plist", False), +) + + +def poe_identity() -> ResidualIdentity: + return ResidualIdentity( + frozenset({"Poe"}), + frozenset({"com.quora.poe.electron"}), + frozenset(), + frozenset({"Poe"}), + frozenset({Source.APPLICATION}), + ) + + +def test_scan_discovers_seven_poe_groups_without_a_rule(tmp_path: Path) -> None: + expected = set() + for relative, is_directory in POE_RELATIVE_PATHS: + path = tmp_path / relative + if is_directory: + path.mkdir(parents=True) + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("poe", encoding="utf-8") + expected.add(path) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(poe_identity()) + assert {candidate.path for candidate in scan.confirmed} == expected + + +def test_scan_never_enters_unmatched_chrome_directory(tmp_path: Path, monkeypatch) -> None: + chrome = tmp_path / "Library/Application Support/Google/Chrome/Default/IndexedDB" + chrome.mkdir(parents=True) + website_data = chrome / "https_poe.com_0.indexeddb.leveldb" + website_data.mkdir() + visited: list[Path] = [] + original = Path.iterdir + + def tracking_iterdir(path: Path): + visited.append(path) + return original(path) + + monkeypatch.setattr(Path, "iterdir", tracking_iterdir) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(poe_identity()) + assert website_data.exists() + assert not scan.confirmed + assert all("Google/Chrome" not in str(path) for path in visited) + + +def test_custom_xdg_root_replaces_default(tmp_path: Path, monkeypatch) -> None: + custom = tmp_path / "custom-config" + default = tmp_path / ".config" + (custom / "example-tool").mkdir(parents=True) + (default / "example-tool").mkdir(parents=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(custom)) + identity = ResidualIdentity( + frozenset({"example-tool"}), frozenset(), frozenset({"example-tool"}), + frozenset(), frozenset({Source.NPM}), + ) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + assert {item.path for item in scan.confirmed} == {custom / "example-tool"} +``` + +- [ ] **Step 2: Run tests and verify RED** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_residual_scanner.py -q +``` + +Expected: failures because `ResidualScanner` does not exist. + +- [ ] **Step 3: Add approved-root resolution** + +Define the approved relative roots and system roots exactly as the design specifies: + +```python +USER_LIBRARY_ROOTS = ( + "Application Support", "Caches", "Preferences", "Preferences/ByHost", + "Logs", "HTTPStorages", "Cookies", "WebKit", "Saved Application State", + "Containers", "Group Containers", "Application Scripts", "LaunchAgents", + "Services", "PreferencePanes", "QuickLook", +) + +SYSTEM_ROOTS = ( + Path("/Library/Application Support"), Path("/Library/Caches"), + Path("/Library/Preferences"), Path("/Library/Logs"), + Path("/Library/LaunchAgents"), Path("/Library/LaunchDaemons"), + Path("/Library/PrivilegedHelperTools"), Path("/Library/Frameworks"), + Path("/Library/PreferencePanes"), Path("/Library/QuickLook"), + Path("/Library/Spotlight"), Path("/Library/Internet Plug-Ins"), + Path("/Library/Audio/Plug-Ins"), Path("/Library/Extensions"), +) + +XDG_ROOTS = { + "XDG_CONFIG_HOME": ".config", + "XDG_CACHE_HOME": ".cache", + "XDG_DATA_HOME": ".local/share", + "XDG_STATE_HOME": ".local/state", +} +``` + +`ResidualScanner._roots()` returns `(root, system_level, kind)` tuples for user Library roots, effective XDG roots, `~/.local/bin`, `$HOME` direct entries, and injected/default system roots. A custom XDG variable replaces, rather than supplements, its default. + +- [ ] **Step 4: Implement direct-child scanning and strong matching** + +Implement `scan()` so that it calls `root.iterdir()` once per root and never calls `iterdir`, `walk`, `glob`, or `rglob` on an unmatched child. Use `lstat()` and store `st_dev`, `st_ino`, and `stat.S_IFMT(st_mode)` in each candidate. + +The initial strong matcher must implement these exact cases: + +```python +def _bundle_evidence(name: str, bundle_ids: frozenset[str]) -> str | None: + for bundle_id in bundle_ids: + if name == bundle_id or any(name.startswith(bundle_id + boundary) for boundary in (".", "-", "_")): + return f"bundle identifier {bundle_id}" + return None +``` + +Human-name comparisons use `unicodedata.normalize("NFC", value).casefold()` and strip only defined leading-dot / standard suffix forms. Bundle identifier comparison remains byte-exact. + +- Exact bundle-ID basename and boundary suffixes are confirmed. +- Exact full display name is confirmed only when `bundle_ids` or `package_ids` provides a second signal. +- Exact package basename or unscoped package leaf is confirmed in approved XDG/application-data roots. +- `~/.local/bin` confirms only executable files or symlinks whose basename exactly matches an executable identity. +- `$HOME` considers direct hidden entries only; it does not recurse. +- `Group Containers`, `/Library/Extensions`, and other platform-managed extension roots are not confirmed in this task. + +After scanning, sort candidates by case-folded path. Parent-child collapsing is added in Task 3. + +- [ ] **Step 5: Verify GREEN and direct-child isolation** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_residual_scanner.py -q +``` + +Expected: Poe, Chrome isolation, and XDG tests pass. + +- [ ] **Step 6: Commit approved-root discovery** + +```bash +git add cleanapp/residual_scanner.py tests/test_residual_scanner.py +git commit -m "Add bounded residual discovery" +test "$(git log -1 --format='%ae')" = "253661133+BruceL017@users.noreply.github.com" +``` + +--- + +### Task 3: Conservative Classification, Exclusions, and Scan Completeness + +**Files:** +- Modify: `cleanapp/residual_scanner.py` +- Modify: `tests/test_residual_scanner.py` + +**Interfaces:** +- Consumes: Task 2 discovery output. +- Produces: complete `confirmed`, `review_only`, `inaccessible_roots`, and `running_pids`; `is_approved_residual_path(path: Path) -> bool` for the deletion boundary. + +- [ ] **Step 1: Write failing safety-classification tests** + +Add focused tests: + +```python +def test_short_go_name_is_review_only_and_home_go_is_not_deleted(tmp_path: Path) -> None: + target = tmp_path / ".go" + target.mkdir() + identity = ResidualIdentity( + frozenset({"go"}), frozenset(), frozenset(), frozenset({"go"}), + frozenset({Source.GO}), + ) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + assert not scan.confirmed + assert {item.path for item in scan.review_only} == {target} + + +@pytest.mark.parametrize("relative", ( + ".cargo", ".npm", ".pnpm-store", "go", + ".cache/uv", ".cache/pip", ".cache/go-build", +)) +def test_shared_package_stores_are_excluded(tmp_path: Path, relative: str) -> None: + target = tmp_path / relative + target.mkdir(parents=True) + identity = ResidualIdentity( + frozenset({target.name}), frozenset(), frozenset({target.name}), + frozenset({target.name}), frozenset({Source.NPM}), + ) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} + + +def test_parent_candidate_collapses_child_candidate(tmp_path: Path) -> None: + parent = tmp_path / "Library/Application Support/Example" + child_root = parent / "nested-root" + child = child_root / "com.vendor.example" + child.mkdir(parents=True) + scanner = ResidualScanner(home=tmp_path, system_roots=(child_root,)) + identity = ResidualIdentity( + frozenset({"Example"}), frozenset({"com.vendor.example"}), + frozenset(), frozenset(), frozenset({Source.APPLICATION}), + ) + scan = scanner.scan(identity) + assert {item.path for item in scan.confirmed} == {parent} + + +def test_system_candidate_is_marked_admin_scoped(tmp_path: Path) -> None: + system_root = tmp_path / "Library/LaunchAgents" + target = system_root / "com.vendor.example.plist" + target.parent.mkdir(parents=True) + target.write_text("label", encoding="utf-8") + identity = ResidualIdentity( + frozenset({"Example"}), frozenset({"com.vendor.example"}), + frozenset(), frozenset(), frozenset({Source.APPLICATION}), + ) + scan = ResidualScanner(home=tmp_path / "home", system_roots=(system_root,)).scan(identity) + assert scan.confirmed[0].system_level is True + + +def test_unreadable_root_is_reported_and_other_roots_continue(tmp_path: Path, monkeypatch) -> None: + blocked = tmp_path / "Library/Caches" + readable = tmp_path / "Library/Preferences" + blocked.mkdir(parents=True) + target = readable / "com.vendor.example.plist" + target.parent.mkdir(parents=True) + target.write_text("ok", encoding="utf-8") + original = Path.iterdir + + def failing_iterdir(path: Path): + if path == blocked: + raise PermissionError("denied") + return original(path) + + monkeypatch.setattr(Path, "iterdir", failing_iterdir) + identity = ResidualIdentity( + frozenset({"Example"}), frozenset({"com.vendor.example"}), + frozenset(), frozenset(), frozenset({Source.APPLICATION}), + ) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + assert blocked in scan.inaccessible_roots + assert target in {item.path for item in scan.confirmed} +``` + +- [ ] **Step 2: Run tests and verify RED** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_residual_scanner.py -q +``` + +Expected: failures for short-name classification, exclusions, deduplication, system scope, or inaccessible-root reporting. + +- [ ] **Step 3: Implement conservative policy** + +Add exact exclusions for protected shell/security entries and shared package roots: + +```python +PROTECTED_HOME_ENTRIES = { + ".zshrc", ".bashrc", ".bash_profile", ".profile", ".gitconfig", + ".ssh", ".gnupg", ".aws", ".kube", +} +SHARED_HOME_ENTRIES = {".cargo", ".npm", ".pnpm-store", ".rustup", "go"} +SHARED_CACHE_ENTRIES = {"cargo", "go-build", "npm", "pip", "pnpm", "uv"} +PLATFORM_MANAGED_ROOT_NAMES = {"Extensions", "Spotlight"} +``` + +Apply these rules: + +- Names whose normalized alphanumeric form is shorter than four characters are review-only even if exact. +- Display-name-only and direct-home-dotfile matches are review-only without a bundle/package second signal. +- `Group Containers` and platform-managed roots are review-only, never confirmed. +- Protected and shared entries are excluded entirely. +- A nonexistent approved root is skipped. Any other `OSError` while checking or enumerating an existing root appends that root to `inaccessible_roots` and scanning continues. +- Collapse any candidate whose path is below an already-selected confirmed parent. +- Populate `running_pids` by comparing process name and the first two command basenames to exact case-folded names/executables; substring matching is allowed only for normalized signals of length at least four. + +Implement `is_approved_residual_path()` by requiring a candidate to be a direct child of a currently approved root and never the root itself. Do not use `resolve()` on the candidate. + +- [ ] **Step 4: Verify GREEN and full scanner regression** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_residual_scanner.py tests/test_scanner.py tests/test_safety.py -q +``` + +Expected: all selected tests pass. + +- [ ] **Step 5: Commit conservative classification** + +```bash +git add cleanapp/residual_scanner.py tests/test_residual_scanner.py +git commit -m "Harden residual candidate classification" +test "$(git log -1 --format='%ae')" = "253661133+BruceL017@users.noreply.github.com" +``` + +--- + +### Task 4: Stat-verified Residual Deletion + +**Files:** +- Modify: `cleanapp/remover.py` +- Modify: `tests/test_analyzer_remover.py` + +**Interfaces:** +- Consumes: `list[ResidualCandidate]` from a user-confirmed scan #2. +- Produces: `Remover.remove_residuals(name: str, candidates: list[ResidualCandidate]) -> CleanupResult`. + +- [ ] **Step 1: Write failing residual-deletion tests** + +Add a candidate helper and the safety cases: + +```python +def residual_candidate(path: Path) -> ResidualCandidate: + import stat + metadata = path.lstat() + return ResidualCandidate( + path, "test identity", False, + metadata.st_dev, metadata.st_ino, stat.S_IFMT(metadata.st_mode), + ) + + +def test_remove_residuals_unlinks_symlink_without_deleting_target(tmp_path: Path, monkeypatch) -> None: + target = tmp_path / "target.txt" + target.write_text("keep", encoding="utf-8") + link = tmp_path / "example-link" + link.symlink_to(target) + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + result = Remover().remove_residuals("Example", [residual_candidate(link)]) + assert not link.exists() + assert target.read_text(encoding="utf-8") == "keep" + assert not result.failures + + +def test_remove_residuals_refuses_replaced_inode(tmp_path: Path, monkeypatch) -> None: + target = tmp_path / "example.plist" + target.write_text("old", encoding="utf-8") + candidate = residual_candidate(target) + target.unlink() + target.write_text("new", encoding="utf-8") + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + result = Remover().remove_residuals("Example", [candidate]) + assert target.read_text(encoding="utf-8") == "new" + assert any("changed after preview" in failure for failure in result.failures) + + +def test_remove_residuals_continues_after_one_failure(tmp_path: Path, monkeypatch) -> None: + changed = tmp_path / "changed" + good = tmp_path / "good" + changed.write_text("old", encoding="utf-8") + good.write_text("delete", encoding="utf-8") + changed_candidate = residual_candidate(changed) + good_candidate = residual_candidate(good) + changed.unlink() + changed.write_text("replacement", encoding="utf-8") + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + result = Remover().remove_residuals("Example", [changed_candidate, good_candidate]) + assert changed.exists() + assert not good.exists() + assert len(result.failures) == 1 + + +def test_remove_residuals_never_invokes_package_manager(tmp_path: Path, monkeypatch) -> None: + target = tmp_path / "example" + target.write_text("delete", encoding="utf-8") + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + monkeypatch.setattr(Remover, "_remove_packages", lambda *_: pytest.fail("package manager rerun")) + result = Remover().remove_residuals("Example", [residual_candidate(target)]) + assert not result.package_actions +``` + +- [ ] **Step 2: Run tests and verify RED** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_analyzer_remover.py -q +``` + +Expected: failure because `remove_residuals` does not exist. + +- [ ] **Step 3: Implement the minimal deletion boundary** + +Import `stat`, `ResidualCandidate`, and `is_approved_residual_path`, then add: + +```python +def remove_residuals(self, name: str, candidates: list[ResidualCandidate]) -> CleanupResult: + started = time.monotonic() + result = CleanupResult(name, False) + for candidate in candidates: + try: + safe = validate_deletion(candidate.path) + if not is_approved_residual_path(safe): + raise UnsafePathError(f"outside approved residual roots: {safe}") + current = safe.lstat() + identity = (current.st_dev, current.st_ino, stat.S_IFMT(current.st_mode)) + previewed = (candidate.device, candidate.inode, candidate.file_type) + if identity != previewed: + raise UnsafePathError(f"changed after preview: {safe}") + self._remove_path(safe, result) + except (OSError, UnsafePathError) as exc: + result.failures.append(f"delete {candidate.path}: {exc}") + result.remaining_paths = [ + candidate.path for candidate in candidates + if candidate.path.exists() or candidate.path.is_symlink() + ] + result.duration_seconds = time.monotonic() - started + return result +``` + +Do not call `_stop_processes()` or `_remove_packages()` in this method. If `_remove_path()` already appended a failure, avoid appending a duplicate around that call. + +- [ ] **Step 4: Verify GREEN and remover regressions** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_analyzer_remover.py tests/test_safety.py -q +``` + +Expected: all selected tests pass. + +- [ ] **Step 5: Commit residual deletion** + +```bash +git add cleanapp/remover.py tests/test_analyzer_remover.py +git commit -m "Add safe residual deletion" +test "$(git log -1 --format='%ae')" = "253661133+BruceL017@users.noreply.github.com" +``` + +--- + +### Task 5: Residual Scan and Verification Reporting + +**Files:** +- Modify: `cleanapp/reporter.py` +- Create: `tests/test_reporter.py` + +**Interfaces:** +- Consumes: scan #2/#3, primary result, optional residual result, and original remaining paths. +- Produces: `show_residual_scan(...)` and `show_verification_result(...)`; preserves existing `show_preview()` and `show_result()` for the primary dry run. + +- [ ] **Step 1: Write failing reporter tests** + +Use `Console(record=True, width=140)` and assert the independent status dimensions: + +```python +def test_show_residual_scan_groups_confirmed_review_and_unverified(tmp_path: Path) -> None: + confirmed_path = tmp_path / "confirmed" + review_path = tmp_path / "review" + confirmed_path.write_text("x", encoding="utf-8") + review_path.write_text("x", encoding="utf-8") + scan = ResidualScan( + confirmed=[residual_candidate(confirmed_path, "bundle identifier com.vendor.app")], + review_only=[residual_candidate(review_path, "display name only")], + inaccessible_roots=[tmp_path / "blocked"], + ) + console = Console(record=True, width=140) + show_residual_scan(console, scan, "Residual scan #2") + output = console.export_text() + assert "Confirmed deletable" in output + assert "Review only (will NOT be deleted by Y)" in output + assert "Unverified" in output + assert "bundle identifier com.vendor.app" in output + + +def test_verification_success_requires_all_verified_dimensions_empty() -> None: + console = Console(record=True, width=140) + show_verification_result( + console, "Example", CleanupResult("Example", False), None, + ResidualScan(), [], + ) + assert "No confirmed residuals remain in the supported, successfully scanned locations." in console.export_text() + + +@pytest.mark.parametrize("scan,original", ( + (ResidualScan(inaccessible_roots=[Path("/blocked")]), []), + (ResidualScan(running_pids=[123]), []), + (ResidualScan(), [Path("/remaining")]), +)) +def test_verification_never_claims_success_when_incomplete(scan: ResidualScan, original: list[Path]) -> None: + console = Console(record=True, width=140) + show_verification_result( + console, "Example", CleanupResult("Example", False), None, scan, original, + ) + assert "No confirmed residuals remain" not in console.export_text() + + +def test_verification_reports_manual_review_without_deletion_claim(tmp_path: Path) -> None: + path = tmp_path / ".go" + path.mkdir() + console = Console(record=True, width=140) + show_verification_result( + console, "go", CleanupResult("go", False), None, + ResidualScan(review_only=[residual_candidate(path, "short name")]), [], + ) + output = console.export_text() + assert "manual-review items remain" in output + assert str(path) in output +``` + +- [ ] **Step 2: Run tests and verify RED** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_reporter.py -q +``` + +Expected: import failures for the two new reporter functions. + +- [ ] **Step 3: Implement grouped scan rendering** + +Add: + +```python +def show_residual_scan(console: Console, scan: ResidualScan, heading: str) -> None: + console.print(f"\n[bold]{heading}[/]") + console.print("\n[bold]Confirmed deletable:[/]") + _show_candidates(console, scan.confirmed) + console.print("\n[bold]Review only (will NOT be deleted by Y):[/]") + _show_candidates(console, scan.review_only) + console.print("\n[bold]Unverified:[/]") + if not scan.inaccessible_roots: + console.print("[dim]None[/]") + for root in scan.inaccessible_roots: + console.print(f"- {root} — permission denied or unreadable") +``` + +`_show_candidates()` computes file/directory count and bytes at display time with existing `measure([candidate.path])`, labels `system_level` as `[admin]` and user paths as `[user]`, adds whether `permission_limited([path])` is true, and includes `candidate.evidence`. A measurement error prints unknown size rather than changing classification. + +- [ ] **Step 4: Implement truthful final rendering** + +`show_verification_result(console, name, primary, residual, verification, original_remaining)` must: + +- print primary failures and residual failures in separate labeled groups; +- list fresh confirmed residuals, review-only paths, inaccessible roots, original paths still present, and running PIDs; +- print the approved success sentence only when confirmed candidates, review-only candidates, inaccessible roots, original remaining paths, and running PIDs are all empty and neither result contains failures; +- when only review-only items remain, state: `All confirmed residuals were removed, but manual-review items remain.`; +- when inaccessible roots exist, state that the scan was incomplete; +- never print the old comprehensive-sounding `Remaining: no paths` line. + +- [ ] **Step 5: Verify GREEN and reporter regressions** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_reporter.py tests/test_cli.py -q +``` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit reporting** + +```bash +git add cleanapp/reporter.py tests/test_reporter.py +git commit -m "Report residual verification truthfully" +test "$(git log -1 --format='%ae')" = "253661133+BruceL017@users.noreply.github.com" +``` + +--- + +### Task 6: Three-stage CLI Orchestration + +**Files:** +- Modify: `cleanapp/cli.py` +- Modify: `tests/test_cli.py` + +**Interfaces:** +- Consumes: all Task 1–5 interfaces. +- Produces: the same workflow for interactive selection and `cleanapp remove NAME` through `_confirm_and_remove()`. + +- [ ] **Step 1: Add failing CLI sequencing tests** + +Create reusable fakes around the CLI import boundaries, then add these behaviors: + +```python +@pytest.mark.parametrize("answer", ("y", "Y")) +def test_real_remove_accepts_y_or_upper_y_for_residuals(monkeypatch, answer: str) -> None: + harness = install_remove_harness(monkeypatch, scans=[scan_with_candidate(), ResidualScan()]) + result = runner.invoke(app, ["remove", "example"], input=f"y\n{answer}\n") + assert result.exit_code == 0 + assert harness.scan_calls == 2 + assert harness.residual_remove_calls == 1 + + +def test_real_remove_preserves_residuals_when_second_answer_is_not_y(monkeypatch) -> None: + candidate_scan = scan_with_candidate() + harness = install_remove_harness(monkeypatch, scans=[candidate_scan, candidate_scan]) + result = runner.invoke(app, ["remove", "example"], input="y\nn\n") + assert result.exit_code == 0 + assert harness.scan_calls == 2 + assert harness.residual_remove_calls == 0 + + +def test_real_remove_skips_second_prompt_when_scan_two_has_no_confirmed_candidates(monkeypatch) -> None: + harness = install_remove_harness(monkeypatch, scans=[ResidualScan(), ResidualScan()]) + result = runner.invoke(app, ["remove", "example"], input="y\n") + assert result.exit_code == 0 + assert "Delete " not in result.stdout + assert harness.scan_calls == 2 + + +@pytest.mark.parametrize("first_scan", (scan_with_candidate(), ResidualScan())) +def test_real_remove_always_runs_fresh_scan_three(monkeypatch, first_scan: ResidualScan) -> None: + harness = install_remove_harness(monkeypatch, scans=[first_scan, ResidualScan()]) + input_text = "y\nn\n" if first_scan.confirmed else "y\n" + result = runner.invoke(app, ["remove", "example"], input=input_text) + assert result.exit_code == 0 + assert harness.scan_calls == 2 + + +def test_scan_three_runs_after_partial_residual_failure(monkeypatch) -> None: + harness = install_remove_harness( + monkeypatch, + scans=[scan_with_candidate(), scan_with_candidate()], + residual_result=CleanupResult("Example", False, failures=["delete failed"]), + ) + result = runner.invoke(app, ["remove", "example"], input="y\ny\n") + assert result.exit_code == 0 + assert harness.scan_calls == 2 + + +def test_dry_run_has_one_current_scan_no_mutation_and_no_residual_prompt(monkeypatch) -> None: + harness = install_remove_harness(monkeypatch, scans=[scan_with_candidate()]) + result = runner.invoke(app, ["remove", "example", "--dry-run"]) + assert result.exit_code == 0 + assert harness.scan_calls == 1 + assert harness.primary_dry_run_calls == 1 + assert harness.residual_remove_calls == 0 + assert "Delete " not in result.stdout + assert "Post-removal verification scan was not performed" in result.stdout +``` + +The harness must assert that `capture_identity()` occurs before fake `Remover.remove()` and must avoid real process, filesystem, or package-manager access. + +- [ ] **Step 2: Run tests and verify RED** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_cli.py -q +``` + +Expected: new sequencing tests fail because only one primary stage exists. + +- [ ] **Step 3: Wire identity capture and dry run** + +At the start of `_confirm_and_remove()`, call `capture_identity(software, rule)` before `Analyzer.analyze()` and any removal. For dry run: + +```python +primary_result = Remover().remove(analysis, dry_run=True) +current_scan = ResidualScanner().scan(identity) +primary_paths = {path.absolute() for path in analysis.paths} +current_scan.confirmed = [item for item in current_scan.confirmed if item.path.absolute() not in primary_paths] +current_scan.review_only = [item for item in current_scan.review_only if item.path.absolute() not in primary_paths] +show_result(console, primary_result) +show_residual_scan(console, current_scan, "Currently detectable additional candidates") +console.print("Dry run only. Post-removal verification scan was not performed because no changes were made.") +return +``` + +There is no second prompt in this branch. + +- [ ] **Step 4: Wire the real three-stage flow** + +After the unchanged first confirmation: + +```python +remover = Remover() +primary_result = remover.remove(analysis) +scanner = ResidualScanner() +scan_two = scanner.scan(identity) +show_residual_scan(console, scan_two, "Residual scan #2") + +residual_result = None +if scan_two.confirmed: + answer = typer.prompt( + f"Delete {len(scan_two.confirmed)} confirmed residual paths? (y/N)", + default="N", + show_default=False, + ) + if answer.casefold() == "y": + residual_result = remover.remove_residuals(software.name, scan_two.confirmed) + +verification = scanner.scan(identity) +original_remaining = [ + path for path in analysis.paths if path.exists() or path.is_symlink() +] +show_verification_result( + console, software.name, primary_result, residual_result, + verification, original_remaining, +) +``` + +Do not return early after scan #2, residual refusal, or residual deletion failures. Declining the first primary prompt remains the only cancellation that skips all removal scans. + +- [ ] **Step 5: Verify GREEN and full behavior regression** + +```bash +uv run --python 3.11 --extra dev pytest tests/test_cli.py tests/test_reporter.py tests/test_analyzer_remover.py tests/test_residual_scanner.py -q +``` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit CLI orchestration** + +```bash +git add cleanapp/cli.py tests/test_cli.py +git commit -m "Wire three-stage residual cleanup" +test "$(git log -1 --format='%ae')" = "253661133+BruceL017@users.noreply.github.com" +``` + +--- + +### Task 7: User Documentation and Release Verification + +**Files:** +- Modify: `README.md` +- Modify: `README.zh-CN.md` + +**Interfaces:** +- Consumes: the finished CLI behavior. +- Produces: accurate one-command usage instructions and the final independently reviewable documentation commit. + +- [ ] **Step 1: Update the concise README workflow** + +Replace the old “unknown paths are not guessed” limitation with this exact promise in `README.md`: + +```markdown +真实卸载采用三阶段流程:先执行已预览的应用或包管理器卸载,再在受支持的用户、XDG 和 `/Library` 位置中扫描与目标身份精确关联的残留;如发现可确认归属的残留,会列出清单并再次询问 `y/N`,最后重新扫描并报告结果。 + +CleanApp 只枚举受支持目录的直接子项,不递归搜索整个用户目录,也不会进入 Chrome、Safari、Firefox、Edge、VS Code 等其他应用的数据目录。弱匹配仅列为人工复核项,不会因一次 `Y` 被删除;工具不会自动调用 `sudo`。 +``` + +Document that dry-run shows currently detectable extra candidates but cannot perform post-removal verification because it makes no changes. + +- [ ] **Step 2: Update the detailed Chinese README** + +Change the numbered removal flow to: + +```markdown +1. 在删除前捕获应用、包和可执行文件身份。 +2. 展示并确认主要卸载计划。 +3. 停止相关进程、调用已检测到的包管理器并删除主要路径。 +4. 扫描受支持位置中的残留并分为“可确认删除”“仅供复核”“无法验证”。 +5. 仅在第二次输入 `y/Y` 后删除“可确认删除”项。 +6. 无论第二次是否确认,都执行一次新的验证扫描。 +7. 分别报告确认残留、人工复核项、无法访问的位置、原计划残留路径、进程和失败。 +``` + +Update the project tree to include `residual_scanner.py`, explain that browser website data is isolated by direct-child scanning, and replace any statement that implies rule-only residual coverage. + +- [ ] **Step 3: Run the complete fresh verification suite** + +Run all commands and require exit code 0: + +```bash +uv run --python 3.11 --extra dev python -m compileall -q cleanapp tests +uv run --python 3.11 --extra dev python -m pytest +uv pip check +rm -rf dist +uv build +uvx --from . cleanapp --version +uvx --from . cleanapp rules +uvx --from . cleanapp doctor +git diff --check +``` + +Expected: complete pytest suite has zero failures; source compiles; dependencies are consistent; sdist/wheel build; all clean-entry CLI startup commands exit 0; diff check is empty. + +- [ ] **Step 4: Verify every acceptance item against evidence** + +Map all 22 design-matrix items to named tests and confirm no provider, YAML rule, or dependency changed. Confirm `git status --short` contains only the two README files before this task's commit. + +- [ ] **Step 5: Commit documentation** + +```bash +git add README.md README.zh-CN.md +git commit -m "Document generic residual cleanup" +test "$(git log -1 --format='%ae')" = "253661133+BruceL017@users.noreply.github.com" +``` + +- [ ] **Step 6: Final branch verification before push and PR** + +Run again from the committed tree: + +```bash +uv run --python 3.11 --extra dev python -m pytest +uv run --python 3.11 --extra dev python -m compileall -q cleanapp tests +uv build +uvx --from . cleanapp --version +git diff --check origin/main...HEAD +git status --short --branch +git log --format='%h %s | %an <%ae>' origin/main..HEAD +``` + +Expected: tests/build/startup pass, working tree is clean, and every commit uses the required noreply email. From 527e463453d029aee837804469697dc66e604f05 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:26:49 +0800 Subject: [PATCH 03/22] Add residual identity capture --- cleanapp/models.py | 27 +++++++++++++++ cleanapp/residual_scanner.py | 63 ++++++++++++++++++++++++++++++++++ tests/test_residual_scanner.py | 62 +++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 cleanapp/residual_scanner.py create mode 100644 tests/test_residual_scanner.py diff --git a/cleanapp/models.py b/cleanapp/models.py index 1df2552..af67141 100644 --- a/cleanapp/models.py +++ b/cleanapp/models.py @@ -18,6 +18,33 @@ class Source(str, Enum): RULE = "Rule" +@dataclass(frozen=True, slots=True) +class ResidualIdentity: + names: frozenset[str] + bundle_ids: frozenset[str] + package_ids: frozenset[str] + executables: frozenset[str] + sources: frozenset[Source] + + +@dataclass(frozen=True, slots=True) +class ResidualCandidate: + path: Path + evidence: str + system_level: bool + device: int + inode: int + file_type: int + + +@dataclass(slots=True) +class ResidualScan: + confirmed: list[ResidualCandidate] = field(default_factory=list) + review_only: list[ResidualCandidate] = field(default_factory=list) + inaccessible_roots: list[Path] = field(default_factory=list) + running_pids: list[int] = field(default_factory=list) + + @dataclass(slots=True) class Software: name: str diff --git a/cleanapp/residual_scanner.py b/cleanapp/residual_scanner.py new file mode 100644 index 0000000..8b75033 --- /dev/null +++ b/cleanapp/residual_scanner.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path +import plistlib + +from .analyzer import expand_pattern +from .models import ResidualIdentity, Rule, Software, Source + + +PACKAGE_SOURCES = { + Source.HOMEBREW_FORMULA, + Source.HOMEBREW_CASK, + Source.NPM, + Source.PNPM, + Source.PIPX, + Source.UV, + Source.CARGO, +} + + +def _bundle_paths(software: Software, rule: Rule | None) -> set[Path]: + paths = set(software.paths) + if rule: + for pattern in rule.path_patterns: + paths.update(expand_pattern(pattern)) + return {path for path in paths if path.suffix.casefold() == ".app" and path.is_dir()} + + +def capture_identity(software: Software, rule: Rule | None) -> ResidualIdentity: + names = {software.name, *software.identifiers} + package_ids = set(software.identifiers) if software.sources & PACKAGE_SOURCES else set() + executables = {path.name for path in software.paths if path.is_file() or path.is_symlink()} + bundle_ids: set[str] = set() + + if rule: + names.update((rule.name, rule.slug, *rule.aliases)) + executables.update(rule.kill) + package_ids.update(package for values in rule.packages.values() for package in values) + + for app in _bundle_paths(software, rule): + for info_path in (app / "Contents").rglob("Info.plist"): + try: + payload = plistlib.loads(info_path.read_bytes()) + except (OSError, plistlib.InvalidFileException): + continue + bundle_id = payload.get("CFBundleIdentifier") + if isinstance(bundle_id, str) and bundle_id: + bundle_ids.add(bundle_id) + for key in ("CFBundleName", "CFBundleDisplayName"): + value = payload.get(key) + if isinstance(value, str) and value: + names.add(value) + executable = payload.get("CFBundleExecutable") + if isinstance(executable, str) and executable: + executables.add(executable) + + return ResidualIdentity( + frozenset(value for value in names if value), + frozenset(bundle_ids), + frozenset(value for value in package_ids if value), + frozenset(value for value in executables if value), + frozenset(software.sources), + ) diff --git a/tests/test_residual_scanner.py b/tests/test_residual_scanner.py new file mode 100644 index 0000000..c41540a --- /dev/null +++ b/tests/test_residual_scanner.py @@ -0,0 +1,62 @@ +from pathlib import Path + +import pytest + +from cleanapp.models import Rule, Software, Source +from cleanapp.residual_scanner import capture_identity + + +def write_bundle(path: Path, **values: str) -> None: + import plistlib + + info = path / "Contents/Info.plist" + info.parent.mkdir(parents=True) + info.write_bytes(plistlib.dumps(values)) + + +def test_capture_identity_reads_main_and_embedded_bundle_metadata(tmp_path: Path) -> None: + app = tmp_path / "Poe.app" + write_bundle( + app, + CFBundleIdentifier="com.quora.poe.electron", + CFBundleName="Poe", + CFBundleExecutable="Poe", + ) + helper = app / "Contents/Frameworks/Poe Helper.app" + write_bundle( + helper, + CFBundleIdentifier="com.quora.poe.electron.helper", + CFBundleExecutable="Poe Helper", + ) + + identity = capture_identity( + Software("Poe", {Source.APPLICATION}, {"Poe"}, {app}), + None, + ) + + assert identity.bundle_ids == frozenset({ + "com.quora.poe.electron", + "com.quora.poe.electron.helper", + }) + assert {"Poe", "Poe Helper"} <= identity.executables + + +@pytest.mark.parametrize("source", list(Source)) +def test_capture_identity_supports_every_source(source: Source) -> None: + identity = capture_identity(Software("Example Tool", {source}, {"example-tool"}), None) + assert identity.sources == frozenset({source}) + assert "Example Tool" in identity.names + + +def test_capture_identity_includes_rule_metadata() -> None: + rule = Rule( + "example", + "Example Desktop", + aliases=["Example"], + kill=["example-helper"], + packages={"npm": ["@vendor/example"]}, + ) + identity = capture_identity(Software("Example", {Source.RULE}, {"example"}), rule) + assert {"Example Desktop", "Example", "example"} <= identity.names + assert "example-helper" in identity.executables + assert "@vendor/example" in identity.package_ids From a0ec474a386c90298640d5a1ac5a45a406fc8f12 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:37:35 +0800 Subject: [PATCH 04/22] Add bounded residual discovery --- cleanapp/residual_scanner.py | 203 ++++++++++++++++++++++++++++++++- tests/test_residual_scanner.py | 76 +++++++++++- 2 files changed, 276 insertions(+), 3 deletions(-) diff --git a/cleanapp/residual_scanner.py b/cleanapp/residual_scanner.py index 8b75033..4807fdc 100644 --- a/cleanapp/residual_scanner.py +++ b/cleanapp/residual_scanner.py @@ -1,10 +1,13 @@ from __future__ import annotations +import os from pathlib import Path import plistlib +import stat +import unicodedata from .analyzer import expand_pattern -from .models import ResidualIdentity, Rule, Software, Source +from .models import ResidualCandidate, ResidualIdentity, ResidualScan, Rule, Software, Source PACKAGE_SOURCES = { @@ -17,6 +20,65 @@ Source.CARGO, } +USER_LIBRARY_ROOTS = ( + "Application Support", + "Caches", + "Preferences", + "Preferences/ByHost", + "Logs", + "HTTPStorages", + "Cookies", + "WebKit", + "Saved Application State", + "Containers", + "Group Containers", + "Application Scripts", + "LaunchAgents", + "Services", + "PreferencePanes", + "QuickLook", +) + +SYSTEM_ROOTS = ( + Path("/Library/Application Support"), + Path("/Library/Caches"), + Path("/Library/Preferences"), + Path("/Library/Logs"), + Path("/Library/LaunchAgents"), + Path("/Library/LaunchDaemons"), + Path("/Library/PrivilegedHelperTools"), + Path("/Library/Frameworks"), + Path("/Library/PreferencePanes"), + Path("/Library/QuickLook"), + Path("/Library/Spotlight"), + Path("/Library/Internet Plug-Ins"), + Path("/Library/Audio/Plug-Ins"), + Path("/Library/Extensions"), +) + +XDG_ROOTS = { + "XDG_CONFIG_HOME": ".config", + "XDG_CACHE_HOME": ".cache", + "XDG_DATA_HOME": ".local/share", + "XDG_STATE_HOME": ".local/state", +} + +STANDARD_SUFFIXES = (".app", ".plist", ".savedstate", ".binarycookies") +PLATFORM_MANAGED_USER_ROOTS = { + "Application Scripts", + "Group Containers", + "PreferencePanes", + "QuickLook", +} +PLATFORM_MANAGED_SYSTEM_SUFFIXES = ( + "/Audio/Plug-Ins", + "/Extensions", + "/Internet Plug-Ins", + "/PreferencePanes", + "/QuickLook", + "/Spotlight", +) + def _bundle_paths(software: Software, rule: Rule | None) -> set[Path]: paths = set(software.paths) @@ -61,3 +123,142 @@ def capture_identity(software: Software, rule: Rule | None) -> ResidualIdentity: frozenset(value for value in executables if value), frozenset(software.sources), ) + + +def _bundle_evidence(name: str, bundle_ids: frozenset[str]) -> str | None: + for bundle_id in bundle_ids: + if name == bundle_id or any( + name.startswith(bundle_id + boundary) for boundary in (".", "-", "_") + ): + return f"bundle identifier {bundle_id}" + return None + + +def _human_basename(name: str) -> str: + value = name[1:] if name.startswith(".") else name + normalized = unicodedata.normalize("NFC", value).casefold() + for suffix in STANDARD_SUFFIXES: + if normalized.endswith(suffix) and len(normalized) > len(suffix): + return normalized[: -len(suffix)] + if name.startswith(".") and normalized.endswith("rc") and len(normalized) > 2: + return normalized[:-2] + return normalized + + +def _is_platform_managed(root: Path, kind: str) -> bool: + if kind.startswith("library:"): + return kind.removeprefix("library:") in PLATFORM_MANAGED_USER_ROOTS + if kind != "system": + return False + root_text = str(root) + return any(root_text.endswith(suffix) for suffix in PLATFORM_MANAGED_SYSTEM_SUFFIXES) + + +class ResidualScanner: + def __init__( + self, + home: Path | None = None, + system_roots: tuple[Path, ...] | None = None, + ) -> None: + self.home = home or Path.home() + self.system_roots = SYSTEM_ROOTS if system_roots is None else system_roots + + def _roots(self) -> tuple[tuple[Path, bool, str], ...]: + roots: list[tuple[Path, bool, str]] = [ + (self.home / "Library" / relative, False, f"library:{relative}") + for relative in USER_LIBRARY_ROOTS + ] + for variable, default in XDG_ROOTS.items(): + configured = os.environ.get(variable) + root = Path(configured).expanduser() if configured else self.home / default + roots.append((root, False, "xdg")) + roots.extend(( + (self.home / ".local/bin", False, "local-bin"), + (self.home, False, "home"), + )) + roots.extend((root, True, "system") for root in self.system_roots) + + unique: list[tuple[Path, bool, str]] = [] + seen: set[Path] = set() + for root in roots: + if root[0] not in seen: + seen.add(root[0]) + unique.append(root) + return tuple(unique) + + def scan(self, identity: ResidualIdentity) -> ResidualScan: + result = ResidualScan() + for root, system_level, kind in self._roots(): + try: + children = list(root.iterdir()) + except (FileNotFoundError, NotADirectoryError, PermissionError): + continue + + for path in children: + try: + metadata = path.lstat() + except OSError: + continue + evidence, confirmed = self._match(path, metadata, kind, identity) + if evidence is None: + continue + candidate = ResidualCandidate( + path, + evidence, + system_level, + metadata.st_dev, + metadata.st_ino, + stat.S_IFMT(metadata.st_mode), + ) + if confirmed and not _is_platform_managed(root, kind): + result.confirmed.append(candidate) + else: + result.review_only.append(candidate) + + result.confirmed.sort(key=lambda candidate: str(candidate.path).casefold()) + result.review_only.sort(key=lambda candidate: str(candidate.path).casefold()) + return result + + def _match( + self, + path: Path, + metadata: os.stat_result, + kind: str, + identity: ResidualIdentity, + ) -> tuple[str | None, bool]: + name = path.name + if kind == "local-bin": + is_executable = stat.S_ISREG(metadata.st_mode) and bool(metadata.st_mode & 0o111) + if name in identity.executables and (is_executable or stat.S_ISLNK(metadata.st_mode)): + return f"executable {name}", True + return None, False + + if kind == "home": + if not name.startswith("."): + return None, False + display_name = _human_basename(name) + for identity_name in identity.names: + if display_name == unicodedata.normalize("NFC", identity_name).casefold(): + second_signal = bool(identity.bundle_ids or identity.package_ids) + return f"display name {identity_name}", second_signal and len(display_name) >= 3 + return None, False + + bundle_evidence = _bundle_evidence(name, identity.bundle_ids) + if bundle_evidence: + return bundle_evidence, True + + normalized_name = _human_basename(name) + if kind == "xdg" or kind.startswith("library:") or kind == "system": + for package_id in identity.package_ids: + package_leaf = package_id.rsplit("/", 1)[-1] + if normalized_name in { + unicodedata.normalize("NFC", package_id).casefold(), + unicodedata.normalize("NFC", package_leaf).casefold(), + }: + return f"package identifier {package_id}", True + + for identity_name in identity.names: + if normalized_name == unicodedata.normalize("NFC", identity_name).casefold(): + second_signal = bool(identity.bundle_ids or identity.package_ids) + return f"display name {identity_name}", second_signal + return None, False diff --git a/tests/test_residual_scanner.py b/tests/test_residual_scanner.py index c41540a..e4ce7e0 100644 --- a/tests/test_residual_scanner.py +++ b/tests/test_residual_scanner.py @@ -2,8 +2,29 @@ import pytest -from cleanapp.models import Rule, Software, Source -from cleanapp.residual_scanner import capture_identity +from cleanapp.models import ResidualIdentity, Rule, Software, Source +from cleanapp.residual_scanner import ResidualScanner, capture_identity + + +POE_RELATIVE_PATHS = ( + ("Library/Application Support/Poe", True), + ("Library/Caches/com.quora.poe.electron", True), + ("Library/Caches/com.quora.poe.electron.ShipIt", True), + ("Library/HTTPStorages/com.quora.poe.electron", True), + ("Library/Cookies/com.quora.poe.electron.binarycookies", False), + ("Library/Preferences/com.quora.poe.electron.plist", False), + ("Library/Preferences/ByHost/com.quora.poe.electron.ShipIt.TEST.plist", False), +) + + +def poe_identity() -> ResidualIdentity: + return ResidualIdentity( + frozenset({"Poe"}), + frozenset({"com.quora.poe.electron"}), + frozenset(), + frozenset({"Poe"}), + frozenset({Source.APPLICATION}), + ) def write_bundle(path: Path, **values: str) -> None: @@ -60,3 +81,54 @@ def test_capture_identity_includes_rule_metadata() -> None: assert {"Example Desktop", "Example", "example"} <= identity.names assert "example-helper" in identity.executables assert "@vendor/example" in identity.package_ids + + +def test_scan_discovers_seven_poe_groups_without_a_rule(tmp_path: Path) -> None: + expected = set() + for relative, is_directory in POE_RELATIVE_PATHS: + path = tmp_path / relative + if is_directory: + path.mkdir(parents=True) + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("poe", encoding="utf-8") + expected.add(path) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(poe_identity()) + assert {candidate.path for candidate in scan.confirmed} == expected + + +def test_scan_never_enters_unmatched_chrome_directory(tmp_path: Path, monkeypatch) -> None: + chrome = tmp_path / "Library/Application Support/Google/Chrome/Default/IndexedDB" + chrome.mkdir(parents=True) + website_data = chrome / "https_poe.com_0.indexeddb.leveldb" + website_data.mkdir() + visited: list[Path] = [] + original = Path.iterdir + + def tracking_iterdir(path: Path): + visited.append(path) + return original(path) + + monkeypatch.setattr(Path, "iterdir", tracking_iterdir) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(poe_identity()) + assert website_data.exists() + assert not scan.confirmed + assert all("Google/Chrome" not in str(path) for path in visited) + + +def test_custom_xdg_root_replaces_default(tmp_path: Path, monkeypatch) -> None: + custom = tmp_path / "custom-config" + default = tmp_path / ".config" + (custom / "example-tool").mkdir(parents=True) + (default / "example-tool").mkdir(parents=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(custom)) + identity = ResidualIdentity( + frozenset({"example-tool"}), + frozenset(), + frozenset({"example-tool"}), + frozenset(), + frozenset({Source.NPM}), + ) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + assert {item.path for item in scan.confirmed} == {custom / "example-tool"} From 62c563d6ad1b4bfac3a6d528a9ebaf3528eaccc7 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:55:01 +0800 Subject: [PATCH 05/22] Require exact package residual matches --- cleanapp/residual_scanner.py | 5 +--- tests/test_residual_scanner.py | 42 ++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/cleanapp/residual_scanner.py b/cleanapp/residual_scanner.py index 4807fdc..16c431c 100644 --- a/cleanapp/residual_scanner.py +++ b/cleanapp/residual_scanner.py @@ -251,10 +251,7 @@ def _match( if kind == "xdg" or kind.startswith("library:") or kind == "system": for package_id in identity.package_ids: package_leaf = package_id.rsplit("/", 1)[-1] - if normalized_name in { - unicodedata.normalize("NFC", package_id).casefold(), - unicodedata.normalize("NFC", package_leaf).casefold(), - }: + if name in {package_id, package_leaf}: return f"package identifier {package_id}", True for identity_name in identity.names: diff --git a/tests/test_residual_scanner.py b/tests/test_residual_scanner.py index e4ce7e0..f234869 100644 --- a/tests/test_residual_scanner.py +++ b/tests/test_residual_scanner.py @@ -99,7 +99,8 @@ def test_scan_discovers_seven_poe_groups_without_a_rule(tmp_path: Path) -> None: def test_scan_never_enters_unmatched_chrome_directory(tmp_path: Path, monkeypatch) -> None: - chrome = tmp_path / "Library/Application Support/Google/Chrome/Default/IndexedDB" + google = tmp_path / "Library/Application Support/Google" + chrome = google / "Chrome/Default/IndexedDB" chrome.mkdir(parents=True) website_data = chrome / "https_poe.com_0.indexeddb.leveldb" website_data.mkdir() @@ -114,7 +115,7 @@ def tracking_iterdir(path: Path): scan = ResidualScanner(home=tmp_path, system_roots=()).scan(poe_identity()) assert website_data.exists() assert not scan.confirmed - assert all("Google/Chrome" not in str(path) for path in visited) + assert all(path != google and google not in path.parents for path in visited) def test_custom_xdg_root_replaces_default(tmp_path: Path, monkeypatch) -> None: @@ -132,3 +133,40 @@ def test_custom_xdg_root_replaces_default(tmp_path: Path, monkeypatch) -> None: ) scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) assert {item.path for item in scan.confirmed} == {custom / "example-tool"} + + +def package_identity(package_id: str) -> ResidualIdentity: + return ResidualIdentity( + frozenset(), + frozenset(), + frozenset({package_id}), + frozenset(), + frozenset({Source.NPM}), + ) + + +def test_package_id_confirms_exact_raw_basename(tmp_path: Path, monkeypatch) -> None: + config = tmp_path / "config" + candidate = config / "example" + candidate.mkdir(parents=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config)) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(package_identity("example")) + + assert {item.path for item in scan.confirmed} == {candidate} + + +@pytest.mark.parametrize("non_exact_name", ("example.plist", "Example")) +def test_package_id_rejects_non_exact_basename( + tmp_path: Path, + monkeypatch, + non_exact_name: str, +) -> None: + config = tmp_path / "config" + candidate = config / non_exact_name + candidate.mkdir(parents=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config)) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(package_identity("example")) + + assert candidate not in {item.path for item in scan.confirmed} From 8afb05bbfb540f607f541ebd660c0cdfb8e3f7bb Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:59:27 +0800 Subject: [PATCH 06/22] Prevent package identity fallback matches --- cleanapp/residual_scanner.py | 7 +++++-- tests/test_residual_scanner.py | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/cleanapp/residual_scanner.py b/cleanapp/residual_scanner.py index 16c431c..1f4a5a3 100644 --- a/cleanapp/residual_scanner.py +++ b/cleanapp/residual_scanner.py @@ -227,6 +227,9 @@ def _match( identity: ResidualIdentity, ) -> tuple[str | None, bool]: name = path.name + package_names = identity.package_ids | frozenset( + package_id.rsplit("/", 1)[-1] for package_id in identity.package_ids + ) if kind == "local-bin": is_executable = stat.S_ISREG(metadata.st_mode) and bool(metadata.st_mode & 0o111) if name in identity.executables and (is_executable or stat.S_ISLNK(metadata.st_mode)): @@ -237,7 +240,7 @@ def _match( if not name.startswith("."): return None, False display_name = _human_basename(name) - for identity_name in identity.names: + for identity_name in identity.names - package_names: if display_name == unicodedata.normalize("NFC", identity_name).casefold(): second_signal = bool(identity.bundle_ids or identity.package_ids) return f"display name {identity_name}", second_signal and len(display_name) >= 3 @@ -254,7 +257,7 @@ def _match( if name in {package_id, package_leaf}: return f"package identifier {package_id}", True - for identity_name in identity.names: + for identity_name in identity.names - package_names: if normalized_name == unicodedata.normalize("NFC", identity_name).casefold(): second_signal = bool(identity.bundle_ids or identity.package_ids) return f"display name {identity_name}", second_signal diff --git a/tests/test_residual_scanner.py b/tests/test_residual_scanner.py index f234869..385919e 100644 --- a/tests/test_residual_scanner.py +++ b/tests/test_residual_scanner.py @@ -170,3 +170,40 @@ def test_package_id_rejects_non_exact_basename( scan = ResidualScanner(home=tmp_path, system_roots=()).scan(package_identity("example")) assert candidate not in {item.path for item in scan.confirmed} + + +def test_captured_package_id_does_not_bypass_exact_matching( + tmp_path: Path, + monkeypatch, +) -> None: + config = tmp_path / "config" + non_exact = {config / "Example", config / "example.plist"} + for candidate in non_exact: + candidate.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config)) + identity = capture_identity( + Software("Different Display", {Source.NPM}, {"example"}), + None, + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert non_exact.isdisjoint(item.path for item in scan.confirmed) + + +def test_captured_scoped_package_confirms_exact_unscoped_leaf( + tmp_path: Path, + monkeypatch, +) -> None: + config = tmp_path / "config" + candidate = config / "example" + candidate.mkdir(parents=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config)) + identity = capture_identity( + Software("Different Display", {Source.NPM}, {"@vendor/example"}), + None, + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert {item.path for item in scan.confirmed} == {candidate} From 17d87fcfaed566905a661c46539ebad7b4fc361a Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:05:55 +0800 Subject: [PATCH 07/22] Clarify short-name residual classification --- docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md b/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md index 2f70940..a8739a5 100644 --- a/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md +++ b/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md @@ -527,7 +527,7 @@ PLATFORM_MANAGED_ROOT_NAMES = {"Extensions", "Spotlight"} Apply these rules: -- Names whose normalized alphanumeric form is shorter than four characters are review-only even if exact. +- Names whose normalized alphanumeric form is shorter than four characters are review-only when the short name is the only ownership signal; an exact full application name with a separate bundle/package identity remains confirmed. - Display-name-only and direct-home-dotfile matches are review-only without a bundle/package second signal. - `Group Containers` and platform-managed roots are review-only, never confirmed. - Protected and shared entries are excluded entirely. From b00322184f2399f5278331cade96ab8bc940438b Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:15:47 +0800 Subject: [PATCH 08/22] Harden residual candidate classification --- cleanapp/residual_scanner.py | 172 ++++++++++++++++- tests/test_residual_scanner.py | 325 ++++++++++++++++++++++++++++++++- 2 files changed, 490 insertions(+), 7 deletions(-) diff --git a/cleanapp/residual_scanner.py b/cleanapp/residual_scanner.py index 1f4a5a3..218f74a 100644 --- a/cleanapp/residual_scanner.py +++ b/cleanapp/residual_scanner.py @@ -6,6 +6,8 @@ import stat import unicodedata +import psutil + from .analyzer import expand_pattern from .models import ResidualCandidate, ResidualIdentity, ResidualScan, Rule, Software, Source @@ -78,6 +80,21 @@ "/QuickLook", "/Spotlight", ) +PROTECTED_HOME_ENTRIES = { + ".zshrc", + ".bashrc", + ".bash_profile", + ".profile", + ".gitconfig", + ".ssh", + ".gnupg", + ".aws", + ".kube", +} +SHARED_HOME_ENTRIES = {".cargo", ".npm", ".pnpm-store", ".rustup", "go"} +SHARED_CACHE_ENTRIES = {"cargo", "go-build", "npm", "pip", "pnpm", "uv"} +SHARED_LIBRARY_CACHE_ENTRIES = {"Homebrew"} +PLATFORM_MANAGED_ROOT_NAMES = {"Extensions", "Spotlight"} def _bundle_paths(software: Software, rule: Rule | None) -> set[Path]: @@ -150,10 +167,130 @@ def _is_platform_managed(root: Path, kind: str) -> bool: return kind.removeprefix("library:") in PLATFORM_MANAGED_USER_ROOTS if kind != "system": return False + if root.name in PLATFORM_MANAGED_ROOT_NAMES: + return True root_text = str(root) return any(root_text.endswith(suffix) for suffix in PLATFORM_MANAGED_SYSTEM_SUFFIXES) +def _normalized_alphanumeric(value: str) -> str: + return "".join( + character + for character in unicodedata.normalize("NFC", value).casefold() + if character.isalnum() + ) + + +def _is_short(value: str) -> bool: + return len(_normalized_alphanumeric(value)) < 4 + + +def _is_excluded(path: Path, kind: str) -> bool: + if kind == "home": + return path.name in PROTECTED_HOME_ENTRIES | SHARED_HOME_ENTRIES + if kind == "xdg:XDG_CACHE_HOME": + return path.name in SHARED_CACHE_ENTRIES + if kind == "library:Caches": + return path.name in SHARED_LIBRARY_CACHE_ENTRIES + return False + + +def _contains_path(parent: Path, child: Path) -> bool: + try: + child.relative_to(parent) + except ValueError: + return False + return parent != child + + +def _approved_roots(home: Path | None = None) -> tuple[Path, ...]: + current_home = home or Path.home() + roots = [current_home / "Library" / relative for relative in USER_LIBRARY_ROOTS] + for variable, default in XDG_ROOTS.items(): + configured = os.environ.get(variable) + roots.append(Path(configured).expanduser() if configured else current_home / default) + roots.extend((current_home / ".local/bin", current_home, *SYSTEM_ROOTS)) + return tuple(dict.fromkeys(Path(os.path.abspath(str(root))) for root in roots)) + + +def is_approved_residual_path(path: Path) -> bool: + """Return whether *path* is exactly one level below an approved scan root.""" + candidate = Path(os.path.abspath(os.path.expanduser(str(path)))) + roots = _approved_roots() + if candidate in roots or candidate.parent not in roots: + return False + + home = Path(os.path.abspath(str(Path.home()))) + if candidate.parent == home: + return ( + candidate.name.startswith(".") + and candidate.name not in PROTECTED_HOME_ENTRIES | SHARED_HOME_ENTRIES + ) + configured_cache = os.environ.get("XDG_CACHE_HOME") + cache_root = ( + Path(configured_cache).expanduser() + if configured_cache + else home / XDG_ROOTS["XDG_CACHE_HOME"] + ) + cache_root = Path(os.path.abspath(str(cache_root))) + if candidate.parent == cache_root and candidate.name in SHARED_CACHE_ENTRIES: + return False + if ( + candidate.parent == home / "Library/Caches" + and candidate.name in SHARED_LIBRARY_CACHE_ENTRIES + ): + return False + if candidate.parent.is_relative_to(home / "Library"): + relative = str(candidate.parent.relative_to(home / "Library")) + if relative in PLATFORM_MANAGED_USER_ROOTS: + return False + if candidate.parent in SYSTEM_ROOTS and _is_platform_managed(candidate.parent, "system"): + return False + return True + + +def _matching_pids(identity: ResidualIdentity) -> list[int]: + exact_signals = { + unicodedata.normalize("NFC", value).casefold() + for value in identity.names | identity.executables + if value + } + substring_signals = { + _normalized_alphanumeric(value) + for value in exact_signals + if not _is_short(value) + } + matched: list[int] = [] + for process in psutil.process_iter(["pid", "name", "cmdline"]): + try: + cmdline = process.info.get("cmdline") or [] + process_names = [process.info.get("name") or ""] + process_names.extend(Path(part).name for part in cmdline[:2]) + process_names = [ + unicodedata.normalize("NFC", value).casefold() + for value in process_names + if value + ] + normalized_process_names = [ + _normalized_alphanumeric(value) for value in process_names + ] + exact_match = any( + signal == process_name + for signal in exact_signals + for process_name in process_names + ) + substring_match = any( + signal in process_name + for signal in substring_signals + for process_name in normalized_process_names + ) + if exact_match or substring_match: + matched.append(process.info["pid"]) + except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess): + continue + return sorted(set(matched)) + + class ResidualScanner: def __init__( self, @@ -171,7 +308,7 @@ def _roots(self) -> tuple[tuple[Path, bool, str], ...]: for variable, default in XDG_ROOTS.items(): configured = os.environ.get(variable) root = Path(configured).expanduser() if configured else self.home / default - roots.append((root, False, "xdg")) + roots.append((root, False, f"xdg:{variable}")) roots.extend(( (self.home / ".local/bin", False, "local-bin"), (self.home, False, "home"), @@ -191,10 +328,15 @@ def scan(self, identity: ResidualIdentity) -> ResidualScan: for root, system_level, kind in self._roots(): try: children = list(root.iterdir()) - except (FileNotFoundError, NotADirectoryError, PermissionError): + except FileNotFoundError: + continue + except OSError: + result.inaccessible_roots.append(root) continue for path in children: + if _is_excluded(path, kind): + continue try: metadata = path.lstat() except OSError: @@ -215,8 +357,25 @@ def scan(self, identity: ResidualIdentity) -> ResidualScan: else: result.review_only.append(candidate) + confirmed_paths = {candidate.path for candidate in result.confirmed} + result.confirmed = [ + candidate for candidate in result.confirmed + if not any( + _contains_path(parent, candidate.path) + for parent in confirmed_paths + ) + ] + result.review_only = [ + candidate for candidate in result.review_only + if not any( + _contains_path(parent, candidate.path) + for parent in confirmed_paths + ) + ] result.confirmed.sort(key=lambda candidate: str(candidate.path).casefold()) result.review_only.sort(key=lambda candidate: str(candidate.path).casefold()) + result.inaccessible_roots.sort(key=lambda root: str(root).casefold()) + result.running_pids = _matching_pids(identity) return result def _match( @@ -233,7 +392,8 @@ def _match( if kind == "local-bin": is_executable = stat.S_ISREG(metadata.st_mode) and bool(metadata.st_mode & 0o111) if name in identity.executables and (is_executable or stat.S_ISLNK(metadata.st_mode)): - return f"executable {name}", True + second_signal = bool(identity.bundle_ids or identity.package_ids) + return f"executable {name}", second_signal or not _is_short(name) return None, False if kind == "home": @@ -243,7 +403,7 @@ def _match( for identity_name in identity.names - package_names: if display_name == unicodedata.normalize("NFC", identity_name).casefold(): second_signal = bool(identity.bundle_ids or identity.package_ids) - return f"display name {identity_name}", second_signal and len(display_name) >= 3 + return f"display name {identity_name}", second_signal return None, False bundle_evidence = _bundle_evidence(name, identity.bundle_ids) @@ -251,11 +411,11 @@ def _match( return bundle_evidence, True normalized_name = _human_basename(name) - if kind == "xdg" or kind.startswith("library:") or kind == "system": + if kind.startswith("xdg:") or kind.startswith("library:") or kind == "system": for package_id in identity.package_ids: package_leaf = package_id.rsplit("/", 1)[-1] if name in {package_id, package_leaf}: - return f"package identifier {package_id}", True + return f"package identifier {package_id}", not _is_short(name) for identity_name in identity.names - package_names: if normalized_name == unicodedata.normalize("NFC", identity_name).casefold(): diff --git a/tests/test_residual_scanner.py b/tests/test_residual_scanner.py index 385919e..7beab38 100644 --- a/tests/test_residual_scanner.py +++ b/tests/test_residual_scanner.py @@ -3,7 +3,11 @@ import pytest from cleanapp.models import ResidualIdentity, Rule, Software, Source -from cleanapp.residual_scanner import ResidualScanner, capture_identity +from cleanapp.residual_scanner import ( + ResidualScanner, + capture_identity, + is_approved_residual_path, +) POE_RELATIVE_PATHS = ( @@ -207,3 +211,322 @@ def test_captured_scoped_package_confirms_exact_unscoped_leaf( scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) assert {item.path for item in scan.confirmed} == {candidate} + + +def test_short_go_name_is_review_only_and_home_go_is_not_deleted(tmp_path: Path) -> None: + target = tmp_path / ".go" + target.mkdir() + identity = ResidualIdentity( + frozenset({"go"}), + frozenset(), + frozenset(), + frozenset({"go"}), + frozenset({Source.GO}), + ) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + assert not scan.confirmed + assert {item.path for item in scan.review_only} == {target} + + +@pytest.mark.parametrize(("relative", "source"), ( + (".cargo", Source.CARGO), + (".npm", Source.NPM), + (".pnpm-store", Source.PNPM), + (".rustup", Source.CARGO), + ("go", Source.GO), + (".cache/cargo", Source.CARGO), + (".cache/npm", Source.NPM), + (".cache/pnpm", Source.PNPM), + (".cache/uv", Source.UV), + (".cache/pip", Source.PIPX), + (".cache/go-build", Source.GO), +)) +def test_shared_package_stores_are_excluded( + tmp_path: Path, + relative: str, + source: Source, +) -> None: + target = tmp_path / relative + target.mkdir(parents=True) + identity = ResidualIdentity( + frozenset({target.name}), + frozenset(), + frozenset({target.name}), + frozenset({target.name}), + frozenset({source}), + ) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} + + +def test_shared_cache_exclusions_apply_to_custom_xdg_root( + tmp_path: Path, + monkeypatch, +) -> None: + cache = tmp_path / "custom-cache" + target = cache / "uv" + target.mkdir(parents=True) + monkeypatch.setenv("XDG_CACHE_HOME", str(cache)) + identity = ResidualIdentity( + frozenset({"uv"}), + frozenset(), + frozenset({"uv"}), + frozenset({"uv"}), + frozenset({Source.UV}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} + + +def test_shared_homebrew_cache_is_excluded(tmp_path: Path) -> None: + target = tmp_path / "Library/Caches/Homebrew" + target.mkdir(parents=True) + identity = ResidualIdentity( + frozenset({"Homebrew"}), + frozenset(), + frozenset({"Homebrew"}), + frozenset(), + frozenset({Source.HOMEBREW_FORMULA}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} + + +def test_short_exact_package_match_is_review_only(tmp_path: Path, monkeypatch) -> None: + config = tmp_path / "config" + target = config / "go" + target.mkdir(parents=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config)) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(package_identity("go")) + + assert not scan.confirmed + assert {item.path for item in scan.review_only} == {target} + + +def test_short_exact_executable_match_is_review_only(tmp_path: Path) -> None: + target = tmp_path / ".local/bin/go" + target.parent.mkdir(parents=True) + target.write_text("binary", encoding="utf-8") + target.chmod(0o755) + identity = ResidualIdentity( + frozenset({"go"}), + frozenset(), + frozenset(), + frozenset({"go"}), + frozenset({Source.GO}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert not scan.confirmed + assert {item.path for item in scan.review_only} == {target} + + +@pytest.mark.parametrize("name", ( + ".zshrc", ".bashrc", ".bash_profile", ".profile", ".gitconfig", + ".ssh", ".gnupg", ".aws", ".kube", +)) +def test_protected_home_entries_are_excluded(tmp_path: Path, name: str) -> None: + target = tmp_path / name + target.mkdir() + identity = ResidualIdentity( + frozenset({name.removeprefix(".").removesuffix("rc")}), + frozenset({"com.vendor.example"}), + frozenset(), + frozenset(), + frozenset({Source.APPLICATION}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} + + +def test_parent_candidate_collapses_child_candidate(tmp_path: Path) -> None: + parent = tmp_path / "Library/Application Support/Example" + child_root = parent / "nested-root" + child = child_root / "com.vendor.example" + child.mkdir(parents=True) + scanner = ResidualScanner(home=tmp_path, system_roots=(child_root,)) + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset({"com.vendor.example"}), + frozenset(), + frozenset(), + frozenset({Source.APPLICATION}), + ) + scan = scanner.scan(identity) + assert {item.path for item in scan.confirmed} == {parent} + + +def test_system_candidate_is_marked_admin_scoped(tmp_path: Path) -> None: + system_root = tmp_path / "Library/LaunchAgents" + target = system_root / "com.vendor.example.plist" + target.parent.mkdir(parents=True) + target.write_text("label", encoding="utf-8") + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset({"com.vendor.example"}), + frozenset(), + frozenset(), + frozenset({Source.APPLICATION}), + ) + scan = ResidualScanner(home=tmp_path / "home", system_roots=(system_root,)).scan(identity) + assert scan.confirmed[0].system_level is True + + +def test_unreadable_root_is_reported_and_other_roots_continue( + tmp_path: Path, + monkeypatch, +) -> None: + blocked = tmp_path / "Library/Caches" + readable = tmp_path / "Library/Preferences" + blocked.mkdir(parents=True) + target = readable / "com.vendor.example.plist" + target.parent.mkdir(parents=True) + target.write_text("ok", encoding="utf-8") + original = Path.iterdir + + def failing_iterdir(path: Path): + if path == blocked: + raise PermissionError("denied") + return original(path) + + monkeypatch.setattr(Path, "iterdir", failing_iterdir) + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset({"com.vendor.example"}), + frozenset(), + frozenset(), + frozenset({Source.APPLICATION}), + ) + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + assert blocked in scan.inaccessible_roots + assert target in {item.path for item in scan.confirmed} + + +def test_nonexistent_root_is_skipped_but_existing_file_root_is_reported( + tmp_path: Path, +) -> None: + missing = tmp_path / "missing" + not_directory = tmp_path / "not-directory" + not_directory.write_text("file", encoding="utf-8") + + scan = ResidualScanner( + home=tmp_path / "home", + system_roots=(missing, not_directory), + ).scan(poe_identity()) + + assert missing not in scan.inaccessible_roots + assert not_directory in scan.inaccessible_roots + + +def test_group_containers_and_platform_managed_roots_are_review_only( + tmp_path: Path, +) -> None: + group = tmp_path / "Library/Group Containers/com.vendor.example" + extension_root = tmp_path / "Library/Extensions" + extension = extension_root / "com.vendor.example" + group.mkdir(parents=True) + extension.mkdir(parents=True) + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset({"com.vendor.example"}), + frozenset(), + frozenset(), + frozenset({Source.APPLICATION}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=(extension_root,)).scan(identity) + + assert not scan.confirmed + assert {item.path for item in scan.review_only} == {group, extension} + + +def test_running_pids_match_exact_and_distinctive_substrings(monkeypatch, tmp_path: Path) -> None: + class FakeProcess: + def __init__(self, pid: int, name: str, cmdline: list[str]) -> None: + self.info = {"pid": pid, "name": name, "cmdline": cmdline} + + processes = ( + FakeProcess(101, "EXAMPLE TOOL", ["/Applications/Example Tool.app/Example Tool"]), + FakeProcess(102, "python3", ["python3", "/opt/example-tool-helper"]), + FakeProcess(103, "googlechrome", ["/Applications/Google Chrome.app/Google Chrome"]), + FakeProcess(104, "go", ["/usr/local/bin/go"]), + FakeProcess(105, "python3", ["python3", "script.py", "/opt/example-tool"]), + ) + monkeypatch.setattr("cleanapp.residual_scanner.psutil.process_iter", lambda _: processes) + identity = ResidualIdentity( + frozenset({"Example Tool", "go"}), + frozenset(), + frozenset(), + frozenset({"Example Tool"}), + frozenset({Source.APPLICATION}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert scan.running_pids == [101, 102, 104] + + +def test_running_pids_ignore_inaccessible_processes(monkeypatch, tmp_path: Path) -> None: + import psutil + + class FailingProcess: + @property + def info(self): + raise psutil.AccessDenied(201) + + monkeypatch.setattr( + "cleanapp.residual_scanner.psutil.process_iter", + lambda _: (FailingProcess(),), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(poe_identity()) + + assert not scan.running_pids + + +def test_approved_residual_path_requires_direct_child( + tmp_path: Path, + monkeypatch, +) -> None: + xdg_root = tmp_path / "config" + direct_child = xdg_root / "example" + nested_child = direct_child / "nested" + root_symlink = tmp_path / "config-link" + nested_child.mkdir(parents=True) + root_symlink.symlink_to(xdg_root, target_is_directory=True) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_root)) + + assert is_approved_residual_path(direct_child) + assert not is_approved_residual_path(xdg_root) + assert not is_approved_residual_path(nested_child) + assert not is_approved_residual_path(root_symlink / "example") + assert not is_approved_residual_path(tmp_path.parent / "unrelated/example") + + +def test_approved_residual_path_preserves_excluded_and_managed_entries( + tmp_path: Path, + monkeypatch, +) -> None: + cache = tmp_path / "custom-cache" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CACHE_HOME", str(cache)) + + assert is_approved_residual_path(tmp_path / ".example") + assert not is_approved_residual_path(tmp_path / "Documents") + assert not is_approved_residual_path(tmp_path / ".ssh") + assert not is_approved_residual_path(tmp_path / ".cargo") + assert not is_approved_residual_path(cache / "uv") + assert not is_approved_residual_path(tmp_path / "Library/Caches/Homebrew") + assert not is_approved_residual_path( + tmp_path / "Library/Group Containers/com.vendor.example" + ) + assert not is_approved_residual_path(Path("/Library/Extensions/example.kext")) From 681b76dc3b1d12e8347449e09234764709f67329 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:40:15 +0800 Subject: [PATCH 09/22] Preserve residual scan safety metadata --- cleanapp/residual_scanner.py | 113 ++++++++++++++------- tests/test_residual_scanner.py | 173 ++++++++++++++++++++++++++++++++- 2 files changed, 249 insertions(+), 37 deletions(-) diff --git a/cleanapp/residual_scanner.py b/cleanapp/residual_scanner.py index 218f74a..5ce1aa2 100644 --- a/cleanapp/residual_scanner.py +++ b/cleanapp/residual_scanner.py @@ -162,10 +162,14 @@ def _human_basename(name: str) -> str: return normalized -def _is_platform_managed(root: Path, kind: str) -> bool: - if kind.startswith("library:"): - return kind.removeprefix("library:") in PLATFORM_MANAGED_USER_ROOTS - if kind != "system": +def _is_platform_managed(root: Path, kinds: frozenset[str]) -> bool: + if any( + kind.removeprefix("library:") in PLATFORM_MANAGED_USER_ROOTS + for kind in kinds + if kind.startswith("library:") + ): + return True + if "system" not in kinds: return False if root.name in PLATFORM_MANAGED_ROOT_NAMES: return True @@ -185,13 +189,25 @@ def _is_short(value: str) -> bool: return len(_normalized_alphanumeric(value)) < 4 -def _is_excluded(path: Path, kind: str) -> bool: - if kind == "home": - return path.name in PROTECTED_HOME_ENTRIES | SHARED_HOME_ENTRIES - if kind == "xdg:XDG_CACHE_HOME": - return path.name in SHARED_CACHE_ENTRIES - if kind == "library:Caches": - return path.name in SHARED_LIBRARY_CACHE_ENTRIES +def _is_cache_root(root: Path, kinds: frozenset[str]) -> bool: + return ( + "xdg:XDG_CACHE_HOME" in kinds + or "library:Caches" in kinds + or ("system" in kinds and root.name == "Caches") + ) + + +def _is_excluded(path: Path, root: Path, kinds: frozenset[str]) -> bool: + if ( + "home" in kinds + and path.name in PROTECTED_HOME_ENTRIES | SHARED_HOME_ENTRIES + ): + return True + if ( + _is_cache_root(root, kinds) + and path.name in SHARED_CACHE_ENTRIES | SHARED_LIBRARY_CACHE_ENTRIES + ): + return True return False @@ -233,18 +249,24 @@ def is_approved_residual_path(path: Path) -> bool: else home / XDG_ROOTS["XDG_CACHE_HOME"] ) cache_root = Path(os.path.abspath(str(cache_root))) - if candidate.parent == cache_root and candidate.name in SHARED_CACHE_ENTRIES: - return False + default_cache_roots = { + cache_root, + home / "Library/Caches", + Path("/Library/Caches"), + } if ( - candidate.parent == home / "Library/Caches" - and candidate.name in SHARED_LIBRARY_CACHE_ENTRIES + candidate.parent in default_cache_roots + and candidate.name in SHARED_CACHE_ENTRIES | SHARED_LIBRARY_CACHE_ENTRIES ): return False if candidate.parent.is_relative_to(home / "Library"): relative = str(candidate.parent.relative_to(home / "Library")) if relative in PLATFORM_MANAGED_USER_ROOTS: return False - if candidate.parent in SYSTEM_ROOTS and _is_platform_managed(candidate.parent, "system"): + if candidate.parent in SYSTEM_ROOTS and _is_platform_managed( + candidate.parent, + frozenset({"system"}), + ): return False return True @@ -300,7 +322,7 @@ def __init__( self.home = home or Path.home() self.system_roots = SYSTEM_ROOTS if system_roots is None else system_roots - def _roots(self) -> tuple[tuple[Path, bool, str], ...]: + def _roots(self) -> tuple[tuple[Path, bool, frozenset[str]], ...]: roots: list[tuple[Path, bool, str]] = [ (self.home / "Library" / relative, False, f"library:{relative}") for relative in USER_LIBRARY_ROOTS @@ -315,33 +337,45 @@ def _roots(self) -> tuple[tuple[Path, bool, str], ...]: )) roots.extend((root, True, "system") for root in self.system_roots) - unique: list[tuple[Path, bool, str]] = [] - seen: set[Path] = set() - for root in roots: - if root[0] not in seen: - seen.add(root[0]) - unique.append(root) - return tuple(unique) + merged: dict[Path, tuple[bool, set[str]]] = {} + order: list[Path] = [] + for root, system_level, kind in roots: + root = Path(os.path.abspath(os.path.expanduser(str(root)))) + current = merged.get(root) + if current is None: + merged[root] = (system_level, {kind}) + order.append(root) + continue + current_system_level, kinds = current + kinds.add(kind) + merged[root] = (current_system_level or system_level, kinds) + return tuple( + (root, merged[root][0], frozenset(merged[root][1])) + for root in order + ) def scan(self, identity: ResidualIdentity) -> ResidualScan: result = ResidualScan() - for root, system_level, kind in self._roots(): + for root, system_level, kinds in self._roots(): try: children = list(root.iterdir()) except FileNotFoundError: continue except OSError: - result.inaccessible_roots.append(root) + if root not in result.inaccessible_roots: + result.inaccessible_roots.append(root) continue for path in children: - if _is_excluded(path, kind): + if _is_excluded(path, root, kinds): continue try: metadata = path.lstat() except OSError: + if root not in result.inaccessible_roots: + result.inaccessible_roots.append(root) continue - evidence, confirmed = self._match(path, metadata, kind, identity) + evidence, confirmed = self._match(path, metadata, kinds, identity) if evidence is None: continue candidate = ResidualCandidate( @@ -352,7 +386,7 @@ def scan(self, identity: ResidualIdentity) -> ResidualScan: metadata.st_ino, stat.S_IFMT(metadata.st_mode), ) - if confirmed and not _is_platform_managed(root, kind): + if confirmed and not _is_platform_managed(root, kinds): result.confirmed.append(candidate) else: result.review_only.append(candidate) @@ -382,21 +416,21 @@ def _match( self, path: Path, metadata: os.stat_result, - kind: str, + kinds: frozenset[str], identity: ResidualIdentity, ) -> tuple[str | None, bool]: name = path.name package_names = identity.package_ids | frozenset( package_id.rsplit("/", 1)[-1] for package_id in identity.package_ids ) - if kind == "local-bin": + if "local-bin" in kinds: is_executable = stat.S_ISREG(metadata.st_mode) and bool(metadata.st_mode & 0o111) if name in identity.executables and (is_executable or stat.S_ISLNK(metadata.st_mode)): second_signal = bool(identity.bundle_ids or identity.package_ids) return f"executable {name}", second_signal or not _is_short(name) return None, False - if kind == "home": + if "home" in kinds: if not name.startswith("."): return None, False display_name = _human_basename(name) @@ -411,11 +445,22 @@ def _match( return bundle_evidence, True normalized_name = _human_basename(name) - if kind.startswith("xdg:") or kind.startswith("library:") or kind == "system": + if any( + kind.startswith("xdg:") or kind.startswith("library:") or kind == "system" + for kind in kinds + ): for package_id in identity.package_ids: package_leaf = package_id.rsplit("/", 1)[-1] if name in {package_id, package_leaf}: - return f"package identifier {package_id}", not _is_short(name) + app_name_with_bundle = bool(identity.bundle_ids) and any( + normalized_name + == unicodedata.normalize("NFC", identity_name).casefold() + for identity_name in identity.names + ) + return ( + f"package identifier {package_id}", + app_name_with_bundle or not _is_short(name), + ) for identity_name in identity.names - package_names: if normalized_name == unicodedata.normalize("NFC", identity_name).casefold(): diff --git a/tests/test_residual_scanner.py b/tests/test_residual_scanner.py index 7beab38..e0d2192 100644 --- a/tests/test_residual_scanner.py +++ b/tests/test_residual_scanner.py @@ -102,6 +102,44 @@ def test_scan_discovers_seven_poe_groups_without_a_rule(tmp_path: Path) -> None: assert {candidate.path for candidate in scan.confirmed} == expected +def test_merged_application_and_cask_identity_confirms_all_poe_groups( + tmp_path: Path, +) -> None: + app = tmp_path / "Applications/Poe.app" + write_bundle( + app, + CFBundleIdentifier="com.quora.poe.electron", + CFBundleName="Poe", + CFBundleExecutable="Poe", + ) + home = tmp_path / "home" + expected = set() + for relative, is_directory in POE_RELATIVE_PATHS: + path = home / relative + if is_directory: + path.mkdir(parents=True) + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("poe", encoding="utf-8") + expected.add(path) + identity = capture_identity( + Software( + "Poe", + {Source.APPLICATION, Source.HOMEBREW_CASK}, + {"Poe"}, + {app}, + ), + None, + ) + + scan = ResidualScanner(home=home, system_roots=()).scan(identity) + + assert identity.package_ids == frozenset({"Poe"}) + assert identity.bundle_ids == frozenset({"com.quora.poe.electron"}) + assert {candidate.path for candidate in scan.confirmed} == expected + assert not scan.review_only + + def test_scan_never_enters_unmatched_chrome_directory(tmp_path: Path, monkeypatch) -> None: google = tmp_path / "Library/Application Support/Google" chrome = google / "Chrome/Default/IndexedDB" @@ -280,8 +318,29 @@ def test_shared_cache_exclusions_apply_to_custom_xdg_root( assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} -def test_shared_homebrew_cache_is_excluded(tmp_path: Path) -> None: - target = tmp_path / "Library/Caches/Homebrew" +@pytest.mark.parametrize( + "name", + ("Homebrew", "npm", "pnpm", "cargo", "go-build", "uv"), +) +def test_shared_user_library_caches_are_excluded(tmp_path: Path, name: str) -> None: + target = tmp_path / "Library/Caches" / name + target.mkdir(parents=True) + identity = ResidualIdentity( + frozenset({name}), + frozenset(), + frozenset({name}), + frozenset(), + frozenset({Source.HOMEBREW_FORMULA}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} + + +def test_shared_system_cache_is_excluded(tmp_path: Path) -> None: + system_cache = tmp_path / "Library/Caches" + target = system_cache / "Homebrew" target.mkdir(parents=True) identity = ResidualIdentity( frozenset({"Homebrew"}), @@ -291,7 +350,10 @@ def test_shared_homebrew_cache_is_excluded(tmp_path: Path) -> None: frozenset({Source.HOMEBREW_FORMULA}), ) - scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + scan = ResidualScanner( + home=tmp_path / "home", + system_roots=(system_cache,), + ).scan(identity) assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} @@ -410,6 +472,39 @@ def failing_iterdir(path: Path): assert target in {item.path for item in scan.confirmed} +def test_child_lstat_failure_marks_root_inaccessible_and_continues( + tmp_path: Path, + monkeypatch, +) -> None: + root = tmp_path / "Library/Preferences" + blocked = {root / "blocked-one", root / "blocked-two"} + target = root / "com.vendor.example.plist" + root.mkdir(parents=True) + for path in blocked: + path.write_text("blocked", encoding="utf-8") + target.write_text("ok", encoding="utf-8") + original = Path.lstat + + def failing_lstat(path: Path): + if path in blocked: + raise PermissionError("denied") + return original(path) + + monkeypatch.setattr(Path, "lstat", failing_lstat) + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset({"com.vendor.example"}), + frozenset(), + frozenset(), + frozenset({Source.APPLICATION}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert scan.inaccessible_roots.count(root) == 1 + assert target in {item.path for item in scan.confirmed} + + def test_nonexistent_root_is_skipped_but_existing_file_root_is_reported( tmp_path: Path, ) -> None: @@ -448,6 +543,63 @@ def test_group_containers_and_platform_managed_roots_are_review_only( assert {item.path for item in scan.review_only} == {group, extension} +def test_xdg_collision_preserves_system_scope(tmp_path: Path, monkeypatch) -> None: + system_root = tmp_path / "Library/LaunchAgents" + target = system_root / "com.quora.poe.electron.plist" + target.parent.mkdir(parents=True) + target.write_text("label", encoding="utf-8") + monkeypatch.setenv("XDG_CONFIG_HOME", str(system_root)) + + scan = ResidualScanner( + home=tmp_path / "home", + system_roots=(system_root,), + ).scan(poe_identity()) + + assert {item.path for item in scan.confirmed} == {target} + assert scan.confirmed[0].system_level is True + + +def test_xdg_collision_preserves_platform_managed_review_only( + tmp_path: Path, + monkeypatch, +) -> None: + extension_root = tmp_path / "Library/Extensions" + target = extension_root / "com.quora.poe.electron" + target.mkdir(parents=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(extension_root)) + + scan = ResidualScanner( + home=tmp_path / "home", + system_roots=(extension_root,), + ).scan(poe_identity()) + + assert not scan.confirmed + assert {item.path for item in scan.review_only} == {target} + assert scan.review_only[0].system_level is True + + +def test_xdg_config_cache_collision_preserves_cache_exclusions( + tmp_path: Path, + monkeypatch, +) -> None: + shared_root = tmp_path / "shared-root" + target = shared_root / "uv" + target.mkdir(parents=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(shared_root)) + monkeypatch.setenv("XDG_CACHE_HOME", str(shared_root)) + identity = ResidualIdentity( + frozenset({"uv"}), + frozenset(), + frozenset({"uv"}), + frozenset(), + frozenset({Source.UV}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} + + def test_running_pids_match_exact_and_distinctive_substrings(monkeypatch, tmp_path: Path) -> None: class FakeProcess: def __init__(self, pid: int, name: str, cmdline: list[str]) -> None: @@ -530,3 +682,18 @@ def test_approved_residual_path_preserves_excluded_and_managed_entries( tmp_path / "Library/Group Containers/com.vendor.example" ) assert not is_approved_residual_path(Path("/Library/Extensions/example.kext")) + + +@pytest.mark.parametrize( + "name", + ("Homebrew", "npm", "pnpm", "cargo", "go-build", "uv"), +) +def test_approved_residual_path_rejects_shared_default_caches( + tmp_path: Path, + monkeypatch, + name: str, +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + + assert not is_approved_residual_path(tmp_path / "Library/Caches" / name) + assert not is_approved_residual_path(Path("/Library/Caches") / name) From 69620b2b3c48364597548e015aa2527cf3f862cb Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:51:38 +0800 Subject: [PATCH 10/22] Add safe residual deletion --- cleanapp/remover.py | 29 ++++++- tests/test_analyzer_remover.py | 141 ++++++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/cleanapp/remover.py b/cleanapp/remover.py index a8643c2..77a3534 100644 --- a/cleanapp/remover.py +++ b/cleanapp/remover.py @@ -3,12 +3,14 @@ import logging from pathlib import Path import shutil +import stat import subprocess import time import psutil -from .models import Analysis, CleanupResult, Source +from .models import Analysis, CleanupResult, ResidualCandidate, Source +from .residual_scanner import is_approved_residual_path from .safety import UnsafePathError, validate_deletion log = logging.getLogger("cleanapp.remover") @@ -47,6 +49,31 @@ def remove(self, analysis: Analysis, dry_run: bool = False) -> CleanupResult: ) return result + def remove_residuals(self, name: str, candidates: list[ResidualCandidate]) -> CleanupResult: + started = time.monotonic() + result = CleanupResult(name, False) + for candidate in candidates: + try: + safe = validate_deletion(candidate.path) + if not is_approved_residual_path(safe): + raise UnsafePathError(f"outside approved residual roots: {safe}") + current = safe.lstat() + identity = (current.st_dev, current.st_ino, stat.S_IFMT(current.st_mode)) + previewed = (candidate.device, candidate.inode, candidate.file_type) + if identity != previewed: + raise UnsafePathError(f"changed after preview: {safe}") + except (OSError, UnsafePathError) as exc: + result.failures.append(f"delete {candidate.path}: {exc}") + continue + self._remove_path(safe, result) + result.remaining_paths = [ + candidate.path + for candidate in candidates + if candidate.path.exists() or candidate.path.is_symlink() + ] + result.duration_seconds = time.monotonic() - started + return result + @staticmethod def _stop_processes(pids: list[int], result: CleanupResult) -> None: processes: list[psutil.Process] = [] diff --git a/tests/test_analyzer_remover.py b/tests/test_analyzer_remover.py index e5476e8..2d7a8f4 100644 --- a/tests/test_analyzer_remover.py +++ b/tests/test_analyzer_remover.py @@ -1,10 +1,26 @@ +from dataclasses import replace from pathlib import Path +import stat + +import pytest from cleanapp.analyzer import Analyzer, permission_limited -from cleanapp.models import Analysis, CleanupResult, Rule, Software, Source +from cleanapp.models import Analysis, CleanupResult, ResidualCandidate, Rule, Software, Source from cleanapp.remover import Remover +def residual_candidate(path: Path) -> ResidualCandidate: + metadata = path.lstat() + return ResidualCandidate( + path, + "test identity", + False, + metadata.st_dev, + metadata.st_ino, + stat.S_IFMT(metadata.st_mode), + ) + + def test_analyze_and_remove_directory(tmp_path: Path, monkeypatch) -> None: data = tmp_path / "example-data" data.mkdir() @@ -51,6 +67,129 @@ def test_removing_symlink_preserves_target(tmp_path: Path, monkeypatch) -> None: assert result.failures == [] +def test_remove_residuals_unlinks_symlink_without_deleting_target(tmp_path: Path, monkeypatch) -> None: + target = tmp_path / "target.txt" + target.write_text("keep", encoding="utf-8") + link = tmp_path / "example-link" + link.symlink_to(target) + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + + result = Remover().remove_residuals("Example", [residual_candidate(link)]) + + assert not link.exists() + assert target.read_text(encoding="utf-8") == "keep" + assert not result.failures + + +def test_remove_residuals_refuses_replaced_inode(tmp_path: Path, monkeypatch) -> None: + target = tmp_path / "example.plist" + target.write_text("old", encoding="utf-8") + candidate = residual_candidate(target) + target.rename(tmp_path / "old-example.plist") + target.write_text("new", encoding="utf-8") + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + + result = Remover().remove_residuals("Example", [candidate]) + + assert target.read_text(encoding="utf-8") == "new" + assert any("changed after preview" in failure for failure in result.failures) + + +@pytest.mark.parametrize( + ("field", "value"), + [("device", -1), ("file_type", stat.S_IFDIR)], +) +def test_remove_residuals_refuses_changed_stat_identity( + tmp_path: Path, + monkeypatch, + field: str, + value: int, +) -> None: + target = tmp_path / "example.plist" + target.write_text("keep", encoding="utf-8") + candidate = replace(residual_candidate(target), **{field: value}) + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + + result = Remover().remove_residuals("Example", [candidate]) + + assert target.read_text(encoding="utf-8") == "keep" + assert any("changed after preview" in failure for failure in result.failures) + + +def test_remove_residuals_continues_after_one_failure(tmp_path: Path, monkeypatch) -> None: + changed = tmp_path / "changed" + good = tmp_path / "good" + changed.write_text("old", encoding="utf-8") + good.write_text("delete", encoding="utf-8") + changed_candidate = residual_candidate(changed) + good_candidate = residual_candidate(good) + changed.rename(tmp_path / "old-changed") + changed.write_text("replacement", encoding="utf-8") + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + + result = Remover().remove_residuals("Example", [changed_candidate, good_candidate]) + + assert changed.exists() + assert not good.exists() + assert len(result.failures) == 1 + + +def test_remove_residuals_records_remove_path_failure_once_and_continues( + tmp_path: Path, + monkeypatch, +) -> None: + blocked = tmp_path / "blocked" + good = tmp_path / "good" + blocked.write_text("keep", encoding="utf-8") + good.write_text("delete", encoding="utf-8") + original_unlink = Path.unlink + + def fake_unlink(path: Path, *args, **kwargs) -> None: + if path == blocked: + raise PermissionError("blocked") + original_unlink(path, *args, **kwargs) + + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + monkeypatch.setattr(Path, "unlink", fake_unlink) + + result = Remover().remove_residuals( + "Example", + [residual_candidate(blocked), residual_candidate(good)], + ) + + assert blocked.exists() + assert not good.exists() + assert len(result.failures) == 1 + + +def test_remove_residuals_refuses_path_outside_approved_roots(tmp_path: Path, monkeypatch) -> None: + target = tmp_path / "example" + target.write_text("keep", encoding="utf-8") + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: False) + + result = Remover().remove_residuals("Example", [residual_candidate(target)]) + + assert target.read_text(encoding="utf-8") == "keep" + assert any("outside approved residual roots" in failure for failure in result.failures) + + +def test_remove_residuals_never_stops_processes_or_invokes_package_manager( + tmp_path: Path, + monkeypatch, +) -> None: + target = tmp_path / "example" + target.write_text("delete", encoding="utf-8") + monkeypatch.setattr("cleanapp.remover.is_approved_residual_path", lambda _: True) + monkeypatch.setattr(Remover, "_stop_processes", lambda *_: pytest.fail("process stop")) + monkeypatch.setattr(Remover, "_remove_packages", lambda *_: pytest.fail("package manager rerun")) + + result = Remover().remove_residuals("Example", [residual_candidate(target)]) + + assert not target.exists() + assert result.stopped_processes == 0 + assert not result.package_actions + + def test_unknown_npm_package_uses_detected_identifier(monkeypatch) -> None: software = Software("example cli", {Source.NPM}, {"@vendor/example-cli"}) analysis = Analysis(software, None, [], [], 0, 0, 0) From 08dd19068969ad9cb56ecc0de0b518c23d16a01d Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:00:28 +0800 Subject: [PATCH 11/22] Report residual verification truthfully --- cleanapp/reporter.py | 123 +++++++++++++++++- tests/test_reporter.py | 286 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 tests/test_reporter.py diff --git a/cleanapp/reporter.py b/cleanapp/reporter.py index d34126e..f90c151 100644 --- a/cleanapp/reporter.py +++ b/cleanapp/reporter.py @@ -1,9 +1,12 @@ from __future__ import annotations +from pathlib import Path + from rich.console import Console from rich.table import Table -from .models import Analysis, CleanupResult, Software +from .analyzer import measure, permission_limited +from .models import Analysis, CleanupResult, ResidualCandidate, ResidualScan, Software def human_bytes(value: int) -> str: @@ -46,6 +49,124 @@ def show_preview(console: Console, analysis: Analysis, limit: int | None = 10) - console.print(f"\n[dim]... {len(analysis.paths) - limit} more (type 'all' to view all)[/]") +def _show_candidates(console: Console, candidates: list[ResidualCandidate]) -> None: + if not candidates: + console.print("[dim]None[/]") + return + for candidate in candidates: + try: + files, directories, size = measure([candidate.path]) + measurement = f"{human_bytes(size)}, {files} files, {directories} folders" + except OSError: + measurement = "unknown size, unknown file/folder counts" + scope = "[admin]" if candidate.system_level else "[user]" + permission = "limited" if permission_limited([candidate.path]) else "available" + console.print( + f"- {scope} {measurement}, permission: {permission} — {candidate.path}", + markup=False, + ) + console.print(f" Evidence: {candidate.evidence}", markup=False) + + +def show_residual_scan(console: Console, scan: ResidualScan, heading: str) -> None: + console.print(f"\n[bold]{heading}[/]") + console.print("\n[bold]Confirmed deletable:[/]") + _show_candidates(console, scan.confirmed) + console.print("\n[bold]Review only (will NOT be deleted by Y):[/]") + _show_candidates(console, scan.review_only) + console.print("\n[bold]Unverified:[/]") + if not scan.inaccessible_roots: + console.print("[dim]None[/]") + for root in scan.inaccessible_roots: + console.print(f"- {root} — permission denied or unreadable") + + +def _show_paths(console: Console, paths: list[Path]) -> None: + if not paths: + console.print("[dim]None[/]") + return + for path in paths: + console.print(f"- {path}") + + +def _show_failures(console: Console, failures: list[str]) -> None: + if not failures: + console.print("[dim]None[/]") + return + for failure in failures: + console.print(f"- {failure}") + + +def show_verification_result( + console: Console, + name: str, + primary: CleanupResult, + residual: CleanupResult | None, + verification: ResidualScan, + original_remaining: list[Path], +) -> None: + console.rule() + console.print(f"[bold]{name} verification result[/]") + + console.print("\n[bold]Primary cleanup failures:[/]") + _show_failures(console, primary.failures) + console.print("\n[bold]Residual cleanup failures:[/]") + residual_failures = residual.failures if residual else [] + _show_failures(console, residual_failures) + + console.print("\n[bold]Confirmed residual paths:[/]") + _show_candidates(console, verification.confirmed) + console.print("\n[bold]Manual-review paths:[/]") + _show_candidates(console, verification.review_only) + console.print("\n[bold]Inaccessible roots:[/]") + _show_paths(console, verification.inaccessible_roots) + console.print("\n[bold]Original planned paths still present:[/]") + _show_paths(console, original_remaining) + console.print("\n[bold]Running PIDs:[/]") + if verification.running_pids: + for pid in verification.running_pids: + console.print(f"- {pid}") + else: + console.print("[dim]None[/]") + + failures_remain = bool(primary.failures or residual_failures) + only_review_remains = bool(verification.review_only) and not any(( + verification.confirmed, + verification.inaccessible_roots, + original_remaining, + verification.running_pids, + failures_remain, + )) + no_remaining_dimensions = not any(( + verification.confirmed, + verification.review_only, + verification.inaccessible_roots, + original_remaining, + verification.running_pids, + failures_remain, + )) + + console.print("\n[bold]Verification status:[/]") + if no_remaining_dimensions: + console.print("No confirmed residuals remain in the supported, successfully scanned locations.") + elif only_review_remains: + console.print("All confirmed residuals were removed, but manual-review items remain.") + else: + if verification.confirmed: + console.print("Confirmed residual paths remain.") + if verification.review_only: + console.print("Manual-review items remain.") + if verification.inaccessible_roots: + console.print("Verification scan was incomplete because some roots were inaccessible.") + if original_remaining: + console.print("Original planned paths remain.") + if verification.running_pids: + console.print("Related processes are still running.") + if failures_remain: + console.print("Cleanup failures remain; review the failure groups above.") + console.rule() + + def show_result(console: Console, result: CleanupResult) -> None: console.rule() state = "Dry run completed" if result.dry_run else ("removed successfully" if not result.failures else "completed with warnings") diff --git a/tests/test_reporter.py b/tests/test_reporter.py new file mode 100644 index 0000000..dedf263 --- /dev/null +++ b/tests/test_reporter.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from pathlib import Path +import stat + +import pytest +from rich.console import Console + +from cleanapp.models import ( + Analysis, + CleanupResult, + ResidualCandidate, + ResidualScan, + Software, + Source, +) +from cleanapp.reporter import ( + show_preview, + show_residual_scan, + show_result, + show_verification_result, +) + + +SUCCESS = "No confirmed residuals remain in the supported, successfully scanned locations." + + +def residual_candidate( + path: Path, + evidence: str, + *, + system_level: bool = False, +) -> ResidualCandidate: + metadata = path.lstat() + return ResidualCandidate( + path=path, + evidence=evidence, + system_level=system_level, + device=metadata.st_dev, + inode=metadata.st_ino, + file_type=stat.S_IFMT(metadata.st_mode), + ) + + +def synthetic_candidate(path: Path, evidence: str = "exact identity") -> ResidualCandidate: + return ResidualCandidate(path, evidence, False, 1, 2, stat.S_IFREG) + + +def rendered(console: Console) -> str: + return console.export_text() + + +def test_show_residual_scan_groups_candidates_with_measurement_and_status( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + confirmed_path = tmp_path / "confirmed" + confirmed_path.write_text("x", encoding="utf-8") + review_path = tmp_path / "review" + review_path.mkdir() + (review_path / "data").write_text("x", encoding="utf-8") + scan = ResidualScan( + confirmed=[residual_candidate(confirmed_path, "bundle identifier com.vendor.app")], + review_only=[ + residual_candidate(review_path, "display name only", system_level=True), + ], + inaccessible_roots=[tmp_path / "blocked"], + ) + monkeypatch.setattr( + "cleanapp.reporter.permission_limited", + lambda paths: [review_path] if paths == [review_path] else [], + ) + + console = Console(record=True, width=140) + show_residual_scan(console, scan, "Residual scan #2") + output = rendered(console) + + assert "Residual scan #2" in output + assert "Confirmed deletable" in output + assert "Review only (will NOT be deleted by Y)" in output + assert "Unverified" in output + assert "[user]" in output + assert "[admin]" in output + assert "1 B, 1 files, 0 folders" in output + assert "1 B, 1 files, 1 folders" in output + assert "permission: available" in output + assert "permission: limited" in output + assert "bundle identifier com.vendor.app" in output + assert "display name only" in output + assert str(tmp_path / "blocked") in output + assert "permission denied or unreadable" in output + + +def test_show_residual_scan_displays_unknown_when_measurement_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + candidate_path = tmp_path / "candidate" + candidate_path.write_text("x", encoding="utf-8") + scan = ResidualScan( + confirmed=[residual_candidate(candidate_path, "exact package identity")], + ) + + def fail_measurement(paths: list[Path]) -> tuple[int, int, int]: + raise OSError("cannot measure") + + monkeypatch.setattr("cleanapp.reporter.measure", fail_measurement) + console = Console(record=True, width=140) + show_residual_scan(console, scan, "Residual scan #2") + output = rendered(console) + + assert "unknown size, unknown file/folder counts" in output + assert str(candidate_path) in output + assert "exact package identity" in output + + +def test_verification_success_requires_all_verified_dimensions_empty() -> None: + console = Console(record=True, width=140) + show_verification_result( + console, + "Example", + CleanupResult("Example", False), + None, + ResidualScan(), + [], + ) + + assert SUCCESS in rendered(console) + + +@pytest.mark.parametrize( + ("primary", "residual", "scan", "original"), + ( + ( + CleanupResult("Example", False), + None, + ResidualScan(confirmed=[synthetic_candidate(Path("/confirmed"))]), + [], + ), + ( + CleanupResult("Example", False), + None, + ResidualScan(review_only=[synthetic_candidate(Path("/review"))]), + [], + ), + ( + CleanupResult("Example", False), + None, + ResidualScan(inaccessible_roots=[Path("/blocked")]), + [], + ), + ( + CleanupResult("Example", False), + None, + ResidualScan(), + [Path("/remaining")], + ), + ( + CleanupResult("Example", False), + None, + ResidualScan(running_pids=[123]), + [], + ), + ( + CleanupResult("Example", False, failures=["primary failed"]), + None, + ResidualScan(), + [], + ), + ( + CleanupResult("Example", False), + CleanupResult("Example", False, failures=["residual failed"]), + ResidualScan(), + [], + ), + ), + ids=( + "confirmed residual", + "manual review", + "inaccessible root", + "original path", + "running PID", + "primary failure", + "residual failure", + ), +) +def test_verification_never_claims_success_when_incomplete( + primary: CleanupResult, + residual: CleanupResult | None, + scan: ResidualScan, + original: list[Path], +) -> None: + console = Console(record=True, width=140) + show_verification_result(console, "Example", primary, residual, scan, original) + + assert SUCCESS not in rendered(console) + + +def test_verification_reports_each_remaining_dimension() -> None: + scan = ResidualScan( + confirmed=[synthetic_candidate(Path("/confirmed"), "confirmed evidence")], + review_only=[synthetic_candidate(Path("/review"), "review evidence")], + inaccessible_roots=[Path("/blocked")], + running_pids=[123, 456], + ) + console = Console(record=True, width=140) + show_verification_result( + console, + "Example", + CleanupResult("Example", False), + None, + scan, + [Path("/original")], + ) + output = rendered(console) + + assert "Confirmed residual paths" in output + assert "/confirmed" in output + assert "Manual-review paths" in output + assert "/review" in output + assert "Inaccessible roots" in output + assert "/blocked" in output + assert "Original planned paths still present" in output + assert "/original" in output + assert "Running PIDs" in output + assert "123" in output + assert "456" in output + assert "Verification scan was incomplete" in output + + +def test_verification_reports_primary_and_residual_failures_separately() -> None: + primary = CleanupResult("Example", False, failures=["brew uninstall failed"]) + residual = CleanupResult("Example", False, failures=["delete residual failed"]) + console = Console(record=True, width=140) + show_verification_result(console, "Example", primary, residual, ResidualScan(), []) + output = rendered(console) + + assert "Primary cleanup failures" in output + assert "brew uninstall failed" in output + assert "Residual cleanup failures" in output + assert "delete residual failed" in output + assert SUCCESS not in output + + +def test_verification_reports_manual_review_without_deletion_claim(tmp_path: Path) -> None: + path = tmp_path / ".go" + path.mkdir() + console = Console(record=True, width=140) + show_verification_result( + console, + "go", + CleanupResult("go", False), + None, + ResidualScan(review_only=[residual_candidate(path, "short name")]), + [], + ) + output = rendered(console) + + assert "All confirmed residuals were removed, but manual-review items remain." in output + assert str(path) in output + assert SUCCESS not in output + + +def test_existing_dry_run_preview_and_result_rendering_are_preserved(tmp_path: Path) -> None: + target = tmp_path / "Example.app" + analysis = Analysis( + software=Software("Example", {Source.APPLICATION}, paths={target}), + rule=None, + paths=[target], + running_pids=[], + file_count=2, + directory_count=1, + total_bytes=1024, + ) + console = Console(record=True, width=140) + + show_preview(console, analysis) + show_result(console, CleanupResult("Example", True)) + output = rendered(console) + + assert "Software: Example" in output + assert "Estimated: 2 files, 1 folders, 1.0 KB" in output + assert f"1. {target}" in output + assert "Example Dry run completed" in output + assert "No process, package, or file was changed." in output + assert "Remaining: no paths, no running process" in output From a7aee00a8c858b98fd66b4b14320a12e59dd9271 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:15:12 +0800 Subject: [PATCH 12/22] Fix residual report accuracy --- cleanapp/reporter.py | 35 ++++++++++++++++++++++++----- tests/test_reporter.py | 50 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/cleanapp/reporter.py b/cleanapp/reporter.py index f90c151..77c04ab 100644 --- a/cleanapp/reporter.py +++ b/cleanapp/reporter.py @@ -1,11 +1,13 @@ from __future__ import annotations +import os from pathlib import Path +import stat from rich.console import Console from rich.table import Table -from .analyzer import measure, permission_limited +from .analyzer import permission_limited from .models import Analysis, CleanupResult, ResidualCandidate, ResidualScan, Software @@ -49,13 +51,36 @@ def show_preview(console: Console, analysis: Analysis, limit: int | None = 10) - console.print(f"\n[dim]... {len(analysis.paths) - limit} more (type 'all' to view all)[/]") +def _raise_walk_error(error: OSError) -> None: + raise error + + +def _measure_candidate(path: Path) -> tuple[int, int, int]: + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode) or stat.S_ISREG(metadata.st_mode): + return 1, 0, metadata.st_size + if not stat.S_ISDIR(metadata.st_mode): + return 0, 0, 0 + + files = total = 0 + directories = 1 + for root, dirs, names in os.walk(path, followlinks=False, onerror=_raise_walk_error): + directories += len(dirs) + for name in dirs: + Path(root, name).lstat() + for name in names: + files += 1 + total += Path(root, name).lstat().st_size + return files, directories, total + + def _show_candidates(console: Console, candidates: list[ResidualCandidate]) -> None: if not candidates: console.print("[dim]None[/]") return for candidate in candidates: try: - files, directories, size = measure([candidate.path]) + files, directories, size = _measure_candidate(candidate.path) measurement = f"{human_bytes(size)}, {files} files, {directories} folders" except OSError: measurement = "unknown size, unknown file/folder counts" @@ -78,7 +103,7 @@ def show_residual_scan(console: Console, scan: ResidualScan, heading: str) -> No if not scan.inaccessible_roots: console.print("[dim]None[/]") for root in scan.inaccessible_roots: - console.print(f"- {root} — permission denied or unreadable") + console.print(f"- {root} — permission denied or unreadable", markup=False) def _show_paths(console: Console, paths: list[Path]) -> None: @@ -86,7 +111,7 @@ def _show_paths(console: Console, paths: list[Path]) -> None: console.print("[dim]None[/]") return for path in paths: - console.print(f"- {path}") + console.print(f"- {path}", markup=False) def _show_failures(console: Console, failures: list[str]) -> None: @@ -94,7 +119,7 @@ def _show_failures(console: Console, failures: list[str]) -> None: console.print("[dim]None[/]") return for failure in failures: - console.print(f"- {failure}") + console.print(f"- {failure}", markup=False) def show_verification_result( diff --git a/tests/test_reporter.py b/tests/test_reporter.py index dedf263..1b6e54f 100644 --- a/tests/test_reporter.py +++ b/tests/test_reporter.py @@ -96,24 +96,43 @@ def test_show_residual_scan_displays_unknown_when_measurement_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: candidate_path = tmp_path / "candidate" - candidate_path.write_text("x", encoding="utf-8") + candidate_path.mkdir() + child = candidate_path / "data" + child.write_text("x", encoding="utf-8") scan = ResidualScan( confirmed=[residual_candidate(candidate_path, "exact package identity")], ) + original_lstat = Path.lstat - def fail_measurement(paths: list[Path]) -> tuple[int, int, int]: - raise OSError("cannot measure") + def fail_child_lstat(path: Path) -> object: + if path == child: + raise PermissionError("cannot measure child") + return original_lstat(path) - monkeypatch.setattr("cleanapp.reporter.measure", fail_measurement) + monkeypatch.setattr(Path, "lstat", fail_child_lstat) console = Console(record=True, width=140) show_residual_scan(console, scan, "Residual scan #2") output = rendered(console) assert "unknown size, unknown file/folder counts" in output + assert "0 B, 1 files, 1 folders" not in output assert str(candidate_path) in output assert "exact package identity" in output +def test_show_residual_scan_preserves_brackets_in_inaccessible_path() -> None: + path = Path("/tmp/[name]") + console = Console(record=True, width=140) + + show_residual_scan( + console, + ResidualScan(inaccessible_roots=[path]), + "Residual scan #2", + ) + + assert str(path) in rendered(console) + + def test_verification_success_requires_all_verified_dimensions_empty() -> None: console = Console(record=True, width=140) show_verification_result( @@ -242,6 +261,29 @@ def test_verification_reports_primary_and_residual_failures_separately() -> None assert SUCCESS not in output +def test_verification_preserves_brackets_in_paths_and_failures() -> None: + inaccessible = Path("/tmp/[inaccessible]") + original = Path("/tmp/[original]") + primary = CleanupResult("Example", False, failures=["failed [detail]"]) + residual = CleanupResult("Example", False, failures=["residual [detail]"]) + console = Console(record=True, width=140) + + show_verification_result( + console, + "Example", + primary, + residual, + ResidualScan(inaccessible_roots=[inaccessible]), + [original], + ) + output = rendered(console) + + assert str(inaccessible) in output + assert str(original) in output + assert "failed [detail]" in output + assert "residual [detail]" in output + + def test_verification_reports_manual_review_without_deletion_claim(tmp_path: Path) -> None: path = tmp_path / ".go" path.mkdir() From 8bce4a0112005792cbd353f948b7908ea308c625 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:28:49 +0800 Subject: [PATCH 13/22] Wire three-stage residual cleanup --- cleanapp/cli.py | 64 +++++++++- tests/test_cli.py | 319 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 379 insertions(+), 4 deletions(-) diff --git a/cleanapp/cli.py b/cleanapp/cli.py index 6962277..b54c68f 100644 --- a/cleanapp/cli.py +++ b/cleanapp/cli.py @@ -14,8 +14,15 @@ from .analyzer import Analyzer from .logging_config import configure_logging from .models import Software, Source -from .reporter import show_preview, show_result, software_table +from .reporter import ( + show_preview, + show_residual_scan, + show_result, + show_verification_result, + software_table, +) from .remover import Remover +from .residual_scanner import ResidualScanner, capture_identity from .rule_engine import RuleEngine, normalize from .scanner import Scanner @@ -58,18 +65,69 @@ def _select(items: list[Software]) -> Software: def _confirm_and_remove(software: Software, dry_run: bool) -> None: rules = RuleEngine().load() rule = RuleEngine.match(software, rules) + identity = capture_identity(software, rule) analysis = Analyzer().analyze(software, rule) show_preview(console, analysis) if len(analysis.paths) > 10 and typer.prompt("Type 'all' to view every path, or press Enter to continue", default="", show_default=False).casefold() == "all": show_preview(console, analysis, limit=None) if dry_run: - show_result(console, Remover().remove(analysis, dry_run=True)) + primary_result = Remover().remove(analysis, dry_run=True) + current_scan = ResidualScanner().scan(identity) + primary_paths = {path.absolute() for path in analysis.paths} + current_scan.confirmed = [ + item for item in current_scan.confirmed + if item.path.absolute() not in primary_paths + ] + current_scan.review_only = [ + item for item in current_scan.review_only + if item.path.absolute() not in primary_paths + ] + show_result(console, primary_result) + show_residual_scan( + console, + current_scan, + "Currently detectable additional candidates", + ) + console.print( + "Dry run only. Post-removal verification scan was not performed because no changes were made.", + soft_wrap=True, + ) return answer = typer.prompt("Confirm deletion? (Y/N)", default="N", show_default=False) if answer.casefold() != "y": console.print("Cancelled. Nothing was changed.") raise typer.Abort() - show_result(console, Remover().remove(analysis)) + remover = Remover() + primary_result = remover.remove(analysis) + scanner = ResidualScanner() + scan_two = scanner.scan(identity) + show_residual_scan(console, scan_two, "Residual scan #2") + + residual_result = None + if scan_two.confirmed: + answer = typer.prompt( + f"Delete {len(scan_two.confirmed)} confirmed residual paths? (y/N)", + default="N", + show_default=False, + ) + if answer.casefold() == "y": + residual_result = remover.remove_residuals( + software.name, + scan_two.confirmed, + ) + + verification = scanner.scan(identity) + original_remaining = [ + path for path in analysis.paths if path.exists() or path.is_symlink() + ] + show_verification_result( + console, + software.name, + primary_result, + residual_result, + verification, + original_remaining, + ) @app.callback(invoke_without_command=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index db3282a..b7c4220 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,11 +1,140 @@ -from typer.testing import CliRunner +from dataclasses import dataclass, field +from pathlib import Path import platform +import stat + +import pytest +from typer.testing import CliRunner +import cleanapp.cli as cli from cleanapp.cli import app +from cleanapp.models import ( + Analysis, + CleanupResult, + ResidualCandidate, + ResidualIdentity, + ResidualScan, + Software, + Source, +) runner = CliRunner() +@dataclass +class RemoveHarness: + software: Software + identity: ResidualIdentity + analysis: Analysis + scans: list[ResidualScan] + primary_result: CleanupResult + residual_result: CleanupResult + events: list[str] = field(default_factory=list) + primary_calls: list[bool] = field(default_factory=list) + residual_calls: list[list[ResidualCandidate]] = field(default_factory=list) + shown_scans: list[tuple[str, ResidualScan]] = field(default_factory=list) + verification_calls: list[tuple[CleanupResult, CleanupResult | None, ResidualScan]] = field(default_factory=list) + scan_calls: int = 0 + + +def candidate(path: Path, evidence: str = "exact identity") -> ResidualCandidate: + return ResidualCandidate(path, evidence, False, 1, 2, stat.S_IFREG) + + +def scan_with_candidate(path: Path | None = None) -> ResidualScan: + return ResidualScan(confirmed=[candidate(path or Path("/virtual/example-residual"))]) + + +def install_remove_harness( + monkeypatch: pytest.MonkeyPatch, + *, + scans: list[ResidualScan], + analysis_paths: list[Path] | None = None, + primary_result: CleanupResult | None = None, + residual_result: CleanupResult | None = None, +) -> RemoveHarness: + software = Software("Example", {Source.APPLICATION}, {"com.example.app"}) + identity = ResidualIdentity( + frozenset({"example"}), + frozenset({"com.example.app"}), + frozenset(), + frozenset({"example"}), + frozenset({Source.APPLICATION}), + ) + harness = RemoveHarness( + software=software, + identity=identity, + analysis=Analysis(software, None, list(analysis_paths or []), [], 0, 0, 0), + scans=scans, + primary_result=primary_result or CleanupResult("Example", False), + residual_result=residual_result or CleanupResult("Example", False), + ) + + class FakeRuleEngine: + def load(self): + return [] + + @staticmethod + def match(item, rules): + return None + + class FakeAnalyzer: + def analyze(self, item, rule): + harness.events.append("analyze") + return harness.analysis + + class FakeRemover: + def remove(self, analysis, dry_run=False): + assert harness.events[0] == "capture" + harness.events.append("primary") + harness.primary_calls.append(dry_run) + return harness.primary_result + + def remove_residuals(self, name, candidates): + harness.events.append("residual") + harness.residual_calls.append(list(candidates)) + return harness.residual_result + + class FakeResidualScanner: + def scan(self, captured_identity): + assert captured_identity is harness.identity + harness.events.append("scan") + index = harness.scan_calls + harness.scan_calls += 1 + return harness.scans[index] + + def fake_capture_identity(item, rule): + assert item is harness.software + harness.events.append("capture") + return harness.identity + + def fake_show_residual_scan(output_console, scan, heading): + harness.shown_scans.append((heading, scan)) + + def fake_show_verification_result( + output_console, + name, + primary, + residual, + verification, + original_remaining, + ): + harness.verification_calls.append((primary, residual, verification)) + + monkeypatch.setattr(cli, "RuleEngine", FakeRuleEngine) + monkeypatch.setattr(cli, "Analyzer", FakeAnalyzer) + monkeypatch.setattr(cli, "Remover", FakeRemover) + monkeypatch.setattr(cli, "ResidualScanner", FakeResidualScanner, raising=False) + monkeypatch.setattr(cli, "capture_identity", fake_capture_identity, raising=False) + monkeypatch.setattr(cli, "show_preview", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "show_result", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "show_residual_scan", fake_show_residual_scan, raising=False) + monkeypatch.setattr(cli, "show_verification_result", fake_show_verification_result, raising=False) + monkeypatch.setattr(cli, "configure_logging", lambda: None) + monkeypatch.setattr(cli, "_scan", lambda: [harness.software]) + return harness + + def test_help() -> None: result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 @@ -32,3 +161,191 @@ def test_doctor_reports_runtime() -> None: assert "Python:" in result.stdout assert "Log directory:" in result.stdout assert "Optional package managers:" in result.stdout + + +@pytest.mark.parametrize( + ("arguments", "input_text"), + ((["remove", "example"], "y\n"), ([], "1\ny\n")), + ids=("named", "interactive"), +) +def test_all_remove_entrypoints_capture_identity_before_analysis_and_primary_remove( + monkeypatch: pytest.MonkeyPatch, + arguments: list[str], + input_text: str, +) -> None: + harness = install_remove_harness( + monkeypatch, + scans=[ResidualScan(), ResidualScan()], + ) + + result = runner.invoke(app, arguments, input=input_text) + + assert result.exit_code == 0 + assert harness.events[:3] == ["capture", "analyze", "primary"] + assert harness.primary_calls == [False] + assert harness.scan_calls == 2 + + +def test_declining_primary_removal_skips_primary_remove_and_residual_scans( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = install_remove_harness(monkeypatch, scans=[]) + + result = runner.invoke(app, ["remove", "example"], input="n\n") + + assert result.exit_code == 1 + assert "Cancelled. Nothing was changed." in result.stdout + assert harness.events == ["capture", "analyze"] + assert harness.primary_calls == [] + assert harness.scan_calls == 0 + + +@pytest.mark.parametrize("answer", ("y", "Y")) +def test_real_remove_accepts_y_or_upper_y_for_residuals( + monkeypatch: pytest.MonkeyPatch, + answer: str, +) -> None: + harness = install_remove_harness( + monkeypatch, + scans=[scan_with_candidate(), ResidualScan()], + ) + + result = runner.invoke(app, ["remove", "example"], input=f"y\n{answer}\n") + + assert result.exit_code == 0 + assert harness.scan_calls == 2 + assert len(harness.residual_calls) == 1 + + +@pytest.mark.parametrize("answer", ("n", "N", "yes", "")) +def test_real_remove_preserves_residuals_when_second_answer_is_not_y( + monkeypatch: pytest.MonkeyPatch, + answer: str, +) -> None: + scan_two = scan_with_candidate() + harness = install_remove_harness( + monkeypatch, + scans=[scan_two, scan_two], + ) + + result = runner.invoke(app, ["remove", "example"], input=f"y\n{answer}\n") + + assert result.exit_code == 0 + assert harness.scan_calls == 2 + assert harness.residual_calls == [] + assert harness.verification_calls[0][2] is scan_two + + +def test_residual_remove_receives_confirmed_candidates_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + confirmed = candidate(Path("/virtual/confirmed")) + review_only = candidate(Path("/virtual/review"), "weak name match") + harness = install_remove_harness( + monkeypatch, + scans=[ + ResidualScan(confirmed=[confirmed], review_only=[review_only]), + ResidualScan(), + ], + ) + + result = runner.invoke(app, ["remove", "example"], input="y\ny\n") + + assert result.exit_code == 0 + assert harness.residual_calls == [[confirmed]] + assert review_only not in harness.residual_calls[0] + + +def test_real_remove_skips_second_prompt_without_confirmed_candidates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + review_only = candidate(Path("/virtual/review"), "weak name match") + harness = install_remove_harness( + monkeypatch, + scans=[ResidualScan(review_only=[review_only]), ResidualScan()], + ) + + result = runner.invoke(app, ["remove", "example"], input="y\n") + + assert result.exit_code == 0 + assert "Delete " not in result.stdout + assert harness.scan_calls == 2 + assert harness.residual_calls == [] + + +def test_scan_three_runs_after_primary_cleanup_warnings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + primary_result = CleanupResult("Example", False, failures=["primary warning"]) + verification = ResidualScan() + harness = install_remove_harness( + monkeypatch, + scans=[ResidualScan(), verification], + primary_result=primary_result, + ) + + result = runner.invoke(app, ["remove", "example"], input="y\n") + + assert result.exit_code == 0 + assert harness.scan_calls == 2 + assert harness.verification_calls == [(primary_result, None, verification)] + + +def test_scan_three_runs_after_partial_residual_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + residual_result = CleanupResult("Example", False, failures=["delete failed"]) + verification = scan_with_candidate(Path("/virtual/still-present")) + harness = install_remove_harness( + monkeypatch, + scans=[scan_with_candidate(), verification], + residual_result=residual_result, + ) + + result = runner.invoke(app, ["remove", "example"], input="y\ny\n") + + assert result.exit_code == 0 + assert harness.scan_calls == 2 + assert harness.verification_calls == [ + (harness.primary_result, residual_result, verification), + ] + + +def test_dry_run_has_one_current_scan_no_mutation_and_no_residual_prompt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + primary_path = tmp_path / "Example.app" + duplicate_confirmed = candidate(primary_path, "duplicate confirmed") + duplicate_review = candidate(primary_path, "duplicate review") + extra_confirmed = candidate(tmp_path / "extra-confirmed") + extra_review = candidate(tmp_path / "extra-review", "weak name match") + current_scan = ResidualScan( + confirmed=[duplicate_confirmed, extra_confirmed], + review_only=[duplicate_review, extra_review], + ) + harness = install_remove_harness( + monkeypatch, + scans=[current_scan], + analysis_paths=[primary_path], + primary_result=CleanupResult("Example", True), + ) + + result = runner.invoke(app, ["remove", "example", "--dry-run"]) + + assert result.exit_code == 0 + assert harness.events == ["capture", "analyze", "primary", "scan"] + assert harness.primary_calls == [True] + assert harness.scan_calls == 1 + assert harness.residual_calls == [] + assert harness.verification_calls == [] + assert current_scan.confirmed == [extra_confirmed] + assert current_scan.review_only == [extra_review] + assert harness.shown_scans == [ + ("Currently detectable additional candidates", current_scan), + ] + assert "Delete " not in result.stdout + assert ( + "Dry run only. Post-removal verification scan was not performed because no changes were made." + in result.stdout + ) From 1b68a0638a46fc54ad574f99c5036cb7766feb51 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:40:04 +0800 Subject: [PATCH 14/22] Document generic residual cleanup --- README.md | 13 ++++++---- README.zh-CN.md | 66 +++++++++++++++++++++++++------------------------ 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 4f809d2..334a485 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CleanApp CLI -CleanApp 是一款面向 macOS 的安全、透明、规则驱动卸载工具。它扫描桌面应用与常见开发工具,在删除前展示将要停止的进程、调用的包管理器和清理的路径,并且只有在用户明确确认后才会执行。 +CleanApp 是一款面向 macOS 的安全、透明、规则驱动卸载工具。它扫描桌面应用与常见开发工具,在删除前捕获应用、包和可执行文件身份,展示将要停止的进程、调用的包管理器和清理的路径,并且只有在用户明确确认后才会执行。 ## 一条命令运行(推荐) @@ -31,7 +31,7 @@ uvx --from git+https://github.com/clawdbot502/clean-cli cleanapp remove cursor - | 包管理器 | Homebrew、npm、pnpm、pipx、uv、Cargo、Go 按本机实际安装情况自动检测 | | 权限 | 普通用户可清理其有权访问的文件;部分系统级 Helper/Daemon 可能受 macOS 权限、ACL 或隐私控制限制 | -仓库包含运行源码、依赖声明、命令入口和全部内置 YAML 规则,不依赖作者电脑中的私有文件、密码或 API Key。这里的“完整流程”是指:扫描 → 选择 → 分析 → 预览 → 确认 → 停止相关进程 → 调用检测到的包管理器 → 清理规则覆盖的路径 → 检查残留 → 输出报告。它不承诺识别任意第三方应用未来新增的未知目录,也不会绕过 macOS 权限保护。 +仓库包含运行源码、依赖声明、命令入口和全部内置 YAML 规则,不依赖作者电脑中的私有文件、密码或 API Key。Applications、Homebrew formula/cask、npm、pnpm、pipx、uv tool、Cargo、Go 和 YAML 规则来源都进入同一套卸载与残留验证流程。最终的“已清理”结论只覆盖受支持且成功扫描的位置;无法访问的位置、人工复核项、原计划残留路径、进程和失败会分别报告。 ## 从 GitHub 安装 @@ -64,7 +64,7 @@ python3 -m venv .venv .venv/bin/cleanapp remove cursor ``` -也可以直接运行 `.venv/bin/cleanapp`,通过数字编号选择软件。真实删除前会展示预览并询问 `Confirm deletion? (Y/N)`;只有输入 `Y` 或 `y` 才会执行。建议任何软件第一次清理时都先使用 `--dry-run`。 +也可以直接运行 `.venv/bin/cleanapp`,通过数字编号选择软件。真实删除前会展示主要卸载计划并询问 `Confirm deletion? (Y/N)`;只有输入 `Y` 或 `y` 才会执行。主要卸载尝试完成后,残留扫描 #2 会分别列出“可确认删除”“仅供复核”和“无法验证”项;仅当第二次 `y/N` 输入为 `y` 或 `Y` 时才删除“可确认删除”项。无论第二次是否确认、是否发现候选项或局部删除是否失败,都会执行新的扫描 #3 并报告结果。建议任何软件第一次清理时都先使用 `--dry-run`。 ## 支持的命令 @@ -77,12 +77,15 @@ python3 -m venv .venv ## 安全与权限边界 -- `--dry-run` 不停止进程、不调用包管理器、不删除文件。 +- `--dry-run` 不停止进程、不调用包管理器、不删除文件;它只执行一次当前状态扫描并展示目前可检测到的额外候选项。由于没有做任何修改,dry-run 不会执行移除后的验证扫描。 - 系统关键目录、过宽目录、危险通配符和指向系统目录的符号链接会被拒绝。 - 预览会标记当前进程可能无权删除的路径。 - 单项失败不会中断后续清理;权限错误与残留会出现在最终报告和日志中。 - 不建议为了绕过提示而直接以 root 身份运行未经审查的清理。应先检查 dry-run 和具体残留,再为终端授予必要且最小的 macOS 权限。 -- 对没有内置规则的软件,CleanApp 可以移除扫描到的 `.app`、包管理器包或工具二进制文件,但不会猜测未知的配置和缓存路径。 + +真实卸载采用三阶段流程:先执行已预览的应用或包管理器卸载,再在受支持的用户、XDG 和 `/Library` 位置中扫描与目标身份精确关联的残留;如发现可确认归属的残留,会列出清单并再次询问 `y/N`,最后重新扫描并报告结果。 + +CleanApp 只枚举受支持目录的直接子项,不递归搜索整个用户目录,也不会进入 Chrome、Safari、Firefox、Edge、VS Code 等其他应用的数据目录。弱匹配仅列为人工复核项,不会因一次 `Y` 被删除;工具不会自动调用 `sudo`。 日志默认保存在 `~/Library/Logs/CleanApp/cleanapp.log`。完整设计、规则格式、构建方式和已知限制参见 [README.zh-CN.md](README.zh-CN.md)。 diff --git a/README.zh-CN.md b/README.zh-CN.md index dd3cb18..914527a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # CleanApp CLI -CleanApp 是一款运行于 macOS 终端的交互式软件卸载工具。它可以扫描桌面应用和常见开发工具,识别软件的安装来源与运行状态,在删除前展示完整预览,并通过规则文件清理应用本体、配置、缓存、日志和后台组件。 +CleanApp 是一款运行于 macOS 终端的交互式软件卸载工具。它可以扫描桌面应用和常见开发工具,识别软件的安装来源与运行状态,在删除前捕获应用、包和可执行文件身份并展示完整预览,再清理应用本体和与目标身份精确关联的残留。 CleanApp 的重点不是“尽可能快地删除”,而是让卸载过程安全、透明、可确认、可扩展。 @@ -31,12 +31,14 @@ uvx --from git+https://github.com/clawdbot502/clean-cli cleanapp remove cursor - - 使用数字编号进行交互选择。 - 删除前显示软件名称、安装来源、运行状态、文件数、目录数和预计释放空间。 - 默认展示前 10 个待删除路径,可输入 `all` 查看完整列表。 -- 只有明确输入 `Y` 或 `y` 才会执行删除。 -- 支持 `--dry-run`,完成扫描、分析和报告,但不修改系统。 +- 主要卸载只有在明确输入 `Y` 或 `y` 后才会执行。 +- 支持 `--dry-run`,完成分析和一次当前状态残留扫描,但不修改系统,也不声称完成了移除后的验证。 - 按安装来源调用 Homebrew、npm、pnpm、pipx、uv 或 Cargo 卸载命令。 - 清理应用、配置、缓存、日志、LaunchAgent、LaunchDaemon 和 Helper Tool。 - 单项删除失败不会中断后续步骤,失败原因会出现在报告和日志中。 -- 清理完成后重新检查残留路径和相关进程。 +- 主要卸载后执行受限的残留扫描,将结果分为“可确认删除”“仅供复核”和“无法验证”。 +- 只有在第二次输入 `y/Y` 后才删除“可确认删除”项;人工复核项不会随之删除。 +- 无论第二次是否确认,都重新扫描并分别报告残留路径、无法访问的位置、进程和失败。 - 通过 YAML 规则扩展软件支持。 ## 环境要求 @@ -124,10 +126,10 @@ python3 -m venv .venv 1. 扫描所有可用来源。 2. 展示带数字编号的软件列表。 3. 要求输入软件编号。 -4. 分析应用、配置、缓存、日志和相关进程。 -5. 展示删除预览。 -6. 要求二次确认。 -7. 执行清理并输出报告。 +4. 在删除前捕获目标身份,并分析应用、配置、缓存、日志和相关进程。 +5. 展示主要卸载预览并要求确认。 +6. 执行主要卸载,再展示残留扫描 #2 的分类结果和可选的第二次确认。 +7. 执行新的验证扫描 #3 并输出报告。 ### 安全预演 @@ -144,6 +146,8 @@ dry-run 会执行真实的扫描和分析,但不会: - 删除文件或目录; - 修改系统状态。 +它还会执行一次当前状态残留扫描,排除已在主要卸载计划中的重复路径,并展示目前可检测到的额外候选项。因为 dry-run 没有执行主要卸载或任何删除,所以不会执行移除后的验证扫描 #3,也不能据此宣称卸载后已无残留。 + ### 实际卸载 检查 dry-run 结果无误后,可以执行: @@ -158,7 +162,9 @@ dry-run 会执行真实的扫描和分析,但不会: Confirm deletion? (Y/N) ``` -只有输入 `Y` 或 `y` 才会继续。输入其他内容或直接按回车都会取消操作。 +只有输入 `Y` 或 `y` 才会继续主要卸载。输入其他内容或直接按回车都会取消操作,不会执行残留扫描。 + +主要卸载尝试完成后,程序会展示残留扫描 #2。只有存在“可确认删除”项时,才会询问 `Delete ... confirmed residual paths? (y/N)`;第二次输入 `y` 或 `Y` 只删除该组,其他输入会保留全部残留。无论第二次是否确认、是否存在可确认候选项或局部删除是否失败,程序都会执行新的扫描 #3 并输出验证结果。 ## 命令说明 @@ -224,34 +230,32 @@ Confirm deletion? (Y/N) 实际卸载会按照以下顺序进行: -1. 停止规则匹配到的相关进程。 -2. 根据安装来源调用包管理器卸载命令。 -3. 删除 macOS 应用本体。 -4. 删除软件配置。 -5. 删除缓存。 -6. 删除日志。 -7. 删除匹配到的 LaunchAgent 或 LaunchDaemon。 -8. 删除匹配到的 Helper Tool。 -9. 再次检查残留路径与运行进程。 -10. 输出清理报告并写入日志。 +1. 在删除前捕获应用、包和可执行文件身份。 +2. 展示并确认主要卸载计划。 +3. 停止相关进程、调用已检测到的包管理器并删除主要路径。 +4. 扫描受支持位置中的残留并分为“可确认删除”“仅供复核”“无法验证”。 +5. 仅在第二次输入 `y/Y` 后删除“可确认删除”项。 +6. 无论第二次是否确认,都执行一次新的验证扫描。 +7. 分别报告确认残留、人工复核项、无法访问的位置、原计划残留路径、进程和失败。 每一步都单独进行错误处理。某个文件没有权限或某个卸载命令失败时,程序会记录原因并继续处理后续项目。 ### “完整清理”的准确边界 -仓库包含扫描器、分析器、删除器、命令入口、依赖声明和全部内置规则。满足环境要求的用户克隆后,可以执行从扫描到残留报告的完整产品流程。不过,“完整”是指当前规则和安装来源覆盖范围内的完整流程,并不等于对所有软件做无法验证的 100% 残留保证: +仓库包含扫描器、分析器、删除器、命令入口、依赖声明和全部内置规则。Applications、Homebrew formula/cask、npm、pnpm、pipx、uv tool、Cargo、Go 和 YAML 规则来源都会进入同一套流程。不过,“完整”只指受支持且成功扫描的位置,并不等于对整块磁盘或任意第三方软件做无法验证的 100% 残留保证: -- 11 个内置规则会清理规则明确列出的应用、配置、缓存、日志、启动项和 Helper。 -- 没有内置规则的软件仍可移除扫描到的 `.app`、包管理器包或工具二进制文件,但 CleanApp 不会猜测其未知数据目录。 -- 第三方软件升级后可能改变路径,需要更新相应 YAML 规则。 -- macOS ACL、隐私控制或系统级目录权限可能阻止部分删除;预览会给出权限提示,最终报告会列出失败与残留。 -- 为避免把用户主目录解析成错误位置,不建议未经审查就以 root 身份运行整个工具。应先 dry-run,再按残留项授予终端最小必要权限。 +- 内置规则继续提供已知应用路径、包标识符和进程信息;通用残留扫描会补充规则覆盖,而不是只清理 YAML 明确列出的路径。 +- 扫描器只枚举受支持的用户 Library、XDG、`~/.local/bin`、用户主目录的直接隐藏子项,以及选定 `/Library` 根目录的直接子项。它不会递归搜索整个用户目录、Documents、项目目录或其他任意位置。 +- Chrome、Safari、Firefox、Edge、VS Code 等其他应用的数据目录是隔离边界。例如扫描 `~/Library/Application Support` 时只检查其直接子项,不会进入不匹配的 `Google/Chrome/...`,因此不会把网站数据当作目标应用残留。 +- 精确身份关联的普通候选项列为“可确认删除”;短名称、弱匹配和平台管理位置列为“仅供复核”,第二次输入 `Y` 也不会删除;无法读取的根目录列为“无法验证”。 +- 系统级候选项会标为需要管理员范围,但 CleanApp 只使用当前进程已有权限,绝不会自动调用 `sudo`。macOS ACL、隐私控制或目录权限导致的失败会保留到最终报告。 +- 只有确认残留、人工复核项、无法访问的位置、原计划残留路径、相关进程和清理失败都为空时,程序才会报告:在受支持且成功扫描的位置中没有确认残留。 ## 安全设计 CleanApp 在真正删除文件前会执行多层检查: -- 默认需要二次确认。 +- 主要卸载前必须确认;只有残留扫描 #2 存在“可确认删除”项时,才会出现默认值为 `N` 的第二次确认。 - 支持完整 dry-run。 - 拒绝删除 `/`、`/System`、`/usr`、`/Applications`、`/Library`、用户主目录等关键路径本身。 - 拒绝删除 `~/.config`、`~/.cache` 等范围过大的公共目录本身。 @@ -262,6 +266,7 @@ CleanApp 在真正删除文件前会执行多层检查: - 进程匹配会排除 CleanApp 自身及其父进程。 - 短进程名称采用精确匹配,减少误判。 - 所有失败都会进入最终报告和日志。 +- 不会自动调用 `sudo` 或静默提升权限。 即使有这些保护,卸载仍然属于可能不可逆的操作。执行实际删除前,应认真查看 dry-run 输出并备份重要数据。 @@ -381,11 +386,7 @@ packages: .venv/bin/python -m pip check ``` -当前项目回归结果: - -```text -24 passed -``` +测试数量会随功能迭代变化,以当前 `pytest` 输出的零失败结果为准。 测试使用临时隔离目录验证实际删除逻辑,不会删除用户的真实应用和配置。 @@ -421,6 +422,7 @@ clean-cli/ │ ├── providers.py # Applications 与包管理器 Provider │ ├── rule_engine.py # YAML 规则加载和匹配 │ ├── analyzer.py # 路径、空间和进程分析 +│ ├── residual_scanner.py # 删除前身份捕获与受限残留扫描 │ ├── safety.py # 删除路径安全检查 │ ├── remover.py # 进程、包和文件清理 │ ├── reporter.py # Rich 预览与清理报告 @@ -437,7 +439,7 @@ clean-cli/ - 当前版本面向 macOS,不支持 Windows 或 Linux 桌面应用卸载。 - 某些系统级 Helper Tool 或 LaunchDaemon 需要额外权限;权限不足时会记录失败并继续。 -- 第三方软件可能在更新后改变数据目录,需要同步更新对应 YAML 规则。 +- 通用扫描不会递归搜索整个用户目录;不在受支持根目录直接子项中的第三方数据,需要通过明确规则或人工复核处理。 - Go 没有统一的全局卸载命令,因此主要通过扫描到的安装目录清理二进制文件。 - 当前版本一次处理一个软件,尚未提供批量删除和完整 TUI。 From a2b3ce22e5f9b8c4c95fe7871db6b61704cc2e60 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:43:41 +0800 Subject: [PATCH 15/22] Protect approved residual root aliases --- cleanapp/residual_scanner.py | 240 +++++++++++++++++++++------------ tests/test_residual_scanner.py | 206 ++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+), 84 deletions(-) diff --git a/cleanapp/residual_scanner.py b/cleanapp/residual_scanner.py index 5ce1aa2..88a3e76 100644 --- a/cleanapp/residual_scanner.py +++ b/cleanapp/residual_scanner.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass import os from pathlib import Path import plistlib @@ -81,6 +82,9 @@ "/Spotlight", ) PROTECTED_HOME_ENTRIES = { + ".local", + ".config", + ".cache", ".zshrc", ".bashrc", ".bash_profile", @@ -97,6 +101,109 @@ PLATFORM_MANAGED_ROOT_NAMES = {"Extensions", "Spotlight"} +@dataclass(frozen=True, slots=True) +class _ApprovedRoot: + display: Path + physical: Path + aliases: frozenset[Path] + system_level: bool + kinds: frozenset[str] + + +def _absolute(path: Path) -> Path: + return Path(os.path.abspath(os.path.expanduser(str(path)))) + + +def _path_key(path: Path) -> str: + return unicodedata.normalize("NFC", str(path)).casefold() + + +def _same_or_below(path: Path, root: Path) -> bool: + path_parts = tuple( + unicodedata.normalize("NFC", part).casefold() for part in path.parts + ) + root_parts = tuple( + unicodedata.normalize("NFC", part).casefold() for part in root.parts + ) + return path_parts[:len(root_parts)] == root_parts + + +def _physical(path: Path) -> Path: + return Path(os.path.realpath(_absolute(path))) + + +def _is_system_protected_root(path: Path) -> bool: + return _same_or_below(path, Path("/System")) or _same_or_below(path, Path("/usr")) + + +def _name_in(name: str, values: set[str]) -> bool: + normalized = unicodedata.normalize("NFC", name).casefold() + return normalized in { + unicodedata.normalize("NFC", value).casefold() for value in values + } + + +def _root_specs( + home: Path, + system_roots: tuple[Path, ...], +) -> tuple[_ApprovedRoot, ...]: + roots: list[tuple[Path, bool, str]] = [ + (home / "Library" / relative, False, f"library:{relative}") + for relative in USER_LIBRARY_ROOTS + ] + for variable, default in XDG_ROOTS.items(): + configured = os.environ.get(variable) + root = Path(configured).expanduser() if configured else home / default + roots.append((root, False, f"xdg:{variable}")) + roots.extend(( + (home / ".local/bin", False, "local-bin"), + (home, False, "home"), + )) + roots.extend((root, True, "system") for root in system_roots) + + merged: dict[str, tuple[Path, Path, set[Path], bool, set[str]]] = {} + order: list[str] = [] + for root, system_level, kind in roots: + lexical = _absolute(root) + physical = _physical(lexical) + if _is_system_protected_root(physical): + continue + key = _path_key(physical) + current = merged.get(key) + if current is None: + merged[key] = (lexical, physical, {lexical}, system_level, {kind}) + order.append(key) + continue + display, canonical, aliases, current_system_level, kinds = current + aliases.add(lexical) + kinds.add(kind) + merged[key] = ( + display, + canonical, + aliases, + current_system_level or system_level, + kinds, + ) + return tuple( + _ApprovedRoot( + merged[key][0], + merged[key][1], + frozenset(merged[key][2]), + merged[key][3], + frozenset(merged[key][4]), + ) + for key in order + ) + + +def _contains_approved_root(path: Path, roots: tuple[_ApprovedRoot, ...]) -> bool: + return any( + _same_or_below(alias, path) + for root in roots + for alias in root.aliases + ) + + def _bundle_paths(software: Software, rule: Rule | None) -> set[Path]: paths = set(software.paths) if rule: @@ -171,10 +278,13 @@ def _is_platform_managed(root: Path, kinds: frozenset[str]) -> bool: return True if "system" not in kinds: return False - if root.name in PLATFORM_MANAGED_ROOT_NAMES: + if _name_in(root.name, PLATFORM_MANAGED_ROOT_NAMES): return True - root_text = str(root) - return any(root_text.endswith(suffix) for suffix in PLATFORM_MANAGED_SYSTEM_SUFFIXES) + root_text = unicodedata.normalize("NFC", str(root)).casefold() + return any( + root_text.endswith(unicodedata.normalize("NFC", suffix).casefold()) + for suffix in PLATFORM_MANAGED_SYSTEM_SUFFIXES + ) def _normalized_alphanumeric(value: str) -> str: @@ -193,19 +303,19 @@ def _is_cache_root(root: Path, kinds: frozenset[str]) -> bool: return ( "xdg:XDG_CACHE_HOME" in kinds or "library:Caches" in kinds - or ("system" in kinds and root.name == "Caches") + or ("system" in kinds and root.name.casefold() == "caches") ) def _is_excluded(path: Path, root: Path, kinds: frozenset[str]) -> bool: if ( "home" in kinds - and path.name in PROTECTED_HOME_ENTRIES | SHARED_HOME_ENTRIES + and _name_in(path.name, PROTECTED_HOME_ENTRIES | SHARED_HOME_ENTRIES) ): return True if ( _is_cache_root(root, kinds) - and path.name in SHARED_CACHE_ENTRIES | SHARED_LIBRARY_CACHE_ENTRIES + and _name_in(path.name, SHARED_CACHE_ENTRIES | SHARED_LIBRARY_CACHE_ENTRIES) ): return True return False @@ -220,53 +330,41 @@ def _contains_path(parent: Path, child: Path) -> bool: def _approved_roots(home: Path | None = None) -> tuple[Path, ...]: - current_home = home or Path.home() - roots = [current_home / "Library" / relative for relative in USER_LIBRARY_ROOTS] - for variable, default in XDG_ROOTS.items(): - configured = os.environ.get(variable) - roots.append(Path(configured).expanduser() if configured else current_home / default) - roots.extend((current_home / ".local/bin", current_home, *SYSTEM_ROOTS)) - return tuple(dict.fromkeys(Path(os.path.abspath(str(root))) for root in roots)) + specs = _root_specs(home or Path.home(), SYSTEM_ROOTS) + return tuple(alias for root in specs for alias in root.aliases) def is_approved_residual_path(path: Path) -> bool: """Return whether *path* is exactly one level below an approved scan root.""" - candidate = Path(os.path.abspath(os.path.expanduser(str(path)))) - roots = _approved_roots() - if candidate in roots or candidate.parent not in roots: + candidate = _absolute(path) + home = _absolute(Path.home()) + roots = _root_specs(home, SYSTEM_ROOTS) + parent_key = _path_key(candidate.parent) + physical_parent_key = _path_key(_physical(candidate.parent)) + root = next( + ( + item + for item in roots + if physical_parent_key == _path_key(item.physical) + and any(parent_key == _path_key(alias) for alias in item.aliases) + ), + None, + ) + if root is None: return False - home = Path(os.path.abspath(str(Path.home()))) - if candidate.parent == home: + if "home" in root.kinds: return ( candidate.name.startswith(".") - and candidate.name not in PROTECTED_HOME_ENTRIES | SHARED_HOME_ENTRIES + and not _name_in( + candidate.name, + PROTECTED_HOME_ENTRIES | SHARED_HOME_ENTRIES, + ) + and not _contains_approved_root(candidate, roots) ) - configured_cache = os.environ.get("XDG_CACHE_HOME") - cache_root = ( - Path(configured_cache).expanduser() - if configured_cache - else home / XDG_ROOTS["XDG_CACHE_HOME"] - ) - cache_root = Path(os.path.abspath(str(cache_root))) - default_cache_roots = { - cache_root, - home / "Library/Caches", - Path("/Library/Caches"), - } - if ( - candidate.parent in default_cache_roots - and candidate.name in SHARED_CACHE_ENTRIES | SHARED_LIBRARY_CACHE_ENTRIES - ): + if _is_excluded(candidate, root.physical, root.kinds): return False - if candidate.parent.is_relative_to(home / "Library"): - relative = str(candidate.parent.relative_to(home / "Library")) - if relative in PLATFORM_MANAGED_USER_ROOTS: - return False - if candidate.parent in SYSTEM_ROOTS and _is_platform_managed( - candidate.parent, - frozenset({"system"}), - ): + if _is_platform_managed(root.physical, root.kinds): return False return True @@ -322,71 +420,45 @@ def __init__( self.home = home or Path.home() self.system_roots = SYSTEM_ROOTS if system_roots is None else system_roots - def _roots(self) -> tuple[tuple[Path, bool, frozenset[str]], ...]: - roots: list[tuple[Path, bool, str]] = [ - (self.home / "Library" / relative, False, f"library:{relative}") - for relative in USER_LIBRARY_ROOTS - ] - for variable, default in XDG_ROOTS.items(): - configured = os.environ.get(variable) - root = Path(configured).expanduser() if configured else self.home / default - roots.append((root, False, f"xdg:{variable}")) - roots.extend(( - (self.home / ".local/bin", False, "local-bin"), - (self.home, False, "home"), - )) - roots.extend((root, True, "system") for root in self.system_roots) - - merged: dict[Path, tuple[bool, set[str]]] = {} - order: list[Path] = [] - for root, system_level, kind in roots: - root = Path(os.path.abspath(os.path.expanduser(str(root)))) - current = merged.get(root) - if current is None: - merged[root] = (system_level, {kind}) - order.append(root) - continue - current_system_level, kinds = current - kinds.add(kind) - merged[root] = (current_system_level or system_level, kinds) - return tuple( - (root, merged[root][0], frozenset(merged[root][1])) - for root in order - ) + def _roots(self) -> tuple[_ApprovedRoot, ...]: + return _root_specs(self.home, self.system_roots) def scan(self, identity: ResidualIdentity) -> ResidualScan: result = ResidualScan() - for root, system_level, kinds in self._roots(): + roots = self._roots() + for root in roots: try: - children = list(root.iterdir()) + children = list(root.display.iterdir()) except FileNotFoundError: continue except OSError: - if root not in result.inaccessible_roots: - result.inaccessible_roots.append(root) + if root.display not in result.inaccessible_roots: + result.inaccessible_roots.append(root.display) continue for path in children: - if _is_excluded(path, root, kinds): + if _is_excluded(path, root.physical, root.kinds): + continue + if "home" in root.kinds and _contains_approved_root(path, roots): continue try: metadata = path.lstat() except OSError: - if root not in result.inaccessible_roots: - result.inaccessible_roots.append(root) + if root.display not in result.inaccessible_roots: + result.inaccessible_roots.append(root.display) continue - evidence, confirmed = self._match(path, metadata, kinds, identity) + evidence, confirmed = self._match(path, metadata, root.kinds, identity) if evidence is None: continue candidate = ResidualCandidate( path, evidence, - system_level, + root.system_level, metadata.st_dev, metadata.st_ino, stat.S_IFMT(metadata.st_mode), ) - if confirmed and not _is_platform_managed(root, kinds): + if confirmed and not _is_platform_managed(root.physical, root.kinds): result.confirmed.append(candidate) else: result.review_only.append(candidate) diff --git a/tests/test_residual_scanner.py b/tests/test_residual_scanner.py index e0d2192..0ae04da 100644 --- a/tests/test_residual_scanner.py +++ b/tests/test_residual_scanner.py @@ -2,6 +2,7 @@ import pytest +import cleanapp.residual_scanner as residual_scanner from cleanapp.models import ResidualIdentity, Rule, Software, Source from cleanapp.residual_scanner import ( ResidualScanner, @@ -409,6 +410,72 @@ def test_protected_home_entries_are_excluded(tmp_path: Path, name: str) -> None: assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} +@pytest.mark.parametrize("name", (".local", ".LOCAL", ".config", ".CACHE")) +def test_home_entries_that_are_or_contain_approved_roots_are_excluded( + tmp_path: Path, + monkeypatch, + name: str, +) -> None: + target = tmp_path / name + target.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + app_name = name.removeprefix(".") + app = tmp_path / "Applications" / f"{app_name}.app" + write_bundle( + app, + CFBundleIdentifier=f"com.vendor.{app_name.casefold()}", + CFBundleName=app_name, + ) + identity = capture_identity( + Software(app_name, {Source.APPLICATION}, {app_name}, {app}), + None, + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} + assert not is_approved_residual_path(target) + + +def test_home_entry_containing_custom_xdg_root_is_excluded( + tmp_path: Path, + monkeypatch, +) -> None: + parent = tmp_path / ".vendor" + configured = parent / "config" + configured.mkdir(parents=True) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(configured)) + identity = ResidualIdentity( + frozenset({"Vendor"}), + frozenset({"com.example.vendor"}), + frozenset(), + frozenset(), + frozenset({Source.APPLICATION}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert parent not in {item.path for item in (*scan.confirmed, *scan.review_only)} + assert not is_approved_residual_path(parent) + + +def test_target_owned_hidden_home_entry_remains_eligible(tmp_path: Path) -> None: + target = tmp_path / ".example" + target.mkdir() + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset({"com.vendor.example"}), + frozenset(), + frozenset(), + frozenset({Source.APPLICATION}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert {item.path for item in scan.confirmed} == {target} + + def test_parent_candidate_collapses_child_candidate(tmp_path: Path) -> None: parent = tmp_path / "Library/Application Support/Example" child_root = parent / "nested-root" @@ -559,6 +626,29 @@ def test_xdg_collision_preserves_system_scope(tmp_path: Path, monkeypatch) -> No assert scan.confirmed[0].system_level is True +def test_xdg_symlink_alias_preserves_system_scope_and_deduplicates( + tmp_path: Path, + monkeypatch, +) -> None: + system_root = tmp_path / "Library/LaunchAgents" + target = system_root / "com.quora.poe.electron.plist" + target.parent.mkdir(parents=True) + target.write_text("label", encoding="utf-8") + alias = tmp_path / "xdg-config" + alias.symlink_to(system_root, target_is_directory=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(alias)) + + scan = ResidualScanner( + home=tmp_path / "home", + system_roots=(system_root,), + ).scan(poe_identity()) + + candidates = [*scan.confirmed, *scan.review_only] + assert len(candidates) == 1 + assert candidates[0].system_level is True + assert candidates[0].path.parent.resolve() == system_root.resolve() + + def test_xdg_collision_preserves_platform_managed_review_only( tmp_path: Path, monkeypatch, @@ -578,6 +668,50 @@ def test_xdg_collision_preserves_platform_managed_review_only( assert scan.review_only[0].system_level is True +def test_xdg_symlink_alias_preserves_platform_managed_review_only( + tmp_path: Path, + monkeypatch, +) -> None: + extension_root = tmp_path / "Library/Extensions" + target = extension_root / "com.quora.poe.electron" + target.mkdir(parents=True) + alias = tmp_path / "xdg-config" + alias.symlink_to(extension_root, target_is_directory=True) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(alias)) + monkeypatch.setattr(residual_scanner, "SYSTEM_ROOTS", (extension_root,)) + + scan = ResidualScanner( + home=tmp_path / "home", + system_roots=(extension_root,), + ).scan(poe_identity()) + + assert not scan.confirmed + assert len(scan.review_only) == 1 + assert scan.review_only[0].system_level is True + assert not is_approved_residual_path(scan.review_only[0].path) + + +def test_xdg_symlink_alias_preserves_group_container_review_only( + tmp_path: Path, + monkeypatch, +) -> None: + home = tmp_path / "home" + group_root = home / "Library/Group Containers" + target = group_root / "com.quora.poe.electron" + target.mkdir(parents=True) + alias = tmp_path / "xdg-config" + alias.symlink_to(group_root, target_is_directory=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(alias)) + + scan = ResidualScanner(home=home, system_roots=()).scan(poe_identity()) + + assert not scan.confirmed + assert len(scan.review_only) == 1 + assert not is_approved_residual_path(scan.review_only[0].path) + + def test_xdg_config_cache_collision_preserves_cache_exclusions( tmp_path: Path, monkeypatch, @@ -600,6 +734,78 @@ def test_xdg_config_cache_collision_preserves_cache_exclusions( assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} +def test_xdg_symlink_alias_preserves_cache_exclusions( + tmp_path: Path, + monkeypatch, +) -> None: + cache_root = tmp_path / "cache" + target = cache_root / "uv" + target.mkdir(parents=True) + alias = tmp_path / "xdg-config" + alias.symlink_to(cache_root, target_is_directory=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(alias)) + monkeypatch.setenv("XDG_CACHE_HOME", str(cache_root)) + identity = ResidualIdentity( + frozenset({"uv"}), + frozenset(), + frozenset({"uv"}), + frozenset(), + frozenset({Source.UV}), + ) + + scan = ResidualScanner(home=tmp_path / "home", system_roots=()).scan(identity) + + assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} + + +def test_multiple_xdg_aliases_to_one_physical_root_produce_one_candidate( + tmp_path: Path, + monkeypatch, +) -> None: + physical_root = tmp_path / "shared" + target = physical_root / "example" + target.mkdir(parents=True) + config_alias = tmp_path / "config-link" + data_alias = tmp_path / "data-link" + config_alias.symlink_to(physical_root, target_is_directory=True) + data_alias.symlink_to(physical_root, target_is_directory=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_alias)) + monkeypatch.setenv("XDG_DATA_HOME", str(data_alias)) + + scan = ResidualScanner(home=tmp_path / "home", system_roots=()).scan( + package_identity("example") + ) + + assert len(scan.confirmed) == 1 + assert scan.confirmed[0].path.parent.resolve() == physical_root.resolve() + + +def test_xdg_root_resolving_under_usr_is_never_scanned_or_approved( + tmp_path: Path, + monkeypatch, +) -> None: + alias = tmp_path / "xdg-config" + alias.symlink_to("/usr", target_is_directory=True) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(alias)) + visited: list[Path] = [] + original = Path.iterdir + + def tracking_iterdir(path: Path): + visited.append(path) + return original(path) + + monkeypatch.setattr(Path, "iterdir", tracking_iterdir) + + ResidualScanner(home=tmp_path / "home", system_roots=()).scan( + package_identity("example") + ) + + assert alias not in visited + assert Path("/usr") not in visited + assert not is_approved_residual_path(alias / "example") + + def test_running_pids_match_exact_and_distinctive_substrings(monkeypatch, tmp_path: Path) -> None: class FakeProcess: def __init__(self, pid: int, name: str, cmdline: list[str]) -> None: From 1a3212317333cb640acbecf7ede4a5508bd490ad Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:51:16 +0800 Subject: [PATCH 16/22] Require owned residual executable evidence --- cleanapp/models.py | 1 + cleanapp/residual_scanner.py | 110 +++++++++++++-- tests/test_residual_scanner.py | 247 +++++++++++++++++++++++++++++++++ 3 files changed, 343 insertions(+), 15 deletions(-) diff --git a/cleanapp/models.py b/cleanapp/models.py index af67141..83794c0 100644 --- a/cleanapp/models.py +++ b/cleanapp/models.py @@ -25,6 +25,7 @@ class ResidualIdentity: package_ids: frozenset[str] executables: frozenset[str] sources: frozenset[Source] + owned_paths: frozenset[Path] = frozenset() @dataclass(frozen=True, slots=True) diff --git a/cleanapp/residual_scanner.py b/cleanapp/residual_scanner.py index 88a3e76..9e89757 100644 --- a/cleanapp/residual_scanner.py +++ b/cleanapp/residual_scanner.py @@ -23,6 +23,13 @@ Source.CARGO, } +EMBEDDED_BUNDLE_ROOTS = ( + ("Contents/Frameworks", ".app"), + ("Contents/XPCServices", ".xpc"), + ("Contents/PlugIns", ".appex"), + ("Contents/Library/LoginItems", ".app"), +) + USER_LIBRARY_ROOTS = ( "Application Support", "Caches", @@ -212,33 +219,85 @@ def _bundle_paths(software: Software, rule: Rule | None) -> set[Path]: return {path for path in paths if path.suffix.casefold() == ".app" and path.is_dir()} +def _read_bundle_info(info_path: Path) -> dict[object, object] | None: + try: + payload = plistlib.loads(info_path.read_bytes()) + except (OSError, plistlib.InvalidFileException): + return None + return payload if isinstance(payload, dict) else None + + +def _bundle_id(payload: dict[object, object]) -> str | None: + value = payload.get("CFBundleIdentifier") + return value if isinstance(value, str) and value else None + + +def _is_linked_bundle_id(bundle_id: str, main_bundle_id: str) -> bool: + return bundle_id == main_bundle_id or any( + bundle_id.startswith(main_bundle_id + boundary) + for boundary in (".", "-", "_") + ) + + +def _add_bundle_metadata( + payload: dict[object, object], + names: set[str], + executables: set[str], + bundle_ids: set[str], +) -> None: + bundle_id = _bundle_id(payload) + if bundle_id: + bundle_ids.add(bundle_id) + for key in ("CFBundleName", "CFBundleDisplayName"): + value = payload.get(key) + if isinstance(value, str) and value: + names.add(value) + executable = payload.get("CFBundleExecutable") + if isinstance(executable, str) and executable: + executables.add(executable) + + def capture_identity(software: Software, rule: Rule | None) -> ResidualIdentity: names = {software.name, *software.identifiers} package_ids = set(software.identifiers) if software.sources & PACKAGE_SOURCES else set() executables = {path.name for path in software.paths if path.is_file() or path.is_symlink()} bundle_ids: set[str] = set() + owned_paths = {_absolute(path) for path in software.paths} if rule: names.update((rule.name, rule.slug, *rule.aliases)) executables.update(rule.kill) package_ids.update(package for values in rule.packages.values() for package in values) + for pattern in rule.path_patterns: + owned_paths.update( + _absolute(path) + for path in expand_pattern(pattern) + if path.exists() or path.is_symlink() + ) for app in _bundle_paths(software, rule): - for info_path in (app / "Contents").rglob("Info.plist"): + main_payload = _read_bundle_info(app / "Contents/Info.plist") + if main_payload is None: + continue + main_bundle_id = _bundle_id(main_payload) + _add_bundle_metadata(main_payload, names, executables, bundle_ids) + if main_bundle_id is None: + continue + for relative, suffix in EMBEDDED_BUNDLE_ROOTS: + root = app / relative try: - payload = plistlib.loads(info_path.read_bytes()) - except (OSError, plistlib.InvalidFileException): + children = list(root.iterdir()) + except OSError: continue - bundle_id = payload.get("CFBundleIdentifier") - if isinstance(bundle_id, str) and bundle_id: - bundle_ids.add(bundle_id) - for key in ("CFBundleName", "CFBundleDisplayName"): - value = payload.get(key) - if isinstance(value, str) and value: - names.add(value) - executable = payload.get("CFBundleExecutable") - if isinstance(executable, str) and executable: - executables.add(executable) + for child in children: + if child.suffix.casefold() != suffix: + continue + payload = _read_bundle_info(child / "Contents/Info.plist") + if payload is None: + continue + bundle_id = _bundle_id(payload) + if bundle_id and _is_linked_bundle_id(bundle_id, main_bundle_id): + _add_bundle_metadata(payload, names, executables, bundle_ids) return ResidualIdentity( frozenset(value for value in names if value), @@ -246,6 +305,7 @@ def capture_identity(software: Software, rule: Rule | None) -> ResidualIdentity: frozenset(value for value in package_ids if value), frozenset(value for value in executables if value), frozenset(software.sources), + frozenset(owned_paths), ) @@ -329,6 +389,23 @@ def _contains_path(parent: Path, child: Path) -> bool: return parent != child +def _exact_owned_path(path: Path, owned_paths: frozenset[Path]) -> bool: + path_key = _path_key(_absolute(path)) + return any(path_key == _path_key(_absolute(owned)) for owned in owned_paths) + + +def _symlink_target_is_owned(path: Path, owned_paths: frozenset[Path]) -> bool: + try: + raw_target = os.readlink(path) + except OSError: + return False + target = Path(raw_target) + if not target.is_absolute(): + target = path.parent / target + target = _absolute(target) + return any(_same_or_below(target, _absolute(owned)) for owned in owned_paths) + + def _approved_roots(home: Path | None = None) -> tuple[Path, ...]: specs = _root_specs(home or Path.home(), SYSTEM_ROOTS) return tuple(alias for root in specs for alias in root.aliases) @@ -498,8 +575,11 @@ def _match( if "local-bin" in kinds: is_executable = stat.S_ISREG(metadata.st_mode) and bool(metadata.st_mode & 0o111) if name in identity.executables and (is_executable or stat.S_ISLNK(metadata.st_mode)): - second_signal = bool(identity.bundle_ids or identity.package_ids) - return f"executable {name}", second_signal or not _is_short(name) + if stat.S_ISLNK(metadata.st_mode): + owned = _symlink_target_is_owned(path, identity.owned_paths) + else: + owned = _exact_owned_path(path, identity.owned_paths) + return f"executable {name}", owned return None, False if "home" in kinds: diff --git a/tests/test_residual_scanner.py b/tests/test_residual_scanner.py index 0ae04da..68ab4b2 100644 --- a/tests/test_residual_scanner.py +++ b/tests/test_residual_scanner.py @@ -67,6 +67,144 @@ def test_capture_identity_reads_main_and_embedded_bundle_metadata(tmp_path: Path assert {"Poe", "Poe Helper"} <= identity.executables +@pytest.mark.parametrize( + ("relative", "bundle_id"), + ( + ("Contents/Frameworks/Example Helper.app", "com.vendor.example.helper"), + ("Contents/XPCServices/Example Service.xpc", "com.vendor.example.service"), + ("Contents/PlugIns/Example Extension.appex", "com.vendor.example-extension"), + ("Contents/Library/LoginItems/Example Login.app", "com.vendor.example_login"), + ), +) +def test_capture_identity_reads_only_supported_linked_direct_children( + tmp_path: Path, + relative: str, + bundle_id: str, +) -> None: + app = tmp_path / "Example.app" + write_bundle( + app, + CFBundleIdentifier="com.vendor.example", + CFBundleName="Example", + CFBundleExecutable="Example", + ) + embedded = app / relative + write_bundle( + embedded, + CFBundleIdentifier=bundle_id, + CFBundleName="Example Component", + CFBundleExecutable="Example Component", + ) + + identity = capture_identity( + Software("Example", {Source.APPLICATION}, {"Example"}, {app}), + None, + ) + + assert bundle_id in identity.bundle_ids + assert "Example Component" in identity.names + assert "Example Component" in identity.executables + + +@pytest.mark.parametrize( + "relative", + ( + "Contents/Frameworks/Sparkle.framework/Resources/Sparkle.app", + "Contents/Frameworks/Nested/Sentry.app", + "Contents/Resources/Sentry.bundle", + ), +) +def test_capture_identity_discards_foreign_nested_bundle_metadata( + tmp_path: Path, + relative: str, +) -> None: + captured = [] + for app_name in ("Alpha", "Beta"): + app = tmp_path / f"{app_name}.app" + write_bundle( + app, + CFBundleIdentifier=f"com.vendor.{app_name.casefold()}", + CFBundleName=app_name, + CFBundleExecutable=app_name, + ) + foreign = app / relative + write_bundle( + foreign, + CFBundleIdentifier="org.sparkle-project.Sparkle", + CFBundleName="Sentry", + CFBundleExecutable="Sparkle", + ) + captured.append(capture_identity( + Software(app_name, {Source.APPLICATION}, {app_name}, {app}), + None, + )) + + for identity in captured: + assert "org.sparkle-project.Sparkle" not in identity.bundle_ids + assert "Sentry" not in identity.names + assert "Sparkle" not in identity.executables + + +@pytest.mark.parametrize( + "relative", + ( + "Contents/Frameworks/Sparkle.app", + "Contents/XPCServices/Sentry.xpc", + "Contents/PlugIns/Sentry.appex", + "Contents/Library/LoginItems/Sparkle.app", + ), +) +def test_capture_identity_discards_foreign_supported_direct_children( + tmp_path: Path, + relative: str, +) -> None: + app = tmp_path / "Example.app" + write_bundle( + app, + CFBundleIdentifier="com.vendor.example", + CFBundleName="Example", + CFBundleExecutable="Example", + ) + foreign = app / relative + write_bundle( + foreign, + CFBundleIdentifier="org.sparkle-project.Sparkle", + CFBundleName="Sentry", + CFBundleExecutable="Sparkle", + ) + + identity = capture_identity( + Software("Example", {Source.APPLICATION}, {"Example"}, {app}), + None, + ) + + assert identity.bundle_ids == frozenset({"com.vendor.example"}) + assert "Sentry" not in identity.names + assert "Sparkle" not in identity.executables + + +def test_capture_identity_records_existing_source_and_rule_owned_paths( + tmp_path: Path, +) -> None: + source = tmp_path / "source-tool" + source.write_text("tool", encoding="utf-8") + concrete_rule_path = tmp_path / "rule-tool" + concrete_rule_path.write_text("tool", encoding="utf-8") + missing_rule_path = tmp_path / "missing-tool" + rule = Rule( + "example", + "Example", + remove=[str(concrete_rule_path), str(missing_rule_path)], + ) + + identity = capture_identity( + Software("Example", {Source.NPM}, {"example"}, {source}), + rule, + ) + + assert identity.owned_paths == frozenset({source.absolute(), concrete_rule_path.absolute()}) + + @pytest.mark.parametrize("source", list(Source)) def test_capture_identity_supports_every_source(source: Source) -> None: identity = capture_identity(Software("Example Tool", {source}, {"example-tool"}), None) @@ -390,6 +528,115 @@ def test_short_exact_executable_match_is_review_only(tmp_path: Path) -> None: assert {item.path for item in scan.review_only} == {target} +def test_unrelated_regular_local_bin_executable_is_review_only(tmp_path: Path) -> None: + target = tmp_path / ".local/bin/example" + target.parent.mkdir(parents=True) + target.write_text("unrelated", encoding="utf-8") + target.chmod(0o755) + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset({"com.vendor.example"}), + frozenset({"example"}), + frozenset({"example"}), + frozenset({Source.APPLICATION, Source.NPM}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert not scan.confirmed + assert {item.path for item in scan.review_only} == {target} + + +def test_regular_local_bin_executable_requires_exact_owned_path(tmp_path: Path) -> None: + target = tmp_path / ".local/bin/example" + target.parent.mkdir(parents=True) + target.write_text("owned", encoding="utf-8") + target.chmod(0o755) + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset(), + frozenset({"example"}), + frozenset({"example"}), + frozenset({Source.NPM}), + frozenset({target.absolute()}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert {item.path for item in scan.confirmed} == {target} + + +def test_local_bin_symlink_outside_owned_paths_is_review_only(tmp_path: Path) -> None: + owned_root = tmp_path / "owned-install" + owned_root.mkdir() + foreign = tmp_path / "foreign/example" + foreign.parent.mkdir() + foreign.write_text("foreign", encoding="utf-8") + target = tmp_path / ".local/bin/example" + target.parent.mkdir(parents=True) + target.symlink_to(foreign) + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset({"com.vendor.example"}), + frozenset({"example"}), + frozenset({"example"}), + frozenset({Source.APPLICATION, Source.NPM}), + frozenset({owned_root.absolute()}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert not scan.confirmed + assert {item.path for item in scan.review_only} == {target} + + +@pytest.mark.parametrize("remove_target", (False, True), ids=("existing", "dangling")) +def test_local_bin_symlink_into_owned_root_is_confirmed( + tmp_path: Path, + remove_target: bool, +) -> None: + owned_root = tmp_path / "owned-install" + executable = owned_root / "bin/example" + executable.parent.mkdir(parents=True) + executable.write_text("owned", encoding="utf-8") + target = tmp_path / ".local/bin/example" + target.parent.mkdir(parents=True) + target.symlink_to(executable) + if remove_target: + executable.unlink() + identity = ResidualIdentity( + frozenset({"Example"}), + frozenset(), + frozenset({"example"}), + frozenset({"example"}), + frozenset({Source.NPM}), + frozenset({owned_root.absolute()}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert {item.path for item in scan.confirmed} == {target} + + +def test_short_local_bin_name_is_confirmed_only_by_exact_ownership(tmp_path: Path) -> None: + target = tmp_path / ".local/bin/go" + target.parent.mkdir(parents=True) + target.write_text("owned", encoding="utf-8") + target.chmod(0o755) + identity = ResidualIdentity( + frozenset({"go"}), + frozenset(), + frozenset({"go"}), + frozenset({"go"}), + frozenset({Source.GO}), + frozenset({target.absolute()}), + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert {item.path for item in scan.confirmed} == {target} + + @pytest.mark.parametrize("name", ( ".zshrc", ".bashrc", ".bash_profile", ".profile", ".gitconfig", ".ssh", ".gnupg", ".aws", ".kube", From a634e1cbc397c1cd3476366933f06e8de0813333 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:53:53 +0800 Subject: [PATCH 17/22] Harden residual CLI integration tests --- tests/test_cli.py | 89 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index b7c4220..ec848d9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,7 +33,9 @@ class RemoveHarness: primary_calls: list[bool] = field(default_factory=list) residual_calls: list[list[ResidualCandidate]] = field(default_factory=list) shown_scans: list[tuple[str, ResidualScan]] = field(default_factory=list) - verification_calls: list[tuple[CleanupResult, CleanupResult | None, ResidualScan]] = field(default_factory=list) + verification_calls: list[ + tuple[str, CleanupResult, CleanupResult | None, ResidualScan, list[Path]] + ] = field(default_factory=list) scan_calls: int = 0 @@ -91,15 +93,18 @@ def remove(self, analysis, dry_run=False): return harness.primary_result def remove_residuals(self, name, candidates): - harness.events.append("residual") + harness.events.append("residual deletion") harness.residual_calls.append(list(candidates)) return harness.residual_result class FakeResidualScanner: def scan(self, captured_identity): assert captured_identity is harness.identity - harness.events.append("scan") index = harness.scan_calls + if harness.primary_calls == [True]: + harness.events.append("current scan") + else: + harness.events.append(f"scan #{index + 2}") harness.scan_calls += 1 return harness.scans[index] @@ -109,6 +114,10 @@ def fake_capture_identity(item, rule): return harness.identity def fake_show_residual_scan(output_console, scan, heading): + if heading == "Residual scan #2": + harness.events.append("display scan #2") + else: + harness.events.append("display current scan") harness.shown_scans.append((heading, scan)) def fake_show_verification_result( @@ -119,7 +128,10 @@ def fake_show_verification_result( verification, original_remaining, ): - harness.verification_calls.append((primary, residual, verification)) + harness.events.append("renderer") + harness.verification_calls.append( + (name, primary, residual, verification, list(original_remaining)) + ) monkeypatch.setattr(cli, "RuleEngine", FakeRuleEngine) monkeypatch.setattr(cli, "Analyzer", FakeAnalyzer) @@ -233,7 +245,16 @@ def test_real_remove_preserves_residuals_when_second_answer_is_not_y( assert result.exit_code == 0 assert harness.scan_calls == 2 assert harness.residual_calls == [] - assert harness.verification_calls[0][2] is scan_two + assert harness.verification_calls[0][3] is scan_two + assert harness.events == [ + "capture", + "analyze", + "primary", + "scan #2", + "display scan #2", + "scan #3", + "renderer", + ] def test_residual_remove_receives_confirmed_candidates_only( @@ -256,6 +277,44 @@ def test_residual_remove_receives_confirmed_candidates_only( assert review_only not in harness.residual_calls[0] +def test_real_remove_records_complete_order_and_final_renderer_arguments( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + surviving_original = tmp_path / "Example.app" + surviving_original.mkdir() + scan_two = scan_with_candidate() + verification = ResidualScan() + harness = install_remove_harness( + monkeypatch, + scans=[scan_two, verification], + analysis_paths=[surviving_original], + ) + + result = runner.invoke(app, ["remove", "example"], input="y\ny\n") + + assert result.exit_code == 0 + assert harness.events == [ + "capture", + "analyze", + "primary", + "scan #2", + "display scan #2", + "residual deletion", + "scan #3", + "renderer", + ] + assert harness.verification_calls == [ + ( + harness.software.name, + harness.primary_result, + harness.residual_result, + verification, + [surviving_original], + ) + ] + + def test_real_remove_skips_second_prompt_without_confirmed_candidates( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -288,7 +347,9 @@ def test_scan_three_runs_after_primary_cleanup_warnings( assert result.exit_code == 0 assert harness.scan_calls == 2 - assert harness.verification_calls == [(primary_result, None, verification)] + assert harness.verification_calls == [ + (harness.software.name, primary_result, None, verification, []), + ] def test_scan_three_runs_after_partial_residual_failure( @@ -307,7 +368,13 @@ def test_scan_three_runs_after_partial_residual_failure( assert result.exit_code == 0 assert harness.scan_calls == 2 assert harness.verification_calls == [ - (harness.primary_result, residual_result, verification), + ( + harness.software.name, + harness.primary_result, + residual_result, + verification, + [], + ), ] @@ -334,7 +401,13 @@ def test_dry_run_has_one_current_scan_no_mutation_and_no_residual_prompt( result = runner.invoke(app, ["remove", "example", "--dry-run"]) assert result.exit_code == 0 - assert harness.events == ["capture", "analyze", "primary", "scan"] + assert harness.events == [ + "capture", + "analyze", + "primary", + "current scan", + "display current scan", + ] assert harness.primary_calls == [True] assert harness.scan_calls == 1 assert harness.residual_calls == [] From d80d29accc8d7c4835bee252af0f0fd983b332be Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:57:41 +0800 Subject: [PATCH 18/22] Document final residual safety constraints --- .../2026-07-14-generic-residual-cleanup.md | 41 +++++++++---------- ...6-07-14-generic-residual-cleanup-design.md | 19 +++++++-- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md b/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md index a8739a5..1a13fb9 100644 --- a/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md +++ b/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md @@ -130,6 +130,7 @@ class ResidualIdentity: package_ids: frozenset[str] executables: frozenset[str] sources: frozenset[Source] + owned_paths: frozenset[Path] = frozenset() @dataclass(frozen=True, slots=True) @@ -188,28 +189,23 @@ def capture_identity(software: Software, rule: Rule | None) -> ResidualIdentity: package_ids = set(software.identifiers) if software.sources & PACKAGE_SOURCES else set() executables = {path.name for path in software.paths if path.is_file() or path.is_symlink()} bundle_ids: set[str] = set() + owned_paths = {path.absolute() for path in software.paths} if rule: names.update((rule.name, rule.slug, *rule.aliases)) executables.update(rule.kill) package_ids.update(package for values in rule.packages.values() for package in values) + for pattern in rule.path_patterns: + owned_paths.update( + path.absolute() + for path in expand_pattern(pattern) + if path.exists() or path.is_symlink() + ) - for app in _bundle_paths(software, rule): - for info_path in (app / "Contents").rglob("Info.plist"): - try: - payload = plistlib.loads(info_path.read_bytes()) - except (OSError, plistlib.InvalidFileException): - continue - bundle_id = payload.get("CFBundleIdentifier") - if isinstance(bundle_id, str) and bundle_id: - bundle_ids.add(bundle_id) - for key in ("CFBundleName", "CFBundleDisplayName"): - value = payload.get(key) - if isinstance(value, str) and value: - names.add(value) - executable = payload.get("CFBundleExecutable") - if isinstance(executable, str) and executable: - executables.add(executable) + # Read the main Contents/Info.plist directly. Enumerate only direct children + # of Contents/Frameworks (.app), XPCServices (.xpc), PlugIns (.appex), and + # Library/LoginItems (.app); retain embedded metadata only when its bundle ID + # equals or starts with the main bundle ID followed by `.`, `-`, or `_`. return ResidualIdentity( frozenset(value for value in names if value), @@ -217,10 +213,11 @@ def capture_identity(software: Software, rule: Rule | None) -> ResidualIdentity: frozenset(value for value in package_ids if value), frozenset(value for value in executables if value), frozenset(software.sources), + frozenset(owned_paths), ) ``` -The `rglob` is allowed only inside an already-known `.app` bundle during identity capture; it is never used on `$HOME` or a residual root. +Do not recursively traverse an app bundle. Foreign framework/resource metadata such as shared Sparkle or Sentry identities is discarded. Capture absolute, non-resolved `Software.paths` and concrete existing rule-expanded paths in `owned_paths`; never add them to package IDs. - [ ] **Step 5: Verify GREEN and regressions** @@ -365,7 +362,7 @@ XDG_ROOTS = { } ``` -`ResidualScanner._roots()` returns `(root, system_level, kind)` tuples for user Library roots, effective XDG roots, `~/.local/bin`, `$HOME` direct entries, and injected/default system roots. A custom XDG variable replaces, rather than supplements, its default. +`ResidualScanner._roots()` tracks lexical/display roots separately from canonical physical roots for user Library roots, effective XDG roots, `~/.local/bin`, `$HOME` direct entries, and injected/default system roots. A custom XDG variable replaces, rather than supplements, its default. Merge kinds and system scope by case-insensitive physical identity, scan one display root per physical root, and reject roots resolving below `/System` or `/usr`. - [ ] **Step 4: Implement direct-child scanning and strong matching** @@ -386,7 +383,7 @@ Human-name comparisons use `unicodedata.normalize("NFC", value).casefold()` and - Exact bundle-ID basename and boundary suffixes are confirmed. - Exact full display name is confirmed only when `bundle_ids` or `package_ids` provides a second signal. - Exact package basename or unscoped package leaf is confirmed in approved XDG/application-data roots. -- `~/.local/bin` confirms only executable files or symlinks whose basename exactly matches an executable identity. +- `~/.local/bin` confirms a matching regular executable only for exact `owned_paths` equality, and confirms a matching symlink only when its non-strict lexical target is equal to or below an owned installation path. Other basename matches are review-only. - `$HOME` considers direct hidden entries only; it does not recurse. - `Group Containers`, `/Library/Extensions`, and other platform-managed extension roots are not confirmed in this task. @@ -517,7 +514,7 @@ Add exact exclusions for protected shell/security entries and shared package roo ```python PROTECTED_HOME_ENTRIES = { - ".zshrc", ".bashrc", ".bash_profile", ".profile", ".gitconfig", + ".local", ".config", ".cache", ".zshrc", ".bashrc", ".bash_profile", ".profile", ".gitconfig", ".ssh", ".gnupg", ".aws", ".kube", } SHARED_HOME_ENTRIES = {".cargo", ".npm", ".pnpm-store", ".rustup", "go"} @@ -531,11 +528,13 @@ Apply these rules: - Display-name-only and direct-home-dotfile matches are review-only without a bundle/package second signal. - `Group Containers` and platform-managed roots are review-only, never confirmed. - Protected and shared entries are excluded entirely. +- HOME candidates equal to or containing any approved root are excluded with case-insensitive comparisons, including custom nested XDG roots. +- Physical-root aliases inherit merged cache, platform-managed, and system-scope policy and cannot produce duplicate physical candidates. - A nonexistent approved root is skipped. Any other `OSError` while checking or enumerating an existing root appends that root to `inaccessible_roots` and scanning continues. - Collapse any candidate whose path is below an already-selected confirmed parent. - Populate `running_pids` by comparing process name and the first two command basenames to exact case-folded names/executables; substring matching is allowed only for normalized signals of length at least four. -Implement `is_approved_residual_path()` by requiring a candidate to be a direct child of a currently approved root and never the root itself. Do not use `resolve()` on the candidate. +Implement `is_approved_residual_path()` by requiring the configured lexical parent and canonical physical parent to match a currently approved root. Never resolve the final candidate entry; use `lstat()` so a candidate symlink is unlinked rather than followed. - [ ] **Step 4: Verify GREEN and full scanner regression** diff --git a/docs/superpowers/specs/2026-07-14-generic-residual-cleanup-design.md b/docs/superpowers/specs/2026-07-14-generic-residual-cleanup-design.md index 210e67a..c23a4f7 100644 --- a/docs/superpowers/specs/2026-07-14-generic-residual-cleanup-design.md +++ b/docs/superpowers/specs/2026-07-14-generic-residual-cleanup-design.md @@ -96,6 +96,7 @@ class ResidualIdentity: package_ids: frozenset[str] executables: frozenset[str] sources: frozenset[Source] + owned_paths: frozenset[Path] = frozenset() @dataclass(frozen=True, slots=True) @@ -170,16 +171,20 @@ Identity must be captured before primary deletion. ### Applications and Homebrew casks -For each `.app` path, read available `Info.plist` files and collect: +For each `.app` path, read its main `Contents/Info.plist` directly and collect: - `CFBundleIdentifier` - `CFBundleName` - `CFBundleDisplayName` - `CFBundleExecutable` -- bundle IDs for embedded helpers, XPC services, app extensions, and login items +- bundle IDs for explicitly supported target-linked embedded helpers, XPC services, app extensions, and login items Bundle IDs remain byte-exact. Human-readable names may use Unicode normalization and case-insensitive comparison. +Embedded metadata is limited to direct `.app` children of `Contents/Frameworks`, direct `.xpc` children of `Contents/XPCServices`, direct `.appex` children of `Contents/PlugIns`, and direct `.app` children of `Contents/Library/LoginItems`. An embedded bundle is captured only when its bundle ID equals the main bundle ID or begins with it followed by `.`, `-`, or `_`. Foreign framework and resource metadata, including shared Sparkle or Sentry identities, is discarded; identity capture never recursively traverses the app bundle. + +`owned_paths` stores absolute, non-resolved paths recorded by `Software` plus concrete existing paths expanded from the matched rule. These paths remain separate from package identifiers. + ### Homebrew formula and cask Capture the exact formula/cask token, the full package name already reported by the provider, and any paths already present on `Software`. The first implementation does not add extra Homebrew metadata subprocesses. @@ -242,12 +247,16 @@ $XDG_STATE_HOME or ~/.local/state Treat `~/.local/bin` as a separate user executable root. Only direct entries are considered, and the binary root is limited to executable files and symlinks associated with captured executable names. +A matching regular executable is confirmed only when its exact absolute path is in `owned_paths`. A matching symlink is confirmed only when its non-strict lexical target is equal to or below an owned installation path; this remains provable after the target is removed and the link becomes dangling. All other executable-name matches are review-only. + ### Home-directory direct entries The scanner may enumerate direct hidden children of `$HOME`, but never recurse from `$HOME`. An entry such as `~/.tool-name` or `~/.tool-namerc` is confirmed only with an exact, sufficiently distinctive name and a second identity signal. Short or generic names remain review-only. +A direct HOME candidate is excluded if it equals or contains any approved root, using case-insensitive comparisons. This protects standard `.local`, `.config`, and `.cache` entries and custom nested roots such as `~/.vendor/config`. + The following are never deleted as whole files or directories: ```text @@ -285,6 +294,8 @@ Shell configuration cleanup is outside this feature unless an exact installer-ow `/System` is never scanned or modified. Package receipts may be queried as metadata but are not directly deleted. +Each lexical root is paired with its canonical physical root. Roots that resolve to the same case-insensitive physical identity are scanned once and merge their cache, platform-managed, and system-scope metadata. Roots resolving below `/System` or `/usr` are rejected. Deletion approval rechecks that the lexical candidate parent is configured and its canonical physical parent still equals the approved physical root; the final candidate entry itself is never resolved. + System candidates require stronger evidence than user-level candidates. Lack of read permission makes a root unverified; lack of write permission marks a candidate as requiring administrator access. The feature never invokes `sudo` automatically. ## Candidate Classification @@ -299,7 +310,7 @@ A path may enter the second `y/Y` deletion list when at least one strong ownersh - the path is a standard suffix of an exact bundle ID, such as `.plist`, `.savedState`, or `.binarycookies`; - a standard application-data root contains a direct child exactly equal to the full application name, and the target also has a bundle or exact package identity; - a launchd plist has an exact associated label and a target-linked program path; -- an executable or symlink exactly matches a captured executable and points into the removed installation root. +- a regular executable exactly matches a captured executable and its exact path is source-owned, or a matching symlink points into a captured owned installation root. ### Review only @@ -339,7 +350,7 @@ Consequently, Chrome data such as `Google/Chrome/Default/IndexedDB/https_poe.com Every confirmed candidate must pass the existing protected-path validation and these additional checks: 1. Use `lstat`; never follow a symlink to delete its destination. -2. Confirm the candidate is still below an approved root and is not the root itself. +2. Confirm the candidate's configured lexical parent and canonical physical parent still identify the same approved root and that the candidate is not that root or an ancestor of another approved root. 3. Record device, inode, and file type during scan #2. 4. Recheck device, inode, and file type immediately before deletion. 5. Refuse deletion if the object changed after preview. From 4e2b95fab6f7b05a483309e7c1276cb517650a17 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:27:44 +0800 Subject: [PATCH 19/22] Reject residual root container candidates --- cleanapp/residual_scanner.py | 72 ++++++++++++----- tests/test_residual_scanner.py | 142 +++++++++++++++++++++++++++++---- 2 files changed, 177 insertions(+), 37 deletions(-) diff --git a/cleanapp/residual_scanner.py b/cleanapp/residual_scanner.py index 9e89757..9b9bf91 100644 --- a/cleanapp/residual_scanner.py +++ b/cleanapp/residual_scanner.py @@ -389,6 +389,40 @@ def _contains_path(parent: Path, child: Path) -> bool: return parent != child +def _collapse_candidates( + confirmed: list[ResidualCandidate], + review_only: list[ResidualCandidate], +) -> tuple[list[ResidualCandidate], list[ResidualCandidate]]: + confirmed_paths = {candidate.path for candidate in confirmed} + return ( + [ + candidate for candidate in confirmed + if not any( + _contains_path(parent, candidate.path) + for parent in confirmed_paths + ) + ], + [ + candidate for candidate in review_only + if not any( + _contains_path(parent, candidate.path) + for parent in confirmed_paths + ) + ], + ) + + +def _physical_directory_contains_approved_root( + path: Path, + metadata: os.stat_result, + roots: tuple[_ApprovedRoot, ...], +) -> bool: + if not stat.S_ISDIR(metadata.st_mode): + return False + physical = _physical(path) + return any(_same_or_below(root.physical, physical) for root in roots) + + def _exact_owned_path(path: Path, owned_paths: frozenset[Path]) -> bool: path_key = _path_key(_absolute(path)) return any(path_key == _path_key(_absolute(owned)) for owned in owned_paths) @@ -406,11 +440,6 @@ def _symlink_target_is_owned(path: Path, owned_paths: frozenset[Path]) -> bool: return any(_same_or_below(target, _absolute(owned)) for owned in owned_paths) -def _approved_roots(home: Path | None = None) -> tuple[Path, ...]: - specs = _root_specs(home or Path.home(), SYSTEM_ROOTS) - return tuple(alias for root in specs for alias in root.aliases) - - def is_approved_residual_path(path: Path) -> bool: """Return whether *path* is exactly one level below an approved scan root.""" candidate = _absolute(path) @@ -430,6 +459,15 @@ def is_approved_residual_path(path: Path) -> bool: if root is None: return False + if _contains_approved_root(candidate, roots): + return False + try: + metadata = candidate.lstat() + except OSError: + return False + if _physical_directory_contains_approved_root(candidate, metadata, roots): + return False + if "home" in root.kinds: return ( candidate.name.startswith(".") @@ -437,7 +475,6 @@ def is_approved_residual_path(path: Path) -> bool: candidate.name, PROTECTED_HOME_ENTRIES | SHARED_HOME_ENTRIES, ) - and not _contains_approved_root(candidate, roots) ) if _is_excluded(candidate, root.physical, root.kinds): return False @@ -516,7 +553,7 @@ def scan(self, identity: ResidualIdentity) -> ResidualScan: for path in children: if _is_excluded(path, root.physical, root.kinds): continue - if "home" in root.kinds and _contains_approved_root(path, roots): + if _contains_approved_root(path, roots): continue try: metadata = path.lstat() @@ -527,6 +564,8 @@ def scan(self, identity: ResidualIdentity) -> ResidualScan: evidence, confirmed = self._match(path, metadata, root.kinds, identity) if evidence is None: continue + if _physical_directory_contains_approved_root(path, metadata, roots): + continue candidate = ResidualCandidate( path, evidence, @@ -540,21 +579,10 @@ def scan(self, identity: ResidualIdentity) -> ResidualScan: else: result.review_only.append(candidate) - confirmed_paths = {candidate.path for candidate in result.confirmed} - result.confirmed = [ - candidate for candidate in result.confirmed - if not any( - _contains_path(parent, candidate.path) - for parent in confirmed_paths - ) - ] - result.review_only = [ - candidate for candidate in result.review_only - if not any( - _contains_path(parent, candidate.path) - for parent in confirmed_paths - ) - ] + result.confirmed, result.review_only = _collapse_candidates( + result.confirmed, + result.review_only, + ) result.confirmed.sort(key=lambda candidate: str(candidate.path).casefold()) result.review_only.sort(key=lambda candidate: str(candidate.path).casefold()) result.inaccessible_roots.sort(key=lambda root: str(root).casefold()) diff --git a/tests/test_residual_scanner.py b/tests/test_residual_scanner.py index 68ab4b2..e0cb4d3 100644 --- a/tests/test_residual_scanner.py +++ b/tests/test_residual_scanner.py @@ -1,9 +1,10 @@ from pathlib import Path +import stat import pytest import cleanapp.residual_scanner as residual_scanner -from cleanapp.models import ResidualIdentity, Rule, Software, Source +from cleanapp.models import ResidualCandidate, ResidualIdentity, Rule, Software, Source from cleanapp.residual_scanner import ( ResidualScanner, capture_identity, @@ -723,21 +724,131 @@ def test_target_owned_hidden_home_entry_remains_eligible(tmp_path: Path) -> None assert {item.path for item in scan.confirmed} == {target} -def test_parent_candidate_collapses_child_candidate(tmp_path: Path) -> None: - parent = tmp_path / "Library/Application Support/Example" - child_root = parent / "nested-root" - child = child_root / "com.vendor.example" - child.mkdir(parents=True) - scanner = ResidualScanner(home=tmp_path, system_roots=(child_root,)) - identity = ResidualIdentity( - frozenset({"Example"}), - frozenset({"com.vendor.example"}), - frozenset(), - frozenset(), - frozenset({Source.APPLICATION}), +def test_candidate_collapse_removes_children_of_confirmed_parent(tmp_path: Path) -> None: + parent_path = tmp_path / "Example" + child_path = parent_path / "child" + review_path = parent_path / "review" + parent = ResidualCandidate(parent_path, "parent", False, 1, 1, stat.S_IFDIR) + child = ResidualCandidate(child_path, "child", False, 1, 2, stat.S_IFREG) + review = ResidualCandidate(review_path, "review", False, 1, 3, stat.S_IFREG) + + confirmed, review_only = residual_scanner._collapse_candidates( + [parent, child], + [review], + ) + + assert confirmed == [parent] + assert review_only == [] + + +def test_shared_byhost_root_is_never_a_candidate( + tmp_path: Path, + monkeypatch, +) -> None: + byhost = tmp_path / "Library/Preferences/ByHost" + byhost.mkdir(parents=True) + app = tmp_path / "Applications/ByHost.app" + write_bundle( + app, + CFBundleIdentifier="com.vendor.byhost", + CFBundleName="ByHost", + ) + monkeypatch.setenv("HOME", str(tmp_path)) + identity = capture_identity( + Software("ByHost", {Source.APPLICATION}, {"ByHost"}, {app}), + None, + ) + + scan = ResidualScanner(home=tmp_path, system_roots=()).scan(identity) + + assert byhost not in {item.path for item in (*scan.confirmed, *scan.review_only)} + assert not is_approved_residual_path(byhost) + + +def test_candidate_containing_nested_non_home_xdg_root_is_rejected( + tmp_path: Path, + monkeypatch, +) -> None: + config_root = tmp_path / "config" + candidate = config_root / "vendor" + data_root = candidate / "data" + data_root.mkdir(parents=True) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_root)) + monkeypatch.setenv("XDG_DATA_HOME", str(data_root)) + + scan = ResidualScanner(home=tmp_path / "home", system_roots=()).scan( + package_identity("vendor") ) - scan = scanner.scan(identity) - assert {item.path for item in scan.confirmed} == {parent} + + assert candidate not in {item.path for item in (*scan.confirmed, *scan.review_only)} + assert not is_approved_residual_path(candidate) + + +def test_regular_directory_containing_physical_root_alias_is_rejected( + tmp_path: Path, + monkeypatch, +) -> None: + config_root = tmp_path / "config" + candidate = config_root / "example" + physical_data_root = candidate / "data" + physical_data_root.mkdir(parents=True) + data_alias = tmp_path / "data-alias" + data_alias.symlink_to(physical_data_root, target_is_directory=True) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_root)) + monkeypatch.setenv("XDG_DATA_HOME", str(data_alias)) + + scan = ResidualScanner(home=tmp_path / "home", system_roots=()).scan( + package_identity("example") + ) + + assert candidate not in {item.path for item in (*scan.confirmed, *scan.review_only)} + assert not is_approved_residual_path(candidate) + + +def test_final_symlink_to_approved_physical_root_remains_unlinkable( + tmp_path: Path, + monkeypatch, +) -> None: + config_root = tmp_path / "config" + config_root.mkdir() + data_root = tmp_path / "data" + data_root.mkdir() + candidate = config_root / "example" + candidate.symlink_to(data_root, target_is_directory=True) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_root)) + monkeypatch.setenv("XDG_DATA_HOME", str(data_root)) + + scan = ResidualScanner(home=tmp_path / "home", system_roots=()).scan( + package_identity("example") + ) + + assert {item.path for item in scan.confirmed} == {candidate} + assert is_approved_residual_path(candidate) + + +def test_deletion_boundary_rejects_candidate_lstat_failure( + tmp_path: Path, + monkeypatch, +) -> None: + config_root = tmp_path / "config" + candidate = config_root / "example" + candidate.parent.mkdir() + candidate.write_text("example", encoding="utf-8") + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_root)) + original = Path.lstat + + def failing_lstat(path: Path): + if path == candidate: + raise PermissionError("denied") + return original(path) + + monkeypatch.setattr(Path, "lstat", failing_lstat) + + assert not is_approved_residual_path(candidate) def test_system_candidate_is_marked_admin_scoped(tmp_path: Path) -> None: @@ -1122,6 +1233,7 @@ def test_approved_residual_path_preserves_excluded_and_managed_entries( monkeypatch, ) -> None: cache = tmp_path / "custom-cache" + (tmp_path / ".example").mkdir() monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_CACHE_HOME", str(cache)) From 01d6e4b431e42b136aa284694968dc3e628c8bce Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:28:58 +0800 Subject: [PATCH 20/22] Record residual confirmation chronology --- tests/test_cli.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index ec848d9..0e971a2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -133,6 +133,13 @@ def fake_show_verification_result( (name, primary, residual, verification, list(original_remaining)) ) + actual_prompt = cli.typer.prompt + + def recording_prompt(text, *args, **kwargs): + if str(text).startswith("Delete ") and "confirmed residual paths" in str(text): + harness.events.append("residual prompt") + return actual_prompt(text, *args, **kwargs) + monkeypatch.setattr(cli, "RuleEngine", FakeRuleEngine) monkeypatch.setattr(cli, "Analyzer", FakeAnalyzer) monkeypatch.setattr(cli, "Remover", FakeRemover) @@ -142,6 +149,7 @@ def fake_show_verification_result( monkeypatch.setattr(cli, "show_result", lambda *args, **kwargs: None) monkeypatch.setattr(cli, "show_residual_scan", fake_show_residual_scan, raising=False) monkeypatch.setattr(cli, "show_verification_result", fake_show_verification_result, raising=False) + monkeypatch.setattr(cli.typer, "prompt", recording_prompt) monkeypatch.setattr(cli, "configure_logging", lambda: None) monkeypatch.setattr(cli, "_scan", lambda: [harness.software]) return harness @@ -252,6 +260,7 @@ def test_real_remove_preserves_residuals_when_second_answer_is_not_y( "primary", "scan #2", "display scan #2", + "residual prompt", "scan #3", "renderer", ] @@ -300,6 +309,7 @@ def test_real_remove_records_complete_order_and_final_renderer_arguments( "primary", "scan #2", "display scan #2", + "residual prompt", "residual deletion", "scan #3", "renderer", From fdbe47478a20c20831ed2ace57a00f20fd2a0ce0 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:29:25 +0800 Subject: [PATCH 21/22] Align residual collapse plan fixture --- .../2026-07-14-generic-residual-cleanup.md | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md b/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md index 1a13fb9..ef6ca08 100644 --- a/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md +++ b/docs/superpowers/plans/2026-07-14-generic-residual-cleanup.md @@ -449,18 +449,22 @@ def test_shared_package_stores_are_excluded(tmp_path: Path, relative: str) -> No assert target not in {item.path for item in (*scan.confirmed, *scan.review_only)} -def test_parent_candidate_collapses_child_candidate(tmp_path: Path) -> None: - parent = tmp_path / "Library/Application Support/Example" - child_root = parent / "nested-root" - child = child_root / "com.vendor.example" - child.mkdir(parents=True) - scanner = ResidualScanner(home=tmp_path, system_roots=(child_root,)) - identity = ResidualIdentity( - frozenset({"Example"}), frozenset({"com.vendor.example"}), - frozenset(), frozenset(), frozenset({Source.APPLICATION}), +def test_candidate_collapse_removes_children_of_confirmed_parent(tmp_path: Path) -> None: + parent_path = tmp_path / "Example" + parent = ResidualCandidate( + parent_path, "parent", False, 1, 1, stat.S_IFDIR, + ) + child = ResidualCandidate( + parent_path / "child", "child", False, 1, 2, stat.S_IFREG, ) - scan = scanner.scan(identity) - assert {item.path for item in scan.confirmed} == {parent} + review = ResidualCandidate( + parent_path / "review", "review", False, 1, 3, stat.S_IFREG, + ) + + confirmed, review_only = _collapse_candidates([parent, child], [review]) + + assert confirmed == [parent] + assert review_only == [] def test_system_candidate_is_marked_admin_scoped(tmp_path: Path) -> None: From 6ffbfa44ea1de3595444b97e9ee30b8303378018 Mon Sep 17 00:00:00 2001 From: BruceL017 <253661133+BruceL017@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:49:39 +0800 Subject: [PATCH 22/22] Make reporter assertions wrap-safe --- tests/test_reporter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_reporter.py b/tests/test_reporter.py index 1b6e54f..1a7cc9d 100644 --- a/tests/test_reporter.py +++ b/tests/test_reporter.py @@ -74,6 +74,7 @@ def test_show_residual_scan_groups_candidates_with_measurement_and_status( console = Console(record=True, width=140) show_residual_scan(console, scan, "Residual scan #2") output = rendered(console) + unwrapped_output = output.replace("\n", "") assert "Residual scan #2" in output assert "Confirmed deletable" in output @@ -87,8 +88,8 @@ def test_show_residual_scan_groups_candidates_with_measurement_and_status( assert "permission: limited" in output assert "bundle identifier com.vendor.app" in output assert "display name only" in output - assert str(tmp_path / "blocked") in output - assert "permission denied or unreadable" in output + assert str(tmp_path / "blocked") in unwrapped_output + assert "permission denied or unreadable" in unwrapped_output def test_show_residual_scan_displays_unknown_when_measurement_fails(