diff --git a/pm/packages.py b/pm/packages.py index eea7d79887..0b71512d12 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -398,6 +398,11 @@ class Venv(StatePackage): candidate = generation / "venv" environment = managed_environment(candidate, env=source_build_environment(project), explicit=explicit or repair, output=sys.stderr) + if not repair: + # Inspection may skip a broken secondary profile, but publishing a replacement + # graph must not silently evict its recorded members (including passed candidates). + from pm.plugins_state import enabled_plugins_ordered + enabled_plugins_ordered() members = [] if repair else (enabled_member_dirs() if plugin_dirs is None else plugin_dirs) try: generation.mkdir(parents=True) diff --git a/tests/pm/test_plugin_survival_contract.py b/tests/pm/test_plugin_survival_contract.py index 82108d6909..49ebff19cd 100644 --- a/tests/pm/test_plugin_survival_contract.py +++ b/tests/pm/test_plugin_survival_contract.py @@ -268,6 +268,69 @@ def test_conflicting_candidate_refused_unenabled_and_unimported(admission_env): subprocess.run([str(sidecar_python), "-c", "import sys; assert sys.prefix != sys.base_prefix"], check=True, timeout=30) +@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH") +def test_malformed_secondary_cannot_evict_recorded_member(admission_env, monkeypatch, caplog): + """A→B→A: passive inspection survives bad config; A's recorded graph does not shrink.""" + from pm.environments import runtime_facts_path, selected_venv + from pm.install import sync_venv, venv_is_current + from pm.lock import Facts + + tmp_path, home_a = admission_env + core = tmp_path / "core" + profile = home_a / "profiles" / "work" + profile.mkdir(parents=True) + _write_enabled(profile, ["profile-dep"]) + member = profile / "plugins" / "profile-dep" + member.mkdir(parents=True) + (member / "pyproject.toml").write_text( + '[project]\nname="profile-dep"\nversion="1"\nrequires-python=">=3.11"\n' + 'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8", + ) + sync_venv(explicit=True) + recorded = Facts(runtime_facts_path(core), strict=True).get("venv") + selected = selected_venv(core) + assert recorded["stamp"] and selected.is_dir() + assert "profile-dep" in (Path(recorded["resolved_lock"]).parent / "pyproject.toml").read_text() + + bad = profile / "config.yaml" + bad.write_text("plugins: [broken]\n", encoding="utf-8") + candidate = home_a / "plugins" / "new-dep" + candidate.mkdir(parents=True) + (candidate / "pyproject.toml").write_text( + '[project]\nname="new-dep"\nversion="1"\nrequires-python=">=3.11"\n' + 'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8", + ) + _write_enabled(home_a, ["new-dep"]) + assert venv_is_current(project_root=core) is False + assert str(bad) in caplog.text + with pytest.raises(ValueError, match="config.yaml"): + sync_venv(explicit=True) + assert Facts(runtime_facts_path(core), strict=True).get("venv") == recorded + assert selected_venv(core) == selected + # Even a precomputed member list cannot bypass a newly broken profile. + with pytest.raises(ValueError, match="config.yaml"): + sync_venv(explicit=True, plugin_dirs=[]) + assert Facts(runtime_facts_path(core), strict=True).get("venv") == recorded + + home_b = tmp_path / "home-b" + _write_enabled(home_b, []) + monkeypatch.setenv("HERMES_HOME", str(home_b)) + sync_venv(explicit=True) + assert selected_venv(core).is_dir() + assert Facts(runtime_facts_path(core), strict=True).get("venv")["stamp"] != recorded["stamp"] + + monkeypatch.setenv("HERMES_HOME", str(home_a)) + with pytest.raises(ValueError, match="config.yaml"): + sync_venv(explicit=True) + assert selected_venv(core) == selected + _write_enabled(profile, ["profile-dep"]) + sync_venv(explicit=True) + restored = Facts(runtime_facts_path(core), strict=True).get("venv") + assert selected_venv(core).is_dir() + assert restored["stamp"] != recorded["stamp"] + assert "profile-dep" in (Path(restored["resolved_lock"]).parent / "pyproject.toml").read_text() + + def test_active_context_home_exported_to_wrapper_subprocess(monkeypatch, tmp_path): from hermes_constants import reset_hermes_home_override, set_hermes_home_override from tools.environments.local import build_subprocess_env diff --git a/tests/pm/test_recovery.py b/tests/pm/test_recovery.py index 7f869d3ee2..09bcaabddc 100644 --- a/tests/pm/test_recovery.py +++ b/tests/pm/test_recovery.py @@ -213,11 +213,12 @@ def test_uncertain_profile_selection_skips_sync_but_not_admission_or_recorded_re ) assert result.stdout.strip() == "1.0" assert Path(Facts(paths.runtime_facts_path()).get("venv")["resolved_lock"]).read_bytes() == old_lock - engine.sync_venv(explicit=True) + with pytest.raises(ValueError, match="config.yaml"): + engine.sync_venv(explicit=True) assert str(sibling_config) in caplog.text - current = selected_venv(core) + assert selected_venv(core) == repaired result = subprocess.run( - [str(current / ("Scripts/python.exe" if os.name == "nt" else "bin/python")), + [str(repaired / ("Scripts/python.exe" if os.name == "nt" else "bin/python")), "-I", "-c", "import core_dep; print(core_dep.__version__)"], cwd=tmp_path, capture_output=True, text=True, check=True, timeout=30, )