diff --git a/scripts/worktree/new.ps1 b/scripts/worktree/new.ps1 index 5e06d62f..e333ea05 100644 --- a/scripts/worktree/new.ps1 +++ b/scripts/worktree/new.ps1 @@ -150,7 +150,19 @@ if ($LASTEXITCODE -ne 0) { throw "venv creation failed (is '$Python' on PATH?)" Push-Location $WorktreePath try { - & $venvPy -m pip install -e ".[$extras]" + # --constraint constraints.lock, exactly as EVERY install in ci.yml does. constraints.lock is the + # HASHLESS export of uv.lock kept in sync by the DEP-1 gate; without it pip RE-RESOLVES inside + # pyproject's ranges and a fresh worktree gets whatever PyPI serves today. + # + # Not hypothetical: a worktree created 2026-07-29 came up with ruff 0.16.0 while constraints.lock + # pinned 0.15.22. That worktree then reported ~829 findings CI does not have — and because the + # ruff pre-commit hook runs `ruff check --fix`, it REWROTE files to match, stripping `# noqa` + # directives the pinned ruff still wants. A venv that lints differently from CI does not merely + # mislead; it edits your source on commit. + # + # This is the same reasoning as the DEP-1 `--require-hashes` install, one rung down: CI proves the + # lock installs, this makes a developer's environment agree with it. + & $venvPy -m pip install --constraint constraints.lock -e ".[$extras]" if ($LASTEXITCODE -ne 0) { throw "pip install -e .[$extras] failed (exit $LASTEXITCODE)" } } finally { Pop-Location diff --git a/tests/test_worktree_venv_constraint.py b/tests/test_worktree_venv_constraint.py new file mode 100644 index 00000000..aaf7a99d --- /dev/null +++ b/tests/test_worktree_venv_constraint.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""A new worktree's venv must resolve to the SAME versions CI uses. + +THE DEFECT THIS EXISTS FOR. ``scripts/worktree/new.ps1`` provisioned each worktree with a bare +``pip install -e ".[dev,harness]"``. Every install in ``ci.yml`` passes ``--constraint +constraints.lock`` — its own comment says why: *"Without it, `uv pip install -e ".[extras]"` +RE-RESOLVES"*. ``new.ps1`` was the one place that omitted it, so a worktree resolved freely inside +``pyproject``'s ranges and could differ from CI on any tool. + +Measured 2026-07-29: a worktree came up with **ruff 0.16.0** while ``constraints.lock`` pinned +**0.15.22**. Two consequences, and the second is why this is a guard rather than a nicety: + +* it reported ~829 findings CI does not have (0.16 enabled stricter defaults), and +* the ``ruff-check`` pre-commit hook runs ``ruff check --fix``, so it **rewrote files** to match — + stripping ``# noqa`` directives the pinned ruff still wants. + +A venv that lints differently from CI does not merely mislead; it edits your source on commit. + +WHY A TEXT TEST. Nothing in the suite can run ``new.ps1`` — it creates a git worktree and a venv, which +is minutes of I/O and mutates the repo's worktree list. The invariant that actually matters is a +property of the COMMAND, so it is asserted against the command. That is the same posture as +``tests/test_ci_venv_pinning.py``, which pins workflow install lines no test can execute either. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +_NEW_PS1 = _REPO / "scripts" / "worktree" / "new.ps1" +_CI = _REPO / ".github" / "workflows" / "ci.yml" +_CONSTRAINTS = _REPO / "constraints.lock" + +#: A project install: ``pip install ... -e ".[extras]"``. Matches the editable-dot form specifically, +#: which is what installs THIS checkout; a lockfile install (``-r``) is a different thing and is not +#: what this module is about. +_EDITABLE_PROJECT_INSTALL = re.compile(r"pip\s+install\b[^\n]*-e\s+[\"']\.\[") + + +def _code_lines(path: Path) -> list[str]: + """Non-comment lines. The rationale comments quote the very command under test, so scanning the + raw text would let an explanation satisfy — or trip — the assertion.""" + return [ + ln for ln in path.read_text(encoding="utf-8").splitlines() if not ln.strip().startswith("#") + ] + + +def test_new_worktree_installs_against_the_constraint_lock() -> None: + """The fix itself: every editable project install in new.ps1 pins to constraints.lock.""" + installs = [ln for ln in _code_lines(_NEW_PS1) if _EDITABLE_PROJECT_INSTALL.search(ln)] + + # Non-vacuity. If the provisioning is restructured away, fail loudly rather than pass by finding + # nothing to check — the failure mode that turns a guard into decoration. + assert installs, ( + f'{_NEW_PS1.name} no longer contains an editable project install (`pip install -e ".[...]"`). ' + "If provisioning moved, re-point this guard at wherever it went." + ) + unconstrained = [ln.strip() for ln in installs if "--constraint" not in ln] + assert not unconstrained, ( + "a worktree venv is provisioned WITHOUT --constraint constraints.lock:\n " + + "\n ".join(unconstrained) + + "\nThat lets pip re-resolve inside pyproject's ranges, so the worktree can lint, typecheck " + "and test against different tool versions than CI. It has already happened: ruff 0.16.0 " + "against a lock pinning 0.15.22, whose `ruff check --fix` hook then rewrote files." + ) + + +def test_the_constraint_file_new_ps1_names_actually_exists() -> None: + """A pin at a path that does not exist fails the install outright — on a developer's first run. + + Cheap to assert, and it is the difference between this change working and it being a typo that + nobody notices until someone creates a worktree. + """ + assert _CONSTRAINTS.is_file(), ( + f"{_NEW_PS1.name} passes `--constraint constraints.lock`, but {_CONSTRAINTS} does not exist. " + "Either the export path moved (see the DEP-1 step in security.yml) or the flag is wrong." + ) + body = _CONSTRAINTS.read_text(encoding="utf-8") + pinned = re.findall(r"(?m)^([A-Za-z0-9._-]+)==", body) + assert len(pinned) > 50, ( + f"constraints.lock parsed to only {len(pinned)} pinned distributions — it is meant to be the " + "hashless export of the whole resolved set. A near-empty constraint file silently constrains " + "almost nothing, which is the same outcome as omitting the flag." + ) + + +def test_new_ps1_matches_the_posture_ci_documents() -> None: + """CI is the reference. If it stops constraining, this guard is enforcing a stale rule. + + Asserted in this direction on purpose: the point is not "new.ps1 contains a flag", it is + "developer provisioning agrees with CI provisioning". Should CI ever move off constraints.lock, + this fails and forces the two to be reconciled deliberately rather than drifting apart again. + """ + ci_installs = [ln for ln in _code_lines(_CI) if _EDITABLE_PROJECT_INSTALL.search(ln)] + assert ci_installs, ( + "ci.yml no longer contains an editable project install — re-point this comparison rather than " + "letting it pass on an empty set." + ) + ci_unconstrained = [ln.strip() for ln in ci_installs if "--constraint" not in ln] + assert not ci_unconstrained, ( + "ci.yml has an editable project install without --constraint:\n " + + "\n ".join(ci_unconstrained) + + "\nIf that is deliberate, new.ps1's constraint is now enforcing a rule CI no longer follows; " + "reconcile the two in one change." + )