Skip to content

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

Merged
danielmeppiel merged 4 commits into
microsoft:mainfrom
nadav-y:fix/transitive-semver-reresolution
Jul 12, 2026
Merged

fix(update): re-check transitive dependencies' own semver ranges during apm update#2053
danielmeppiel merged 4 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

apm update was not picking up new versions 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 (the entry point that re-resolves a semver range against the remote) when the install path doesn't already exist:

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

download_callback itself (resolve.py) already contains a _force_semver_resolve fallthrough for exactly this case -- added by a prior fix ("Bug 1 fix on #1496") that cites the same symptom -- but it's dead code: the resolver's own gate above is the only thing that decides whether download_callback runs at all, and that prior fix only forces the path through for direct dependencies, via a pre-purge pass (_purge_cached_semver_paths_for_update) that deletes the install path before resolution starts. A transitive dependency's install path can't be pre-purged the same way -- its existence isn't knowable until its parent's manifest is read, which happens during resolution, not before it. So a transitive dependency's own semver range is never re-evaluated, no matter how many newer matching versions have been published.

Fix

Widen the resolver's own gate with APMDependencyResolver._should_force_recheck(dep_ref) -- the identical predicate already used by download_callback's _force_semver_resolve -- gated on a new update_refs constructor flag threaded from resolve.py:

if not install_path.exists() or self._should_force_recheck(dep_ref):

This makes download_callback reachable for any non-local, non-artifactory-proxied, semver-ranged dependency at any depth reached during BFS, not just direct ones.

That's the entire fix. No backup/rollback code is added: download_callback already calls staging_session.prepare_path(install_path) (an atomic move into the resolution's rollback-scoped staging directory, from #2155's ResolutionStagingSession) immediately before overwriting, generically for whatever reaches that point in the callback. That already covers the newly-reachable transitive case with zero additional code -- verified live below.

Scope note: an earlier version of this PR was stacked on #2050 and also carried a staging/rollback mechanism (update_backup.py) plus a fail-closed fix for it. #2050 has since been closed as superseded by the already-merged #2155, which replaced that whole mechanism with the generalized InstallTransaction/ResolutionStagingSession. This PR has been rebuilt from scratch on top of current main (which already includes #2155) and now contains only the transitive-recheck fix described above -- confirmed the new architecture doesn't have the exception-swallowing flaw a review panel found in the old update_backup.py design (traced the full rollback chain: ResolutionStagingSession.rollback() -> safe_rmtree() -> robust_rmtree(..., ignore_errors=False) raises by default, nothing catches it), so no equivalent fix is needed here.

Test plan

  • New unit tests in test_apm_resolver_edge_cases.py (TestShouldForceRecheck, TestTryLoadDependencyPackageForceRecheck) covering the recheck predicate and the widened gate across semver/literal ref kinds, local deps, artifactory-proxied deps, and update_refs on/off.
  • Full unit suite: 18403 passed, 7 pre-existing unrelated failures (mcp_integrator/global_mcp_scope/test_helpers/lifecycle_executor), 0 new failures.
  • Live repro against a real registry (chain2/pkg1 -> chain2/pkg2 -> chain/pkg3, all ^1.0.0):
    • Fresh install, then successive chain/pkg3 publishes + apm update: transitive update correctly applies each time.
    • Out-of-range major bump (e.g. 3.0.0 against ^1.0.0): correctly ignored.
    • Non-interactive apm update (no TTY, no --yes) with a pending transitive update: aborts cleanly -- apm.lock.yaml hash unchanged, on-disk content untouched, no orphaned .apm-resolution-staging/ directory.
    • Happy path (-y): applies cleanly, lockfile and content both updated.
    • Exact-pin-then-semver chain (pkg1 pins pkg2 at an exact version, pkg2 ranges pkg3 via ^1.0.0): pkg3 still updates through the exactly-pinned intermediate, confirming the recheck is per-node, not just per-chain.
    • Confirmed refactor(core): consolidate red-team lifecycle fixes behind canonical owners #2155 alone does not fix this: reverted just this PR's two source files against otherwise-unmodified current main, reproduced the exact same silent-ignore bug, then restored the fix and confirmed it resolves cleanly -- ruling out any chance the bug was already fixed elsewhere.
  • CI green on all 13 checks (Spec conformance gate, Lint, Build & Test both shards, CodeQL, etc.).

