Skip to content

fix(install): prune lockfile when all dependencies are removed from manifest#1926

Merged
danielmeppiel merged 2 commits into
microsoft:mainfrom
nadav-y:fix/lockfile-prune-empty-manifest
Jun 27, 2026
Merged

fix(install): prune lockfile when all dependencies are removed from manifest#1926
danielmeppiel merged 2 commits into
microsoft:mainfrom
nadav-y:fix/lockfile-prune-empty-manifest

Conversation

@nadav-y

@nadav-y nadav-y commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Removing every apm dependency from apm.yml (leaving apm: []) cleans the deployed files but leaves the lockfile listing the dropped dependencies. The committed apm.lock.yaml then claims deps the manifest no longer declares.

Reproduced on a clean project:

$ apm install        # apm.yml declares one dep (-> 3 with transitives)
$ grep -c repo_url apm.lock.yaml
3
# edit apm.yml: dependencies.apm: []
$ apm install
[i] Cleaned 4 files from packages no longer in apm.yml
$ grep -c repo_url apm.lock.yaml
3      # <-- stale; should be 0

Root cause

LockfileBuilder.build_and_save early-returns when installed_packages is empty:

if not self.ctx.installed_packages and not self.ctx.lockfile_only:
    self._sync_cache_pin_markers_from_disk()
    return

Emptying the manifest leaves installed_packages empty, so this returns before the orphan-prune path (_merge_existing, which already drops entries not in intended_dep_keys) can run. Partial removal (drop one of several deps) was unaffected, because installed_packages was 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.

  • Suppressed for only_packages installs (partial install intentionally preserves unlisted entries).
  • Ignores the . self-entry (local deployed files), which is not a manifest dep.
  • No-op installs with deps still present do not churn the lockfile (verified: generated_at unchanged across a cached re-install).

Behavior

Scenario Before After
Remove all deps (apm: []), reinstall lockfile keeps stale entries lockfile pruned to dependencies: []
Remove one of several deps pruned correctly pruned correctly (unchanged)
Cached no-op install no churn no churn (unchanged)

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_packages suppression, 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

…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>
@nadav-y nadav-y requested a review from danielmeppiel as a code owner June 26, 2026 12:33
Copilot AI review requested due to automatic review settings June 26, 2026 12:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 for only_packages partial 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.

Comment on lines 68 to 69
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) ----------
@sergio-sisternes-epam sergio-sisternes-epam added the panel-review Trigger the apm-review-panel gh-aw workflow label Jun 26, 2026
@github-actions github-actions Bot mentioned this pull request Jun 26, 2026
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>
@danielmeppiel

Copy link
Copy Markdown
Collaborator

APM Review Panel: ship_now

Community fix restores lockfile-manifest consistency when all dependencies are removed, closing a reliability gap aligned with P6 Reliability over magic.

cc @nadav-y @sergio-sisternes-epam -- a fresh advisory pass is ready for your review.

Unanimous panel with zero required findings. This is a focused community bug fix: a single guard predicate restores the package-manager invariant that the lockfile follows the manifest. The shepherd folded the two actionable follow-ups: a hermetic build_and_save regression test with mutation-break evidence, and a user-facing CHANGELOG entry crediting the contributor. CI is green, test evidence is concrete, and no specialist raised an architectural, security, output, docs, or performance concern above nit level.

Aligned with: portable_by_manifest: lockfile now faithfully reflects manifest state even when the dependency list is empty; oss_community_driven: external contributor fix is credited in the changelog; pragmatic_as_npm: removing all deps and reinstalling leaves no phantom lockfile entries.

Growth signal. The changelog entry now turns this community fix into a visible lockfile-integrity proof point for evaluators.

Panel summary

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
Loading
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()"]
Loading

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_save regression 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 -- deleted and 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_KEY import 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 passes None; 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.

@danielmeppiel danielmeppiel removed the panel-review Trigger the apm-review-panel gh-aw workflow label Jun 27, 2026
@danielmeppiel danielmeppiel merged commit 66c7738 into microsoft:main Jun 27, 2026
14 checks passed
danielmeppiel added a commit that referenced this pull request Jun 28, 2026
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>
danielmeppiel added a commit that referenced this pull request Jun 28, 2026
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>
danielmeppiel added a commit that referenced this pull request Jun 28, 2026
#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants