fix(pm): fail updates when package resolution fails

A failed lookup was reported as no change and returned success.
Track failures where exceptions occur rather than parsing status text.
Report independent successful lookups, then stop before any pin,
install or dependency refresh if one lookup failed.

Verification: the real PM CLI exercises check and apply with failed,
mixed, current and manual-source results. Mutation boundaries stay
unreached and the temporary lock stays unchanged. The focused gate
passed 36 tests with no failures. Lint passed.
No upstream package, real pin table or installed environment changed.
This commit is contained in:
ethernet
2026-09-09 23:55:53 -04:00
parent ecf5a6879e
commit 8afc241e6e
2 changed files with 80 additions and 0 deletions

View File

@@ -289,6 +289,7 @@ def cmd_update(args) -> int:
target = args.target or current_target()
resolved = []
failures = []
for name in names:
package = get_package(name)
targets = [t for t in ALL_TARGETS if package.missing_reason(t) is None]
@@ -300,6 +301,7 @@ def cmd_update(args) -> int:
decision = resolve_package(package, targets, lockfile.version(name))
except Exception as e: # an upstream index outage must not kill the whole check
decision = Resolved(name, lockfile.version(name), package.version_style, reason=f"resolve failed: {e}")
failures.append(name)
resolved.append(decision)
# ── report ────────────────────────────────────────────────────────────
@@ -318,6 +320,9 @@ def cmd_update(args) -> int:
print(f"{d.name:<{width}} {d.locked or '—'} → {d.version}{per}")
else:
print(f"{d.name:<{width}} {d.locked} up to date")
if failures:
print(f"pm update: resolution failed for {', '.join(failures)}; no changes applied")
return 1
if args.check:
if args.uv:
print("uv deps: would run `uv update` + venv sync")

View File

@@ -0,0 +1,75 @@
"""A failed update lookup is not a current result or permission to apply."""
import importlib
import pytest
from pm import cli, paths, registry
from pm.lock import Lockfile
from pm.package import Package
from pm.store import current_target
class UpdateFixture(Package):
def __init__(self, name, versions):
self.name = name
self.versions = versions
self.lookups = 0
def missing_reason(self, target):
return None if target == current_target() else "not a fixture target"
def latest_versions(self, target, locked=None):
self.lookups += 1
if isinstance(self.versions, Exception):
raise self.versions
return self.versions
def prepare(tmp_path, monkeypatch, packages):
lock = Lockfile(tmp_path / "lock.json")
for package in packages:
lock.set_pin(package.name, "1.0", {})
monkeypatch.setitem(registry._packages, package.name, package)
lock.save()
monkeypatch.setattr(paths, "lockfile_path", lambda: lock.path)
monkeypatch.setattr(paths, "repo_root", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools"))
def forbidden(*args, **kwargs):
pytest.fail("lookup-only result reached a mutation or dependency refresh")
for name in ("_pin_artifacts", "_install_names", "_run_live", "pm_uv"):
monkeypatch.setattr(cli, name, forbidden)
monkeypatch.setattr(importlib.import_module("pm.ensure"), "sync_venv", forbidden)
return lock
@pytest.mark.parametrize("check", [True, False])
@pytest.mark.parametrize("mixed", [True, False])
def test_failed_resolution_stops_every_apply_path(tmp_path, monkeypatch, capsys, check, mixed):
failed = UpdateFixture("failed-lookup", TimeoutError("fixture index unavailable"))
healthy = UpdateFixture("healthy-lookup", ["2.0"])
packages = [failed, healthy] if mixed else [failed]
lock = prepare(tmp_path, monkeypatch, packages)
before = lock.path.read_bytes()
args = ["update", *[p.name for p in packages], "--uv", "--npm"]
if check:
args.append("--check")
assert cli.main(args) == 1
assert "fixture index unavailable" in capsys.readouterr().out
assert lock.path.read_bytes() == before
assert not (tmp_path / "tools").exists()
if mixed:
assert healthy.lookups == 1
@pytest.mark.parametrize("versions", [["1.0"], []])
def test_current_and_manual_results_are_successful_without_writes(tmp_path, monkeypatch, versions):
package = UpdateFixture("no-update", versions)
lock = prepare(tmp_path, monkeypatch, [package])
before = lock.path.read_bytes()
for flags in (["--check"], []):
assert cli.main(["update", package.name, *flags]) == 0
assert lock.path.read_bytes() == before
assert not (tmp_path / "tools").exists()