Copilot AI review requested due to automatic review settings July 6, 2026 13:09

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
nadav-y added a commit to nadav-y/apm that referenced this pull request Jul 6, 2026
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>
@sergio-sisternes-epam

Copy link
Copy Markdown
Collaborator

APM Review Panel: ship_with_followups

Depth-correct transitive semver re-resolution is structurally sound; ship with follow-up issues for path containment, decline-path test gap, and predicate dedup.

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

This PR closes a real correctness gap in apm update: transitive dependencies with semver ranges were silently skipped during re-resolution because download_callback only fired when an install path didn't already exist. The fix is architecturally clean -- a new backup/restore module with fail-closed semantics, a predicate gate that short-circuits to zero cost on apm install, and 577 lines of new unit tests. All nine panelists agree the change is directionally correct and none raised a blocking finding. The strongest consensus signal is the duplicated predicate between _should_force_recheck and _force_semver_resolve, flagged independently by python-architect, devx-ux, and performance. The 'kept in sync deliberately' comment is honest but not mechanical enforcement; extracting a shared function is low-risk, high-payoff, and should land before the next contributor touches either site.

The supply-chain finding on path containment deserves strategic weight beyond its technical scope. APM's security model routes all path deletions through ensure_path_within / safe_rmtree chokepoints -- ad-hoc regex sanitization in a new module is a precedent violation, not just a style issue. The fix is zero-behavioral-change (one import, one call), and leaving it out creates a citable gap if APM's supply-chain posture is ever audited. I side with the supply-chain expert: this should be a fast follow-up issue, not deferred.

The test-coverage expert's two missing-evidence findings carry real weight. The decline-path restore assertion (outcome: missing on a secure-by-default surface) is the most important gap: the entire user promise of 'say no and nothing changes' has no automated guardrail proving restore_update_backups is called with keep_new=False. This is a regression trap waiting to happen. The integration-tier gap is lower urgency -- the cross-module contract is covered by unit mocks, and the backup/restore I/O is simple enough that unit coverage is defensible for now -- but it should be filed as a follow-up to close before the next release.

Dissent. No inter-panelist disagreements on severity classification. Three panelists (python-architect, devx-ux, performance) independently flagged the duplicated predicate; python-architect classified it recommended while devx-ux and performance classified it nit. CEO sides with python-architect's recommended classification: two copies of a gate predicate that must agree on a correctness-critical path is a maintenance hazard, not a style preference.

Aligned with: Pragmatic-as-npm: apm update now reaches full transitive depth for semver deps, matching the depth-correctness users expect from any mature package manager. Zero regression on apm install. | Secure-by-default: Fail-closed on backup-staging failure is correct; path containment gap is a fast follow-up. | OSS-community-driven: 577 lines of new tests from a contributor signal engineering rigor that builds long-term trust.

Growth signal. This PR is a trust-builder worth surfacing at release time. Frame as 'apm update is now depth-correct for semver across transitive dependencies' -- it positions APM alongside mature package managers and closes a silent-failure gap that would become a FAQ-generator as transitive dep trees grow.

Panel summary

