mutgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mutgate/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """mutgate — named mutations as contracts on a test suite.
2
+
3
+ from mutgate import Mutation
4
+ MUTATIONS = [
5
+ Mutation("keep-larger", file="pkg/engine.py",
6
+ old="if birth[a] >= birth[b]:", new="if size[a] >= size[b]:",
7
+ fires=("TestElderRule",)),
8
+ Mutation("tie-to-higher-index", file="pkg/engine.py",
9
+ old="(birth[a], -a) >= (birth[b], -b)", new="(birth[a], a) >= (birth[b], b)",
10
+ invisible=True), # an invariance: must fire nothing
11
+ ]
12
+
13
+ Then `mutgate run tests/mutations.py`. Each mutation is applied in a sandbox copy, the
14
+ tests run, and the contract judged: OK, DECORATION (a named guard did not fire),
15
+ OVERREACH (something outside the contract fired — two copies of one convention?),
16
+ VISIBLE (an invariance was broken), NOT_APPLIED (the text was not found), ERROR.
17
+ """
18
+
19
+ from .core import Mutation, Report, Verdict, load, run
20
+
21
+ __version__ = "0.1.0"
22
+ __all__ = ["Mutation", "Report", "Verdict", "load", "run", "__version__"]
mutgate/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import run_cli
2
+
3
+ run_cli()
mutgate/cli.py ADDED
@@ -0,0 +1,82 @@
1
+ """`mutgate run tests/mutations.py` — check every named mutation's contract.
2
+
3
+ Exit codes: 0 every contract holds; 1 a verdict is not OK; 2 the baseline is red or the
4
+ declaration cannot be loaded.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import shutil
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+ from .core import load, run
16
+
17
+
18
+ def _parser() -> argparse.ArgumentParser:
19
+ ap = argparse.ArgumentParser(prog="mutgate",
20
+ description="Named mutations as contracts on a test suite.")
21
+ ap.add_argument("--version", action="version", version=f"mutgate {__version__}")
22
+ sub = ap.add_subparsers(dest="cmd", required=True)
23
+
24
+ r = sub.add_parser("run", help="apply each mutation in a sandbox and judge its contract")
25
+ r.add_argument("file", help="the mutations file (defines MUTATIONS, optionally TESTS/PATHS/ROOT/PYTHON)")
26
+ r.add_argument("--tests", nargs="+", help="pytest targets (override the file's TESTS)")
27
+ r.add_argument("--only", nargs="+", metavar="NAME", help="run only these mutations")
28
+ r.add_argument("--python", help="interpreter to run pytest with (default: this one)")
29
+ r.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
30
+ help="abort a pytest run that takes longer (verdict ERROR)")
31
+ r.add_argument("--pytest-arg", action="append", default=[], metavar="ARG",
32
+ help="extra argument passed to pytest (repeatable)")
33
+ r.add_argument("--markdown", action="store_true", help="print the build-record table instead of the plain one")
34
+ r.add_argument("--keep", action="store_true", help="keep the sandbox directory")
35
+ r.add_argument("-x", "--stop", action="store_true", help="stop at the first verdict that is not OK")
36
+ r.add_argument("-v", "--verbose", action="store_true")
37
+
38
+ ls = sub.add_parser("list", help="list the mutations a file declares")
39
+ ls.add_argument("file")
40
+ return ap
41
+
42
+
43
+ def main(argv=None) -> int:
44
+ args = _parser().parse_args(argv)
45
+ try:
46
+ decl = load(Path(args.file))
47
+ except Exception as exc: # noqa: BLE001 — every load failure is a usage error here
48
+ print(f"mutgate: cannot load {args.file}: {exc}", file=sys.stderr)
49
+ return 2
50
+ if args.cmd == "list":
51
+ w = max(len(m.name) for m in decl.mutations) if decl.mutations else 8
52
+ for m in decl.mutations:
53
+ exp = "invisible" if m.invisible else ", ".join(m.fires)
54
+ print(f"{m.name:<{w}} {m.file} -> {exp}")
55
+ print(f"\nroot {decl.root}\ntests {' '.join(decl.tests)}\npaths {' '.join(decl.paths)}")
56
+ return 0
57
+ log = (lambda s: print(f"mutgate: {s}", file=sys.stderr, flush=True)) if args.verbose else None
58
+ python = args.python or decl.python or sys.executable
59
+ if not (Path(python).is_file() or shutil.which(python)):
60
+ print(f"mutgate: interpreter not found: {python}", file=sys.stderr)
61
+ return 2
62
+ try:
63
+ report = run(decl.mutations, decl.root, args.tests or decl.tests,
64
+ python=python, paths=decl.paths, only=args.only, keep=args.keep,
65
+ stop_early=args.stop, extra_pytest_args=args.pytest_arg, log=log,
66
+ timeout=args.timeout)
67
+ except ValueError as exc:
68
+ print(f"mutgate: {exc}", file=sys.stderr)
69
+ return 2
70
+ if report.baseline_failed or report.baseline_error:
71
+ print(report.table())
72
+ return 2
73
+ print(report.markdown() if args.markdown else report.table())
74
+ return 0 if report.ok else 1
75
+
76
+
77
+ def run_cli() -> None:
78
+ sys.exit(main())
79
+
80
+
81
+ if __name__ == "__main__":
82
+ run_cli()
mutgate/core.py ADDED
@@ -0,0 +1,453 @@
1
+ """Named mutations as contracts on a test suite.
2
+
3
+ A *mutation* is an exact-string replacement in one source file of the project under test,
4
+ applied in a sandbox copy (the working tree is never touched), together with a *contract*:
5
+ the set of tests it must turn red, or the statement that it must turn nothing red.
6
+
7
+ Four verdicts do the work:
8
+
9
+ OK the mutation fired exactly the tests the contract names (plus any it may fire)
10
+ DECORATION a test the contract says must fire did not — the guard is a decoration
11
+ OVERREACH a test outside the contract fired — the mutation reaches further than its
12
+ author believed, which is the tell for two copies of one convention
13
+ VISIBLE an `invisible` mutation (an invariance: swap a convention the measurement
14
+ must not depend on) fired something
15
+
16
+ and two failure modes that are not verdicts about the suite:
17
+
18
+ NOT_APPLIED the `old` text was not found exactly `count` times, so nothing was mutated —
19
+ a mutation that silently does not land looks identical to a working guard
20
+ ERROR the suite could not run (a mutation that does not compile, a collection
21
+ error, a usage error)
22
+
23
+ A red baseline aborts the whole run: mutations are meaningless on a suite that already fails.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import importlib.util
29
+ import os
30
+ import re
31
+ import shutil
32
+ import subprocess
33
+ import sys
34
+ import tempfile
35
+ from dataclasses import dataclass, field
36
+ from pathlib import Path
37
+ from typing import Iterable, Optional, Sequence
38
+
39
+ __all__ = ["Mutation", "Verdict", "Report", "Sandbox", "run", "load", "run_pytest"]
40
+
41
+ _IGNORE_DIRS = {".git", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache",
42
+ ".ruff_cache", "node_modules", ".tox", ".nox", "dist", "build"}
43
+ _STATUSES = ("OK", "DECORATION", "OVERREACH", "VISIBLE", "NOT_APPLIED", "ERROR")
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Mutation:
48
+ """One named mutation and its contract.
49
+
50
+ file path of the source file, relative to the project root
51
+ old/new exact text to replace; `old` must occur exactly `count` times in the file
52
+ fires test-id fragments (any substring of a pytest node id, e.g. "TestK2ElderRule"
53
+ or "test_x.py::TestK2::test_ratio") that MUST fail under the mutation
54
+ may_fire fragments that are allowed to fail as well, without being required
55
+ invisible True for an invariance: the mutation must fail NOTHING
56
+ count how many occurrences of `old` the file must contain (all are replaced)
57
+ note free text, carried into reports
58
+ """
59
+ name: str
60
+ file: str
61
+ old: str
62
+ new: str
63
+ fires: tuple[str, ...] = ()
64
+ may_fire: tuple[str, ...] = ()
65
+ invisible: bool = False
66
+ count: int = 1
67
+ note: str = ""
68
+
69
+ def __post_init__(self):
70
+ for attr in ("fires", "may_fire"):
71
+ val = getattr(self, attr)
72
+ if isinstance(val, str):
73
+ # a bare string would become its own letters, each matching every node id,
74
+ # and every contract would read OK (review 2026-09-07, item 3)
75
+ raise ValueError(f"{self.name}: {attr} must be a tuple of fragments, not a string; "
76
+ f"write ({val!r},)")
77
+ val = tuple(val)
78
+ if any(not isinstance(f, str) or not f.strip() for f in val):
79
+ raise ValueError(f"{self.name}: every {attr} entry must be a non-empty string")
80
+ object.__setattr__(self, attr, val)
81
+ if not self.name:
82
+ raise ValueError("a mutation needs a name")
83
+ if self.old == self.new:
84
+ raise ValueError(f"{self.name}: old and new are identical; nothing would change")
85
+ if not self.old:
86
+ raise ValueError(f"{self.name}: old must be non-empty")
87
+ if self.count < 1:
88
+ raise ValueError(f"{self.name}: count must be >= 1")
89
+ if self.invisible and (self.fires or self.may_fire):
90
+ raise ValueError(f"{self.name}: an invisible mutation fires nothing; drop fires/may_fire")
91
+ if not self.invisible and not self.fires:
92
+ raise ValueError(f"{self.name}: name the tests it must fire, or mark it invisible")
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Verdict:
97
+ mutation: Mutation
98
+ status: str
99
+ fired: tuple[str, ...] = () # failing test node ids under the mutation
100
+ missing: tuple[str, ...] = () # contract entries that matched no failing test
101
+ unexpected: tuple[str, ...] = () # failing tests matching neither fires nor may_fire
102
+ detail: str = ""
103
+
104
+ def __post_init__(self):
105
+ if self.status not in _STATUSES:
106
+ raise ValueError(self.status)
107
+
108
+ @property
109
+ def ok(self) -> bool:
110
+ return self.status == "OK"
111
+
112
+
113
+ @dataclass
114
+ class Report:
115
+ root: Path
116
+ tests: tuple[str, ...]
117
+ baseline_failed: tuple[str, ...] = ()
118
+ baseline_error: str = ""
119
+ verdicts: list[Verdict] = field(default_factory=list)
120
+
121
+ @property
122
+ def ok(self) -> bool:
123
+ return not self.baseline_failed and not self.baseline_error and all(v.ok for v in self.verdicts)
124
+
125
+ def summary(self) -> str:
126
+ counts = {s: sum(v.status == s for v in self.verdicts) for s in _STATUSES}
127
+ parts = [f"{n} {s}" for s, n in counts.items() if n]
128
+ return ", ".join(parts) if parts else "no mutations"
129
+
130
+ def table(self) -> str:
131
+ lines = []
132
+ if self.baseline_error:
133
+ lines.append(f"BASELINE ERROR: {self.baseline_error}")
134
+ return "\n".join(lines)
135
+ if self.baseline_failed:
136
+ lines.append("BASELINE RED — nothing mutated. Failing before any mutation:")
137
+ lines.extend(f" {t}" for t in self.baseline_failed)
138
+ return "\n".join(lines)
139
+ w = max((len(v.mutation.name) for v in self.verdicts), default=8)
140
+ lines.append(f"{'mutation':<{w}} {'verdict':<11} fired expected")
141
+ for v in self.verdicts:
142
+ exp = "nothing" if v.mutation.invisible else ", ".join(v.mutation.fires)
143
+ lines.append(f"{v.mutation.name:<{w}} {v.status:<11} {len(v.fired):>5} {exp}")
144
+ if v.status == "DECORATION":
145
+ lines.extend(f"{'':<{w}} did not fire: {m}" for m in v.missing)
146
+ if v.status in ("OVERREACH", "DECORATION"):
147
+ lines.extend(f"{'':<{w}} unexpected: {u}" for u in v.unexpected)
148
+ if v.status == "VISIBLE":
149
+ lines.extend(f"{'':<{w}} fired: {f}" for f in v.fired)
150
+ if v.status in ("NOT_APPLIED", "ERROR"):
151
+ lines.append(f"{'':<{w}} {v.detail}")
152
+ lines.append(f"\n{self.summary()}")
153
+ return "\n".join(lines)
154
+
155
+ def markdown(self) -> str:
156
+ """A table for a design note's build record: what each mutation fires."""
157
+ rows = ["| mutation | must fire | fired | verdict |", "|---|---|---|---|"]
158
+ for v in self.verdicts:
159
+ exp = "*nothing (invariance)*" if v.mutation.invisible else ", ".join(f"`{f}`" for f in v.mutation.fires)
160
+ fired = _collapse(v.fired)
161
+ rows.append(f"| {v.mutation.name} | {exp} | {fired} | {v.status} |")
162
+ return "\n".join(rows)
163
+
164
+
165
+ def _collapse(ids: Sequence[str]) -> str:
166
+ """Failing node ids collapsed to their class or module level, for a readable cell."""
167
+ seen = []
168
+ for nid in ids:
169
+ parts = nid.split("::")
170
+ key = "::".join(parts[:2]) if len(parts) >= 3 else nid
171
+ if key not in seen:
172
+ seen.append(key)
173
+ return ", ".join(f"`{k}`" for k in seen) if seen else "—"
174
+
175
+
176
+ # ---------------------------------------------------------------------------------------
177
+ # the sandbox: a copy of the project the mutations are applied to
178
+ # ---------------------------------------------------------------------------------------
179
+
180
+ def project_files(root: Path) -> list[Path]:
181
+ """Files to copy: `git ls-files` (tracked plus untracked-but-not-ignored, so uncommitted
182
+ work is included and the venv is not) when `root` is in a git repo; otherwise a walk
183
+ that skips the usual build and cache directories."""
184
+ root = Path(root)
185
+ try:
186
+ out = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others",
187
+ "--exclude-standard"], capture_output=True, check=True)
188
+ rels = [r for r in out.stdout.decode("utf-8", "surrogateescape").split("\0") if r]
189
+ files = [root / r for r in rels]
190
+ return [f for f in files if f.is_file()]
191
+ except (subprocess.CalledProcessError, FileNotFoundError):
192
+ pass
193
+ files = []
194
+ for dirpath, dirnames, filenames in os.walk(root):
195
+ dirnames[:] = [d for d in dirnames if d not in _IGNORE_DIRS and not d.endswith(".egg-info")]
196
+ files.extend(Path(dirpath) / f for f in filenames)
197
+ return files
198
+
199
+
200
+ class Sandbox:
201
+ """A temporary copy of the project. Mutations are applied and restored here, one at a
202
+ time; the working tree is never written."""
203
+
204
+ def __init__(self, root: Path, keep: bool = False):
205
+ self.root = Path(root).resolve()
206
+ self.keep = keep
207
+ # resolved: on a symlinked TMPDIR (macOS /var -> /private/var) an unresolved dir
208
+ # fails its own containment check in `path` (review 2026-09-07, item 1)
209
+ self.dir = Path(tempfile.mkdtemp(prefix="mutgate-")).resolve()
210
+ for src in project_files(self.root):
211
+ rel = src.relative_to(self.root)
212
+ dst = self.dir / rel
213
+ dst.parent.mkdir(parents=True, exist_ok=True)
214
+ shutil.copy2(src, dst)
215
+
216
+ def path(self, rel: str) -> Path:
217
+ p = (self.dir / rel).resolve()
218
+ if self.dir not in p.parents and p != self.dir:
219
+ raise ValueError(f"{rel} escapes the sandbox")
220
+ return p
221
+
222
+ def apply(self, m: Mutation) -> tuple[bool, str, str]:
223
+ """(applied, original_text, detail). Applied only if `old` occurs exactly `count`
224
+ times; otherwise nothing is written and `detail` says what was found."""
225
+ p = self.path(m.file)
226
+ if not p.is_file():
227
+ return False, "", f"{m.file}: no such file in the project"
228
+ text = p.read_text(encoding="utf-8")
229
+ found = text.count(m.old)
230
+ if found != m.count:
231
+ return False, text, f"{m.file}: `old` found {found} time(s), contract says {m.count}"
232
+ p.write_text(text.replace(m.old, m.new), encoding="utf-8")
233
+ return True, text, ""
234
+
235
+ def restore(self, m: Mutation, original: str) -> None:
236
+ self.path(m.file).write_text(original, encoding="utf-8")
237
+
238
+ def close(self) -> None:
239
+ if not self.keep:
240
+ shutil.rmtree(self.dir, ignore_errors=True)
241
+
242
+
243
+ # ---------------------------------------------------------------------------------------
244
+ # running pytest and reading which tests failed
245
+ # ---------------------------------------------------------------------------------------
246
+
247
+ def _node_id(rest: str) -> str:
248
+ """The node id at the start of a summary line's remainder: everything up to the first
249
+ " - " that is not inside a parametrisation bracket (ids like `test_x[a - b]` keep it)."""
250
+ depth = 0
251
+ i = 0
252
+ while i < len(rest):
253
+ ch = rest[i]
254
+ if ch == "[":
255
+ depth += 1
256
+ elif ch == "]" and depth:
257
+ depth -= 1
258
+ elif depth == 0 and rest.startswith(" - ", i):
259
+ return rest[:i].strip()
260
+ i += 1
261
+ return rest.strip()
262
+
263
+
264
+ _CUT_SHORT_LINE = re.compile(r"^!{3,} stopping after \d+ failures? !{3,}\s*$", re.MULTILINE)
265
+ TIMEOUT = -9999 # return code standing for "pytest did not finish in time"
266
+ CUT_SHORT = -9998 # pytest stopped early (maxfail), so the fired set is incomplete
267
+
268
+
269
+ def run_pytest(cwd: Path, tests: Sequence[str], python: str = sys.executable,
270
+ paths: Sequence[str] = ("src", "."), extra_args: Sequence[str] = (),
271
+ env: Optional[dict] = None, timeout: Optional[float] = None) -> tuple[int, list[str], str]:
272
+ """Run pytest in `cwd`; return (returncode, failing node ids, tail of the output).
273
+
274
+ Failures are read from pytest's own short summary (`-rfE`), so node ids are exactly the
275
+ ones a contract names. `paths` are prepended to PYTHONPATH relative to `cwd`, so an
276
+ editable install elsewhere cannot shadow the sandbox copy. A run that exceeds `timeout`
277
+ seconds returns `TIMEOUT` as the code."""
278
+ e = dict(os.environ if env is None else env)
279
+ pp = [str(Path(cwd) / p) for p in paths]
280
+ if e.get("PYTHONPATH"):
281
+ pp.append(e["PYTHONPATH"])
282
+ e["PYTHONPATH"] = os.pathsep.join(pp)
283
+ e.setdefault("PYTHONDONTWRITEBYTECODE", "1")
284
+ # mutgate OWNS three flags: -rfE (the summary it reads), no:cacheprovider (a clean
285
+ # sandbox) and --maxfail=0 AFTER every user argument, because a contract needs the whole
286
+ # failure set and a project's addopts "-x" / "--maxfail=N" would truncate it to a
287
+ # consistent-looking single failure (review 2026-09-07, second round)
288
+ cmd = [python, "-m", "pytest", "-q", "-rfE", "-p", "no:cacheprovider", "--no-header",
289
+ *extra_args, "--maxfail=0", *tests]
290
+ try:
291
+ proc = subprocess.run(cmd, cwd=str(cwd), env=e, capture_output=True, text=True, timeout=timeout)
292
+ except subprocess.TimeoutExpired as exc:
293
+ out = (exc.stdout or b"")
294
+ out = out.decode("utf-8", "replace") if isinstance(out, bytes) else out
295
+ return TIMEOUT, [], f"pytest exceeded {timeout} s\n" + "\n".join(out.strip().splitlines()[-25:])
296
+ failed = []
297
+ for line in proc.stdout.splitlines():
298
+ if line.startswith("FAILED ") or line.startswith("ERROR "):
299
+ nid = _node_id(line.split(" ", 1)[1])
300
+ # "ERROR tests/x.py" with no "::" is a COLLECTION error (the file did not
301
+ # import), not a red test; it is reported through the return code instead
302
+ if line.startswith("ERROR ") and "::" not in nid:
303
+ continue
304
+ if nid and nid not in failed:
305
+ failed.append(nid)
306
+ tail = (proc.stdout + proc.stderr).strip().splitlines()[-25:]
307
+ if _CUT_SHORT_LINE.search(proc.stdout):
308
+ # the belt: pytest announces a truncated run ("!!! stopping after N failures !!!");
309
+ # a conftest setting config.option.maxfail can get past the flag above. Anchored to
310
+ # a whole line at column 0: a target's own captured output can quote the phrase
311
+ # (mutgate's suite does), and that must not read as a truncated run
312
+ return CUT_SHORT, failed, "the run was cut short (maxfail); the fired set is incomplete\n" + "\n".join(tail)
313
+ return proc.returncode, failed, "\n".join(tail)
314
+
315
+
316
+ # ---------------------------------------------------------------------------------------
317
+ # the contract check
318
+ # ---------------------------------------------------------------------------------------
319
+
320
+ _NO_SUMMARY = ("pytest reported failures but none could be read from its short summary; is "
321
+ "the summary suppressed (addopts --no-summary / -p no:terminal)? mutgate needs -rfE")
322
+
323
+
324
+ def _classify(m: Mutation, rc: int, fired: list[str], tail: str) -> Verdict:
325
+ fired_t = tuple(fired)
326
+ if rc in (TIMEOUT, CUT_SHORT):
327
+ return Verdict(m, "ERROR", fired_t, detail=tail)
328
+ if rc == 1 and not fired:
329
+ # the gate that cannot fail, in the classifier itself: an invisible mutation that
330
+ # broke the suite would read OK, a firing one DECORATION (review 2026-09-07, item 2)
331
+ return Verdict(m, "ERROR", fired_t, detail=f"{_NO_SUMMARY}\n{tail}")
332
+ if rc not in (0, 1):
333
+ # only 0 and 1 mean the test loop ran to completion. An interrupted run (exit 2)
334
+ # can carry a PARTIAL fired set -- a conftest setting session.shouldstop after the
335
+ # first failure gives one failure in the summary and "!!! Interrupted !!!", not the
336
+ # maxfail line -- and a partial set is never a verdict (review, third round)
337
+ why = {2: "interrupted, or collection failed (does the mutated file still compile?); "
338
+ "any fired set is partial",
339
+ 3: "pytest internal error", 4: "pytest usage error", 5: "no tests collected"}.get(rc, f"pytest exit {rc}")
340
+ return Verdict(m, "ERROR", fired_t, detail=f"{why}\n{tail}")
341
+ if m.invisible:
342
+ return Verdict(m, "VISIBLE" if fired else "OK", fired_t)
343
+ missing = tuple(e for e in m.fires if not any(e in f for f in fired))
344
+ allowed = m.fires + m.may_fire
345
+ unexpected = tuple(f for f in fired if not any(e in f for e in allowed))
346
+ if missing:
347
+ return Verdict(m, "DECORATION", fired_t, missing, unexpected)
348
+ if unexpected:
349
+ return Verdict(m, "OVERREACH", fired_t, missing, unexpected)
350
+ return Verdict(m, "OK", fired_t)
351
+
352
+
353
+ def run(mutations: Iterable[Mutation], root: Path, tests: Sequence[str],
354
+ python: str = sys.executable, paths: Sequence[str] = ("src", "."),
355
+ only: Optional[Sequence[str]] = None, keep: bool = False, stop_early: bool = False,
356
+ extra_pytest_args: Sequence[str] = (), log=None, timeout: Optional[float] = None) -> Report:
357
+ """Apply each mutation in a sandbox copy of `root`, run `tests`, and judge the contract.
358
+
359
+ The baseline (no mutation) runs first; if it is red the report says so and nothing is
360
+ mutated. Each mutation is applied, run, and restored before the next."""
361
+ root = Path(root).resolve()
362
+ tests = tuple(tests)
363
+ report = Report(root=root, tests=tests)
364
+ mutations = list(mutations)
365
+ if only is not None:
366
+ known = {m.name for m in mutations}
367
+ unknown = [n for n in only if n not in known]
368
+ if unknown:
369
+ raise ValueError(f"--only names no declared mutation: {unknown}")
370
+ mutations = [m for m in mutations if m.name in only]
371
+ if not mutations:
372
+ raise ValueError("no mutations to run: an emptied declaration must not pass green")
373
+ log = log or (lambda s: None)
374
+ sb = Sandbox(root, keep=keep)
375
+ try:
376
+ log(f"sandbox {sb.dir}")
377
+ rc, failed, tail = run_pytest(sb.dir, tests, python, paths, extra_pytest_args, timeout=timeout)
378
+ if rc not in (0, 1) or (rc == 1 and not failed):
379
+ note = _NO_SUMMARY if rc == 1 else ""
380
+ report.baseline_error = f"baseline could not run (pytest exit {rc}) {note}\n{tail}"
381
+ return report
382
+ if failed:
383
+ report.baseline_failed = tuple(failed)
384
+ return report
385
+ log("baseline green")
386
+ for m in mutations:
387
+ applied, original, detail = sb.apply(m)
388
+ if not applied:
389
+ v = Verdict(m, "NOT_APPLIED", detail=detail)
390
+ else:
391
+ try:
392
+ rc, failed, tail = run_pytest(sb.dir, tests, python, paths, extra_pytest_args,
393
+ timeout=timeout)
394
+ finally:
395
+ sb.restore(m, original)
396
+ v = _classify(m, rc, failed, tail)
397
+ report.verdicts.append(v)
398
+ log(f"{m.name}: {v.status} ({len(v.fired)} fired)")
399
+ if stop_early and not v.ok:
400
+ break
401
+ finally:
402
+ sb.close()
403
+ return report
404
+
405
+
406
+ # ---------------------------------------------------------------------------------------
407
+ # the declaration file
408
+ # ---------------------------------------------------------------------------------------
409
+
410
+ @dataclass(frozen=True)
411
+ class Declaration:
412
+ mutations: tuple[Mutation, ...]
413
+ root: Path
414
+ tests: tuple[str, ...]
415
+ paths: tuple[str, ...]
416
+ python: Optional[str] = None
417
+
418
+
419
+ def _find_root(start: Path) -> Path:
420
+ for p in [start, *start.parents]:
421
+ if (p / "pyproject.toml").is_file() or (p / ".git").exists():
422
+ return p
423
+ return start
424
+
425
+
426
+ def load(path: Path) -> Declaration:
427
+ """Import a mutations file. It must define `MUTATIONS` (a sequence of `Mutation`);
428
+ optionally `TESTS` (pytest targets, default the file's own directory), `PATHS`
429
+ (PYTHONPATH entries relative to the root, default ("src", ".")), `ROOT` (relative to the
430
+ declaration file; default: the nearest ancestor holding pyproject.toml or .git) and
431
+ `PYTHON` (interpreter)."""
432
+ path = Path(path).resolve()
433
+ spec = importlib.util.spec_from_file_location(f"_mutgate_decl_{path.stem}", path)
434
+ if spec is None or spec.loader is None:
435
+ raise ValueError(f"cannot import {path}")
436
+ mod = importlib.util.module_from_spec(spec)
437
+ spec.loader.exec_module(mod)
438
+ if not hasattr(mod, "MUTATIONS"):
439
+ raise ValueError(f"{path} defines no MUTATIONS")
440
+ muts = tuple(mod.MUTATIONS)
441
+ for m in muts:
442
+ if not isinstance(m, Mutation):
443
+ raise ValueError(f"{path}: MUTATIONS holds a {type(m).__name__}, not a Mutation")
444
+ names = [m.name for m in muts]
445
+ dupes = sorted({n for n in names if names.count(n) > 1})
446
+ if dupes:
447
+ raise ValueError(f"{path}: duplicate mutation names {dupes}")
448
+ root_decl = getattr(mod, "ROOT", None)
449
+ root = (_find_root(path.parent) if root_decl is None else (path.parent / root_decl)).resolve()
450
+ tests_default = str(path.parent.relative_to(root)) if root in path.parent.parents or root == path.parent else "tests"
451
+ tests = tuple(getattr(mod, "TESTS", (tests_default,)))
452
+ paths = tuple(getattr(mod, "PATHS", ("src", ".")))
453
+ return Declaration(muts, root, tests, paths, getattr(mod, "PYTHON", None))
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.5
2
+ Name: mutgate
3
+ Version: 0.1.0
4
+ Summary: Named mutations as contracts on a test suite: each mutation must turn exactly the tests it names red, or nothing at all.
5
+ Project-URL: Homepage, https://github.com/JimGalasyn/mutgate
6
+ Project-URL: Issues, https://github.com/JimGalasyn/mutgate/issues
7
+ Project-URL: Changelog, https://github.com/JimGalasyn/mutgate/blob/main/CHANGELOG.md
8
+ Author: James P. Galasyn, Claude Théodore
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: contracts,mutation-testing,pytest,reproducibility,scientific-software,test-quality,testing
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Testing
24
+ Requires-Python: >=3.10
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest-cov>=4; extra == 'test'
27
+ Requires-Dist: pytest>=8; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # mutgate
31
+
32
+ **Named mutations as contracts on a test suite.** Each mutation is a deliberate, named
33
+ change to the code under test, together with the list of tests it must turn red — or the
34
+ statement that it must turn nothing red. `mutgate` applies each one in a sandbox copy, runs
35
+ the suite, and judges the contract.
36
+
37
+ ```python
38
+ # tests/mutations.py
39
+ from mutgate import Mutation
40
+
41
+ MUTATIONS = [
42
+ Mutation("keep-larger", file="pkg/engine.py",
43
+ old="if (birth[a], -a) >= (birth[b], -b):",
44
+ new="if size[a] >= size[b]:",
45
+ fires=("TestElderRule",)),
46
+ Mutation("skip-absolute-floor", file="pkg/engine.py",
47
+ old="if u.birth < abs_floor:", new="if False:",
48
+ fires=("TestAbsoluteFloor",)),
49
+ Mutation("tie-to-higher-index", file="pkg/engine.py",
50
+ old="(birth[a], -a) >= (birth[b], -b)",
51
+ new="(birth[a], a) >= (birth[b], b)",
52
+ invisible=True), # an invariance: the measurement must not depend on it
53
+ ]
54
+ ```
55
+
56
+ ```
57
+ $ mutgate run tests/mutations.py
58
+ mutation verdict fired expected
59
+ keep-larger OK 3 TestElderRule
60
+ skip-absolute-floor DECORATION 0 TestAbsoluteFloor
61
+ did not fire: TestAbsoluteFloor
62
+ tie-to-higher-index OK 0 nothing
63
+
64
+ 2 OK, 1 DECORATION
65
+ ```
66
+
67
+ ## Why
68
+
69
+ A green test suite guards only the defects that shaped it. The usual way to find out
70
+ whether a test is load-bearing is to break the code on purpose and see whether the test
71
+ notices — and in practice that is done by hand, in a scratch copy, with `sed`, once, and then
72
+ the result is written into a design note as prose. `mutgate` makes that a checked artefact:
73
+
74
+ - **the mutation is named and exact.** `old` must occur exactly `count` times in the file,
75
+ or the verdict is `NOT_APPLIED`. A mutation that silently does not land looks identical
76
+ to a working guard; this is the trap that motivated the tool.
77
+ - **the contract says which tests must fire.** A named test that does not fire is a
78
+ `DECORATION` — a guard that would not go red.
79
+ - **a test outside the contract firing is a finding, not noise.** `OVERREACH` means the
80
+ mutation reaches further than its author believed. When a convention (a tie-breaking rule,
81
+ a sign, an ordering) is changed and tests that should only *relabel* things fail on
82
+ *values*, the convention exists in two places that have drifted apart.
83
+ - **an invariance is a mutation that must fire nothing.** `invisible=True` pins that the
84
+ suite passes unchanged under the alternative convention; if it does not, the verdict is
85
+ `VISIBLE`. ⚠ An `OK` on an invariance is a *negative* result, and it is only as strong as
86
+ the evidence that the mutated line ran: a dead function, a site the named `TESTS` never
87
+ reach, or a suite that imports an installed copy instead of the sandbox all read `OK`
88
+ honestly. **Pair every invariance with a firing mutation at the same site in the same
89
+ declaration** — the known-positive control — so the file itself proves the site is
90
+ exercised by these tests. An unpaired `OK` on an invariance is unevidenced.
91
+ - **the working tree is never touched.** Each mutation is applied and restored in a
92
+ temporary copy (`git ls-files`, so uncommitted work is included and the venv is not), and
93
+ the copy is put first on `PYTHONPATH` so an editable install elsewhere cannot shadow it.
94
+ - **a red baseline aborts.** Mutations mean nothing on a suite that already fails.
95
+ - **a run whose failures cannot be read is an error, never a verdict.** If the project's
96
+ pytest configuration suppresses the short summary (`--no-summary`), pytest's exit code
97
+ says "failed" while nothing parses; that is reported as `ERROR`, not silently as `OK`.
98
+ And a run cut short is never a verdict either: `mutgate` owns `--maxfail=0` after every
99
+ user argument (a project's `-x` would truncate the fired set to one test), and a
100
+ `conftest` that forces `maxfail` anyway trips pytest's own "stopping after N failures"
101
+ line, which reads as `ERROR`.
102
+
103
+ The sandbox holds the project's files but not its `.git`; a suite that shells out to git
104
+ (a `setuptools_scm`-style version check, say) goes red at baseline and says so.
105
+
106
+ ## What it is not
107
+
108
+ It is not automatic mutation testing. Tools like `mutmut` and `cosmic-ray` generate operator
109
+ mutations at random and report a kill rate; that answers "how thorough is this suite?".
110
+ `mutgate` answers a narrower question that random mutation cannot: *does this specific guard
111
+ fire under the specific defect it was written for, and only that?* The two are complementary.
112
+
113
+ ## Declaration file
114
+
115
+ A Python file (conventionally `tests/mutations.py`) defining:
116
+
117
+ | name | required | meaning |
118
+ |---|---|---|
119
+ | `MUTATIONS` | yes | a sequence of `Mutation` |
120
+ | `TESTS` | no | pytest targets; default: the declaration file's own directory |
121
+ | `PATHS` | no | `PYTHONPATH` entries relative to the root; default `("src", ".")` |
122
+ | `ROOT` | no | project root, relative to the declaration file; default: the nearest ancestor with `pyproject.toml` or `.git` |
123
+ | `PYTHON` | no | interpreter to run pytest with; default: the one running `mutgate` |
124
+
125
+ `Mutation(name, file, old, new, fires=(), may_fire=(), invisible=False, count=1, note="")`.
126
+ Entries in `fires` and `may_fire` are substrings of pytest node ids, so `"TestElderRule"`,
127
+ `"test_engine.py::TestElderRule"` and `"test_tie_goes_to_lower_index"` all work.
128
+
129
+ ## CLI
130
+
131
+ ```
132
+ mutgate run tests/mutations.py [--tests T ...] [--only NAME ...] [--python PY]
133
+ [--pytest-arg ARG] [--markdown] [--keep] [-x] [-v]
134
+ mutgate list tests/mutations.py
135
+ ```
136
+
137
+ `--markdown` prints a table meant to be pasted into a design note's build record. Exit code
138
+ 0 when every contract holds, 1 when a verdict is not `OK`, 2 when the baseline is red or the
139
+ file cannot be loaded.
140
+
141
+ ## In CI
142
+
143
+ ```yaml
144
+ - run: pip install mutgate
145
+ - run: mutgate run tests/mutations.py
146
+ ```
147
+
148
+ Each mutation runs the named tests once, so the cost is the suite's cost times the number of
149
+ mutations; point `TESTS` at the module the mutations concern.
150
+
151
+ ## Install
152
+
153
+ ```
154
+ pip install mutgate
155
+ ```
156
+
157
+ No runtime dependencies. Python 3.10+. MIT.
@@ -0,0 +1,9 @@
1
+ mutgate/__init__.py,sha256=owlSDq3ktIA-dvpIMBbJ51hI2gPBAkgB9QszrrgvUSQ,1022
2
+ mutgate/__main__.py,sha256=bV6F-JJz7HFmmQqzfQ-_u__AOs-LNYme3uFK7e9FtWE,36
3
+ mutgate/cli.py,sha256=DdZdLlPhGrf1dQmcSRIxzNycqJIA8_oTuAhYCErpT8E,3627
4
+ mutgate/core.py,sha256=dajh1vYNsR02VwHxI0HLJdYw2bdG7Cb2zsvr4PwR1-s,21284
5
+ mutgate-0.1.0.dist-info/METADATA,sha256=d3e9Zf_SiUQJ8dr5-eTBQCPdTBLjSA0_FrsMXaTnW0o,7491
6
+ mutgate-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ mutgate-0.1.0.dist-info/entry_points.txt,sha256=dPl0jY2PgdRhTNUdDCM_m3su4fFwBQfRVxzLPag8Br8,48
8
+ mutgate-0.1.0.dist-info/licenses/LICENSE,sha256=pXi-REGP6qna7DadI_rzujveoXI4u3PxyD-Af-G_XPc,1073
9
+ mutgate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mutgate = mutgate.cli:run_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James P. Galasyn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.