fix(pm): prune recorded extras the tree no longer declares

The venv ledger unions its recorded extras into every sync. When a source
update removes an extra (hindsight, 73c598e319/285768fdfb), installs that
recorded it pass `--extra hindsight` to `uv sync` forever and fail with
"Extra `hindsight` is not defined in any project's optional-dependencies
table". Every CLI launch then retries the failed source-update completion,
and gateways keep booting the previous generation's code.

Drop recorded extras the checkout's pyproject no longer declares, in both
the sync target and the currency probe. An explicitly requested unknown
extra still fails loudly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matt Healey
2026-09-24 20:29:35 -07:00
committed by ethernet
parent 3be17b1d5c
commit 24e8c341c1
3 changed files with 40 additions and 2 deletions

View File

@@ -542,6 +542,22 @@ def _member_inputs(plugins: PluginInput | None) -> dict:
raise TypeError(f"{type(plugins).__name__} changes plugin state; only a sync may carry it")
def _still_declared(package, recorded: list[str]) -> list[str]:
"""The recorded extras this tree still declares.
An extra the source removed (``hindsight``) would otherwise ride the ledger
into every later ``uv sync`` and fail it with "Extra is not defined". Only
recorded extras are pruned; an explicitly requested unknown extra still fails.
"""
from pm.features import declared_extras
root = package.project_root()
if not (root / "pyproject.toml").is_file():
return list(recorded)
declared = set(declared_extras(root))
return [extra for extra in recorded if extra in declared]
def venv_is_current(*, extras: list[str] | None = None, plugins: Members | Candidates | None = None,
project_root: Path | None = None) -> bool:
"""Probe the requested union without changing recorded dependency state."""
@@ -559,7 +575,7 @@ def venv_is_current(*, extras: list[str] | None = None, plugins: Members | Candi
or not isinstance(fact.get("extras"), list)
or any(not isinstance(extra, str) for extra in fact["extras"])):
raise ValueError("invalid recorded dependency state")
enabled = sorted(set(fact["extras"]) | set(extras or []))
enabled = sorted(set(_still_declared(package, fact["extras"])) | set(extras or []))
stamp = package.expected_stamp(enabled, **_member_inputs(plugins))
return _runtime_state_matches(fact, stamp, project_root=root)
@@ -651,7 +667,7 @@ def _target_selection(package, fact: dict, *, extras, inputs: dict, repair: bool
return enabled, stamp, {"repair": True}
# The first writable generation replaces, rather than layers on,
# the payload. Retain its extras until a recorded selection owns them.
enabled = sorted(set(fact.get("extras", shipped or [])) | set(extras or []))
enabled = sorted(set(_still_declared(package, fact.get("extras", shipped or []))) | set(extras or []))
return enabled, package.expected_stamp(enabled, **inputs), inputs

View File

@@ -59,6 +59,10 @@ def test_check_uses_real_pm_selection_and_keeps_invalid_evidence(admission_env,
config.write_bytes(old_config)
check('current')
# A recorded extra only counts while the tree still declares it.
pyproject = core / 'pyproject.toml'
pyproject.write_text(pyproject.read_text(encoding='utf-8')
+ '[project.optional-dependencies]\nchanged-extra = []\n', encoding='utf-8')
altered = json.loads(pristine)
altered['packages']['venv']['extras'] = ['changed-extra']
facts_path.write_text(json.dumps(altered), encoding='utf-8')

View File

@@ -329,6 +329,24 @@ def test_failed_real_sync_preserves_previous_selection_and_retries(source_launch
assert not any(marker.exists() for marker in markers)
@pytest.mark.platforms("posix")
def test_source_update_that_removes_a_recorded_extra_still_syncs(source_launch):
"""A dropped extra (hindsight, 73c598e319) must not brick every later sync."""
root, store_python, _ = source_launch
pm.sync_venv(["all", "launch-extra"], explicit=True, project_root=root)
assert _fact(root)["extras"] == ["all", "launch-extra"]
pyproject = root / "pyproject.toml"
pyproject.write_text(pyproject.read_text(encoding="utf-8").replace("launch-extra = []\n", ""),
encoding="utf-8")
pm.lock_project(root, offline=True, explicit=True)
assert not pm.venv_is_current(project_root=root)
assert venv_sync.prepare_launch(root, []) == store_python
assert _fact(root)["extras"] == ["all"]
assert pm.venv_is_current(project_root=root)
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("mode", ["script", "module", "command"])
def test_real_bootstrap_reexecs_before_app_imports(source_launch, tmp_path, isolated_python, mode):