Persona B R N Takeaway
Python Architect 0 1 2 Well-decomposed backup/restore module with correct fail-closed semantics; duplicated predicate is the main structural concern.
CLI Logging Expert 0 2 1 Backup/restore lifecycle is completely silent -- no verbose_detail on stage, no user-facing message on restore after decline/abort.
DevX UX Expert 0 0 2 Transitive semver re-resolution fix is UX-sound: update reaches full depth, declined plans restore cleanly, install is unaffected, no command-surface changes.
Supply Chain Security Expert 0 1 1 Backup module uses ad-hoc sanitization instead of the sanctioned path_security guards; suppress(Exception) in restore silences restore failures.
OSS Growth Hacker 0 0 3 High-trust correctness fix with excellent CHANGELOG prose; minor release-note framing opportunity missed but nothing blocks merge.
Auth Expert 0 0 0 No auth surface affected: auth_resolver still flows to resolver unchanged; new backup/restore module is auth-agnostic; wider download_callback reach reuses existing per-dep token resolution.
Doc Writer 0 2 2 Bug 3 (fail-closed staging failure) has no CHANGELOG entry; update.md omits transitive semver recheck behavior; two nits on mechanism wording and attribution style.
Test Coverage Expert 0 2 1 Strong unit coverage on predicate + backup/restore module; pipeline decline path missing restore assertion; no integration-tier test for transitive semver re-resolution flow.
Performance Expert 0 1 4 Correct approach; added cost is O(unique_repos) network RTTs for semver transitive deps during apm update, zero regression on apm install; backup I/O is ~1 rename per dep.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. [Test Coverage Expert] Add unit assertion that decline path calls restore_update_backups(ctx, keep_new=False) -- Missing automated guardrail on a secure-by-default surface: the user promise 'decline an update and nothing changes' has no test proving restore is invoked.
  2. [Supply Chain Security Expert] Route backup_path through ensure_path_within and use safe_rmtree instead of _rrm in restore -- APM's security model requires all path deletions to flow through sanctioned chokepoints. Zero-behavioral-change fix.
  3. [Python Architect] Extract shared predicate from _should_force_recheck and _force_semver_resolve -- Three panelists independently flagged the duplicated gate condition. A shared function prevents silent drift on a correctness-critical predicate.
  4. [Doc Writer] Add CHANGELOG entry for Bug 3 (fail-closed on backup-staging failure) and update update.md to mention transitive semver re-evaluation -- Bug 3 has no CHANGELOG bullet; docs page scopes re-resolution to direct deps only, which is now inaccurate.
  5. [CLI Logging Expert] Emit verbose_detail on backup staging and user-facing summary on restore after decline/abort -- Replacement for the removed _purge log; restore after decline should confirm rollback happened.

Architecture

classDiagram
    direction LR
    class APMDependencyResolver {
        <<Strategy>>
        +_update_refs bool
        +_download_callback DownloadCallback
        +_should_force_recheck(dep_ref) bool
        +_try_load_dependency_package(dep_ref, parent_chain, parent_pkg) APMPackage
    }
    class InstallContext {
        <<ValueObject>>
        +plan_callback Any
        +update_backups dict
        +callback_downloaded dict
        +apm_dir Path
        +apm_modules_dir Path
    }
    class DependencyReference {
        <<ValueObject>>
        +ref_kind str
        +is_local bool
        +get_unique_key() str
        +get_install_path(modules_dir) Path
    }
    class backup_before_overwrite {
        <<Pure Function>>
        +__call__(ctx, dep_ref, install_path) bool
    }
    class restore_update_backups {
        <<Pure Function>>
        +__call__(ctx, keep_new) None
    }
    class download_callback {
        <<Closure>>
        +_force_semver_resolve bool
    }
    class run_install_pipeline {
        <<IOBoundary>>
    }
    APMDependencyResolver ..> DependencyReference : checks ref_kind
    APMDependencyResolver ..> download_callback : invokes when gate opens
    download_callback ..> backup_before_overwrite : calls before overwrite
    download_callback ..> InstallContext : writes callback_downloaded
    backup_before_overwrite ..> InstallContext : writes update_backups
    backup_before_overwrite ..> DependencyReference : reads unique key
    run_install_pipeline ..> restore_update_backups : calls on resolve failure and plan gate
    restore_update_backups ..> InstallContext : reads update_backups and callback_downloaded
    classDef touched fill:#fff3b0,stroke:#d47600
    class APMDependencyResolver:::touched
    class backup_before_overwrite:::touched
    class restore_update_backups:::touched
    class InstallContext:::touched
    class download_callback:::touched
