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/.githooks/_resolve +47 -0
- harness/.githooks/hoist +45 -0
- harness/.githooks/pre-commit +7 -0
- harness/.githooks/pre-push +10 -0
- harness/.githooks/prepare-commit-msg +7 -0
- harness/__init__.py +0 -0
- harness/cli.py +532 -0
- harness/config.py +203 -0
- harness/docs/PROJECT_STATUS.md +38 -0
- harness/docs/PROMPT.md +39 -0
- harness/docs/plan.md +68 -0
- harness/docs/specs/another_spec.md +56 -0
- harness/docs/specs/base.md +56 -0
- harness/gate.py +297 -0
- harness/js-scaffold/PROMPT.md +8 -0
- harness/js-scaffold/README.md +12 -0
- harness/js-scaffold/index.html +47 -0
- harness/js-scaffold/package-lock.json +176 -0
- harness/js-scaffold/package.json +16 -0
- harness/js-scaffold/quiz.js +21 -0
- harness/js-scaffold/specs/quiz.md +6 -0
- harness/js-scaffold/test.js +8 -0
- harness/ralph.ps1 +84 -0
- harness/ralph.sh +34 -0
- harness/temp.pyproject.toml +407 -0
- harness/tests/mutation/mutmut-cicd-stats.json +11 -0
- harness/tests/mutation/test_check_mutmut.py +135 -0
- harness/tests/preferences/test_preferences.py +498 -0
- harness/tests/preferences/test_preferences_properties.py +245 -0
- loopgate-0.1.0.dist-info/METADATA +478 -0
- loopgate-0.1.0.dist-info/RECORD +37 -0
- loopgate-0.1.0.dist-info/WHEEL +4 -0
- loopgate-0.1.0.dist-info/entry_points.txt +2 -0
- loopgate-0.1.0.dist-info/licenses/LICENSE +21 -0
- mutation/check_mutmut.py +163 -0
- preferences/__init__.py +0 -0
- preferences/preferences.py +306 -0
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,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))
|