Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the shard count from the runtime inventory

With the current 24-module inventory, 23 shards work only because shard 0 receives both export-only __init__.py and one mutatable module. If a PR removes any handwritten runtime module, shard 0 receives only __init__.py; scripts.mutation_results then 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 👍 / 👎.

steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand All @@ -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

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Grant the quality checkout read access

Because this workflow sets top-level permissions: {}, the new Quality Gate inherits a GITHUB_TOKEN without contents: read; unlike the existing test and mutation jobs, it does not override that permission before invoking actions/checkout. The gate can therefore fail at checkout on every trigger before validating the shard reports, so add job-level contents: read or avoid checking out the repository.

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) }}
Expand Down
28 changes: 18 additions & 10 deletions maintainers/mutation-testing.md
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Correct the documented shard-job count

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
Expand Down
20 changes: 8 additions & 12 deletions maintainers/quality-policy.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,16 +299,9 @@
"pyrefly",
"check",
"--output-format=json",
"src/volcano_sdk/realtime.py",
"src/volcano_sdk/auth.py",
"src/volcano_sdk/storage.py",
"src/volcano_sdk/functions.py",
"src/volcano_sdk/durable_authoring.py",
"src/volcano_sdk/database.py",
"src/volcano_sdk/_function_resolution.py",
"src/volcano_sdk/_transport.py",
"src/volcano_sdk/_session.py",
"src/volcano_sdk/_session_operations.py"
"--project-excludes",
"src/volcano_sdk/_generated/**",
"src/volcano_sdk/*.py"
]
},
"mypy": {
Expand Down Expand Up @@ -400,7 +393,10 @@
"generated": "python -m scripts.check_openapi",
"lint": "ruff check --config pyproject.toml .",
"mutation": "bash scripts/mutation.sh",
"mutation-full": "MUTATION_FULL=1 bash scripts/mutation.sh",
"mutation-full": {
"interpreter": "bash",
"shell": "MUTATION_FULL=1 bash scripts/mutation.sh"
},
"mypy": "mypy --config-file pyproject.toml",
"package-check": {
"interpreter": "bash",
Expand All @@ -412,7 +408,7 @@
"policy": "bash scripts/check_quality_policy.sh",
"quality": [
"checks",
"mutation"
"mutation-full"
],
"test": {
"interpreter": "bash",
Expand Down
16 changes: 4 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -458,16 +458,8 @@ partial_branches = []
source_paths = ["src/volcano_sdk"]
type_check_command = [
"pyrefly", "check", "--output-format=json",
"src/volcano_sdk/realtime.py",
"src/volcano_sdk/auth.py",
"src/volcano_sdk/storage.py",
"src/volcano_sdk/functions.py",
"src/volcano_sdk/durable_authoring.py",
"src/volcano_sdk/database.py",
"src/volcano_sdk/_function_resolution.py",
"src/volcano_sdk/_transport.py",
"src/volcano_sdk/_session.py",
"src/volcano_sdk/_session_operations.py",
"--project-excludes", "src/volcano_sdk/_generated/**",
"src/volcano_sdk/*.py",
]
cache_invalidation_files = ["tests/**/*.py"]
pytest_add_cli_args_test_selection = ["tests/unit"]
Expand All @@ -486,7 +478,7 @@ also_copy = [
]

[tool.poe.tasks]
quality = ["checks", "mutation"]
quality = ["checks", "mutation-full"]
checks = ["policy", "audit", "generated", "lint", "format-check", "types", "test", "coverage", "contract-check", "package-check", "package-extras"]
policy = "bash scripts/check_quality_policy.sh"
build = "uv build --no-sources --require-hashes"
Expand All @@ -498,7 +490,7 @@ types = ["mypy", "basedpyright"]
mypy = "mypy --config-file pyproject.toml"
basedpyright = "basedpyright --project pyproject.toml"
mutation = "bash scripts/mutation.sh"
mutation-full = "MUTATION_FULL=1 bash scripts/mutation.sh"
mutation-full = { shell = "MUTATION_FULL=1 bash scripts/mutation.sh", interpreter = "bash" }

[tool.poe.tasks.test]
interpreter = "bash"
Expand Down
118 changes: 118 additions & 0 deletions scripts/check_mutation_shards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Require every runtime module in exactly one successful mutation shard."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document why the custom shard checker is necessary

This adds a custom enforcement mechanism without documenting the native-tool gap it fills, while maintainers/mutation-testing.md still says poe quality mutates changed modules and that CI runs the mutation task. Update the maintainer guidance to describe the new full/sharded orchestration and why Mutmut/GitHub's native configuration cannot enforce cross-shard completeness; the repository explicitly requires that justification for custom checks.

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])))
2 changes: 1 addition & 1 deletion scripts/check_quality_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from collections.abc import Iterable

GENERATED = "src/volcano_sdk/_generated"
LOCK_SHA256 = "489b1a41632f67d528dbb10267bb8921c38c78726c430d990b4ee99ebdd9236a"
LOCK_SHA256 = "64c4fcc180777984cca637b2cbe41684b984ab8e33884da9aa6d0def1ddd048e"
TYPE_FIXTURES = {
"tests/typing/contract_steps.py",
"tests/typing/durable_callbacks.py",
Expand Down
24 changes: 23 additions & 1 deletion scripts/mutation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ set -euo pipefail
# Forked macOS workers must not query SystemConfiguration through urllib/httpx.
export NO_PROXY='*' no_proxy='*'

# An invalid baseline must not turn every mutant into a type-checked result.
pyrefly check --output-format=json --project-excludes 'src/volcano_sdk/_generated/**' 'src/volcano_sdk/*.py'

# Refresh mutmut's test-to-mutant map so newly added tests are selected.
rm -rf -- mutants

Expand Down Expand Up @@ -51,11 +54,30 @@ else
done < reports/mutation-changed.bin
fi

if [[ -n ${MUTATION_SHARD_INDEX:-} ]]; then
if [[ ${MUTATION_FULL:-0} != 1 || ! ${MUTATION_SHARD_INDEX} =~ ^[0-9]+$ || ! ${MUTATION_SHARD_COUNT:-} =~ ^[0-9]+$ || ${MUTATION_SHARD_COUNT:-0} -eq 0 || ${MUTATION_SHARD_INDEX} -ge ${MUTATION_SHARD_COUNT} ]]; then
echo 'Invalid full-mutation shard configuration' >&2
exit 1
fi
all_modules=("${modules[@]}")
modules=()
for index in "${!all_modules[@]}"; do
if (( index % MUTATION_SHARD_COUNT == MUTATION_SHARD_INDEX )); then
modules+=("${all_modules[index]}")
fi
done
fi

if (( ${#modules[@]} == 0 )); then
echo 'No runtime modules selected for mutation' >&2
exit 1
fi

for path in "${modules[@]}"; do
printf '%s\0' "$path" >> "$targets"
done

if [[ ${MUTATION_FULL:-0} == 1 ]]; then
if [[ ${MUTATION_FULL:-0} == 1 && -z ${MUTATION_SHARD_INDEX:-} ]]; then
if ! mutmut run --max-children 1; then
printf '%s\0' "full mutation run" >> "$failed"
fi
Expand Down
Loading
Loading