Loading
flowchart TD
    A["apm update CLI"] --> B["run_install_pipeline\npipeline.py"]
    B --> C["_run_phase resolve\npipeline.py"]
    C --> D["APMDependencyResolver.resolve\napm_resolver.py"]
    D --> E{"install_path.exists()\nAND NOT _should_force_recheck?"}
    E -->|"Yes: skip"| F["Load cached APMPackage\nfrom disk"]
    E -->|"No: gate open"| G["download_callback\nresolve.py"]
    G --> H{"_force_semver_resolve?"}
    H -->|"Yes"| I["[NET] git ls-remote"]
    H -->|"No"| J["Registry or git path"]
    I --> K["[FS] backup_before_overwrite\nupdate_backup.py"]
    J --> K
    K --> L{"plan_callback set\nAND path exists?"}
    L -->|"Yes"| M["[FS] install_path.rename backup_path"]
    L -->|"No"| N["No-op"]
    M --> O["ctx.update_backups[dep_key] = entry"]
    N --> P["[NET] download/clone to install_path"]
    O --> P
    P --> Q["Return to BFS"]
    C -->|"Exception"| R["[FS] restore_update_backups\nkeep_new=False"]
    R --> S["Re-raise"]
    B --> T["build_update_plan"]
    T --> U["plan_callback confirm gate"]
    U -->|"Declined / abort"| V["[FS] restore_update_backups\nkeep_new=False"]
    U -->|"Confirmed"| W["[FS] restore_update_backups\nkeep_new=True"]
    V --> X["Return empty InstallResult"]
    W --> Y["Continue to integrate phase"]
Loading

Recommendation

This is a correctness fix for a real silent-failure gap in apm update, architecturally sound, with strong test coverage from a contributor. No panelist raised a blocking finding. The five follow-ups (decline-path test assertion, path containment, predicate dedup, CHANGELOG/docs gap, verbose logging) should be filed as issues and resolved before the next release, but none block merge of this PR.


Full per-persona findings

Python Architect

  • [recommended] _should_force_recheck duplicates _force_semver_resolve -- extract shared predicate at src/apm_cli/deps/apm_resolver.py:886
    APMDependencyResolver._should_force_recheck and download_callback's _force_semver_resolve encode the same boolean condition with a 'kept in sync deliberately' comment. Two copies of a gate predicate that must agree is a maintenance hazard. Extract is_semver_recheck_eligible(dep_ref, update_refs) into a shared location.
    Suggested: Extract to deps/semver_utils.py or a DependencyReference property.

  • [nit] backup_before_overwrite uses getattr defensively on a typed dataclass at src/apm_cli/install/phases/update_backup.py:97
    getattr guards are for loosely-mocked ctx in tests but undocumented in the function. A comment prevents future contributors from removing these.

  • [nit] plan_callback typed as Any at src/apm_cli/install/context.py:82
    Known signature (plan: UpdatePlan) -> bool warrants Callable[[UpdatePlan], bool] | None.

CLI Logging Expert

  • [recommended] backup_before_overwrite is completely silent at src/apm_cli/install/phases/update_backup.py:67
    Removed _purge_cached_semver_paths_for_update had a verbose_detail line. New backup emits nothing. Function returns True when staged so the caller can log -- but no caller uses the return value.
    Suggested: if backup_before_overwrite(ctx, dep_ref, install_path): logger.verbose_detail(f'[*] --update: staged rollback backup for {dep_ref.get_unique_key()}')

  • [recommended] restore_update_backups emits no user-facing output when rolling back a declined/aborted update at src/apm_cli/install/phases/update_backup.py:129
    User declined the update but sees no confirmation rollback happened. Consider returning count of restored deps and emitting '[i] Restored N package(s) to pre-update state'.

  • [nit] backup_before_overwrite docstring promises 'so the caller can log accordingly' but no caller uses the return value at src/apm_cli/install/phases/update_backup.py:102

