-
Notifications
You must be signed in to change notification settings - Fork 0
feat(python): require full sharded mutation on pull requests #365
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
139f6d9
ddf27cf
ae89a7a
0fa660e
40abe66
9e1da0e
56f55c4
bae3f2b
06a6b1b
c40e717
ed1a6d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,11 +59,15 @@ jobs: | |
| if-no-files-found: ignore | ||
|
|
||
| mutation: | ||
| name: Mutation Gate | ||
| name: Mutation Gate (${{ matrix.shard }}) | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 40 | ||
| timeout-minutes: 90 | ||
| permissions: | ||
| contents: read | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22] | ||
| steps: | ||
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | ||
| with: | ||
|
|
@@ -76,15 +80,16 @@ jobs: | |
| with: | ||
| version: "0.12.17" | ||
| - run: uv sync --locked | ||
| - name: Mutate changed and critical runtime modules | ||
| - name: Mutate all handwritten runtime modules in this shard | ||
| env: | ||
| MUTATION_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} | ||
| run: uv run --locked poe mutation | ||
| MUTATION_SHARD_INDEX: ${{ matrix.shard }} | ||
| MUTATION_SHARD_COUNT: 23 | ||
| run: uv run --locked poe mutation-full | ||
| - name: Preserve mutation outcomes | ||
| if: ${{ always() }} | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: mutation-outcomes | ||
| name: mutation-outcomes-${{ matrix.shard }} | ||
| path: reports/mutation.json | ||
| if-no-files-found: error | ||
|
|
||
|
|
@@ -94,7 +99,23 @@ jobs: | |
| needs: [test, mutation] | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 5 | ||
| permissions: | ||
| contents: read | ||
| steps: | ||
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because this workflow sets top-level Useful? React with 👍 / 👎. |
||
| with: | ||
| persist-credentials: false | ||
| - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | ||
| with: | ||
| python-version: "3.12" | ||
| - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 | ||
| with: | ||
| pattern: mutation-outcomes-* | ||
| path: reports/shards | ||
| - name: Require every runtime module in one mutation shard | ||
| run: | | ||
| git ls-files -z 'src/volcano_sdk/*.py' > reports/shards/source.bin | ||
| python -m scripts.check_mutation_shards reports/shards reports/shards/source.bin 23 | ||
| - name: Require every mandatory job to succeed | ||
| env: | ||
| RESULTS: ${{ toJSON(needs.*.result) }} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,29 @@ | ||
| # Mutation testing | ||
|
|
||
| `uv run --locked poe quality` runs all native checks and mutates every changed | ||
| handwritten runtime module plus the lock acquisition, guard, renewal, and worker | ||
| modules. CI uses the same `checks` and `mutation` tasks in separate jobs, then | ||
| requires both through `Quality Gate`. The weekly `poe mutation-full` task audits | ||
| every handwritten runtime module without a debt baseline. | ||
| `uv run --locked poe quality` runs all native checks and mutates every handwritten | ||
| runtime module. CI runs `checks` and divides `mutation-full` across eight jobs; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Update this to say 23 jobs: the CI matrix defines shards 0–22 and the Quality Gate explicitly requires 23 reports. Describing eight jobs makes the new maintainer guidance internally contradictory and gives maintainers the wrong topology when investigating capacity, timeouts, or future shard-count changes. Useful? React with 👍 / 👎. |
||
| the required `Quality Gate` checks every job and the complete runtime inventory. | ||
| The weekly full audit also runs `mutation-full` without sharding. `poe mutation` | ||
| remains a faster local diagnostic for changed modules and the lock runtime. | ||
|
|
||
| Mutmut's [native configuration](https://mutmut.readthedocs.io/en/latest/) lives | ||
| in `pyproject.toml`; it excludes only the generated OpenAPI client. Mutmut can | ||
| select modules by name but has no Git-changed-module option and returns success | ||
| when mutants survive. `scripts/mutation.sh` selects module names from Git and | ||
| `scripts/mutation_results.py` reads only those modules' native metadata. The | ||
| report at `reports/mutation.json` distinguishes killed, statically invalid, | ||
| select modules by name but cannot divide a run across CI jobs or verify that | ||
| their combined results cover every tracked source module. GitHub's matrix | ||
| reports job success without checking that source inventory. | ||
| `scripts/mutation.sh` assigns Git-tracked runtime modules to shards, | ||
| `scripts/mutation_results.py` reads each shard's native metadata, and | ||
| `scripts/check_mutation_shards.py` checks that all 23 reports cover each | ||
| handwritten module exactly once. The export-only `__init__.py` shares a shard | ||
| with a mutatable module. `reports/mutation.json` distinguishes killed, | ||
| statically invalid, | ||
| surviving, uncovered, timed-out, crashed, interrupted, and missing results. | ||
| A pytest internal error is a harness crash, not a killed mutant. | ||
| The pinned Pyrefly check rejects type-invalid realtime and auth mutants before pytest; | ||
| The pinned Pyrefly check rejects type-invalid mutants in every handwritten | ||
| runtime module before pytest; | ||
| the report counts these as `type_checked`, separately from test-killed mutants. | ||
| The runner checks the unmutated source first, so an existing type error cannot | ||
| make every mutant appear invalid. | ||
| Surviving, uncovered, timed-out, crashed, and incomplete mutants still fail. | ||
| Mutmut passes pytest `-x` so a selected test's first assertion failure kills the | ||
| mutant before a later selected test can hang on the same defect. Mutants that | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| """Require every runtime module in exactly one successful mutation shard.""" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This adds a custom enforcement mechanism without documenting the native-tool gap it fills, while AGENTS.md reference: AGENTS.md:L3-L5 Useful? React with 👍 / 👎. |
||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import sys | ||
| from collections import Counter | ||
| from pathlib import Path | ||
| from typing import cast | ||
|
|
||
|
|
||
| class MutationShardError(Exception): | ||
| """A required mutation shard or runtime module is missing or failed.""" | ||
|
|
||
|
|
||
| def source_modules(path: Path) -> set[str]: | ||
| """Read the tracked handwritten runtime inventory. | ||
|
|
||
| Returns: | ||
| Runtime module paths. | ||
|
|
||
| """ | ||
| return { | ||
| os.fsdecode(raw) | ||
| for raw in path.read_bytes().split(b"\0") | ||
| if raw and not raw.startswith(b"src/volcano_sdk/_generated/") | ||
| } | ||
|
|
||
|
|
||
| def valid_outcomes(raw: object) -> bool: | ||
| """Accept only nonempty native killed or statically invalid outcomes. | ||
|
|
||
| Returns: | ||
| Whether the result counts are complete and successful. | ||
|
|
||
| """ | ||
| if not isinstance(raw, dict): | ||
| return False | ||
| counts = cast("dict[object, object]", raw) | ||
| if not counts or any( | ||
| name not in {"killed", "type_checked"} | ||
| or not isinstance(count, int) | ||
| or isinstance(count, bool) | ||
| or count < 0 | ||
| for name, count in counts.items() | ||
| ): | ||
| return False | ||
| validated_counts = cast("dict[str, int]", counts) | ||
| return sum(validated_counts.values()) > 0 | ||
|
|
||
|
|
||
| def shard_modules(path: Path) -> list[str]: | ||
| """Validate one native Mutmut result report. | ||
|
|
||
| Returns: | ||
| Paths audited by this shard. | ||
|
|
||
| Raises: | ||
| MutationShardError: The report is incomplete or contains failed mutants. | ||
|
|
||
| """ | ||
| raw = cast("object", json.loads(path.read_text(encoding="utf-8"))) | ||
| if not isinstance(raw, dict): | ||
| msg = f"Invalid mutation report: {path}" | ||
| raise MutationShardError(msg) | ||
| report = cast("dict[str, object]", raw) | ||
| modules = report.get("modules") | ||
| outcomes = report.get("outcomes") | ||
| failures = report.get("failures") | ||
| if ( | ||
| not isinstance(modules, list) | ||
| or not modules | ||
| or not isinstance(failures, list) | ||
| or failures | ||
| ): | ||
| msg = f"Incomplete mutation report: {path}" | ||
| raise MutationShardError(msg) | ||
| typed_modules = cast("list[object]", modules) | ||
| if not all(isinstance(module, str) for module in typed_modules): | ||
| msg = f"Invalid mutation modules: {path}" | ||
| raise MutationShardError(msg) | ||
| if not valid_outcomes(outcomes): | ||
| msg = f"Failed mutation outcomes: {path}" | ||
| raise MutationShardError(msg) | ||
| return cast("list[str]", modules) | ||
|
|
||
|
|
||
| def main(root: Path, source_path: Path, count: int) -> int: | ||
| """Check shard presence, successful results, and complete source coverage. | ||
|
|
||
| Returns: | ||
| Zero only when every runtime module appears exactly once. | ||
|
|
||
| Raises: | ||
| MutationShardError: A shard or source module is missing or invalid. | ||
|
|
||
| """ | ||
| expected = source_modules(source_path) | ||
| if not expected or count < 1: | ||
| msg = "Empty source inventory or invalid shard count" | ||
| raise MutationShardError(msg) | ||
| actual: Counter[str] = Counter() | ||
| for index in range(count): | ||
| report = root / f"mutation-outcomes-{index}" / "mutation.json" | ||
| actual.update(shard_modules(report)) | ||
| extra = sorted(set(actual) - expected) | ||
| missing = sorted(expected - set(actual)) | ||
| duplicate = sorted(name for name, occurrences in actual.items() if occurrences != 1) | ||
| if extra or missing or duplicate: | ||
| msg = f"Mutation shard inventory differs: {extra=}, {missing=}, {duplicate=}" | ||
| raise MutationShardError(msg) | ||
| print(f"Mutation shards covered {len(expected)} handwritten runtime modules") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main(Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3]))) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the current 24-module inventory, 23 shards work only because shard 0 receives both export-only
__init__.pyand one mutatable module. If a PR removes any handwritten runtime module, shard 0 receives only__init__.py;scripts.mutation_resultsthen reports “No mutants were tested,” so the mandatory mutation job fails even though every remaining mutatable module was covered. Derive the shard topology from the inventory or otherwise ensure every shard includes a mutatable module.Useful? React with 👍 / 👎.