diff --git a/Makefile b/Makefile index e0ce73d..706bc67 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ lint: ## Run template test lint checks $(UV) --with interrogate interrogate --fail-under 100 tests/ typecheck: ## Run template test type checks - $(UV) --with pytest --with pytest-copier --with pyyaml --with syrupy --with make-parser ty check tests/ + $(UV) --with pytest --with pytest-copier --with pyyaml --with syrupy --with make-parser ty@0.0.56 check tests/ help: ## Show available targets @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ diff --git a/docs/adr-003-pin-ty-version.md b/docs/adr-003-pin-ty-version.md new file mode 100644 index 0000000..d5be16b --- /dev/null +++ b/docs/adr-003-pin-ty-version.md @@ -0,0 +1,42 @@ +# ADR-003: Pin the ty typechecker version + +## Status + +Accepted. + +## Context + +Both the parent repository and generated projects typecheck with `ty`. The +parent Makefile ran it through an unpinned `uvx` invocation, and the templated +dev dependency group declared `"ty"` without a version, so every install +resolved the latest release. + +`ty` is pre-1.0 and its diagnostics change between releases. When ty 0.0.56 +landed, estate repositories with unpinned installations broke without any code +change: the falcon-correlate and polythene main branches were red for days. +An unpinned typechecker makes gate outcomes depend on release timing rather +than on the code under review. + +## Decision + +Pin `ty` to an exact version everywhere it is installed: + +- the parent Makefile's `typecheck` target runs `uvx … ty@0.0.56 check`; +- the templated dev dependency group declares `"ty==0.0.56"`, which pins the + generated `uv run ty` invocations; +- contract tests assert the pinned forms so drift fails the suite. + +Version bumps are deliberate: update the pin, fix any new diagnostics it +surfaces, and land both in a single pull request. Update the parent Makefile, +the templated dev group, and the contract assertions together, and re-render +both template toggle states before merging. + +## Consequences + +- Gate results are reproducible: a new ty release cannot break `main` in the + parent repository or in generated projects. +- New ty diagnostics arrive only when a maintainer chooses to take them, + alongside the fixes they require. +- The pin must be maintained by hand; generated projects inherit whatever + version the template pinned at render time and manage their own bumps + thereafter. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 0f9869b..190c41c 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -15,7 +15,11 @@ template itself. - `make lint` — runs Ruff lint checks and `interrogate --fail-under 100` against the `tests/` directory in the parent template test suite. - `make typecheck` — runs `ty check` against the parent template test suite, - supplying the test dependencies through `uvx`. + supplying the test dependencies through `uvx`. The `ty` version is pinned + (`ty@0.0.56`) because unpinned installations broke estate repositories when + ty 0.0.56 landed (falcon-correlate and polythene mains were red for days). + Version bumps are deliberate: update the pin, fix any new diagnostics, and + land both in one pull request (see ADR-003). - `make test` — runs the template test suite via `uvx`, supplying `pytest-copier`, `pyyaml`, `syrupy`, and `make-parser` without a manually managed virtual environment. @@ -45,6 +49,9 @@ Docker-dependent tests isolated from the standard template test gate: `template/Makefile.jinja` defines the generated developer workflow. The default `all` target runs build, formatting, linting, typechecking, and tests. +Generated projects pin `ty` in the dev dependency group (`ty==0.0.56`), so +`make typecheck` resolves the pinned typechecker; bumps follow the same +deliberate policy as the parent repository (ADR-003). The generated `audit` target is an explicit security gate run by CI. It runs `pip-audit` for every rendered project and, when `use_rust` is enabled, also runs `cargo audit` in the Rust extension crate. diff --git a/template/docs/developers-guide.md b/template/docs/developers-guide.md index 98ff017..472e7cb 100644 --- a/template/docs/developers-guide.md +++ b/template/docs/developers-guide.md @@ -12,6 +12,11 @@ considered complete. `make lint` runs Ruff, `interrogate --fail-under 100 $(PYTHON_TARGETS)` for 100% docstring coverage across `$(PYTHON_TARGETS)`, and Pylint. +`make typecheck` runs `ty`, pinned in the dev dependency group +(`ty==0.0.56`): unpinned installations broke repositories when ty 0.0.56 +landed. Bump the pin deliberately — update the version and fix any new +diagnostics in the same pull request. + Run `make audit` as the dependency vulnerability gate. It runs `pip-audit` for Python dependencies, and Rust-enabled projects also run `cargo audit` from the `rust_extension` crate directory. diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 9f2fddf..9c4ed4d 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -14,7 +14,7 @@ dev = [ "pip-audit", "ruff", "pyright", - "ty", + "ty==0.0.56", "pytest-timeout", "pytest-xdist", ] diff --git a/tests/helpers/pyproject_contracts.py b/tests/helpers/pyproject_contracts.py index 4425daf..adf603a 100644 --- a/tests/helpers/pyproject_contracts.py +++ b/tests/helpers/pyproject_contracts.py @@ -32,19 +32,22 @@ def _assert_pyproject_contracts( assert isinstance(dev_dependencies, list), ( "expected generated pyproject.toml to include a dev dependency group" ) - for dependency in [ + expected_dev_dependencies = { "pytest", "interrogate", "pip-audit", "ruff", "pyright", - "ty", + "ty==0.0.56", "pytest-timeout", "pytest-xdist", - ]: - assert dependency in dev_dependencies, ( - f"expected generated dev dependencies to include {dependency}" - ) + } + assert set(dev_dependencies) == expected_dev_dependencies, ( + "expected generated dev dependencies to match the pinned contract" + ) + assert len(dev_dependencies) == len(expected_dev_dependencies), ( + "expected generated dev dependencies to contain no duplicates" + ) pytest_options = require_mapping( require_mapping(pyproject, "tool", "pyproject.toml"), "pytest", diff --git a/tests/test_helpers.py b/tests/test_helpers.py index b549b42..56831a5 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -8,7 +8,10 @@ from __future__ import annotations +import os +import shlex import subprocess +import sys from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -464,8 +467,10 @@ def test_parent_makefile_test_target_uses_requisite_pytest_command() -> None: ) assert ( "$(UV) --with pytest --with pytest-copier --with pyyaml --with syrupy " - "--with make-parser ty check tests/" in makefile - ), "expected parent Makefile typecheck target to run ty with test dependencies" + "--with make-parser ty@0.0.56 check tests/" in makefile + ), ( + "expected parent Makefile typecheck target to run pinned ty with test dependencies" + ) assert "test: ## Run template tests" in makefile, ( "expected parent Makefile to expose a documented test target" ) @@ -490,6 +495,55 @@ def test_parent_makefile_test_target_uses_requisite_pytest_command() -> None: ) +@pytest.mark.skipif(sys.platform == "win32", reason="requires a POSIX shell shim") +def test_parent_makefile_typecheck_invokes_pinned_ty(tmp_path: Path) -> None: + """Validate the parent ``make typecheck`` boundary runs pinned ty. + + Parameters + ---------- + tmp_path : Path + Temporary directory holding the fake ``uvx`` shim and its capture + file. + + Returns + ------- + None + The test passes when ``make typecheck`` resolves ``uvx`` from PATH + and invokes it with the pinned ``ty@0.0.56`` tool and the ``check`` + subcommand. + """ + capture = tmp_path / "uvx-args.txt" + shim = tmp_path / "uvx" + shim.write_text( + f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {shlex.quote(str(capture))}\n", + encoding="utf-8", + ) + shim.chmod(0o755) + environment = os.environ.copy() + environment["PATH"] = f"{tmp_path}{os.pathsep}{environment['PATH']}" + + result = subprocess.run( + ["make", "typecheck"], + cwd=REPO_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, ( + "expected make typecheck to succeed with the fake uvx shim: " + f"{result.stdout}\n{result.stderr}" + ) + invocation = capture.read_text(encoding="utf-8").splitlines() + assert "ty@0.0.56" in invocation, ( + "expected make typecheck to invoke the pinned ty version through uvx" + ) + assert "check" in invocation, ( + "expected make typecheck to run the ty check subcommand" + ) + + def _ci_workflow(*, persist_credentials: str, coverage_inputs: str) -> str: """Return a minimal generated CI workflow for coverage-contract tests.""" return f"""\