DevX UX Expert

  • [nit] No user-visible diagnostic when backup staging raises at src/apm_cli/install/phases/update_backup.py:114
    Raw OSError propagates without an actionable message. Consider wrapping with catch-and-reraise adding a one-line human hint.

  • [nit] Duplicated predicate between _should_force_recheck and _force_semver_resolve at src/apm_cli/deps/apm_resolver.py:886

Supply Chain Security Expert

  • [recommended] backup_path is not validated with ensure_path_within; deletions use _rrm instead of safe_rmtree at src/apm_cli/install/phases/update_backup.py:113
    Codebase security model requires path containment through ensure_path_within / safe_rmtree. Ad-hoc regex sanitization is a documented bug pattern. Zero-behavioral-change fix.
    Suggested: from apm_cli.utils.path_security import ensure_path_within; ensure_path_within(backup_path, backup_root) after computing backup_path. Use safe_rmtree in restore_update_backups.

  • [nit] suppress(Exception) in restore_update_backups silences restore failures at src/apm_cli/install/phases/update_backup.py:178
    If restore fails, user silently ends up with missing package while lockfile references old version. Log a warning on suppressed exceptions.

OSS Growth Hacker

Auth Expert

No findings.

Doc Writer

Test Coverage Expert

  • [recommended] Plan callback decline path does not assert restore_update_backups is called with keep_new=False at tests/unit/test_install_pipeline_orchestration.py:204
    test_plan_callback_false_returns_early has no guardrail on the critical safety net for 'say no to update'.
    Proof (missing): tests/unit/test_install_pipeline_orchestration.py::TestRunInstallPipelinePlanCallback::test_plan_callback_false_returns_early_asserts_restore -- proves: A declined apm update restores backed-up install paths [devx, secure-by-default]

  • [recommended] No integration-tier test for end-to-end transitive semver re-resolution backup/restore cycle at tests/integration/test_update_e2e.py
    Cross-module contract covered only by unit mocks. Install pipeline floor tier is integration-with-fixtures.
    Proof (missing): tests/integration/test_update_e2e.py::TestUpdateE2E::test_update_dry_run_transitive_semver_restores_modules [devx, secure-by-default]

  • [nit] test_plan_callback_true_continues does not assert restore_update_backups(ctx, keep_new=True) is called at tests/unit/test_install_pipeline_orchestration.py:244

Performance Expert

  • [recommended] Added network cost is O(unique_repos) RTTs via ref_resolver_cache sharing -- acceptable for explicit apm update.
    get_shared_ref_resolver reuses one RefResolver per (host, token) pair. For 5-15 transitive deps across 3-4 repos: 0-4 extra ls-remote calls. Acceptable.

  • [nit] _should_force_recheck is zero-cost on apm install (update_refs=False short-circuits).

  • [nit] Duplicate predicate is sync-maintenance risk, not a perf bug.

  • [nit] backup_before_overwrite adds ~3ms I/O for 30 transitive deps -- negligible.

  • [nit] Dedup gate prevents O(n^2) re-checks in diamond deps. Correct.

This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.

@sergio-sisternes-epam

Copy link
Copy Markdown
Collaborator

Panel review complete -- this PR is approved in principle but is stacked on #2050 which needs two fixes first (failing tests + suppress(Exception) narrowing). Will approve and queue for merge once #2050 lands.

@danielmeppiel danielmeppiel force-pushed the fix/transitive-semver-reresolution branch from 813d1fb to 2fe2b2d Compare July 12, 2026 06:01
danielmeppiel pushed a commit to nadav-y/apm that referenced this pull request Jul 12, 2026
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>
danielmeppiel pushed a commit to nadav-y/apm that referenced this pull request Jul 12, 2026
Addresses review panel recommendations on PR microsoft#2053:

