Skip to main content

halter

"Halt, there are problems with this." An auditor for a change to a Python tree.

halter takes one git revision (or the working tree) of a Python repository, runs the tree's own tests and a battery of checks over the diff against a baseline, and answers accept or refuse with one line per check. It has no plan, no model and no network: everything it decides is decided from the tree, the tools it shells out to, and its own gate code. It is the audit-only release of the saddle harness.

What it audits

For the diff baseline..tree, in this order:

check what passes
syntax every changed Python file parses
ruff the diff introduces no new ruff check finding and is ruff format-clean
tests the test command exits 0
coverage every changed line runs under the tests (changed-line coverage 100%)
dead-code every private definition the diff adds is mentioned somewhere in the tree
public-deletions every public definition the baseline had is still defined
red-phase new tests fail before the change and pass after it; with no test change, coverage plus a mutation kill-rate of at least 85% carries the proof
assertion-preservation pre-existing tests keep their assertions
mutation mutmut mutants on the changed lines are killed by the tests (100% on small samples)

Four checks that only make sense against a plan (node-scope, target-scope, property-coverage, requirement-binding) are reported n/a. Untracked files in the working tree count as part of the change; mutants/, .coverage, bytecode and tool caches at the tree's top level do not.

Verdicts are cached under ~/.cache/halter/audit keyed by the tree hash, the baseline, the test command and a hash of halter's own gate modules and tool versions, so a second audit of the same tree is served without re-running anything. --no-cache disables that.

Exit codes

exit verdict meaning
0 accept every applicable check passed
1 refuse at least one check failed; the report says which
2 could not audit not a git repository, unknown revision or baseline, a linked worktree
3 nothing to audit tree and baseline are identical

With --json the result dict is printed instead of the text report; the exit code is the same.

Usage

halter                       # the working tree of . against HEAD
halter REV                   # commit REV against REV^, from a fresh clone
halter REV --baseline B      # commit REV against B
halter --repo PATH           # audit another repository
halter --test-command CMD    # default: python -m pytest -q
halter --json                # machine-readable result
halter --no-cache            # neither read nor write the verdict cache

In REV mode the commit is checked out in a temporary clone, so nothing uncommitted in the source repository reaches the gates and nothing is written to it.

Prerequisites

  • Python >= 3.12.
  • From pip, installed with halter: coverage, ruff, mutmut (3.x) and pydantic. halter calls ruff, coverage and mutmut by name, so the environment that provides halter must also put them on PATH (pipx install halter and a plain venv both do).
  • From the OS: git, and prlimit (util-linux) for the memory ceiling.
  • The audited tree's own test command must be runnable from the tree with the interpreter on PATH; the default is python -m pytest -q, so the tree's dependencies and pytest must be importable there.

Memory ceiling and timeouts

Every subprocess that executes the audited tree's code, that is the test command and mutmut run, is launched under prlimit --as=6GiB (RLIMIT_AS, 6 GiB). Code that grows past it gets a MemoryError in its own process and its tests fail; the audit itself keeps running.

The test command is bounded at 300 s and the mutation run at 600 s of wall clock. A run that exceeds its bound is reported as a failed check, not a crash.

A worked example

A throwaway repository with two commits: n.py returning 1, then a fix to 2 with a test.

git init example && cd example
printf 'def f():\n    return 1\n' > n.py
git add -A && git commit -m baseline
printf 'def f():\n    return 2\n' > n.py
printf 'from n import f\n\n\ndef test_f():\n    assert f() == 2\n' > test_n.py
git add -A && git commit -m 'fix f'

Audit the second commit:

$ halter HEAD
audit tree d6ec5987d44d baseline b32d48a01db2 surface 374ed4f61324 fresh
PASS syntax                  2 file(s) parsed
PASS ruff                    2 file(s) clean
PASS tests                   'python -m pytest -q' exited 0
PASS coverage                every changed line runs
PASS dead-code               every private definition added is mentioned elsewhere in the tree
PASS public-deletions        every public definition the baseline had is still defined
PASS red-phase               fail pre-change, pass post-change
n/a  node-scope              audit: no plan assigns the diff a node kind
n/a  target-scope            audit: no plan declares target files
n/a  property-coverage       audit: no plan pairs a property test with an implementation
PASS assertion-preservation  0 pre-existing test(s) keep their assertions
n/a  requirement-binding     audit: no plan declares requirements
PASS mutation                killed 1 of 1 changed-line mutants (100.0% >= 100.0%)
verdict: accept
$ echo $?
0

Now add an untested module to the working tree and audit that:

$ printf 'def g():\n    return 7\n' > m.py
$ halter
audit tree cf04ccbe17e6 baseline 381ea238b0de surface 374ed4f61324 fresh
PASS syntax                  3 file(s) parsed
PASS ruff                    1 file(s) clean
PASS tests                   'python -m pytest -q' exited 0
FAIL coverage                no test runs m.py:1, m.py:2
PASS dead-code               every private definition added is mentioned elsewhere in the tree
PASS public-deletions        every public definition the baseline had is still defined
FAIL red-phase               tests unchanged and coverage failed; nothing proves the change
n/a  node-scope              audit: no plan assigns the diff a node kind
n/a  target-scope            audit: no plan declares target files
n/a  property-coverage       audit: no plan pairs a property test with an implementation
PASS assertion-preservation  1 pre-existing test(s) keep their assertions
n/a  requirement-binding     audit: no plan declares requirements
FAIL mutation                killed 0 of 1 changed-line mutants (0.0% < 100.0%) (small sample: 1 mutant(s), all must die): survived 1: m.x_g__mutmut_1; 1 untested (no test runs the mutated function)
verdict: refuse
$ echo $?
1

halter HEAD --baseline HEAD prints verdict: nothing to audit and exits 3; halter no-such-rev prints error: cannot check out revision 'no-such-rev': ... on stderr and exits 2.

Developing

python3 -m venv .venv && .venv/bin/pip install -e . pytest
PATH="$PWD/.venv/bin:$PATH" .venv/bin/python -m pytest -q

The gates shell out to ruff, coverage and mutmut by name, hence the PATH. The test suite stubs mutmut and never runs real mutants.

License

Copyright (C) 2026 Eliza H

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public
License along with this program.  If not, see
<https://www.gnu.org/licenses/>.

Release files for halter 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for halter 0.1.0
File Size Uploaded
halter-0.1.0.tar.gz 94.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for halter 0.1.0
File Interpreter ABI Platform
halter-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 185.8 kB

Release files / halter-0.1.0.tar.gz

Download URL halter-0.1.0.tar.gz
Size 94.6 kB
Tags Source
SHA-256 checksum
How to use checksums
09e9a66578595a0e85fc43ddb70dd88f323810c0b38399a862dbe7c39f858a6f
BLAKE2b-256 checksum
How to use checksums
1a37931d59dc3a2579cfc182a50d277f1f9a7b5d686a266b7d69464d81c97bf4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / halter-0.1.0-py3-none-any.whl

Download URL halter-0.1.0-py3-none-any.whl
Size 91.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0dcb0c238128ed9b0e8ef650834461d6c803d4dc14a2011c56b36631333b1a3d
BLAKE2b-256 checksum
How to use checksums
899f8597dc4f4b76e5c5d1807123a9ae0d246698a63a917c479a64539a935f07
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

0.1.1

2 release files

This release

0.1.0 This release

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page