Skip to content
Merged
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
110 changes: 110 additions & 0 deletions .github/scripts/mutation_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Turn a finished `mutmut run` into a report a reviewer can act on without rerunning it.

`mutmut results` lists survivor names and `mutmut show` prints one diff per process
start, about two seconds each; hundreds of survivors would take longer than the run.
This reads the same state in one process and writes:

mutation-report/summary.json counts, score, per-file breakdown
mutation-report/survivors.md every non-killed mutant with its diff, by file and function

It imports mutmut internals, so it is tied to the version pinned in the workflow.
Run from the repository root after `mutmut run`.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
from collections import Counter, defaultdict
from pathlib import Path

from mutmut.mutation.data import SourceFileMutationData
from mutmut.mutation.diff_apply import get_diff_for_mutant
from mutmut.stats import status_by_exit_code
from mutmut.utils.file_utils import walk_mutatable_files

OUT = Path("mutation-report")

# Statuses that mean the tests did not catch the change. Timeouts and suspicious
# results are listed too: they are not proof of a gap, but each needs a look.
REPORTED = ("survived", "no tests", "timeout", "suspicious", "segfault")


def _function_of(mutant: str) -> str:
# secretscreen._config.x_merge__mutmut_5 -> merge ; class methods: xǁClassǁmethod__mutmut_3
name = mutant.rpartition(".")[-1].rpartition("__mutmut_")[0]
return name.removeprefix("x_").removeprefix("x").replace("ǁ", ".").strip(".")


def main() -> int:
totals: Counter[str] = Counter()
per_file: dict[str, Counter[str]] = {}
found: dict[str, dict[str, list[tuple[str, str, str]]]] = defaultdict(lambda: defaultdict(list))

for path in walk_mutatable_files():
data = SourceFileMutationData(path=path)
data.load()
if not data.exit_code_by_key:
continue
counts: Counter[str] = Counter()
for mutant, code in sorted(data.exit_code_by_key.items()):
status = status_by_exit_code[code]
counts[status] += 1
if status in REPORTED:
found[str(path)][_function_of(mutant)].append((mutant, status, get_diff_for_mutant(mutant, path=path)))
per_file[str(path)] = counts
totals.update(counts)

if not per_file:
print("no mutation results found; run `mutmut run` first", file=sys.stderr)
return 1

not_checked = totals["not checked"]
if not_checked:
print(f"{not_checked} mutants were never checked; the run did not finish", file=sys.stderr)
return 1

total = sum(totals.values())
commit = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=False).stdout.strip()
summary = {
"commit": commit,
"run_url": os.environ.get("RUN_URL", ""),
"total": total,
"statuses": dict(totals),
"score": round(100 * totals["killed"] / total, 1),
"files": {
p: {"total": sum(c.values()), "survived": c["survived"], "statuses": dict(c)} for p, c in per_file.items()
},
}

OUT.mkdir(exist_ok=True)
(OUT / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")

lines = [
f"# Mutation survivors at {commit[:12]}",
"",
f"Score {summary['score']}% — {totals['killed']} killed of {total}. "
+ ", ".join(f"{s}: {totals[s]}" for s in REPORTED if totals[s]),
"",
"| File | Mutants | Survived | Other unkilled |",
"|---|---:|---:|---:|",
]
for p, c in sorted(per_file.items(), key=lambda kv: -kv[1]["survived"]):
other = sum(c[s] for s in REPORTED if s != "survived")
lines.append(f"| `{p}` | {sum(c.values())} | {c['survived']} | {other} |")
for p in sorted(found, key=lambda f: -sum(len(v) for v in found[f].values())):
lines += ["", f"## `{p}`"]
for func in sorted(found[p], key=lambda fn: -len(found[p][fn])):
lines += ["", f"### `{func}` — {len(found[p][func])}"]
for mutant, status, diff in found[p][func]:
lines += ["", f"`{mutant}` ({status})", "", "```diff", diff.rstrip(), "```"]
(OUT / "survivors.md").write_text("\n".join(lines) + "\n")

print(f"score {summary['score']}%: {totals['killed']}/{total} killed, {totals['survived']} survived")
return 0


if __name__ == "__main__":
sys.exit(main())
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.13"
cache: pip
Expand All @@ -50,7 +50,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Initialize CodeQL
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: python

- name: Autobuild
uses: github/codeql-action/autobuild@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
73 changes: 73 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Mutation

# Coverage says a line ran; mutation testing says a test would notice if it were
# wrong. For a redaction library that is the property that matters: a surviving
# mutant in a key pattern or entropy threshold is a secret that leaks with the
# suite still green.
#
# Informational, not a gate. The report is the product: survivors.md lists every
# mutant the suite did not kill, with its diff, so it can be triaged without
# rerunning anything. A threshold gets added once the survivors have been read,
# not before.
#
# The score depends only on src/ and tests/, so it reruns when they change on
# main. The monthly run catches what a pinned file cannot: the runner's Python
# and the standard library moving underneath unchanged code.

on:
push:
branches: [main]
paths:
- "src/**"
- "tests/**"
- ".github/workflows/mutation.yml"
- ".github/scripts/mutation_report.py"
# A change to the tooling itself is proven on the pull request, before it
# can break the scheduled run on main.
pull_request:
paths:
- ".github/workflows/mutation.yml"
- ".github/scripts/mutation_report.py"
schedule:
- cron: "0 7 1 * *"
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
mutmut:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.13"
cache: pip

# mutmut is pinned here rather than in [dev]: .github/scripts/mutation_report.py
# reads its internals, and nothing else needs it.
- name: Install dependencies
run: pip install -e ".[dev]" mutmut==3.8.0

- name: Run mutmut
run: mutmut run --max-children "$(nproc)"

- name: Build report
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python .github/scripts/mutation_report.py
head -n 20 mutation-report/survivors.md >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: mutation-report
path: mutation-report/
retention-days: 90
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.13"

Expand All @@ -31,4 +31,4 @@ jobs:
python -m build

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
2 changes: 1 addition & 1 deletion .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ jobs:
publish_results: true

- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: results.sarif
2 changes: 1 addition & 1 deletion .github/workflows/test-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
# base revision of src/ out in place.
fetch-depth: 0

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.13"
cache: pip
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ build/
.coverage
.coverage.*
htmlcov/

# mutation testing (mutation.yml)
mutants/
mutation-report/
10 changes: 9 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ secretscreen = "secretscreen._cli:main"
# linters previously carried upper bounds still applies and is now covered by
# the pin: an unpinned ruff minor bump (0.15 -> 0.16) silently changed
# formatting rules and broke an unrelated PR.
dev = ["pytest==9.1.1", "pytest-cov==7.1.0", "ruff==0.16.10", "mypy==2.4.0"]
dev = ["pytest==9.1.1", "pytest-cov==7.1.0", "ruff==0.17.1", "mypy==2.4.0"]

[project.urls]
Homepage = "https://github.com/featurecreep-cron/secretscreen"
Expand Down Expand Up @@ -100,3 +100,11 @@ allowed_tags = [
[tool.semantic_release.branches.main]
match = "main"
prerelease = false

[tool.mutmut]
# The architecture tests read the package source to check its shape. Under
# mutmut that source is the instrumented copy, which imports mutmut, so they
# fail on every mutant -- and being static, they could never kill one anyway.
source_paths = ["src/secretscreen"]
pytest_add_cli_args_test_selection = ["tests/"]
pytest_add_cli_args = ["--ignore=tests/test_architecture.py"]
Loading