Skip to content

fix(update): re-check transitive dependencies' own semver ranges during apm update#2053

Open
nadav-y wants to merge 6 commits into
microsoft:mainfrom
nadav-y:fix/transitive-semver-reresolution
Open

fix(update): re-check transitive dependencies' own semver ranges during apm update#2053
nadav-y wants to merge 6 commits into
microsoft:mainfrom
nadav-y:fix/transitive-semver-reresolution

Conversation

@nadav-y

@nadav-y nadav-y commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

This branch's diff contains three bugs in apm update's dependency resolution and rollback safety. Bug 1 was already fixed by #2050 and is only present here because this branch stacks on it. Bugs 2 and 3 are new in this PR.

Bug 1 (fixed in #2050): torn state on declined/aborted update. Resolving a semver range downloads the candidate version to disk before the user confirms the update plan. A declined confirmation, a non-interactive abort, or --dry-run used to leave apm_modules/ already advanced while apm.lock.yaml stayed on the old version.

Bug 2 (this PR): transitive dependencies never get re-checked. apm update was not picking up a new version of a transitive dependency, even when the intermediate package's manifest still allowed it:

org/pkg1 -> org/pkg2 (^1.0.0) -> org/pkg3 (^1.0.0)

Publishing a new org/pkg3 matching ^1.0.0 was silently ignored by apm update run from org/pkg1, even though org/pkg2's manifest hadn't changed. Root cause: APMDependencyResolver._try_load_dependency_package only calls download_callback (which re-resolves a semver range against the remote) when the install path doesn't already exist:

if not install_path.exists():
    ...call download_callback...

#2050 forced this recheck for direct dependencies only (a pre-purge pass that ran before BFS resolution started, since transitive deps aren't known until their parent's manifest is read). Once org/pkg2 exists locally, org/pkg3's own range is never re-evaluated -- the walk sees its path already exists and stops.

Bug 3 (this PR, caught in Copilot review): a failed backup silently allowed an unprotected overwrite. backup_before_overwrite swallowed every exception during staging and returned False. If staging failed (e.g. a rename error) on an existing install path, the caller proceeded to overwrite it anyway with no rollback point. A later declined/aborted update would then see that dep as downloaded but with no backup entry -- indistinguishable from a fresh add -- and delete it outright, permanently losing the original content.

Fix

  • apm_resolver.py: Added APMDependencyResolver._should_force_recheck(dep_ref), gated on a new update_refs flag. Widens the existing-path check to if not install_path.exists() or self._should_force_recheck(dep_ref):, so download_callback runs for any non-local, non-artifactory-proxied, semver-ranged dependency at any depth. (fixes Bug 2)
  • resolve.py: Removed the direct-only pre-purge pass (no longer needed); threads update_refs into the resolver.
  • update_backup.py: Moved backup staging inline into download_callback, called immediately before a dependency's install path is overwritten, for direct and transitive deps alike -- sidesteps the chicken-and-egg problem of not knowing transitive deps ahead of time. ctx.update_backups now stores dep_key -> (dep_ref, backup_path) tuples so a transitive dep's backup survives even if it never reaches ctx.all_apm_deps/ctx.deps_to_install. (fixes Bug 2's backup coverage)
  • update_backup.py: backup_before_overwrite no longer suppresses staging exceptions -- it now raises when a backup is actually required, so the caller never proceeds to overwrite without one. The existing resolve-phase exception handler in pipeline.py restores any backups already staged for other deps this run before re-raising, so the whole update aborts cleanly instead of silently losing content. (fixes Bug 3)
  • context.py / pipeline.py: Comment updates only, no functional change.

A regression surfaced during development: widening only the resolver gate (without widening backup coverage) caused restore_update_backups to treat newly-reachable transitive deps as fresh adds with no backup, deleting them outright on decline. Caught via live repro before landing the inline-backup fix.

Test plan

  • New unit tests in test_apm_resolver_edge_cases.py (TestShouldForceRecheck, TestTryLoadDependencyPackageForceRecheck) covering the recheck predicate and gate across semver/literal ref kinds, local deps, artifactory-proxied deps, and update_refs on/off.
  • Rewrote test_update_backup.py for the inline, tuple-based API, including a transitive-dep-not-in-all_apm_deps regression test and a staging-failure test asserting it raises with the original content untouched.
  • Full unit suite: 18177 passed, 7 pre-existing unrelated failures (mcp_integrator/global_mcp_scope/lifecycle_executor), 0 new failures.
  • Live repro against a real registry:

Branch note

Stacked on #2050 (fix/update-plan-gate-torn-state) -- same update_backup.py mechanism, so the diff includes #2050's commits until it merges. Please review/merge #2050 first; commits unique to this PR start after 2683e8ea.

nadav-y and others added 4 commits July 6, 2026 12:01
…ed/dry-run apm update cannot leave apm_modules/ ahead of apm.lock.yaml

download_callback materializes a re-resolved semver dep's new content to
apm_modules/ during the resolve phase, before apm update's plan-confirmation
gate runs. When that gate does not commit -- the user declines, the shell is
non-interactive (no TTY, no --yes), or --dry-run is passed -- the new content
was already written to disk while apm.lock.yaml stayed on the old version,
silently leaving the project in a torn state. Reproduced live for both
registry-sourced and git-sourced dependencies.

_purge_cached_semver_paths_for_update (moved to the new update_backup.py,
which also keeps install/phases/resolve.py under its LOC budget) now moves a
semver dep's existing install path aside instead of deleting it outright,
but only when apm update's plan_callback is present -- apm install --update
has no decline path and keeps the original delete-and-redownload behavior
unchanged. Once the plan-confirmation gate resolves, pipeline.py's new
try/finally calls restore_update_backups: on commit, backups for
successfully re-downloaded deps are discarded; otherwise every purged dep is
reverted to its original content, and any freshly-downloaded dep with no
prior backup (a new add swept into the same resolve pass) is removed
outright. The finally block runs even when the non-interactive path raises
SystemExit, so the rollback cannot be skipped by that abort.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ups on resolve-phase failure, fix collision-prone naming

Four real issues from the automated Copilot review, all confirmed by reading
the actual code paths before fixing:

- restore_update_backups was only ever invoked from the plan-confirmation
  gate in pipeline.py. If resolver.resolve_dependencies() itself raises
  (e.g. a network error on a *different* dep, or a bad transitive manifest)
  after purge_cached_semver_paths_for_update already moved some dep aside,
  nothing restored it -- the backup was orphaned in .apm-update-backup/
  with the dep's install path left missing. Added a try/except around the
  resolve phase call in pipeline.py that restores staged backups before
  re-raising, with two new pipeline tests covering both the failure path
  and the (still correct) no-op when no backups were staged.

- restore_update_backups looked up dep objects via ctx.deps_to_install,
  which is only populated after resolve_dependencies() returns
  successfully -- exactly the failure window above. Switched to
  ctx.all_apm_deps (populated before resolve starts, and guaranteed to
  contain every dep purge_cached_semver_paths_for_update could have
  purged) merged with ctx.deps_to_install (extends coverage to transitive
  adds when resolution did complete).

- _sanitize_backup_name collapsed distinct dep keys to the same backup
  directory name (e.g. "owner/repo" and "owner_repo" both became
  "owner_repo"), risking one dep's backup silently overwriting another's.
  Appended a short hash of the original key. New unit tests assert
  distinct keys never collide.

- purge_cached_semver_paths_for_update logged "cleared cached install
  path" unconditionally even when the rename/rmtree inside the
  suppress(Exception) block actually failed, misleadingly implying
  semver re-resolution would occur when it might not. Now only logs on
  confirmed success.

Also added type hints (per repo convention) and fixed a stale docstring
reference to the old (now-public, no-underscore) function name.

The isinstance-based coercion this required in restore_update_backups
(rather than a plain `... or {}` fallback) is defensive against any
caller whose ctx doesn't populate these fields as real dicts/lists --
irrelevant in production (InstallContext's dataclass defaults guarantee
this), but it also happens to make the function robust against loosely-
mocked ctx objects in unrelated tests that now incidentally reach this
code path via the new pipeline.py exception handler.

Full unit suite: 18162 passed, 6 pre-existing failures (confirmed
identical on main, unrelated to this change), 0 new failures. ruff clean.
Re-verified the live repro (registry + git-sourced, decline/non-interactive/
dry-run/happy-path) end-to-end after every change in this commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Mode B detector flags this PR's changes under src/apm_cli/install/
as net-new normative behaviour requiring a spec anchor + manifest row +
Appendix C entry + conformance test. It isn't: apm update's plan-
confirmation gate already documents (in its own code comment) that
confirmation happens "before downloads begin so a 'no' answer cancels
cleanly without touching the cache." This PR fixes a bug where that
promise wasn't actually kept for semver re-resolution -- it restores the
existing contract, it doesn't define a new one.

apm-spec-waiver: bug fix restoring existing update-confirm-gate no-side-effect contract
…ng apm update

APMDependencyResolver only invoked download_callback when a dependency's
install path didn't already exist on disk. The prior fix for the
plan-confirmation torn-state bug (microsoft#2050) forced this recheck for direct
dependencies only (a pre-purge pass before BFS resolution starts), so any
transitive dependency's own semver range was never re-evaluated against
the remote, no matter how many newer matching versions had been
published -- e.g. pkg1 -> pkg2 -> pkg3, all pinned to ^1.0.0: publishing
pkg3 1.0.4 was silently ignored by `apm update` in pkg1, since pkg2 (and
therefore pkg3) already existed locally.

Replace the direct-only pre-purge with a per-node resolver gate
(APMDependencyResolver._should_force_recheck, threaded via a new
update_refs constructor flag) that widens the existing-path check for any
non-local, non-artifactory-proxied, semver-ranged dependency -- covering
every depth reached during BFS, not just direct deps.

Move the backup-before-overwrite staging inline into download_callback
(update_backup.backup_before_overwrite), since a transitive dependency's
existence isn't known until its parent's manifest is read -- a pre-pass
can't know what to back up ahead of time. ctx.update_backups now stores
(dep_ref, backup_path) tuples so a transitive dep's backup can be
restored even if it never appears in ctx.all_apm_deps or
ctx.deps_to_install (e.g. resolution fails before those populate).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 6, 2026 13:09
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

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 apm update so that semver-ranged dependencies are re-evaluated against the remote at any depth (including transitives), and extends the staged-backup/rollback mechanism so update-plan confirmation can safely decline/abort without leaving apm_modules/ ahead of apm.lock.yaml.

Changes:

  • Widen dependency resolution so existing install paths can still invoke download_callback when update_refs=True and the dep is a non-local, non-proxied semver ref.
  • Move update rollback staging to an inline backup_before_overwrite() call inside download_callback, and reconcile staged backups at the plan-confirmation gate (plus early restore on resolve-phase exceptions).
  • Add/refresh unit tests for the new recheck predicate and the inline backup/restore behavior; add changelog entries for #2050 and this fix.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/apm_cli/deps/apm_resolver.py Adds update_refs + _should_force_recheck() and widens the existing-path gate so transitive semver deps can be rechecked.
src/apm_cli/install/phases/resolve.py Removes direct-only pre-purge and stages backups inline before overwrites; threads update_refs into the resolver.
src/apm_cli/install/phases/update_backup.py Introduces the inline backup + restore reconciliation helpers for update-plan gating.
src/apm_cli/install/pipeline.py Plumbs plan_callback into context and ensures backups are reconciled on plan decision (and restored on resolve exceptions when staged).
src/apm_cli/install/context.py Documents and adds plan_callback and the new update_backups shape on InstallContext.
tests/unit/deps/test_apm_resolver_edge_cases.py Adds coverage for _should_force_recheck() and for callback invocation on existing paths when updating.
tests/unit/install/phases/test_update_backup.py New unit tests for sanitization + inline staging + restore behavior (including a transitive-only restore case).
tests/unit/test_install_pipeline_orchestration.py Adds coverage ensuring resolve-phase failures trigger restore when backups were staged.
CHANGELOG.md Adds Unreleased fixes entries for #2050 and #2053.

Comment thread src/apm_cli/install/phases/update_backup.py Outdated
Addresses Copilot review on microsoft#2053: backup_before_overwrite suppressed
every exception during staging and returned False, so a rename/mkdir
failure on an existing install path let the caller proceed straight to
overwriting it with no rollback point staged. A later declined/aborted
update would then see that dep in ctx.callback_downloaded but absent
from ctx.update_backups -- indistinguishable from a fresh add -- and
delete it outright, permanently losing the original content.

Now raises once staging is actually required (plan_callback set and
install_path exists), instead of swallowing the error. The resolve-phase
exception handler already added in pipeline.py (for microsoft#2050's Copilot
review) restores any backups staged for other deps this run before
re-raising, so the whole apm update aborts cleanly -- recoverable, unlike
a silent, undetected loss of the original content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.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.

2 participants