From 5d0e246990f6fef41d2fbe9fd8f79b8573b60cb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:34:45 +0000 Subject: [PATCH 1/5] chore(deps-dev): bump ruff Bumps the pip-minors group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.16.10 to 0.17.0 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.10...0.17.0) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.17.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: pip-minors ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0fcab17..265b647 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.0", "mypy==2.4.0"] [project.urls] Homepage = "https://github.com/featurecreep-cron/secretscreen" From b51c634d0aee896cc5dc8ab2bc75de09fbde8836 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:33:48 +0000 Subject: [PATCH 2/5] chore(deps-dev): bump ruff Bumps the pip-patches group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.17.0 to 0.17.1 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.17.0...0.17.1) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.17.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: pip-patches ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 265b647..b93bb9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.17.0", "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" From a58dba6b8dc6ab012967266f3f3875d3d381dfd8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:37:02 +0000 Subject: [PATCH 3/5] chore(deps): bump the actions group with 6 updates Bumps the actions group with 6 updates: | Package | From | To | | --- | --- | --- | | [actions/setup-python](https://github.com/actions/setup-python) | `7.0.0` | `7.1.0` | | [github/codeql-action/init](https://github.com/github/codeql-action) | `4.39.0` | `4.39.1` | | [github/codeql-action/autobuild](https://github.com/github/codeql-action) | `4.39.0` | `4.39.1` | | [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.39.0` | `4.39.1` | | [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.14.2` | `1.14.3` | | [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.39.0` | `4.39.1` | Updates `actions/setup-python` from 7.0.0 to 7.1.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0) Updates `github/codeql-action/init` from 4.39.0 to 4.39.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370) Updates `github/codeql-action/autobuild` from 4.39.0 to 4.39.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370) Updates `github/codeql-action/analyze` from 4.39.0 to 4.39.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370) Updates `pypa/gh-action-pypi-publish` from 1.14.2 to 1.14.3 - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/dc37677b2e1c63e2034f94d8a5b11f265b73ba33...7245906fb61fa33a13554f9d5fb856fe61758086) Updates `github/codeql-action/upload-sarif` from 4.39.0 to 4.39.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: github/codeql-action/init dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: github/codeql-action/autobuild dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: github/codeql-action/analyze dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.14.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- .github/workflows/codeql.yml | 6 +++--- .github/workflows/publish.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- .github/workflows/test-gate.yml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d626856..5bd88cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c0b8374..6c08539 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7632eac..499172e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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" @@ -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 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 3b61733..1a9ec9a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -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 diff --git a/.github/workflows/test-gate.yml b/.github/workflows/test-gate.yml index d3cb909..a355e9d 100644 --- a/.github/workflows/test-gate.yml +++ b/.github/workflows/test-gate.yml @@ -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 From 7fb3f699b226d1f680b334de2a8842d7d73bf321 Mon Sep 17 00:00:00 2001 From: Cron Date: Sun, 25 Oct 2026 13:42:31 -0500 Subject: [PATCH 4/5] ci: add recurring mutation testing with a triage report mutmut over the whole package, on main when src/ or tests/ change and monthly. Informational: the uploaded report lists every unkilled mutant with its diff so survivors can be triaged without a rerun. No threshold until the first report has been read. Local baseline at 0d2cca8: 2600 mutants, 1785 killed, 815 survived (68.7%), 5.5 min on 4 workers. --- .github/scripts/mutation_report.py | 110 +++++++++++++++++++++++++++++ .github/workflows/mutation.yml | 67 ++++++++++++++++++ .gitignore | 4 ++ pyproject.toml | 8 +++ 4 files changed, 189 insertions(+) create mode 100644 .github/scripts/mutation_report.py create mode 100644 .github/workflows/mutation.yml diff --git a/.github/scripts/mutation_report.py b/.github/scripts/mutation_report.py new file mode 100644 index 0000000..5bcb3ca --- /dev/null +++ b/.github/scripts/mutation_report.py @@ -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()) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..deb6cdf --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,67 @@ +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" + 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 diff --git a/.gitignore b/.gitignore index d5ee4bb..024babe 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ build/ .coverage .coverage.* htmlcov/ + +# mutation testing (mutation.yml) +mutants/ +mutation-report/ diff --git a/pyproject.toml b/pyproject.toml index b93bb9c..bdf086b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] From 769334ce651f32abe0b7231cafdf4c86138ce2bd Mon Sep 17 00:00:00 2001 From: Cron Date: Sun, 25 Oct 2026 13:42:46 -0500 Subject: [PATCH 5/5] ci: run the mutation workflow on pull requests that change it --- .github/workflows/mutation.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index deb6cdf..95075f2 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -22,6 +22,12 @@ on: - "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: