From e60492434978fa4ca5c40959f4885f7a579cdfbd Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 7 Jul 2026 12:07:18 +0200 Subject: [PATCH 1/4] Pin ty to 0.0.56 The ty 0.0.56 release broke estate repositories whose CI installed ty unpinned (falcon-correlate and polythene mains were red for days). Pin every ty installation site to 0.0.56 so upgrades become deliberate: - the parent Makefile's typecheck target now runs uvx ty@0.0.56; - the templated dev dependency group now declares ty==0.0.56, which pins the generated Makefile's `uv run ty` invocations; - the contract tests assert the pinned forms. Both rendered toggle states resolve ty 0.0.56 and pass `make typecheck`, and the template's own gates pass with the pin in place. --- Makefile | 2 +- template/pyproject.toml.jinja | 2 +- tests/helpers/pyproject_contracts.py | 2 +- tests/test_helpers.py | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 0ddc5ed..4d2875b 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ spelling: ## Enforce en-GB-oxendict spelling $(MD_FILES_FIND) | xargs -0 $(TYPOS) --config typos.toml --force-exclude typecheck: ## Run template test type checks - $(UV) --with hypothesis --with pytest --with pytest-copier --with pyyaml --with syrupy --with make-parser ty check tests/ + $(UV) --with hypothesis --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/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..88c8bec 100644 --- a/tests/helpers/pyproject_contracts.py +++ b/tests/helpers/pyproject_contracts.py @@ -38,7 +38,7 @@ def _assert_pyproject_contracts( "pip-audit", "ruff", "pyright", - "ty", + "ty==0.0.56", "pytest-timeout", "pytest-xdist", ]: diff --git a/tests/test_helpers.py b/tests/test_helpers.py index ab45d7b..1a62771 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -515,8 +515,10 @@ def test_parent_makefile_test_target_uses_requisite_pytest_command() -> None: ) assert ( "$(UV) --with hypothesis --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" ) From 0f1e944327beb8e71f03841d65537e9b4a94931e Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 7 Jul 2026 13:00:42 +0200 Subject: [PATCH 2/4] Document the ty pin and test the typecheck boundary Address review feedback on the ty pin: - document the ty==0.0.56 pin in the parent developers' guide and the generated developers' guide, including why it exists (unpinned installations broke falcon-correlate and polythene mains when ty 0.0.56 landed) and the policy that bumps are deliberate (update the pin and fix any new diagnostics in one pull request); - record the decision as ADR-003, following the repository's existing ADR convention; - add an integration test that runs the parent `make typecheck` with a fake uvx shim on PATH and asserts the captured invocation includes the pinned ty@0.0.56 token (POSIX-only, skipped on Windows), keeping the existing string-contract assertions. --- docs/adr-004-pin-ty-version.md | 42 +++++++++++++++++++++++++ docs/developers-guide.md | 11 +++++-- template/docs/developers-guide.md | 5 +++ tests/test_helpers.py | 52 +++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 docs/adr-004-pin-ty-version.md diff --git a/docs/adr-004-pin-ty-version.md b/docs/adr-004-pin-ty-version.md new file mode 100644 index 0000000..75006f7 --- /dev/null +++ b/docs/adr-004-pin-ty-version.md @@ -0,0 +1,42 @@ +# ADR-004: 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 e982774..96fe409 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, including Hypothesis, through `uvx`. + supplying the test dependencies, including Hypothesis, 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-004). - `make test` — runs the template test suite via `uvx`, supplying Hypothesis, `pytest-copier`, `pyyaml`, `syrupy`, and `make-parser` without a manually managed virtual environment. @@ -49,7 +53,10 @@ 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, tests, and spelling. The spelling recipe runs last so generated configuration cannot race tests when -callers enable parallel Make execution. +callers enable parallel Make execution. 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-004). 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 f92893d..6700616 100644 --- a/template/docs/developers-guide.md +++ b/template/docs/developers-guide.md @@ -12,6 +12,11 @@ before being 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/tests/test_helpers.py b/tests/test_helpers.py index 1a62771..5bd0177 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 @@ -543,6 +546,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 _coverage_main_workflow(*, guard: str, rust_setup: str = "") -> str: """Return a minimal generated coverage-main workflow for contract tests.""" return f"""\ From cceb6ef0c00f5a9a9b531623bc10ab9348762902 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 8 Jul 2026 15:13:14 +0200 Subject: [PATCH 3/4] Tighten dev dependency contract to an exact set match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the loose membership check on `dev_dependencies` with an exact set equality assertion plus a duplicate-count guard. The old `dependency in dev_dependencies` loop would silently pass if an extra unpinned `ty` entry co-existed alongside `ty==0.0.56`. The new assertion requires the rendered list to match the pinned contract precisely — no extra entries, no duplicates. --- tests/helpers/pyproject_contracts.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/helpers/pyproject_contracts.py b/tests/helpers/pyproject_contracts.py index 88c8bec..adf603a 100644 --- a/tests/helpers/pyproject_contracts.py +++ b/tests/helpers/pyproject_contracts.py @@ -32,7 +32,7 @@ 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", @@ -41,10 +41,13 @@ def _assert_pyproject_contracts( "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", From 1d1b4cca402dddbc0c7c45aaa1fb45b518b311ab Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 25 Jul 2026 17:52:22 +0200 Subject: [PATCH 4/4] Resolve make via shutil.which in typecheck boundary test The parent-Makefile typecheck boundary test spawned `make` by bare name, leaving executable resolution to the subprocess layer at spawn time. Resolve it through `shutil.which` first, assert it is present, and pass the absolute path, so the test controls which binary runs rather than relying on implicit PATH lookup. Co-Authored-By: Claude Fable 5 --- tests/test_helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 5bd0177..3bda56d 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -10,6 +10,7 @@ import os import shlex +import shutil import subprocess import sys from pathlib import Path @@ -573,8 +574,11 @@ def test_parent_makefile_typecheck_invokes_pinned_ty(tmp_path: Path) -> None: environment = os.environ.copy() environment["PATH"] = f"{tmp_path}{os.pathsep}{environment['PATH']}" + make_executable = shutil.which("make") + assert make_executable is not None, "expected make to be available on PATH" + result = subprocess.run( - ["make", "typecheck"], + [make_executable, "typecheck"], cwd=REPO_ROOT, env=environment, check=False,