loopgate 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.
harness/ralph.sh ADDED
@@ -0,0 +1,34 @@
1
+ #!/bin/sh
2
+ # Ralph: hand docs/PROMPT.md to a fresh-context agent and loop. The repo is the only memory.
3
+ # Keep Ralph Dumb: start the worker, give it the prompt, print a line, repeat. Nothing else.
4
+ # Setup (deps + git hooks) is `harness install`. The gate runs from the git hooks on commit.
5
+ # Want logs? Redirect this script: `harness/ralph.sh ... > run.log 2>&1`.
6
+ #
7
+ # Usage:
8
+ # harness/ralph.sh [max_iterations] [max_minutes_per_iteration] <agent command...>
9
+ # e.g.
10
+ # harness/ralph.sh 10 20 claude -p --permission-mode acceptEdits
11
+ # harness/ralph.sh 10 20 codex exec --json --sandbox workspace-write -
12
+ #
13
+ # **** Motto: Keep Ralph Dumb. ****
14
+ set -eu
15
+
16
+ # Mark loop commits so the gate (run by the git hooks) applies containment to the worker.
17
+ export RALPH_LOOP=1
18
+
19
+ MAX_ITERATIONS=$1
20
+ MAX_MINUTES=$2
21
+ TIMEOUT=$TIMEOUT
22
+ shift 2
23
+
24
+ i=1
25
+ while [ "$i" -le "$MAX_ITERATIONS" ]; do
26
+ printf '{"type":"ralph","iteration":%s,"max_iterations":%s,"max_minutes":%s,"timestamp":"%s"}\n' \
27
+ "$i" "$MAX_ITERATIONS" "$MAX_MINUTES" "$(date '+%Y-%m-%dT%H:%M')"
28
+
29
+ printf '%s\n\nRALPH_ITERATION=%s/%s\n' "$RALPH_PROMPT" "$i" "$MAX_ITERATIONS" \
30
+ | "$TIMEOUT" "$((MAX_MINUTES * 60))" "$@"
31
+ i=$((i + 1))
32
+ done
33
+
34
+ printf '{"type":"ralph","completed":%s,"max_minutes":%s,"timestamp":"%s"}\n' "$((i - 1))" "$MAX_MINUTES" "$(date '+%Y-%m-%dT%H:%M')"
@@ -0,0 +1,407 @@
1
+ [project]
2
+ name = "loopgate"
3
+ requires-python = ">=3.10"
4
+ version = "0.1.0"
5
+ [project.scripts]
6
+ harness = "harness.cli:main" # creates the executable `harness`
7
+ [build-system]
8
+ requires = ["hatchling>=1.32"]
9
+ build-backend = "hatchling.build" # allows `harness` commands, runs the executable
10
+ [dependency-groups]
11
+ dev = [
12
+ "complexipy",
13
+ "coverage",
14
+ "hypothesis",
15
+ "mutmut",
16
+ "pip-audit",
17
+ "pylint",
18
+ "pyright",
19
+ "pytest",
20
+ "pytest-cov",
21
+ "pytest-xdist",
22
+ "ruff",
23
+ "semgrep",
24
+ "tomlkit",
25
+ "typer"
26
+ ]
27
+ # ==============================================================================
28
+ # Harness Configuration — single source of truth for agents and every check.
29
+ # harness/gate.py parses [tool.harness] once at import into the constants it uses.
30
+ # CI runs `harness gate`, so it uses the same commands.
31
+ # Humans own this file (it is agent-forbidden below).
32
+ # ==============================================================================
33
+ [tool.harness]
34
+ [tool.harness.settings]
35
+ behavior = "fail" # "fail" or "warn": change to "warn" if your project has too many blocking failures to start
36
+ languages = ["py"]
37
+ error_diff_lines = 500 # 400 ~90th percentile of pull requests, 200 LOC generally ok
38
+
39
+ # argv preset per agent for `harness run <agent>`
40
+ [tool.harness.agents]
41
+ claude = [
42
+ "claude",
43
+ "--model", "opus",
44
+ "--permission-mode", "auto",
45
+ # "--bare" # for one-shot minimal run: skips MCP, hooks, plugins, CLAUDE.md, reduced startup, and sets
46
+ # CLAUDE_CODE_SIMPLE + uses CLAUDE_CODE_OAUTH_TOKEN (ANTHROPIC_API_KEY billed), no .claude log
47
+ "--no-session-persistence", # no-save session data good for disposable auto tasks
48
+ "--output-format", "stream-json",
49
+ "--verbose", "-p",
50
+ ]
51
+ codex = [
52
+ # env -u clears the CODEX_* session/thread ids so a child agent never binds to the parent conversation
53
+ "env", "-u", "CODEX_THREAD_ID", "-u", "CODEX_CONVERSATION_ID", "-u", "CODEX_SESSION_ID",
54
+ "codex",
55
+ "exec",
56
+ "--model", "gpt-5.5",
57
+ "--json",
58
+ "--sandbox", "danger-full-access", "-",
59
+ ]
60
+ agy = [
61
+ "agy",
62
+ "--model", "gemini-3.5-flash-low",
63
+ "--dangerously-skip-permissions",
64
+ "--add-dir", ".",
65
+ "--log-file", "{log_path}",
66
+ "--prompt", "-"
67
+ ]
68
+ copilot = [
69
+ "copilot",
70
+ "--model=auto", # if paid: https://docs.github.com/en/copilot/reference/ai-models/supported-models
71
+ "--output-format", "json", "--stream", "on",
72
+ "--allow-all", "--no-ask-user",
73
+ ]
74
+ # EXAMPLE OF WHAT Javascript checks might be
75
+ #
76
+ # [tool.harness.preflight]
77
+ # checks = ["npm", "--prefix", "harness/js-scaffold", "run", "preflight"]
78
+ # [tool.harness.gate]
79
+ # checks = ["npm", "--prefix", "harness/js-scaffold", "run", "gate"]
80
+ #
81
+ # EXAMPLE OF WHAT Ruby checks might be
82
+ #
83
+ # [tool.harness.rb.preflight]
84
+ # checks = ["bundle", "exec", "rubocop"]
85
+ # [tool.harness.rb.gate]
86
+ # checks = ["bundle", "exec", "rspec"]
87
+
88
+ [tool.harness.preflight]
89
+ ruff_lint = ["ruff", "check", "--no-cache", "--show-fixes", "."]
90
+ pylint = ["pylint", "."] # second tool in category can just be named the tool
91
+ ruff_format = ["ruff", "format", "--no-cache", "--check"]
92
+ complexity = ["complexipy", ".", "--suggest-refactors"]
93
+
94
+ [tool.harness.gate]
95
+ audit = ["pip-audit"] # or audit = ["uv", "audit", "--preview-features", "audit"]
96
+ security = [
97
+ "semgrep", "scan",
98
+ "--error", # exit nonzero on findings so the gate blocks the commit, not just reports
99
+ "--config", "auto",
100
+ "--config", "p/secrets",
101
+ "--exclude-rule", "yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag",
102
+ ".",
103
+ ]
104
+ types = ["pyright", "--outputjson"]
105
+ test = ["pytest", "-p", "no:cacheprovider", "-n", "auto", "--cov", "--cov-report=term-missing", "--cov-fail-under=100", "--durations=5"]
106
+
107
+ [tool.harness.FORBIDDEN]
108
+ DIRS = ["harness/", ".githooks/", ".github/", ".git/", "preferences/", "tests/preferences/", "mutation", "tests/mutation/"]
109
+ FILES = [
110
+ "agents.md",
111
+ "docs/prompt.md",
112
+ "docs/plan.md", # delete/comment line if you want agents to manage the core plan
113
+ # tooling/config files that would weaken checks:
114
+ "tox.ini",
115
+ "setup.cfg",
116
+ "pytest.ini",
117
+ ".pytest.ini",
118
+ "pytest.toml",
119
+ ".pytest.toml",
120
+ ".coveragerc",
121
+ ".coveragerc.toml",
122
+ "ruff.toml",
123
+ ".ruff.toml",
124
+ ".flake8",
125
+ "pylintrc",
126
+ ".pylintrc",
127
+ "pylintrc.toml",
128
+ ".pylintrc.toml",
129
+ "pyrightconfig.json",
130
+ "mypy.ini",
131
+ ".mypy.ini",
132
+ "ty.toml",
133
+ "pyrefly.toml",
134
+ ".pyrefly.toml",
135
+ "complexipy.toml",
136
+ ".complexipy.toml",
137
+ "radon.cfg",
138
+ ".semgrepignore",
139
+ "semgrep.yml",
140
+ "semgrep.yaml",
141
+ ".semgrep.yml",
142
+ ".semgrep.yaml",
143
+ "semgrep.config.yml",
144
+ "semgrep.config.yaml",
145
+ ".bandit",
146
+ "sonar-project.properties",
147
+ ".snyk",
148
+ ".gitmodules",
149
+ ".gitattributes",
150
+ "pyproject.toml", # agents will claim inability to add dependencies with this forbidden
151
+ "requirements.txt",
152
+ "uv.lock", # if *.lock remains, agents will leave it uncommitted if file changes
153
+ "poetry.lock"
154
+ ]
155
+ PATTERNS = [
156
+ "# noqa",
157
+ "# flake8",
158
+ "type: ignore",
159
+ "type:ignore",
160
+ "no_type_check",
161
+ "pyright: ignore",
162
+ "from typing import any",
163
+ "from typing import cast",
164
+ "from typing import no_type_check",
165
+ "mypy: ignore",
166
+ "ty: ignore",
167
+ "pyrefly: ignore",
168
+ "pragma: no cover",
169
+ "eslint-disable",
170
+ "ts-ignore",
171
+ "ts-nocheck",
172
+ "ts-expect-error",
173
+ "--no-verify",
174
+ "hookspath",
175
+ "pytest.mark.skip",
176
+ "fail_under",
177
+ "cov-fail-under",
178
+ "# pylint:",
179
+ "pytest.skip",
180
+ "pytest.mark.xfail",
181
+ "pytest.mark.skipif",
182
+ "nosemgrep",
183
+ "# nosec",
184
+ "ruff: noqa",
185
+ "ruff: disable",
186
+ "ruff: ignore",
187
+ "fmt: off",
188
+ "fmt: skip",
189
+ "yapf: disable",
190
+ "complexipy: ignore",
191
+ "pragma: no mutate"
192
+ ]
193
+
194
+ # ==============================================================================
195
+ # Pyright Configuration
196
+ # ==============================================================================
197
+ [tool.pyright]
198
+ typeCheckingMode = "standard" # Pyright's has: "strict", "standard", "basic"
199
+ reportAssertAlwaysTrue = "error" # Catch asserts that are always true.
200
+ reportUnusedImport = "none" # Handled by Ruff F401
201
+ reportUnusedVariable = "none" # Handled by Ruff F841
202
+ verboseOutput = true # JSON report output when Pyright is run
203
+ include = ["."] # Type-check shipped code plus repository preferences.
204
+ exclude = ["scratchpad", "mutants", "**/node_modules", "**/__pycache__", ".venv", "**/.*"]
205
+
206
+ # ==============================================================================
207
+ # Test Configuration
208
+ # ==============================================================================
209
+ [tool.pytest.ini_options]
210
+ minversion = "9.0"
211
+ addopts = ["-ra"] # Print useful short reasons for outcomes
212
+ testpaths = ["tests"]
213
+ pythonpath = ["."] # Repo root: tests import root packages by full dotted path
214
+
215
+ # ==============================================================================
216
+ # Test Coverage Configuration
217
+ # ==============================================================================
218
+ [tool.coverage]
219
+ run.source = ["src", "preferences", "mutation"] # ADD EVERY DIRECTORY THAT SHIPS CODE TO ENSURE 100% COVERAGE
220
+ report.show_missing = true # Show exact missed lines when coverage fails
221
+ report.skip_covered = false # Keep fully covered files visible in reports
222
+ report.fail_under = 100 # Block merges unless all shipped code is covered
223
+ skip_covered = false # Same behavior for older coverage config readers
224
+
225
+ # ==============================================================================
226
+ # Hypothesis Property Tests Configuration
227
+ # ==============================================================================
228
+ [tool.hypothesis]
229
+ max_examples = 50 # Generate 50 random test cases per property test (default 100)
230
+ deadline = 500 # Fails tests if an individual example takes longer than 500ms
231
+ derandomize = true # false default seeds random, uncovers more bugs. True = deterministic runs
232
+
233
+ # ==============================================================================
234
+ # Mutmut (Mutation Testing) Configuration
235
+ # ==============================================================================
236
+ [tool.mutmut]
237
+ max-children = 2
238
+ source_paths = ["preferences", "src"] # what to mutate
239
+ also_copy = ["preferences", "mutation", ".githooks"] # not mutated paths, copied for imports
240
+
241
+ # ==============================================================================
242
+ # Cognitive Complexity Complexipy Configuration
243
+ # ==============================================================================
244
+ [tool.complexipy]
245
+ paths = ["src", "preferences", "mutation"]
246
+ exclude = ["**/tests/**"] # Test code can be more branching and example-heavy than production code
247
+ max-complexity-allowed = 15 # Cognitive complexity cap catches nested, hard-to-read flow (default 15)
248
+ no-ignore = false # Disallow `# complexipy: ignore`, fix complexity instead of suppressing it
249
+ report-ignored = true # List every file:line with a suppressive ignore comment
250
+ failed = true # Show only functions over the threshold so gate output stays actionable
251
+ sort = "asc" # Sort results in ascending order
252
+ quiet = false # Suppress output
253
+ ignore-complexity = false # Don't exit with error on max-complexity-allowed threshold breach
254
+
255
+ # ==============================================================================
256
+ # Pylint Configuration
257
+ # ==============================================================================
258
+ [tool.pylint]
259
+ main.load-plugins = ["pylint.extensions.docparams"]
260
+ main.ignore = [
261
+ ".git",
262
+ ".venv",
263
+ "__pycache__",
264
+ "migrations",
265
+ "build",
266
+ "dist",
267
+ "scratchpad",
268
+ "tests",
269
+ "**/tests/**",
270
+ ".worktrees",
271
+ "worktrees",
272
+ "mutants",
273
+ ".tox",
274
+ "tox",
275
+ "harness"
276
+ ]
277
+ format.max-line-length = 120 # Keep Pylint line accounting aligned with Ruff E501
278
+ format.max-module-lines = 750 # Enable Pylint C0302 because Ruff has no equivalent, includes docstrings
279
+ parameter_documentation.accept-no-param-doc = false # Require Args docs when Pylint docparams sees params
280
+ reports.reports = "yes" # Print detailed Pylint reports so humans see why score changed
281
+ reports.score = true # Keep Pylint's score visible as a coarse trend signal
282
+ "messages control".enable = ["F", "I", "R0022", "missing-param-doc"] # Show fatal/info, stale options, param docs
283
+ design.max-attributes = 10 # Max instance attributes (variables) allowed in a class
284
+
285
+ # ==============================================================================
286
+ # Unified Ruff Configuration
287
+ # ==============================================================================
288
+ [tool.ruff]
289
+ target-version = "py310"
290
+ preview = true # Enable newer Ruff rules before they are fully stable
291
+ line-length = 120 # Keep code readable without forcing narrow wrapping
292
+ force-exclude = true # Respect excluded paths even when they are passed directly
293
+ exclude = [
294
+ "*.md",
295
+ ".git",
296
+ ".venv",
297
+ "__pycache__",
298
+ "build",
299
+ "dist",
300
+ "scratchpad",
301
+ "node_modules",
302
+ ".pytest_cache",
303
+ ".ruff_cache",
304
+ ".uv",
305
+ "**/.pylint_bits/**", # Stops Ruff from fighting Pylint's internal cache
306
+ "harness" # changes to ruff rules should not trigger non-project harness code
307
+ ]
308
+ [tool.ruff.format]
309
+ quote-style = "double" # Quote style for strings ("single" or "double")
310
+ indent-style = "space" # Indent style ("space" or "tab")
311
+ docstring-code-format = true # How to format code blocks embedded in docstrings
312
+ [tool.ruff.lint]
313
+ fixable = ["ALL"]
314
+ unfixable = [] # Allow Ruff to auto-fix what it can
315
+ mccabe.max-complexity = 15 # Prevent structural bloat, path-count limit for functions, ruff default=10
316
+ pycodestyle.max-doc-length = 120 # Enforce W505 for comments and docstrings
317
+ pycodestyle.ignore-overlong-task-comments = true # Let TODO-style tracker lines exceed max doc length
318
+ pydocstyle.convention = "google" # Interpret Args/Returns/Raises sections as Google-style docstrings
319
+ pydoclint.ignore-one-line-docstrings = true # Allow obvious one-line docstrings light
320
+ select = [
321
+ "A", # flake8-builtins (prevents shadowing Python builtins)
322
+ "ARG", # flake8-unused-arguments (dead params and fake extension points)
323
+ "ASYNC", # flake8-async (catches blocks/misuse in async frameworks)
324
+ "B", # flake8-bugbear (catches common backend design flaws)
325
+ "BLE", # flake8-blind-except (blocks quiet 'except Exception: pass')
326
+ "C", # C-prefixed rules, including mccabe and comprehensions
327
+ "C4", # flake8-comprehensions (.e.g unnecessary list comprehensions)
328
+ "C90", # Enables McCabe cyclomatic complexity checks
329
+ "COM", # flake8-commas. Trailing comma added.
330
+ "D102", # No missing docstring in public method
331
+ "D103", # No missing docstring in public function
332
+ "D202", # No blank line between a function docstring and the function body
333
+ "D300", # Use """ triple quotes for docstrings """
334
+ "D417", # No missing arg description in docstring for {definition}: {name}
335
+ "D419", # No empty docstrings
336
+ "DOC", # Global. Validates parameter & return documentation
337
+ "DOC102", # Redundancy of "DOC". Documented parameter {id} must be in the function's signature
338
+ "DOC201", # Redundancy of "DOC". Structured docstrings must include Returns when code returns.
339
+ "DOC501", # Redundancy of "DOC". Raised exception {id} missing from docstring
340
+ "DTZ", # flake8-datetimez (forbid naive datetime usage)
341
+ "E", # pycodestyle: Catches objective syntax rule violations
342
+ "EM", # flake8-errmsg (keep exception messages clean and reusable)
343
+ "ERA", # Catches commented-out code bloat
344
+ "F", # Pyflakes (unused imports, undefined variables)
345
+ "FLY", # Converts .format() and % string into modern f-string
346
+ "FURB", # Modern simplifications, some loop/set mutation patterns
347
+ "G", # flake8-logging-format (safe logging format conventions)
348
+ "I", # isort (clean, predictable import sorting)
349
+ "ISC", # flake8-implicit-str-concat, Catch missing commas in lists
350
+ "LOG", # flake8-logging (logging misuse and avoidable root logger use)
351
+ "N", # Enable pep8-naming rules globally
352
+ "PERF", # Avoids inefficient loop/allocation patterns
353
+ "PGH", # pygrep-hooks (blanket suppression hygiene) Bans generic # noqa or # type: ignore
354
+ "PIE", # flake8-pie, removes unnecessary code constructs
355
+ "PLC", # Pylint convention rules
356
+ "PLE", # Pylint error rules
357
+ "PLR", # Pylint Refactor (controls complexity and argument bloat)
358
+ "PLW", # Pylint warning rules
359
+ "PT", # flake8-pytest-style (idiomatic pytest without docstring noise) e.g. pytest.raise not try/except
360
+ "PTH", # flake8-use-pathlib (prefer Path over error-prone os/path string handling)
361
+ "PYI", # Prevents bad practices in files that handle interfaces/architecture
362
+ "RET", # Cleaner return flow, fewer lazy else chains.
363
+ "RSE", # flake8-raise (clean raise statements)
364
+ "RUF", # Ruff-specific rules
365
+ "S", # flake8-bandit (security checks: SQLi, hardcoded credentials)
366
+ "SIM", # flake8-simplify (prefer simpler control flow and syntax)
367
+ "SLF001", # No start with single underscore outside defining module
368
+ "T10", # flake8-debugger (forbid breakpoint/debugger calls)
369
+ "T20", # flake8-print (forbids print statements; forces logging)
370
+ "TID", # flake8-tidy-imports (forces clean absolute imports)
371
+ "TRY", # tryceratops (exception handling hygiene)
372
+ "UP", # Modern Python idioms
373
+ "W", # Catches stylistic choices that harm readability
374
+ ]
375
+ ignore = [
376
+ "COM812", # Let Ruff format own trailing commas to avoid formatter conflicts, missing-trailing-comma
377
+ "PLR2004", # Allow 'magic values" (unnamed numbers), start-process-with-partial-path
378
+ "S607", # Allow resolving git/uv from PATH, start-process-with-partial-path
379
+ "RUF201" # Do not complain no string rule-codes-in-selectors (too new since v.0.15.22)
380
+ ]
381
+ [tool.ruff.lint.pylint] # Prevent unreadable complexity and code
382
+ max-nested-blocks = 5 # Stop indentation madness
383
+ max-positional-args = 5 # Max total params, prevents big initializers, limits positional params can pass
384
+ max-args = 5 # Prevents big initializers, Limits all named parameters in function def (* uncounted)
385
+ max-public-methods = 20 # Cap public methods per class
386
+ max-statements = 50 # Cap executable statements per function/method
387
+ max-branches = 12 # Cap branching paths per function/method; long if/elif chains are banned
388
+ max-returns = 6 # Cap return exits per function/method
389
+ max-locals = 15 # Cap local variable sprawl per function/method
390
+
391
+ [tool.ruff.lint.flake8-tidy-imports]
392
+ ban-relative-imports = "all" # Enforce absolute imports
393
+ # banned-api."typing.cast".msg = "Do not use cast(); fix the type at the boundary instead." EXAMPLE
394
+ # banned-api."re".msg = "Regex is slow. Use Python built-in string manipulation operations." EXAMPLE
395
+
396
+ [tool.ruff.lint.per-file-ignores]
397
+ "**/__init__.py" = ["D104"] # __init__.py Package marker files not required to have package docstrings
398
+ "**/tests/**/*.py" = [
399
+ "D100", # Test modules do not need noisy docstrings, undocumented-public-module
400
+ "D101", # Test classes do not need noisy docstrings, undocumented-public-class
401
+ "D102", # Test helper methods do not need noisy docstrings, undocumented-public-method
402
+ "D103", # Test functions describe behavior through test names, undocumented-public-function
403
+ "D104", # Test package boundaries do not need package docstrings, undocumented-public-package
404
+ "DOC201", # Tests explain behavior through assertions, docstring-missing-returns
405
+ "S101", # Allow 'assert' statements (needed for pytest)
406
+ "PLR0915", # Tests may need larger setup functions
407
+ ]
@@ -0,0 +1,11 @@
1
+ {
2
+ "killed": 132,
3
+ "survived": 0,
4
+ "total": 133,
5
+ "no_tests": 0,
6
+ "skipped": 0,
7
+ "suspicious": 0,
8
+ "timeout": 1,
9
+ "check_was_interrupted_by_user": 0,
10
+ "segfault": 0
11
+ }
@@ -0,0 +1,135 @@
1
+ """Tests for the mutmut CI report checker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import runpy
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from click import unstyle
11
+
12
+ from mutation import check_mutmut
13
+ from mutation.check_mutmut import MINIMUM_MUTATION_SCORE, analyze_mutmut_report_passed
14
+
15
+
16
+ def test_module_output_from_mutation_directory_is_exact(
17
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
18
+ ) -> None:
19
+ """The standalone module finds the repository report and exits without a traceback."""
20
+ module_dir = tmp_path / "mutation"
21
+ module_dir.mkdir()
22
+ checker = Path(__file__).parents[2] / "mutation" / "check_mutmut.py"
23
+ report = tmp_path / "mutants" / "mutmut-cicd-stats.json"
24
+ report.parent.mkdir()
25
+ report.write_text(
26
+ json.dumps({"killed": 1, "survived": 1, "total": 2, "skipped": 0, "timeout": 0}), encoding="utf-8"
27
+ )
28
+
29
+ monkeypatch.chdir(tmp_path)
30
+ with pytest.raises(SystemExit) as exc_info:
31
+ runpy.run_path(str(checker), run_name="__main__")
32
+
33
+ assert exc_info.value.code == 1
34
+ output = " ".join(unstyle(capsys.readouterr().out).split())
35
+ leading_rule, title, results = output.partition("MUTMUT MUTATION RESULTS")
36
+ assert title == "MUTMUT MUTATION RESULTS"
37
+ assert not leading_rule.strip("─ ")
38
+ assert results.strip("─ ") == ("killed 1 survived 1 total 2 skipped 0 timeout 0 Mutation Score: 50.0")
39
+
40
+ monkeypatch.chdir(module_dir)
41
+ monkeypatch.setattr(check_mutmut, "__file__", str(module_dir / "check_mutmut.py"))
42
+ assert check_mutmut.update_mutation_score() == pytest.approx(50.0)
43
+ badge = json.loads((tmp_path / "mutation-score.json").read_text(encoding="utf-8"))
44
+ assert badge["message"] == "50.0%"
45
+
46
+
47
+ def test_report_with_timeout_passes_and_renders(
48
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
49
+ ) -> None:
50
+ """A timeout counts as detected and remains visible in the report."""
51
+ report = tmp_path / "mutants" / "mutmut-cicd-stats.json"
52
+ report.parent.mkdir()
53
+ report.write_text(Path(__file__).with_name("mutmut-cicd-stats.json").read_text(encoding="utf-8"), encoding="utf-8")
54
+
55
+ checker = Path(__file__).parents[2] / "mutation" / "check_mutmut.py"
56
+ monkeypatch.chdir(tmp_path)
57
+ runpy.run_path(str(checker), run_name="__main__")
58
+
59
+ output = " ".join(unstyle(capsys.readouterr().out).split())
60
+ assert "MUTMUT MUTATION RESULTS" in output
61
+ assert "timeout 1" in output
62
+ assert "Mutation Score: 100.0" in output
63
+ assert json.loads((tmp_path / "mutation-score.json").read_text(encoding="utf-8")) == {
64
+ "schemaVersion": 1,
65
+ "label": "mutation",
66
+ "message": "100.0%",
67
+ "color": "#177445",
68
+ }
69
+
70
+
71
+ def test_report_checker_entry_point_exits_zero_and_prints_score(
72
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
73
+ ) -> None:
74
+ """The script entry point reads the default report path and exposes pass/fail through the process exit."""
75
+ report = tmp_path / "mutants" / "mutmut-cicd-stats.json"
76
+ report.parent.mkdir()
77
+ report.write_text(Path(__file__).with_name("mutmut-cicd-stats.json").read_text(encoding="utf-8"), encoding="utf-8")
78
+
79
+ checker = Path(__file__).parents[2] / "mutation" / "check_mutmut.py"
80
+ monkeypatch.chdir(tmp_path)
81
+ runpy.run_path(str(checker), run_name="__main__")
82
+
83
+ output = " ".join(unstyle(capsys.readouterr().out).split())
84
+ assert "MUTMUT MUTATION RESULTS" in output
85
+ assert "Mutation Score: 100.0" in output
86
+
87
+
88
+ def test_report_enforces_threshold(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
89
+ """Mutation scores at or above the minimum pass, while lower scores fail."""
90
+ data = json.loads(Path(__file__).with_name("mutmut-cicd-stats.json").read_text(encoding="utf-8"))
91
+ data["survived"] = 1
92
+ data["total"] += 1
93
+ report = tmp_path / "mutmut-cicd-stats.json"
94
+ report.write_text(json.dumps(data), encoding="utf-8")
95
+ mutation_score = analyze_mutmut_report_passed(str(report))
96
+ assert MINIMUM_MUTATION_SCORE <= mutation_score < 100.0
97
+
98
+ passing_report = {"killed": MINIMUM_MUTATION_SCORE, "timeout": 0, "total": 100, "skipped": 0}
99
+ report.write_text(json.dumps(passing_report), encoding="utf-8")
100
+ assert analyze_mutmut_report_passed(str(report)) == pytest.approx(MINIMUM_MUTATION_SCORE)
101
+
102
+ failing_score = MINIMUM_MUTATION_SCORE - 1
103
+ report.write_text(json.dumps({"killed": failing_score, "timeout": 0, "total": 100, "skipped": 0}), encoding="utf-8")
104
+ assert analyze_mutmut_report_passed(str(report)) == pytest.approx(failing_score)
105
+ output = " ".join(unstyle(capsys.readouterr().out).split())
106
+ assert f"Mutation Score: {failing_score}" in output
107
+
108
+
109
+ def test_entry_point_fails_below_threshold(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
110
+ """The script entry point exits 1 when the score is below the minimum."""
111
+ report = tmp_path / "mutants" / "mutmut-cicd-stats.json"
112
+ report.parent.mkdir()
113
+ report.write_text(json.dumps({"killed": 0, "timeout": 0, "total": 1, "skipped": 0}), encoding="utf-8")
114
+
115
+ checker = Path(__file__).parents[2] / "mutation" / "check_mutmut.py"
116
+ monkeypatch.chdir(tmp_path)
117
+ with pytest.raises(SystemExit) as exc_info:
118
+ runpy.run_path(str(checker), run_name="__main__")
119
+
120
+ assert exc_info.value.code == 1
121
+
122
+
123
+ def test_missing_report_returns_zero(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
124
+ """A missing export reports the error and returns a zero score."""
125
+ assert analyze_mutmut_report_passed(str(tmp_path / "missing.json")) == pytest.approx(0.0)
126
+ assert "Mutmut report not" in capsys.readouterr().out
127
+
128
+
129
+ def test_malformed_report_fails(tmp_path: Path) -> None:
130
+ """Malformed JSON is reported as malformed JSON."""
131
+ report = tmp_path / "mutmut-cicd-stats.json"
132
+ report.write_text("{", encoding="utf-8")
133
+
134
+ with pytest.raises(json.JSONDecodeError):
135
+ analyze_mutmut_report_passed(str(report))