fix(uninstall): clean removed-only target files - #2658
Daniel Meppiel (danielmeppiel) merged 3 commits into
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a global uninstall edge case where tracked files deployed under a target used only by the removed package could be orphaned after uninstall. It does so by running target-scoped reconciliation before mutating/removing the manifest and lockfile state, and by scoping reconciliation to only the removed dependency keys.
Changes:
- Invoke target-scoped deployed-file reconciliation early in
apm uninstall --global, before state removal, and abort uninstall (preserving state) if tracked files remain. - Extend
reconcile_target_deployed_files()to support optionaldependency_keysscoping and apply user-scope target profiles when classifying legacy ledger entries. - Add a static architecture check enforcing the uninstall -> manifest reconciliation route, plus unit/integration regression coverage for the orphaned-file scenario.
Show a summary per file
| File | Description |
|---|---|
| src/apm_cli/commands/uninstall/cli.py | Runs target-file reconciliation before manifest/lockfile mutation; aborts uninstall if tracked files remain. |
| src/apm_cli/install/manifest_reconcile.py | Adds dependency-key scoping and user-scope target profile handling for reconciliation/ledger classification. |
| scripts/check_target_instruction_contraction_owner.py | Extends the contraction-owner static check to cover uninstall routing. |
| tests/unit/install/test_target_contraction_logging.py | Adds regression test ensuring selected removed ownership is cleaned without affecting same-target survivors. |
| tests/unit/scripts/test_check_target_instruction_contraction_owner.py | Adds mutation test proving the new uninstall routing rule is enforced. |
| tests/integration/test_global_scope_e2e.py | Adds end-to-end test that reproduces and prevents the global uninstall orphan-file case. |
Review details
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
| cleanup_target_names = list(APMPackage.from_apm_yml(manifest_path).canonical_targets) | ||
| cleanup_targets = resolve_targets( | ||
| deploy_root, | ||
| user_scope=scope is InstallScope.USER, | ||
| explicit_target=cleanup_target_names or None, | ||
| ) | ||
| reconcile_target_deployed_files( | ||
| project_root=deploy_root, | ||
| lockfile=lockfile, | ||
| active_targets=cleanup_targets, | ||
| declared_targets=cleanup_targets, | ||
| diagnostics=DiagnosticCollector(verbose=verbose), | ||
| dependency_keys=removed_keys, | ||
| user_scope=scope is InstallScope.USER, | ||
| ) | ||
| retained_removed_files = { | ||
| dep_key: dep.deployed_files | ||
| for dep_key, dep in lockfile.dependencies.items() | ||
| if dep_key in removed_keys and dep.deployed_files | ||
| } | ||
| if retained_removed_files: | ||
| raise RuntimeError( | ||
| "Uninstall could not remove all tracked target files; " | ||
| "package state was preserved. Resolve the retained files and retry." | ||
| ) |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 1 | 0 | 0 | Declared-target uninstall needed ownership-aware cleanup. |
| CLI Logging Expert | 1 | 0 | 0 | Pre-mutation cleanup needed command-visible diagnostics. |
| DevX UX Expert | 0 | 1 | 0 | Retained paths must be named before retry. |
| Supply Chain Security Expert | 1 | 0 | 0 | Same-target cleanup must preserve survivor-owned files. |
| OSS Growth Hacker | 0 | 2 | 0 | Recovery and release messaging should be actionable. |
| Doc Writer | 0 | 1 | 1 | Retry-safe behavior and ordering needed reference updates. |
| Test Coverage Expert | 1 | 0 | 0 | Edited-file state preservation needed lifecycle coverage. |
| Performance Expert | 1 | 0 | 0 | Selected cleanup needed to avoid repeated full projections. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Python Architect] (blocking-severity) Repair declared-target
uninstall so active-target retention is not classified as failure. - [Supply Chain Security Expert] (blocking-severity) Preserve
survivor-owned paths during same-target package removal. - [CLI Logging Expert] (blocking-severity) Surface cleanup through the
command logger and cover emitted diagnostics. - [Performance Expert] (blocking-severity) Avoid repeated full legacy
projection rebuilds for selected ownership cleanup. - [Test Coverage Expert] (blocking-severity) Add a lifecycle regression
for edited files that preserves state for retry.
Recommendation
The indicated follow-ups have been folded into the recovery iteration. CI
evidence on the updated head will determine the next advisory pass.
Full per-persona findings
Python Architect
- [blocking] Declared-target uninstall classified expected active-target
retention as failure. The current recovery keeps physical deletion in the
canonical reconciler while preserving legacy ownership for the existing
survivor handoff.
CLI Logging Expert
- [blocking] Pre-mutation cleanup needed default-visible output. The
recovery routes cleanup throughCommandLoggermethods.
DevX UX Expert
- [recommended] Retained paths need an actionable retry message. The
recovery lists every retained path and tells the user to resolve it.
Supply Chain Security Expert
- [blocking] Same-target survivor ownership must not be deleted. The
recovery separates shared survivor paths from unique removed paths.
OSS Growth Hacker
- [recommended] The retry path and release narrative need to be
user-actionable. The recovery adds a changelog entry.
Auth Expert -- inactive
The touched uninstall, manifest reconciliation, checker, and test files do not
modify token handling, credential resolution, remote operations, or
authentication diagnostics.
Doc Writer
- [recommended] The reference uninstall behavior needed the retry-safe
failure mode and corrected ordering.
Test Coverage Expert
- [blocking] Edited-file retry behavior lacked an integration regression.
The recovery adds a current-source global lifecycle test.
Performance Expert
- [blocking] Selected cleanup needed to avoid repeated full projection
rebuilds. The recovery batches compatibility ownership updates.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7f609216-e959-4b6e-9b22-6ffe2d1a5044
apm-spec-waiver: Restores existing target ownership cleanup; no new OpenAPM contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7f609216-e959-4b6e-9b22-6ffe2d1a5044
fix(uninstall): clean removed-only target files
TL;DR
Global uninstall now cleans files owned only by removed packages before deleting the ownership state needed to find them. The canonical manifest reconciliation owner performs the cleanup without rewriting shared ownership before the existing survivor handoff. An edited file aborts with its path and preserves manifest, lockfile, and module state for retry. Closes #2656.
Important
Cleanup failure preserves package state and names every retained path so users can resolve it and rerun uninstall.
Problem (WHY)
Approach (WHAT)
reconcile_target_deployed_files().Implementation (HOW)
src/apm_cli/commands/uninstall/cli.py-- invokes selected-owner cleanup before state mutation, routes visibility throughCommandLogger, and names retained paths before exiting for retry.src/apm_cli/install/manifest_reconcile.py-- adds selected-owner physical cleanup that preserves shared legacy ownership, skips URI resources, scopes existing ledger records by value, and batches normal compatibility-view updates.src/apm_cli/core/deployment_ledger.pyandsrc/apm_cli/core/command_logger.py-- centralize batch legacy projection and the command-level cleanup messages.Diagrams
Legend: selected-owner cleanup deletes unique filesystem paths before state mutation, while shared ownership and URI resources continue through their existing handoff paths.
flowchart LR U[uninstall] --> R[reconcile target deployed files] R --> F[delete unique filesystem paths] R --> H[preserve shared ownership and URI resources] F --> S[remove manifest lockfile module state] H --> S classDef new stroke-dasharray: 5 5; class R,F new;Trade-offs
Benefits
Validation
Warning
Local pylint could not run because
uvcould not fetchsetuptools/wheel:Socket is not connected (os error 57). GitHub CI ran pylint successfully in its passing Lint job.Scenario Evidence
tests/integration/test_global_scope_e2e.py::TestGlobalUninstallLifecycle::test_uninstall_global_cleans_removed_only_target_before_state_removal(regression-trap for #2656)tests/unit/test_uninstall_shared_ownership.py::test_uninstall_transfers_shared_deployed_path_ownershiptests/integration/test_global_scope_e2e.py::TestGlobalUninstallLifecycle::test_uninstall_global_preserves_state_for_user_edited_removed_target_filetests/integration/test_architecture_authorities.py::test_target_instruction_contraction_uses_manifest_reconciliationHow to test
tests/unit/test_uninstall_shared_ownership.pyand confirm a shared file is retained for the survivor.bash scripts/lint-architecture-boundaries.shand confirm the canonical route is accepted.apm-spec-waiver: Restores existing target ownership cleanup; no new OpenAPM contract.
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com