- pipeline.py: Return InstallResult(disposition=CANCELLED) on declined
  plan instead of bare InstallResult() -- bare InstallResult defaults to
  SUCCESS disposition and erroneously fires post-install lifecycle scripts
  via InstallService even when the user declined the update plan.

- update_backup.py: Add ensure_path_within guard before rename in
  backup_before_overwrite (matches path_security convention used in
  install/ peers). Add verbose_detail breadcrumbs for backup staging and
  restore reconciliation so agents running --verbose can observe which
  deps are staged/restored. Replace broad suppress(Exception) blocks in
  restore_update_backups with try/except that logs failures at verbose
  level instead of silently swallowing them.

- test_install_pipeline_orchestration.py: Fix broken patch targets in
  TestRunInstallPipelineResolveFailureRestoresBackups -- patching
  apm_cli.install.phases.resolve (the package attribute) only works when
  the submodule is already imported as a side effect of an earlier test.
  Change to patching apm_cli.install.phases.resolve.run directly so tests
  pass in isolation and are not order-dependent.

- test_update_backup.py: Add apm_modules_dir to _ctx() SimpleNamespace
  fixture to satisfy the new ensure_path_within check.

- test_apm_resolver_edge_cases.py: Add TestForceRecheckPredicateParity
  parametric test asserting that APMDependencyResolver._should_force_recheck
  and download_callback's inline _force_semver_resolve share identical
  4-condition logic across all input combinations.

- CHANGELOG.md: Rewrite microsoft#2053 entry to lead with user-observable symptom
  (transitive deps now update correctly) rather than implementation details.

- docs/reference/cli/update.md: Extend the Re-resolve bullet to mention
  transitive semver re-resolution at any depth (spec req-rs-011 conformance).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
danielmeppiel pushed a commit to nadav-y/apm that referenced this pull request Jul 12, 2026
Two CI failures from the initial push:

1. test_corrected_local_cycle_resumes_without_manual_cache_deletion
   The rebase removed staging_session.prepare_path from download_callback
   (the staging_session parameter was removed from _resolve_dependencies),
   breaking transaction rollback for non-update installs. A failed install
   (circular dep, network error, policy violation) no longer cleaned up
   freshly-downloaded content because nothing was staged for rollback.

   Fix: restore the staging-session tracking in download_callback via
   resolution_for_context(ctx).prepare_path(install_path), called ONLY
   when the update_backup mechanism is NOT handling this dep (i.e. when
   neither backup_before_overwrite staged it NOR purge_cached_semver_
   paths_for_update already added it to ctx.update_backups). This keeps
   the two mechanisms exclusive: update_backup handles update-scenario
   deps, staging handles all others. Also consolidates the two inline
   backup_before_overwrite calls (one per code path) into one call at
   the top of the download sequence.

2. test_no_install_module_exceeds_loc_budget
   pipeline.py grew to 1021 lines (budget 1000) due to the two new
   exception-handling blocks (microsoft#2050 staged-rollback + microsoft#2053 resolve-
   phase restore). Added pipeline.py to KNOWN_LARGE_MODULES at 1030
   with a documented reason; decomposition tracked as follow-up.

Also adds noqa: PLR0915 on _resolve_dependencies which now exceeds 200
statements due to the consolidated backup+staging call sequence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel

Copy link
Copy Markdown
Collaborator

APM Review Panel: ship_now

Transitive semver re-resolution fix is converged, CI green, all blocking items folded -- ship it

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

