fix(install): prune lockfile when all dependencies are removed from manifest#1926
Conversation
…anifest Removing every apm dependency from apm.yml left the lockfile listing the dropped deps. The deployed-file cleanup phase ran (files were removed), but the lockfile builder early-returned on empty installed_packages before the orphan-prune path (_merge_existing) could rebuild a manifest-matching lockfile. Partial removal (one of several deps) already pruned correctly, because installed_packages was non-empty and the full path ran. Guard the early-return with _has_orphan_lockfile_entries(): fall through to the rebuild/prune path when the existing lockfile records apm deps the manifest no longer declares. Suppressed for only_packages installs, which intentionally preserve unlisted entries, and ignores the "." self-entry. No-op installs with deps still present do not churn the lockfile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a lockfile correctness gap in the install pipeline: when a project removes all APM dependencies from apm.yml, apm install now prunes orphaned entries from apm.lock.yaml instead of leaving stale locked dependencies behind.
Changes:
- Adjusts
LockfileBuilder.build_and_save()to skip the “nothing installed” early-return when the existing lockfile contains dependency entries that are no longer intended by the manifest. - Introduces
_has_orphan_lockfile_entries()to detect orphaned lockfile deps (while suppressing the check foronly_packagespartial installs and ignoring the"."self-entry). - Adds unit tests covering orphan detection edge cases.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/apm_cli/install/phases/lockfile.py |
Avoids early-return when orphans exist; adds _has_orphan_lockfile_entries() guard logic. |
tests/unit/install/phases/test_lockfile_orphan_prune.py |
Adds regression tests for orphan detection, including only_packages and "." self-entry handling. |
| def build_and_save(self) -> None: | ||
| """Assemble lockfile from ctx state and write it (no-op when nothing was installed).""" |
| except Exception as e: | ||
| self._handle_failure(e) | ||
|
|
||
| # -- private helpers (verbatim from original inline block) ---------- |
Add a build_and_save regression trap that exercises the empty-manifest prune path end to end and records the user-visible lockfile result. Also add the unreleased changelog entry for PR microsoft#1926. Addresses CEO follow-up from test-coverage-expert on PR microsoft#1926. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 0 | 1 | Clean, minimal guard in LockfileBuilder; no architecture concern. |
| CLI Logging Expert | 0 | 0 | 0 | No CLI output or logging paths changed. |
| DevX UX Expert | 0 | 0 | 0 | Restores the expected package-manager mental model. |
| Supply Chain Security Expert | 0 | 0 | 1 | Security-positive lockfile-manifest consistency; no new risk surface. |
| OSS Growth Hacker | 0 | 0 | 0 | Community contribution is now credited in CHANGELOG. |
| Doc Writer | 0 | 0 | 0 | CHANGELOG entry is concise and format-correct. |
| Test Coverage Expert | 0 | 0 | 0 | Seven passing regression tests cover helper and write-path behavior. |
| Performance Expert | 0 | 0 | 0 | No perf regression; O(N) scan only on the empty-install edge path. |
B = highest-signal findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Architecture
classDiagram
direction LR
class LockfileBuilder {
<<Facade>>
+build_and_save() None
-_has_orphan_lockfile_entries() bool
-_merge_existing(lockfile) None
}
class InstallContext {
<<ValueObject>>
+installed_packages list
+existing_lockfile LockFile
+intended_dep_keys set
+only_packages list
+lockfile_only bool
}
class LockFile {
+dependencies dict
+from_installed_packages() LockFile
+save(path) None
}
LockfileBuilder *-- InstallContext : reads
LockfileBuilder ..> LockFile : builds
class LockfileBuilder:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["build_and_save()"] --> B{"installed_packages empty and not lockfile_only?"}
B -->|no| C["build lockfile normally"]
B -->|yes| D{"_has_orphan_lockfile_entries()?"}
D -->|no| E["sync cache pin markers and return"]
D -->|yes| C
C --> F["_merge_existing() drops orphans"]
F --> G["_write_if_changed()"]
Recommendation
Ship now. The in-scope follow-ups were folded into this PR, CI is green, and remaining nits are either optional refactor ideas or defensive polish outside this bug-fix scope.
Folded in this run
- (panel) Add a hermetic
build_and_saveregression test for empty-manifest lockfile pruning -- resolved in 25a4f2e. - (panel) Add a user-facing CHANGELOG entry crediting @nadav-y -- resolved in 25a4f2e.
Deferred (out-of-scope follow-ups)
These items were surfaced by the panel or Copilot but cross the stated scope of this PR. Each one suggests a separate follow-up.
- (panel) Extract shared orphan-detection helper if another caller appears -- scope boundary: PR scope is one lockfile prune bug; extracting a cross-command helper would refactor adjacent install surfaces without current need.
Regression-trap evidence (mutation-break gate)
tests/unit/install/phases/test_lockfile_orphan_prune.py::TestHasOrphanLockfileEntries::test_build_and_save_prunes_orphans_when_manifest_is_empty-- deletedand not self._has_orphan_lockfile_entries(); test FAILED as expected; guard restored.
Lint contract
uv run --extra dev ruff check src/ tests/, uv run --extra dev ruff format --check src/ tests/, pylint R0801, and bash scripts/lint-auth-signals.sh passed before push.
CI
All PR checks passed on head 25a4f2e8db24a349d90803cabc0e31d6f0d72d16: Lint, Build & Test Shard 1/2, Coverage Combine, PR Binary Smoke, APM Self-Check, Analyze, CodeQL, Spec conformance gate, NOTICE Drift Check, gate, and license/cla (after 0 CI fix iteration(s)).
Mergeability status
Captured from gh pr view 1926 --json mergeable,mergeStateStatus,statusCheckRollup immediately after the last push of this run.
| PR | head SHA | CEO stance | iters | folds | defers | Copilot rounds | CI | mergeable | mergeStateStatus | notes |
|---|---|---|---|---|---|---|---|---|---|---|
| #1926 | 25a4f2e |
ship_now | 1 | 2 | 1 | 2 | green | MERGEABLE | BLOCKED | pending required review |
Convergence
1 outer iteration(s); 2 Copilot round(s). Final panel verdict: ship_now.
Ready for maintainer review.
Full per-persona findings
Python Architect
- [nit]
_SELF_KEYimport could be hoisted for readability. The runtime impact is negligible and the current lazy import is acceptable in this scope.
CLI Logging Expert
No findings.
DevX UX Expert
No findings.
Supply Chain Security Expert
- [nit]
intended_dep_keys or set()is fail-closed if a future nonstandard context passesNone; no action required for this dataclass-backed path.
OSS Growth Hacker
No findings after the CHANGELOG fold.
Auth Expert -- inactive
PR touches lockfile pruning code, tests, and changelog only; no auth surface.
Doc Writer
No findings.
Test Coverage Expert
No findings after the regression-test fold. Proof: uv run --extra dev pytest tests/unit/install/phases/test_lockfile_orphan_prune.py -q -> 7 passed.
Performance Expert
No findings.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Integration test test_apm_install_writes_cache_pin_marker_for_each_remote_dep regressed after PR #1926 added orphan lockfile pruning: a remote dep recorded in the lockfile but absent from the manifest is now treated as an orphan and pruned from the rebuilt lockfile, so its `.apm-pin` marker was never written -- even though the dep's cached payload survives on disk under apm_modules/. That broke the supply-chain contract from PR #1137: every cached remote dep must carry a `.apm-pin` marker so a later `apm audit` drift replay can fail-closed on a stale cache. The regression went unnoticed because the integration matrix only runs on dispatch/tag/schedule, not on PRs. Restore self-heal at the product level: build_and_save now syncs markers from the pre-existing on-disk lockfile FIRST, before orphan-pruning rewrites it. Best-effort and idempotent; unpinned-dep warnings stay suppressed here since the primary post-build sync already surfaces them. - cache_pin.py: add keyword-only `warn_unpinned: bool = True` to sync_markers_for_lockfile so the secondary pass stays silent. - phases/lockfile.py: add _sync_cache_pin_markers_from_existing() driven by ctx.existing_lockfile, invoked at the top of build_and_save. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The preceding commit restores the PR #1137 cache-pin (.apm-pin) marker self-heal contract that PR #1926's orphan-lockfile-pruning silently regressed. This touches src/apm_cli/install/ (a Mode B critical path) but introduces NO net-new OpenAPM v0.1 normative behaviour: the .apm-pin cache marker is an APM-implementation-internal supply-chain / audit-hardening feature, not part of the OpenAPM interop spec (it has no spec anchor, no requirements-manifest row, and no Appendix C entry, because other implementations are not required to honour it). Adding a req-XXX for it would wrongly pollute the interop spec with implementation-specific surface, so the waiver -- not a new spec requirement -- is the correct classification. apm-spec-waiver: restores PR #1137 cache-pin (.apm-pin) self-heal regressed by #1926; APM-internal install audit hardening, not OpenAPM v0.1 normative surface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
#1947) * fix(lifecycle): make script executor + lifecycle tests pass on Windows Two cross-platform source bugs and three test-portability bugs caused the lifecycle/script-executor suite to fail only on Windows CI: - _append_to_script_log dropped every write on Windows: the POSIX world-readable tamper check (st_mode & 0o077) is always truthy for Windows os.fstat modes, so the log self-healed to a fresh O_EXCL file and returned without writing, leaving scripts.log empty. Gate the permission-bit check behind a POSIX check. - _resolve_cwd containment used a hardcoded '/' separator, clamping valid relative cwds to the project root on Windows. Use os.sep. - test_lifecycle_trust_gate: emit apm.yml via yaml.safe_dump and a raw Python string so Windows backslash paths survive YAML + Python parsing. - test_script_executors: replace hardcoded POSIX path-literal assertions with platform-agnostic Path/os.path equivalents. - test_lifecycle_executor_paths: use a portable python sleep instead of the non-Windows 'sleep' builtin for the timeout path. - test_lifecycle_scripts: pin platform.system to Linux for the unix effective_command test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(install): self-heal cache-pin markers for orphan-pruned remote deps Integration test test_apm_install_writes_cache_pin_marker_for_each_remote_dep regressed after PR #1926 added orphan lockfile pruning: a remote dep recorded in the lockfile but absent from the manifest is now treated as an orphan and pruned from the rebuilt lockfile, so its `.apm-pin` marker was never written -- even though the dep's cached payload survives on disk under apm_modules/. That broke the supply-chain contract from PR #1137: every cached remote dep must carry a `.apm-pin` marker so a later `apm audit` drift replay can fail-closed on a stale cache. The regression went unnoticed because the integration matrix only runs on dispatch/tag/schedule, not on PRs. Restore self-heal at the product level: build_and_save now syncs markers from the pre-existing on-disk lockfile FIRST, before orphan-pruning rewrites it. Best-effort and idempotent; unpinned-dep warnings stay suppressed here since the primary post-build sync already surfaces them. - cache_pin.py: add keyword-only `warn_unpinned: bool = True` to sync_markers_for_lockfile so the secondary pass stays silent. - phases/lockfile.py: add _sync_cache_pin_markers_from_existing() driven by ctx.existing_lockfile, invoked at the top of build_and_save. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(spec-conformance): waive Mode B gate for cache-pin regression fix The preceding commit restores the PR #1137 cache-pin (.apm-pin) marker self-heal contract that PR #1926's orphan-lockfile-pruning silently regressed. This touches src/apm_cli/install/ (a Mode B critical path) but introduces NO net-new OpenAPM v0.1 normative behaviour: the .apm-pin cache marker is an APM-implementation-internal supply-chain / audit-hardening feature, not part of the OpenAPM interop spec (it has no spec anchor, no requirements-manifest row, and no Appendix C entry, because other implementations are not required to honour it). Adding a req-XXX for it would wrongly pollute the interop spec with implementation-specific surface, so the waiver -- not a new spec requirement -- is the correct classification. apm-spec-waiver: restores PR #1137 cache-pin (.apm-pin) self-heal regressed by #1926; APM-internal install audit hardening, not OpenAPM v0.1 normative surface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Removing every apm dependency from
apm.yml(leavingapm: []) cleans the deployed files but leaves the lockfile listing the dropped dependencies. The committedapm.lock.yamlthen claims deps the manifest no longer declares.Reproduced on a clean project:
Root cause
LockfileBuilder.build_and_saveearly-returns wheninstalled_packagesis empty:Emptying the manifest leaves
installed_packagesempty, so this returns before the orphan-prune path (_merge_existing, which already drops entries not inintended_dep_keys) can run. Partial removal (drop one of several deps) was unaffected, becauseinstalled_packageswas non-empty and the full path ran.Fix
Guard the early-return with
_has_orphan_lockfile_entries(): fall through to the rebuild/prune path when the existing lockfile records apm deps the manifest no longer declares. The fall-through reuses the existing prune + MCP/local-state-preservation logic unchanged.only_packagesinstalls (partial install intentionally preserves unlisted entries)..self-entry (local deployed files), which is not a manifest dep.generated_atunchanged across a cached re-install).Behavior
apm: []), reinstalldependencies: []Tests
tests/unit/install/phases/test_lockfile_orphan_prune.py-- 6 cases on_has_orphan_lockfile_entries: orphan detected on emptied manifest, partial orphan, no orphan when all intended,only_packagessuppression, no existing lockfile, and.self-entry exclusion.Verified live against a JFrog registry (registry deps) and
microsoft/apm-sample-package(git deps) -- the desync and fix reproduce identically for both sources, confirming this is a general install-path fix, not source-specific.apm-spec-waiver: lockfile-correctness fix -- prunes orphaned entries on a depless manifest so the lockfile matches apm.yml; reuses the existing prune path, no new normative behavior