diff --git a/copier.yml b/copier.yml index 7f861d9..0c8fc22 100644 --- a/copier.yml +++ b/copier.yml @@ -18,8 +18,23 @@ use_rust: python_version: type: str - default: "3.10" + default: "3.12" help: Minimum supported Python version + validator: >- + {% if not (python_version | regex_search('^3[.][0-9]+$')) %} + Python version must use the 3.X format (for example, 3.12). + {% endif %} + +# Computed: mutmut mutation testing needs a base interpreter of 3.13 or +# greater because the shared workflow's helper scripts require >= 3.13. + +mutmut_supported: + type: bool + default: >- + {{ python_version is string + and (python_version | regex_search('^3[.][0-9]+$')) + and (python_version.split('.')[1] | int >= 13) }} + when: false codescene_project_id: type: str diff --git a/docs/developers-guide.md b/docs/developers-guide.md index f2db147..884832f 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -100,6 +100,16 @@ Docker availability, and runs `make test WITH_ACT=1`. Rust-enabled workflows pass `rust_extension/Cargo.toml` to the coverage action because the generated Python project root does not contain a Rust manifest. +The mutation-testing workflow template is rendered when either mutation engine +is available. Its mutmut job requires a minimum Python version of 3.13 or +newer; the hidden `mutmut_supported` Copier answer enforces that gate. The +cargo-mutants job is gated independently by `use_rust`, so a Rust-enabled +project with a Python 3.12 baseline still receives Rust mutation testing. Both +jobs delegate to reusable `leynos/shared-actions` workflows. Dependabot owns +their pinned commit SHAs; contract tests and documentation must not duplicate +those values because Dependabot cannot update them outside the workflow +template. + ### Workflow pins and Dependabot Dependabot owns the upgrade of GitHub Actions and reusable workflows, including diff --git a/template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja b/template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja new file mode 100644 index 0000000..e2de1f6 --- /dev/null +++ b/template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja @@ -0,0 +1,62 @@ +name: Mutation testing + +# Thin caller of the shared mutation-testing reusable workflows; caller +# guides: leynos/shared-actions docs/mutation-mutmut-workflow.md and +# docs/mutation-cargo-workflow.md. +# Scheduled runs mutate only files changed within the detection window; +# manual dispatch runs mutate everything. To test a branch, select it in +# the Actions "Run workflow" control. + +on: + schedule: + # Stagger this cron per repository so estate runs do not bunch up; + # existing repositories occupy the 03:05-09:05 UTC band. + - cron: "30 9 * * *" # Daily, 09:30 UTC + workflow_dispatch: + +# Default the token to no scopes; jobs opt in explicitly. +permissions: {} + +# Serialize runs per ref: a dispatch during a scheduled run (or vice +# versa) queues rather than racing. Runs are informational, so queued +# runs wait instead of cancelling. +concurrency: + group: mutation-testing-${{ "{{" }} github.ref {{ "}}" }} + cancel-in-progress: false + +jobs: +{%- if mutmut_supported %} + mutation: + permissions: + contents: read + id-token: write # OIDC workflow-source resolution + uses: leynos/shared-actions/.github/workflows/mutation-mutmut.yml@859416a90eb3987b46a57682c5d6b8964ad3f0a6 + with: + # Flat package layout: the package directory sits at the + # repository root, so no module prefix is stripped. + paths: "{{ package_name }}/" + module-prefix-strip: "" + # Always >= 3.13 here: this job renders only when the project's + # baseline interpreter satisfies the shared workflow's helper + # scripts. + python-version: "{{ python_version }}" +{%- endif %} +{%- if use_rust %} + + mutation-rust: + permissions: + contents: read + id-token: write # OIDC workflow-source resolution + uses: leynos/shared-actions/.github/workflows/mutation-cargo.yml@859416a90eb3987b46a57682c5d6b8964ad3f0a6 + with: + # The extension crate is the only Cargo target and lives outside + # the repository root, so it is declared as an extra crate and the + # root path list is emptied. + paths: "" + extra-crate-dirs: "rust_extension" + # The shared workflow always fans the root target out on manual + # dispatch, and this repository has no root crate, so that leg + # fails; keep it to a single shard. Scheduled scoped runs and the + # rust_extension target are unaffected. + shard-count: 1 +{%- endif %} diff --git a/template/docs/developers-guide.md b/template/docs/developers-guide.md.jinja similarity index 87% rename from template/docs/developers-guide.md rename to template/docs/developers-guide.md.jinja index dadf5da..6d0c056 100644 --- a/template/docs/developers-guide.md +++ b/template/docs/developers-guide.md.jinja @@ -62,6 +62,20 @@ actions under `.github/`. - `.github/workflows/get-codescene-sha.yml` is manually dispatched. It fetches the CodeScene coverage CLI installer, computes its SHA-256 digest, and writes the result to the `CODESCENE_CLI_SHA256` repository variable. +{% if mutmut_supported or use_rust %} +- `.github/workflows/mutation-testing.yml` runs daily at 09:30 UTC and supports + manual dispatch. It delegates to reusable workflows from `leynos/shared-actions`; + stagger the generated cron schedule before adopting it alongside other + repositories. +{% if mutmut_supported %} + The mutmut job is generated only when the minimum Python version is 3.13 or + newer because the shared workflow helpers require Python 3.13 or newer. +{% endif %} +{% if use_rust %} + Rust-enabled projects also receive a cargo-mutants job for `rust_extension/`, + independently of the Python version baseline. +{% endif %} +{% endif %} - `.github/actions/build-wheels` wraps `cibuildwheel` with `uvx` and uploads architecture-specific wheel artefacts. - `.github/actions/pure-python-wheel` builds a pure Python wheel with diff --git a/template/docs/users-guide.md.jinja b/template/docs/users-guide.md.jinja index 3c7607a..b8e95bf 100644 --- a/template/docs/users-guide.md.jinja +++ b/template/docs/users-guide.md.jinja @@ -49,6 +49,25 @@ Dependabot pull requests; a weekly scheduled audit on the default branch is the compensating control. Rust-enabled projects also run `cargo audit` from the `rust_extension` crate directory. +{% if mutmut_supported or use_rust %} +## Scheduled Mutation Testing + +The `.github/workflows/mutation-testing.yml` workflow runs mutation testing +daily at 09:30 UTC and can also be started manually from GitHub Actions. Adjust +the generated cron schedule to stagger it against other repositories. + +{% if mutmut_supported %} +For projects whose minimum Python version is 3.13 or newer, the workflow runs +mutmut against `{{ package_name }}/`. The Python mutation job is omitted for +older baselines because the shared workflow helpers require Python 3.13 or +newer. +{% endif %} +{% if use_rust %} +Rust-enabled projects also run cargo-mutants against the crate in +`rust_extension/`, independently of the Python version baseline. +{% endif %} +{% endif %} + ## Rust Test Behaviour Rust-enabled projects use `cargo nextest run` when `cargo-nextest` is available. diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 9c4ed4d..061a844 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -294,6 +294,17 @@ enable = [ "too-many-statements", ] +{%- if mutmut_supported %} + +[tool.mutmut] +# mutmut copies the tree before running the suite: tests that read +# repository-root paths outside tests/ must either add those paths to +# `also_copy` or guard themselves with a skipif. See the shared-actions +# caller guide (docs/mutation-mutmut-workflow.md). +source_paths = ["{{ package_name }}/"] +pytest_add_cli_args_test_selection = ["tests/"] +{%- endif %} + [tool.pytest.ini_options] # Tests automatically killed after seconds elapsed timeout = 30 diff --git a/tests/helpers/pyproject_contracts.py b/tests/helpers/pyproject_contracts.py index adf603a..e7ea0ce 100644 --- a/tests/helpers/pyproject_contracts.py +++ b/tests/helpers/pyproject_contracts.py @@ -15,12 +15,16 @@ def _assert_pyproject_contracts( - *, package_name: str, pyproject: dict[str, Any], use_rust: bool + *, + package_name: str, + pyproject: dict[str, Any], + use_rust: bool, + python_version: str, ) -> None: """Assert generated Python packaging contracts.""" project = require_mapping(pyproject, "project", "pyproject.toml") assert project.get("name"), "expected generated project metadata to include a name" - assert project.get("requires-python") == ">=3.10", ( + assert project.get("requires-python") == f">={python_version}", ( "expected generated pyproject.toml to use the requested Python version" ) dependency_groups = require_mapping( diff --git a/tests/helpers/rendering.py b/tests/helpers/rendering.py index 2409931..a6ac6a3 100644 --- a/tests/helpers/rendering.py +++ b/tests/helpers/rendering.py @@ -43,20 +43,22 @@ def run_quality_gates(project: CopierProject) -> None: def render_project( - tmp_path: Path, + destination: Path, copier: CopierFixture, *, project_name: str, package_name: str, use_rust: bool = False, - python_version: str = "3.10", + python_version: str = "3.12", ) -> CopierProject: """Render a generated Python project with explicit template answers. Parameters ---------- - tmp_path : pathlib.Path - Temporary directory used as the generated project destination. + destination : pathlib.Path + Destination directory for the generated project. Callers may pass a + subdirectory of their pytest temporary directory to distinguish + rendered variants. copier : CopierFixture ``pytest-copier`` fixture bound to this template repository. project_name : str @@ -65,7 +67,7 @@ def render_project( Python import package name answer passed to Copier. use_rust : bool, default=False Whether to include the optional PyO3 extension. - python_version : str, default="3.10" + python_version : str, default="3.12" Minimum supported Python version answer passed to Copier. Returns @@ -79,7 +81,7 @@ def render_project( Propagates rendering failures raised by Copier or ``pytest-copier``. """ return copier.copy( - tmp_path, + destination, project_name=project_name, package_name=package_name, use_rust=use_rust, diff --git a/tests/helpers/tooling_contracts.py b/tests/helpers/tooling_contracts.py index d56f6f3..ceb244c 100644 --- a/tests/helpers/tooling_contracts.py +++ b/tests/helpers/tooling_contracts.py @@ -53,6 +53,7 @@ def assert_generated_tooling_contracts( build_wheels_action: str, pure_wheel_action: str, use_rust: bool, + python_version: str = "3.12", ) -> None: """Assert generated Python/Rust tooling contracts from one validator. @@ -60,6 +61,9 @@ def assert_generated_tooling_contracts( ---------- package_name : str Generated Python import package name. + python_version : str, default="3.12" + Minimum supported Python version used to render the project, matched + against the generated ``requires-python`` constraint. agents : str UTF-8 text of the generated ``AGENTS.md`` file. pyproject : dict[str, Any] @@ -118,6 +122,7 @@ def assert_generated_tooling_contracts( package_name=package_name, pyproject=pyproject, use_rust=use_rust, + python_version=python_version, ) _assert_agents_contracts(agents) _assert_agents_make_targets_mirror_makefile( diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 3bda56d..b3bd4fe 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -41,6 +41,19 @@ REPO_ROOT = Path(__file__).resolve().parent.parent +"""Validate direct helper-module error handling and edge cases. +The tests in this module exercise support helpers without rendering a full +Copier project. They keep helper fallibility contracts explicit by checking +``pytest.fail`` conversion paths, generated-file schema helpers, and tooling +contract assertions directly. +""" +if TYPE_CHECKING: + from pytest_copier.plugin import CopierProject +REPO_ROOT = Path(__file__).resolve().parent.parent +_TEST_ACTION_SHA = "0" * 40 +_DEPENDABOT_UPDATED_TEST_ACTION_SHA = "1" * 40 + + @pytest.mark.parametrize( ("output", "expected"), [ @@ -422,8 +435,8 @@ def test_ci_coverage_action_contract_accepts_any_pinned_sha_rejects_branch_ref() ) dependabot_bumped_workflow = workflow.replace( - "927edd45ae77be4251a8a18ca9eb5613a2e32cbd", - "0123456789abcdef0123456789abcdef01234567", + _TEST_ACTION_SHA, + _DEPENDABOT_UPDATED_TEST_ACTION_SHA, ) assert_ci_coverage_action_contract( ci_workflow=dependabot_bumped_workflow, @@ -431,9 +444,7 @@ def test_ci_coverage_action_contract_accepts_any_pinned_sha_rejects_branch_ref() use_rust=False, ) - branch_ref_workflow = workflow.replace( - "@927edd45ae77be4251a8a18ca9eb5613a2e32cbd", "@main" - ) + branch_ref_workflow = workflow.replace(f"@{_TEST_ACTION_SHA}", "@main") with pytest.raises( AssertionError, match="expected CI to use the shared coverage action pinned to a 40-hex", @@ -496,7 +507,7 @@ def test_parent_makefile_test_target_uses_requisite_pytest_command() -> None: """ makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") - assert ".PHONY: help check-fmt lint spelling test typecheck" in makefile, ( + assert ".PHONY: help check-fmt fmt lint spelling test typecheck" in makefile, ( "expected parent Makefile to mark documented gate targets as phony" ) assert "check-fmt: ## Verify template test formatting" in makefile, ( @@ -614,7 +625,7 @@ def _coverage_main_workflow(*, guard: str, rust_setup: str = "") -> str: steps: {rust_setup}\ - name: Generate coverage - uses: leynos/shared-actions/.github/actions/generate-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd + uses: leynos/shared-actions/.github/actions/generate-coverage@{_TEST_ACTION_SHA} with: output-path: coverage.xml format: cobertura @@ -622,7 +633,7 @@ def _coverage_main_workflow(*, guard: str, rust_setup: str = "") -> str: with-ratchet: 'true' - name: Upload coverage data to CodeScene if: {guard} - uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd + uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@{_TEST_ACTION_SHA} with: format: cobertura path: coverage.xml @@ -691,7 +702,7 @@ def test_coverage_main_workflow_contract_requires_rust_setup() -> None: rust_setup = ( " - name: Set up Rust\n" " uses: leynos/shared-actions/.github/actions/setup-rust" - "@927edd45ae77be4251a8a18ca9eb5613a2e32cbd\n" + f"@{_TEST_ACTION_SHA}\n" ) rust_manifest = " cargo-manifest: rust_extension/Cargo.toml\n" workflow = _coverage_main_workflow( @@ -727,8 +738,8 @@ def test_coverage_main_workflow_contract_accepts_any_pinned_sha_rejects_branch_r workflow = _coverage_main_workflow(guard="env.CS_ACCESS_TOKEN != ''") dependabot_bumped_workflow = workflow.replace( - "927edd45ae77be4251a8a18ca9eb5613a2e32cbd", - "0123456789abcdef0123456789abcdef01234567", + _TEST_ACTION_SHA, + _DEPENDABOT_UPDATED_TEST_ACTION_SHA, ) assert_coverage_main_workflow_contract( coverage_main_workflow=dependabot_bumped_workflow, @@ -737,7 +748,7 @@ def test_coverage_main_workflow_contract_accepts_any_pinned_sha_rejects_branch_r ) branch_ref_workflow = workflow.replace( - "generate-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd", + f"generate-coverage@{_TEST_ACTION_SHA}", "generate-coverage@main", ) with pytest.raises( @@ -752,7 +763,7 @@ def test_coverage_main_workflow_contract_accepts_any_pinned_sha_rejects_branch_r ) upload_branch_ref_workflow = workflow.replace( - "upload-codescene-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd", + f"upload-codescene-coverage@{_TEST_ACTION_SHA}", "upload-codescene-coverage@main", ) with pytest.raises( @@ -779,7 +790,7 @@ def _ci_workflow(*, persist_credentials: str, coverage_inputs: str) -> str: persist-credentials: {persist_credentials} - name: Test and Measure Coverage if: ${{{{ github.event_name == 'pull_request' }}}} - uses: leynos/shared-actions/.github/actions/generate-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd + uses: leynos/shared-actions/.github/actions/generate-coverage@{_TEST_ACTION_SHA} with: output-path: coverage.xml format: cobertura diff --git a/tests/test_template.py b/tests/test_template.py index aa77494..07c2071 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -14,17 +14,23 @@ from __future__ import annotations import ast +import re import shutil import subprocess from pathlib import Path +from typing import Any import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st from pytest_copier.plugin import CopierFixture from syrupy.assertion import SnapshotAssertion from tests.helpers.generated_files import ( parse_toml_file, + parse_yaml_mapping, read_generated_text, + require_mapping, ) from tests.helpers.rendering import ( check_generated_import, @@ -39,6 +45,41 @@ ) +DEVELOPER_MUTATION_INTRO = """\ +- `.github/workflows/mutation-testing.yml` runs daily at 09:30 UTC and supports + manual dispatch. It delegates to reusable workflows from `leynos/shared-actions`; + stagger the generated cron schedule before adopting it alongside other + repositories.""" +DEVELOPER_MUTMUT_GUIDANCE = """\ + The mutmut job is generated only when the minimum Python version is 3.13 or + newer because the shared workflow helpers require Python 3.13 or newer.""" +DEVELOPER_RUST_MUTATION_GUIDANCE = """\ + Rust-enabled projects also receive a cargo-mutants job for `rust_extension/`, + independently of the Python version baseline.""" +USER_MUTATION_INTRO = """\ +## Scheduled Mutation Testing + +The `.github/workflows/mutation-testing.yml` workflow runs mutation testing +daily at 09:30 UTC and can also be started manually from GitHub Actions. Adjust +the generated cron schedule to stagger it against other repositories.""" +USER_MUTMUT_GUIDANCE = """\ +For projects whose minimum Python version is 3.13 or newer, the workflow runs +mutmut against `mutation_pkg/`. The Python mutation job is omitted for +older baselines because the shared workflow helpers require Python 3.13 or +newer.""" +USER_RUST_MUTATION_GUIDANCE = """\ +Rust-enabled projects also run cargo-mutants against the crate in +`rust_extension/`, independently of the Python version baseline.""" + + +def _rendered_section(text: str, *, start: str, end: str) -> str: + """Return an optional rendered section with Jinja-only blank lines collapsed.""" + if start not in text: + return "" + section = text.partition(start)[2].partition(end)[0] + return re.sub(r"\n{3,}", "\n\n", f"{start}{section}").strip() + + def test_python_only_help_output_snapshot( copier: CopierFixture, tmp_path: Path, snapshot: SnapshotAssertion ) -> None: @@ -406,12 +447,14 @@ def test_generated_tooling_contracts( None The test passes when the generated tooling contracts are satisfied. """ + python_version = "3.12" project = render_project( tmp_path / target_dir, copier, project_name=project_name, package_name=package_name, use_rust=use_rust, + python_version=python_version, ) run_quality_gates(project) @@ -507,3 +550,357 @@ def test_generated_github_workflows_match_act_validation_contract( package_name=package_name, use_rust=use_rust, ) + + +def _assert_mutation_documentation( + *, + developer_guide: str, + users_guide: str, + expect_mutmut: bool, + use_rust: bool, +) -> None: + """Assert developer and user guidance matches the active mutation gates.""" + expected_developer_guidance = "\n\n".join( + section + for section, enabled in ( + (DEVELOPER_MUTATION_INTRO, expect_mutmut or use_rust), + (DEVELOPER_MUTMUT_GUIDANCE, expect_mutmut), + (DEVELOPER_RUST_MUTATION_GUIDANCE, use_rust), + ) + if enabled + ) + expected_user_guidance = "\n\n".join( + section + for section, enabled in ( + (USER_MUTATION_INTRO, expect_mutmut or use_rust), + (USER_MUTMUT_GUIDANCE, expect_mutmut), + (USER_RUST_MUTATION_GUIDANCE, use_rust), + ) + if enabled + ) + assert ( + _rendered_section( + developer_guide, + start="- `.github/workflows/mutation-testing.yml`", + end="- `.github/actions/build-wheels`", + ) + == expected_developer_guidance + ), "expected developer mutation guidance to match the active mutation gates" + assert ( + _rendered_section( + users_guide, + start="## Scheduled Mutation Testing", + end="## Rust Test Behaviour", + ) + == expected_user_guidance + ), "expected user mutation guidance to match the active mutation gates" + + +def _assert_mutmut_pyproject_config( + *, pyproject: dict[str, Any], package_name: str, expect_mutmut: bool +) -> None: + """Assert ``[tool.mutmut]`` renders only for baselines of 3.13 or greater.""" + mutmut_config = pyproject.get("tool", {}).get("mutmut") + if expect_mutmut: + assert mutmut_config == { + "source_paths": [f"{package_name}/"], + "pytest_add_cli_args_test_selection": ["tests/"], + }, "expected mutmut configuration for baselines of 3.13 or greater" + else: + assert mutmut_config is None, ( + "expected no mutmut configuration below a 3.13 baseline" + ) + + +def _assert_mutation_workflow_metadata(workflow: dict[str, Any]) -> None: + """Assert the schedule, permissions, and concurrency shared by all callers.""" + # PyYAML parses the ``on:`` key as the boolean ``True``. + triggers = workflow.get(True) + assert isinstance(triggers, dict), ( + "expected the mutation workflow to declare on: triggers" + ) + assert triggers.get("schedule") == [{"cron": "30 9 * * *"}], ( + "expected the mutation workflow to run daily at 09:30 UTC" + ) + assert "workflow_dispatch" in triggers, ( + "expected the mutation workflow to allow manual dispatch" + ) + assert workflow.get("permissions") == {}, ( + "expected the mutation workflow to default the token to no scopes" + ) + concurrency = require_mapping(workflow, "concurrency", "mutation workflow") + assert concurrency.get("group") == "mutation-testing-${{ github.ref }}", ( + "expected per-ref concurrency serialization for the mutation workflow" + ) + assert concurrency.get("cancel-in-progress") is False, ( + "expected queued mutation runs to wait rather than cancel in progress" + ) + + +def _assert_mutation_job_gating( + *, + jobs: dict[str, Any], + expect_mutmut: bool, + use_rust: bool, + package_name: str, + python_version: str, +) -> None: + """Assert job presence and shared-workflow inputs for the active gates.""" + assert ("mutation" in jobs) == expect_mutmut, ( + "expected the mutmut job only for baselines of 3.13 or greater" + ) + assert ("mutation-rust" in jobs) == use_rust, ( + "expected the cargo-mutants job only for Rust variants" + ) + if expect_mutmut: + mutation_inputs = require_mapping( + require_mapping(jobs, "mutation", "mutation workflow jobs"), + "with", + "mutmut job", + ) + assert mutation_inputs.get("python-version") == python_version, ( + "expected the mutmut job to run on the project's baseline Python" + ) + assert mutation_inputs.get("paths") == f"{package_name}/", ( + "expected the mutmut job to mutate the flat-layout package directory" + ) + assert mutation_inputs.get("module-prefix-strip") == "", ( + "expected the mutmut job to strip no module prefix for a flat layout" + ) + if use_rust: + rust_inputs = require_mapping( + require_mapping(jobs, "mutation-rust", "mutation workflow jobs"), + "with", + "cargo-mutants job", + ) + assert rust_inputs.get("paths") == "", ( + "expected the cargo-mutants job to empty the root path list" + ) + assert rust_inputs.get("extra-crate-dirs") == "rust_extension", ( + "expected the cargo-mutants job to target the extension crate directory" + ) + assert rust_inputs.get("shard-count") == 1, ( + "expected the cargo-mutants job to confine the root target to one shard" + ) + + +def _assert_shared_workflow_shas(jobs: dict[str, Any]) -> None: + """Assert every job pins its shared mutation workflow to a commit SHA.""" + expected_workflows = { + "mutation": "mutation-mutmut.yml", + "mutation-rust": "mutation-cargo.yml", + } + for job_name, job in jobs.items(): + uses = str(job.get("uses", "")) if isinstance(job, dict) else "" + workflow_ref, separator, revision = uses.partition("@") + assert workflow_ref == ( + f"leynos/shared-actions/.github/workflows/{expected_workflows[job_name]}" + ), f"expected {job_name} to use its shared mutation workflow" + assert separator == "@", ( + f"expected {job_name} shared mutation workflow reference to contain @" + ) + assert re.fullmatch(r"[0-9a-f]{40}", revision), ( + f"expected {job_name} shared mutation workflow revision to be a " + "40-character hexadecimal commit SHA" + ) + + +@pytest.mark.parametrize( + ("target_dir", "use_rust", "python_version", "expect_mutmut"), + [ + ("mutation-312-pure", False, "3.12", False), + ("mutation-312-rust", True, "3.12", False), + ("mutation-313-pure", False, "3.13", True), + ("mutation-314-rust", True, "3.14", True), + ], +) +def test_generated_mutation_testing_gating( + copier: CopierFixture, + tmp_path: Path, + target_dir: str, + use_rust: bool, + python_version: str, + expect_mutmut: bool, +) -> None: + """Rendered mutation testing follows the interpreter and Rust gates. + + Parameters + ---------- + copier + ``pytest-copier`` fixture used to render the template. + tmp_path + Temporary directory where the rendered project is created. + target_dir + Temporary project directory name for the rendered variant. + use_rust + Whether the rendered variant includes the optional Rust extension. + python_version + Minimum supported Python version answer passed to Copier. + expect_mutmut + Whether the baseline interpreter supports the mutmut workflow + (3.13 or greater). + + Returns + ------- + None + The test passes when the mutmut job and ``[tool.mutmut]`` section + render only for baselines of 3.13 or greater, the cargo-mutants job + renders only with the Rust extension, the workflow file is absent when + both gates are off, and the rendered schedule, permissions, + concurrency, and shared-workflow job inputs match the template + contract. + """ + package_name = "mutation_pkg" + project = render_project( + tmp_path / target_dir, + copier, + project_name="MutationProj", + package_name=package_name, + use_rust=use_rust, + python_version=python_version, + ) + _assert_mutation_documentation( + developer_guide=read_generated_text(project / "docs" / "developers-guide.md"), + users_guide=read_generated_text(project / "docs" / "users-guide.md"), + expect_mutmut=expect_mutmut, + use_rust=use_rust, + ) + _assert_mutmut_pyproject_config( + pyproject=parse_toml_file(project / "pyproject.toml"), + package_name=package_name, + expect_mutmut=expect_mutmut, + ) + + workflow_path = project / ".github" / "workflows" / "mutation-testing.yml" + if not expect_mutmut and not use_rust: + assert not workflow_path.exists(), ( + "expected no mutation workflow when both gates are off" + ) + return + workflow = parse_yaml_mapping( + read_generated_text(workflow_path), "mutation workflow" + ) + _assert_mutation_workflow_metadata(workflow) + jobs = require_mapping(workflow, "jobs", "mutation workflow") + _assert_mutation_job_gating( + jobs=jobs, + expect_mutmut=expect_mutmut, + use_rust=use_rust, + package_name=package_name, + python_version=python_version, + ) + _assert_shared_workflow_shas(jobs) + + +@settings( + # Each example renders a full Copier project, so cap the run to keep CI + # runtime predictable; the explicit ``@example`` cases still pin the + # boundary minors (0, 12, 13, 14). + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(minor=st.integers(min_value=0, max_value=20)) +@example(minor=0) +@example(minor=12) +@example(minor=13) +@example(minor=14) +def test_python_version_minor_controls_mutmut_generation( + copier: CopierFixture, + tmp_path_factory: pytest.TempPathFactory, + minor: int, +) -> None: + """Every valid Python 3 minor consistently controls mutmut generation. + + Parameters + ---------- + copier + ``pytest-copier`` fixture used to render the template. + tmp_path_factory + Factory creating an isolated destination for every generated example. + minor + Generated Python minor version in the representative range 0 through + 20. + + Returns + ------- + None + The property holds when Python 3.13 and newer render the mutmut + workflow job and configuration, while older minors render neither. + + Notes + ----- + Hypothesis's function-scoped fixture health check is suppressed because + ``pytest-copier`` supplies ``copier`` at function scope. Each example is + isolated by a fresh destination from ``tmp_path_factory``. + """ + project = render_project( + tmp_path_factory.mktemp(f"mutation-property-{minor}"), + copier, + project_name=f"MutationProperty{minor}", + package_name=f"mutation_property_{minor}", + use_rust=False, + python_version=f"3.{minor}", + ) + workflow_path = project / ".github" / "workflows" / "mutation-testing.yml" + pyproject = parse_toml_file(project / "pyproject.toml") + mutmut_config = pyproject.get("tool", {}).get("mutmut") + + if minor >= 13: + assert workflow_path.exists(), ( + "expected supported Python minor to render the mutation workflow" + ) + workflow = parse_yaml_mapping( + read_generated_text(workflow_path), "mutation workflow" + ) + jobs = require_mapping(workflow, "jobs", "mutation workflow") + assert "mutation" in jobs, ( + "expected supported Python minor to render the mutmut job" + ) + assert mutmut_config is not None, ( + "expected supported Python minor to render [tool.mutmut]" + ) + else: + assert not workflow_path.exists(), ( + "expected unsupported Python minor to omit the mutation workflow" + ) + assert mutmut_config is None, ( + "expected unsupported Python minor to omit [tool.mutmut]" + ) + + +@pytest.mark.parametrize("python_version", ["3", "three.13", "3.13.1", "4.0"]) +def test_python_version_rejects_unexpected_formats( + copier: CopierFixture, + tmp_path: Path, + python_version: str, +) -> None: + """Reject malformed baseline versions at the Copier answer boundary. + + Parameters + ---------- + copier + ``pytest-copier`` fixture used to render the template. + tmp_path + Temporary directory where the rendered project would be created. + python_version + Invalid version answer supplied to Copier. + + Returns + ------- + None + The examples cover every rejected grammar shape: a missing minor, + non-numeric components, an extra patch component, and a non-3 major. + Copier reports the format error before evaluating dependent answers. + """ + with pytest.raises( + ValueError, + match=r"Python version must use the 3\.X format", + ): + render_project( + tmp_path / "invalid-python-version", + copier, + project_name="InvalidVersion", + package_name="invalid_version", + python_version=python_version, + )