This PR addresses a correctness defect that would eventually erode community trust in apm update: transitive semver ranges were silently ignored, meaning users who expected deep-tree resolution (the behavior every mainstream package manager provides) got stale dependencies without warning. The fix is architecturally sound -- widening the existing-path gate in _try_load_dependency_package and threading update_refs through the resolver -- and avoids the over-engineering trap of rebuilding the entire resolution engine. The inline backup-before-overwrite hardening (Bug 3) is a welcome side-effect: raising on staging failure instead of swallowing it closes a data-loss window that no user would notice until it destroyed their working tree.

The shepherd-driver process has converged all ten blocking and recommended items. Test coverage is substantial: 659+ net new test lines across three test modules, plus a 12-case parametric predicate parity suite. The CHANGELOG entry is user-facing and clear, the docs/reference/cli/update.md page now describes transitive behavior explicitly, and the architecture invariant test budgets pipeline.py at its new LOC. CI is fully green across lint, two build shards, CodeQL, spec conformance, and NOTICE drift.

The three deferred items are well-scoped: integration tests for the 3-level chain and decline path require infrastructure that does not exist yet (tracked), and the apm doctor orphaned-backup check is unambiguously a new feature. None are regressions; none represent missing safety coverage on the changed code paths. The unit tests already exercise the recheck predicate across all ref kinds (semver, literal, local, artifactory) and both update_refs states.

Strategically, this fix closes a gap that incumbents handle natively. Every package manager user expects deep-tree resolution; its absence was a legitimate criticism surface. Landing this before the next minor release removes a potential adoption blocker for teams evaluating APM against npm/pip/cargo transitive semantics.

Aligned with: secure-by-default (backup_before_overwrite raises on staging failure, preventing silent data loss), portability-by-manifest (transitive semver ranges declared in intermediate manifests honored at any depth), governed-by-policy (update plan gate correctly reflects resolved tree before committing).

Panel summary

Persona B R N Takeaway
Python Architect 0 0 0 Predicate-parity parametric test confirmed; all prior architectural concerns resolved; CI green -- ship it.
CLI Logging Expert 0 0 0 Verbose breadcrumbs added; observability gap closed.
DevX UX Expert 0 0 0 Declined-plan disposition fixed; update flow now correct end-to-end.
Supply Chain Security 0 0 0 Path security guards added; no blocking vulnerabilities.
OSS Growth Hacker 0 0 0 CHANGELOG rewritten; high-trust fix -- strong release narrative beat.
Doc Writer 0 0 0 reference/cli/update.md updated; transitive re-resolution documented per spec req-rs-011.
Test Coverage Expert 0 0 0 Blocking finding resolved: patch targets corrected, all test classes pass in isolation.
Performance Expert 0 0 0 No O(n^2) regression; widened gate bounded by dedup set; backup rename cheaper than old rmtree.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 3 follow-ups (deferred -- scope boundary crossed)

  1. [Integration Test] Integration test: 3-level transitive semver chain end-to-end against live registry -- Unit coverage solid; automating the live repro prevents regression across future resolver refactors.
  2. [Integration Test] Integration test: decline path asserts apm_modules/ untouched (not just lockfile) -- The rollback safety guarantee is load-bearing for user trust; proven by unit test + manual repro but not automated integration.
  3. [New Feature] apm doctor check for orphaned .apm-update-backup/ directories -- Defensive UX for edge cases where a crash leaves staging artifacts; not blocking but improves operational confidence.

Shepherd run summary (iteration 1):

  • Copilot rounds: 1 (zero inline comments, drained after 1 fetch)
  • Folded items: 10 (test fix, CANCELLED disposition, verbose logging, path_security guards, restore logging, predicate parity test, CHANGELOG rewrite, docs update, LOC budget, staging rollback fix)
  • Deferred items: 3 (integration tests, apm doctor) -- scope boundary: integration test infrastructure and new feature
  • CI iterations: 2 (first push: staging regression + LOC budget failure; second push: all green)
  • Lint evidence: ruff check + ruff format + pylint R0801 + auth-signals all silent on final SHA
  • CI evidence: https://github.com/microsoft/apm/actions/runs/29182970554 (all SUCCESS)
  • Head SHA: 5706407
  • Mergeable: MERGEABLE | MergeStateStatus: BLOCKED (pending required review -- normal gate)
Copilot signals reviewed (round 1)

Copilot posted 1 overview review (no inline comments).

  • Review ID 4636176721: OVERVIEW -- "Copilot reviewed 9 out of 9 changed files... generated 1 comment." -- Classification: NOT-LEGIT for inline purposes (no actionable inline finding; the single generated comment is a summary, not a defect report). No inline items to fold.

@nadav-y nadav-y marked this pull request as draft July 12, 2026 07:54
@nadav-y

nadav-y commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Putting on draft to rebase after #2052 / #2155 merge and look into comments and tests.
I'll reopen soon

-- edit: Done

nadav-y and others added 2 commits July 12, 2026 11:00
…ng apm update

APMDependencyResolver only invoked download_callback when a dependency's
install path didn't already exist on disk. For a transitive dependency
chain (e.g. pkg1 -> pkg2 -> pkg3, all pinned to ^1.0.0), publishing a new
pkg3 version was silently ignored by `apm update` run from pkg1, since
pkg2 (and therefore pkg3) already existed locally and the resolver never
re-invoked the callback for it.

download_callback (resolve.py) already had a `_force_semver_resolve`
fallthrough for this exact case, added by a prior fix (Bug 1 on microsoft#1496)
citing the same symptom -- but it was unreachable: the resolver's own
gate (`if not install_path.exists()`) is the only thing that decides
whether download_callback runs at all, and that fix only forced this for
DIRECT dependencies via a pre-purge pass
(`_purge_cached_semver_paths_for_update`) that rmtrees the install path
before resolution starts. A transitive dependency's own install path is
never known ahead of time (its existence isn't discoverable until its
parent's manifest is read, which happens during resolution, not before
it), so it was never purged and download_callback's own re-check logic
never got a chance to run.

Fix: widen the resolver's own gate with a new
APMDependencyResolver._should_force_recheck(dep_ref) predicate --
identical to download_callback's existing _force_semver_resolve check --
gated on a new `update_refs` constructor flag threaded from resolve.py.
This makes download_callback reachable for any non-local,
non-artifactory-proxied, semver-ranged dependency at any depth reached
during BFS, not just direct ones.

No additional backup/rollback code needed: download_callback already
calls `staging_session.prepare_path(install_path)` (an atomic move into
the resolution's rollback-scoped staging directory) immediately before
overwriting, generically for whatever reaches that point -- this already
covers the newly-reachable transitive case with zero changes, verified
live (a declined/aborted apm update leaves the transitive dependency's
prior content and apm.lock.yaml entry untouched, with no orphaned
staging directory).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Mode B detector flags this PR's changes under
src/apm_cli/deps/apm_resolver.py and src/apm_cli/install/phases/resolve.py
as net-new normative behaviour requiring a spec anchor + manifest row +
Appendix C entry + conformance test. It isn't: apm update's dependency
resolution already documents (in resolve.py's own code comments and
download_callback's pre-existing, previously-unreachable
_force_semver_resolve branch, itself citing "Bug 1 fix on microsoft#1496") that a
semver-ranged dependency must be re-checked against the remote when
--update/--refresh is set. This PR fixes a bug where that promise wasn't
actually kept for transitive dependencies -- it restores the existing
contract, it doesn't define a new one.

apm-spec-waiver: bug fix restoring existing update-refresh semver re-check contract to transitive deps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nadav-y nadav-y force-pushed the fix/transitive-semver-reresolution branch from 5706407 to 31bc4a0 Compare July 12, 2026 08:02
…lity

Fixes a stray "it's" -> "its" typo along the way.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nadav-y nadav-y marked this pull request as ready for review July 12, 2026 08:32
@danielmeppiel danielmeppiel merged commit 4633dbd into microsoft:main Jul 12, 2026
10 checks passed
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