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.
@@ -0,0 +1,163 @@
1
+ """FAIL CI WHEN MUTMUT'S AGGREGATE EXPORT CONTAINS TEST GAPS.
2
+
3
+ Mutation testing mutates the source code, e.g. a boolean flips, a constant shifts, a comparison widens.
4
+ A mutant that dies proves a test assertion was working on that line. A mutant that SURVIVES is a variant of
5
+ source code that shows tests execute (i.e. test coverage is met there) BUT the test is flimsy:
6
+ has weak assertions, overly-mock, happy path only, missing edge cases, no real assert in test, weak logic.
7
+
8
+ i.e. Mutmut tests the tests. Mutmut breaks source then runs test suite. If test fails, the break was noticed,
9
+ a mutated source code variant is "killed." If every test passes after mutation the break went unnoticed,
10
+ mutant code variant "survived".
11
+
12
+ Mutmut docs: https://mutmut.readthedocs.io/
13
+ mutmut run # generate mutants and run the test suite against all source code + its mutants
14
+ mutmut browse # TUI over the survivors
15
+ mutmut results # plain-text summary
16
+
17
+ Config in pyproject.toml [tool.mutmut]
18
+
19
+ Each mutant variant has a name, e.g. tests.test_file.x_lazy_assert__mutmut_2. Mutmut records mutant as killed
20
+ or survived before you inspect, and re-uses variant until source code changes.
21
+
22
+ CASE:
23
+ - best: 100% test coverage, run mutmut -> many mutations, none survive, nothing for you to kill
24
+ (tests are strongly sensitive to change)
25
+ - good: <100% coverage, run mutmut -> many mutations, none survive, you have to hunt some
26
+ (the tests that exist are good but you miss coverage)
27
+ - worst: 100% coverage, run mutmut -> many mutations, ALL survive + no easy kills
28
+ (there are many low quality tests)
29
+ - realistic: run mutmut, some mutants created, some survive, you find some to kill
30
+
31
+ PROCESS:
32
+
33
+ 1. Run mutmut: mutations appear / are killed
34
+ 2. Inspect: You look at surviving mutations
35
+ 3. Test Update: You add or improve test assertions
36
+ 4. Re-run: You ensure the mutant is killed
37
+
38
+ 5. Updating Source Code
39
+ - Dead Code
40
+ Sometimes a mutant survives because source code is redundant, e.g. If changing a line doesn't break a test,
41
+ ask if that code is needed. Maybe delete the useless code instead of writing tests.
42
+ - Surviving Mutants
43
+ You do not need to reach zero mutants, e.g. if changing source effects performance negatively, message output
44
+ would change, equivalent code swap e.g. i < 10 => i != 10
45
+
46
+ Locally you can run `mutmut run` to generate mutants. Your test suite must be green.
47
+ Then `mutmut export-cicd-stats` to get a report.
48
+ Run *this* script to use that report to get a score and badge-capable score for your README.
49
+ ```
50
+ > mutmut run
51
+ ⠼ Generating mutants
52
+ done in 2457ms (6 files mutated, 7 ignored, 0 unmodified)
53
+ ⠧ Running stats
54
+ done
55
+ ⠼ Running clean tests
56
+ done
57
+ ⠏ Running forced fail test
58
+ done
59
+ Running mutation testing
60
+ ⠼ 1434/1434 🎉 1207 🫥 0 ⏰ 1 🤔 0 🙁 226 🔇 0 🧙 0
61
+ 5.28 mutations/second
62
+ > mutmut export-cicd-stats
63
+ Saved CI/CD stats to mutants/mutmut-cicd-stats.json
64
+ > python mutation/check_mutmut.py # writes `mutation-score.json` in the format Shields expects and prints:
65
+
66
+ ───────────────MUTMUT MUTATION RESULTS ───────────────
67
+
68
+ killed 1207
69
+ survived 226
70
+ total 1434
71
+ no_tests 0
72
+ skipped 0
73
+ suspicious 0
74
+ timeout 1
75
+ check_was_interrupted_by_user 0
76
+ segfault 0
77
+ Mutation Score: 84.2
78
+
79
+ Add to README:
80
+ [![mutation](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/OWNER/REPO/BRANCH/
81
+ mutation-score.json)](https://github.com/OWNER/REPO/blob/BRANCH/mutation-score.json)
82
+
83
+ > git commit && git push
84
+ """
85
+
86
+ from __future__ import annotations
87
+
88
+ import json
89
+ import os
90
+ from pathlib import Path
91
+
92
+ from rich import print as rprint
93
+ from rich.console import Console
94
+ from rich.table import Table
95
+
96
+ console = Console(force_terminal=True, color_system=None if os.environ.get("RALPH_LOOP") else "auto")
97
+
98
+ MINIMUM_MUTATION_SCORE = 60.0 # increase over time
99
+
100
+
101
+ def update_mutation_score() -> float:
102
+ """Read the Mutmut report and write the latest repository mutation score.
103
+
104
+ Returns:
105
+ Latest calculated mutation score.
106
+ """
107
+ stats_report_path = Path("mutants/mutmut-cicd-stats.json")
108
+ badge_score = Path("mutation-score.json")
109
+ if not stats_report_path.exists():
110
+ repo_root = Path(__file__).resolve().parents[1]
111
+ stats_report_path = repo_root / stats_report_path
112
+ badge_score = repo_root / badge_score
113
+ mutation_score = analyze_mutmut_report_passed(str(stats_report_path))
114
+ badge = {"schemaVersion": 1, "label": "mutation", "message": f"{mutation_score:.1f}%", "color": "#177445"}
115
+ badge_score.write_text(json.dumps(badge, indent=4) + "\n", encoding="utf-8")
116
+ return mutation_score
117
+
118
+
119
+ def analyze_mutmut_report_passed(file_path: str = "mutants/mutmut-cicd-stats.json") -> float:
120
+ """Read the mutmut CI JSON results created each Sunday night.
121
+
122
+ Args:
123
+ file_path (str): Default filepath to read mutmut run stats from.
124
+
125
+ Returns:
126
+ mutation_score: float value, killed + timeout mutants as a% of all
127
+
128
+ Raises:
129
+ JSONDecodeError: If the report does not contain valid JSON.
130
+ """
131
+ mutation_score: float = 0.0
132
+ if not Path(file_path).exists():
133
+ rprint(f"[bold red]Mutmut report not at {file_path}[/]:\nRun `mutmut run && mutmut export-cicd-stats`")
134
+ return mutation_score
135
+ data: dict[str, int] = {}
136
+ with Path(file_path).open("r", encoding="utf-8") as fp:
137
+ try:
138
+ data = json.load(fp)
139
+ except json.JSONDecodeError:
140
+ rprint(rf"[red]JSONDecodeError [/]'{file_path}'")
141
+ raise
142
+
143
+ total_mutants = data.get("total", 0)
144
+ skipped = data.get("skipped", 0)
145
+ tested_mutants = total_mutants - skipped
146
+ if tested_mutants > 0:
147
+ killed = data.get("killed", 0)
148
+ timeout = data.get("timeout", 0)
149
+ mutation_score = ((killed + timeout) / tested_mutants) * 100
150
+
151
+ console.rule("[bold cyan]MUTMUT MUTATION RESULTS[/]", style="blink cyan on grey15")
152
+ table = Table(box=None)
153
+ for stat, result in data.items():
154
+ table.add_row(f"[dim]{stat}[/]", f"[blue] {result}[/]")
155
+ table.add_row("[cyan]Mutation Score:[/]", f"[dim yellow2]{mutation_score:.1f}[/]")
156
+ console.print(table, justify="center")
157
+
158
+ return mutation_score
159
+
160
+
161
+ if __name__ == "__main__":
162
+ if update_mutation_score() < MINIMUM_MUTATION_SCORE:
163
+ raise SystemExit(1)
File without changes
@@ -0,0 +1,306 @@
1
+ """AST-based structural style checks for staged Python files.
2
+
3
+ OPTIONAL for humans to use or edit! The functions below are examples to use. Or delete.
4
+
5
+ Agents in the loop cannot edit this file. It's in `FORBIDDEN_DIRS` at `harness/gate.py`.
6
+
7
+ This module should reflect the repo owner's personal coding style hates. It's personal.
8
+ e.g. indiscriminate __underscore_names, **star-unpacking, pointless classes, loops instead of Set math.
9
+
10
+ Use this file ONLY for rules that ruff, pylint, and pyright cannot express but you want enforced. Keep short.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ from collections.abc import Callable
17
+
18
+ # A check looks at ONE AST node and returns a complaint or None if the node is fine.
19
+ # It never walks the tree. root preferences_violations does the walk and feeds nodes to functions.
20
+ Check = Callable[[ast.AST], "str | None"]
21
+
22
+
23
+ def is_in_class(node: ast.AST) -> bool:
24
+ """Checks if ode is inside a class
25
+
26
+ Args:
27
+ node: The node in question to check, is it in a class?
28
+
29
+ Returns:
30
+ bool True is in class, else False
31
+ """
32
+ current = getattr(node, "parent", None)
33
+ while current:
34
+ if isinstance(current, ast.ClassDef):
35
+ return True
36
+ current = getattr(current, "parent", None)
37
+ return False
38
+
39
+
40
+ def chaotic_continue_statements(node: ast.AST) -> str | None:
41
+ """Catch continue statements that are hard to follow: inside a while loop, or buried two or more
42
+ if/for blocks deep. A single `if` guard directly inside a loop is fine -- that is normal Python.
43
+
44
+ Example:
45
+ for x in xs: -> fine: a plain continue in its loop
46
+ continue
47
+ for x in xs: -> flagged: two `if` blocks deep
48
+ if a:
49
+ if b:
50
+ continue
51
+ for x in xs: -> flagged: nested loops
52
+ for y in ys:
53
+ continue
54
+ while cond: -> flagged: any continue in a while loop (freeze risk)
55
+ continue
56
+
57
+ Args:
58
+ node: One piece of the parsed code to look at.
59
+
60
+ Returns:
61
+ A short message if the continue is in a while loop or over-nested, otherwise None.
62
+ """
63
+ # Ban continue inside while loops to prevent infinite freezes.
64
+ if isinstance(node, ast.While) and any(isinstance(child, ast.Continue) for child in ast.walk(node)):
65
+ return "'continue' inside a while loop banned to prevent infinite freezes"
66
+ # Only continue statements can be over-nested; a guard here keeps the walk below un-nested.
67
+ if not isinstance(node, ast.Continue):
68
+ return None
69
+ # A continue is over-nested when it sits two or more if/for blocks deep. One 'if' guard directly
70
+ # inside its loop (the common `for ...: if ...: continue`) is fine; anything deeper is not.
71
+ blocks: list[str] = []
72
+ ancestor = getattr(node, "parent", None)
73
+ while ancestor is not None:
74
+ if isinstance(ancestor, ast.If | ast.For | ast.While):
75
+ blocks.append(type(ancestor).__name__)
76
+ ancestor = getattr(ancestor, "parent", None)
77
+ # Every continue needs one enclosing loop; a single 'if' above that loop is still fine.
78
+ if len(blocks) >= 2 and blocks not in (["If", "For"], ["If", "While"]):
79
+ return "Overly-nested 'continue' detected inside multiple if/for blocks"
80
+ return None
81
+
82
+
83
+ def lazy_any_type_hints(node: ast.AST) -> str | None:
84
+ """Catch agents using 'Any' to escape strict type checks.
85
+
86
+ Args:
87
+ node: The AST node under inspection.
88
+
89
+ Returns:
90
+ A complaint if the arg is annotated `Any`/`typing.Any`, else None.
91
+ """
92
+ if isinstance(node, ast.arg) and node.annotation:
93
+ item_is_any = isinstance(node.annotation, ast.Name) and node.annotation.id == "Any" # matches Any
94
+ uses_typing_dot_any = (
95
+ isinstance(node.annotation, ast.Attribute)
96
+ and isinstance(node.annotation.value, ast.Name)
97
+ and node.annotation.value.id == "typing"
98
+ and node.annotation.attr == "Any"
99
+ ) # matches typing.Any
100
+ if item_is_any or uses_typing_dot_any:
101
+ return f"Lazy 'Any' type hint detected for argument '{node.arg}'"
102
+ return None
103
+
104
+
105
+ def lambda_found(node: ast.AST) -> str | None:
106
+ """Catches all lambdas. Ruff E731 only flags lambdas directly assigned to a variable name.
107
+
108
+ Args:
109
+ node: The AST node under inspection.
110
+
111
+ Returns:
112
+ A complaint if the node is a lambda, else None.
113
+ """
114
+ if isinstance(node, ast.Lambda):
115
+ return "Lambda found hurting readability and adding complexity, prefer map() or filter()"
116
+ return None
117
+
118
+
119
+ def named_with_underscore_and_not_in_class_or_dunder(node: ast.AST) -> str | None:
120
+ """A def, arg, or assignment target starts with '_' and is not in a class object.
121
+
122
+ The conventional discard name ``_`` is exempt.
123
+
124
+ Args:
125
+ node: The AST node under inspection.
126
+
127
+ Returns:
128
+ A complaint if the name has a prohibited leading underscore, else None.
129
+ """
130
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
131
+ name = node.name
132
+ elif isinstance(node, ast.arg):
133
+ name = node.arg
134
+ elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
135
+ name = node.id
136
+ else:
137
+ return None
138
+ if name.startswith("__") and not name.endswith("__"):
139
+ return f"Name '{name} starts with a dunder, rename it"
140
+ if name != "_" and name.startswith("_") and not name.endswith("__") and not is_in_class(node):
141
+ return f"Name '{name}' starts with underscore and is not in a class"
142
+
143
+ return None
144
+
145
+
146
+ def hidden_signature_star_args(node: ast.AST) -> str | None:
147
+ """Reject function definitions that use *args, **kwargs, a bare *, or /.
148
+
149
+ Example:
150
+ def send(*args, **kwargs): ... -> flagged
151
+ def send(a, *, b): ... -> flagged
152
+ def send(a, /, b): ... -> flagged
153
+ def send(a, b): ... -> fine
154
+
155
+ Args:
156
+ node: One piece of the parsed code to look at.
157
+
158
+ Returns:
159
+ A complaint if the definition uses *args, **kwargs, a bare *, or /, otherwise None.
160
+ """
161
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and (
162
+ node.args.vararg or node.args.kwarg or node.args.kwonlyargs or node.args.posonlyargs
163
+ ):
164
+ return "'*args', '**kwargs', '*', and '/' hide the function signature, use explicit parameters"
165
+ return None
166
+
167
+
168
+ def dynamic_star_call(node: ast.AST) -> str | None:
169
+ """Do not spread a variable in a call's arguments with *, e.g. f(*items).
170
+
171
+ When you write f(*items), you can't tell how many arguments f is really getting, and the call breaks
172
+ if the list is the wrong length. Spreading a list or tuple written out right there, like f(*[1, 2, 3]),
173
+ is fine because its length is plain to see. A variable, or a list that spreads something else inside
174
+ it like f(*[1, *items]), is not.
175
+
176
+ We only look at '*', not '**'. Keyword unpacking like f(**opts) is a normal, readable Python idiom
177
+ (passing config through, or super().__init__(**kwargs)), and the one wasteful case, f(**{"a": 1}),
178
+ is already caught by ruff's PIE804. Positional '*' is the riskier one: the wrong length is a crash.
179
+
180
+ Example:
181
+ f(*items) -> flagged: 'items' could be any length
182
+ f(*[1, *items]) -> flagged: the list grows with 'items'
183
+ f(*[1, 2, 3]) -> fine: exactly three arguments always
184
+ f(**kwargs) -> left alone on purpose: keyword unpacking is a normal, readable pattern
185
+
186
+ Args:
187
+ node: One piece of the parsed code to look at.
188
+
189
+ Returns:
190
+ A short message if a * argument is not a plain, fixed-length list or tuple, otherwise None.
191
+ """
192
+ if isinstance(node, ast.Call):
193
+ for arg in node.args:
194
+ # A '*' spread is fine only on a written-out list/tuple whose length you can see.
195
+ # A variable, or a literal that spreads something inside (like [1, *more]), hides it.
196
+ if isinstance(arg, ast.Starred) and not (
197
+ isinstance(arg.value, ast.List | ast.Tuple)
198
+ and not any(isinstance(element, ast.Starred) for element in arg.value.elts)
199
+ ):
200
+ return "Dynamic '*' call hides positional arguments; pass explicit arguments"
201
+ return None
202
+
203
+
204
+ def pointless_class(node: ast.AST) -> str | None:
205
+ """A plain class with no base/decorator/keyword and at most one method. Beyond too-few-public-methods
206
+ R0903 because leaves classes with parents and only attacks bare classes.
207
+
208
+ Args:
209
+ node: The AST node under inspection.
210
+
211
+ Returns:
212
+ A complaint if the node is such a pointless class, else None.
213
+ """
214
+ if isinstance(node, ast.ClassDef) and not (node.bases or node.keywords or node.decorator_list):
215
+ methods = [item for item in node.body if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef)]
216
+ if len(methods) <= 1:
217
+ return f"'{node.name}': no base, decorator, or behavior: use function or Pydantic"
218
+ return None
219
+
220
+
221
+ def lazy_assert(node: ast.AST) -> str | None:
222
+ """No empty checks or lazy conditions but test nothing.
223
+ ast.Constant catches True, False, None, 1, 0, 'pass'
224
+ The others catch literal list/dict/tuple structures like [] or {}
225
+
226
+ Args:
227
+ node: The AST node under inspection.
228
+
229
+ Returns:
230
+ A complaint if the node is a lazy constant/literal assert, else None.
231
+ """
232
+ if isinstance(node, ast.Assert) and isinstance(node.test, (ast.Constant, ast.List, ast.Dict, ast.Tuple)):
233
+ return "Lazy test assertion detected"
234
+ return None
235
+
236
+
237
+ def objects_injected_into_runtime_memory(node: ast.AST) -> str | None:
238
+ """Check ast.Call nodes to find name calls that manipulate global state.
239
+ Python keeps internal memory dictionary of each current variable/function. Do not allow calling globals()
240
+ or locals() to grab/inject variables to runtime (instead of writing e.g. a dict).
241
+
242
+ Args:
243
+ node: The AST node under inspection.
244
+
245
+ Returns:
246
+ A complaint if the node calls globals()/locals(), else None.
247
+ """
248
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in {"globals", "locals"}:
249
+ return "Dynamic injection of memory registry spotted"
250
+ return None
251
+
252
+
253
+ def complex_comprehension(node: ast.AST) -> str | None:
254
+ """Use Type Set math or loops when comprehensions become complex.
255
+
256
+ Args:
257
+ node: The AST node under inspection.
258
+
259
+ Returns:
260
+ A complaint if a multi-generator comprehension also filters, else None.
261
+ """
262
+ if isinstance(node, ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp) and len(node.generators) > 1:
263
+ for generator in node.generators:
264
+ if generator.ifs:
265
+ return "Overly complex comprehension, use a loop or type Set math"
266
+ return None
267
+
268
+
269
+ # To add a style rule: write a dumb one-node function above and register it here under its kind.
270
+ CHECKS: dict[str, Check] = {
271
+ "named_with_underscore_and_not_in_class_or_dunder": named_with_underscore_and_not_in_class_or_dunder,
272
+ "hidden_signature_star_args": hidden_signature_star_args,
273
+ "dynamic_star_call": dynamic_star_call,
274
+ "pointless_class": pointless_class,
275
+ "lazy_assert": lazy_assert,
276
+ "objects_injected_into_runtime_memory": objects_injected_into_runtime_memory,
277
+ "lambda_found": lambda_found,
278
+ "lazy_any_type_hints": lazy_any_type_hints,
279
+ "chaotic_continue_statements": chaotic_continue_statements,
280
+ "complex_comprehension": complex_comprehension,
281
+ }
282
+
283
+
284
+ def preferences_violations(path: str, source: str) -> str:
285
+ """Run every registered check on one Python file in a single AST walk.
286
+
287
+ Args:
288
+ path: File path used to prefix each violation message.
289
+ source: Python source text to parse and walk.
290
+
291
+ Returns:
292
+ Violation messages string
293
+ """
294
+ violations: list[str] = []
295
+ tree = ast.parse(source)
296
+ for parent in ast.walk(tree): # link each node to its parent so checks can inspect nesting
297
+ for child in ast.iter_child_nodes(parent):
298
+ child.__dict__["parent"] = parent # nodes are parent->child, add parent<-child for nested checks
299
+ for node in ast.walk(tree):
300
+ lineno = getattr(node, "lineno", "?")
301
+ for check in CHECKS.values():
302
+ message = check(node)
303
+ if message:
304
+ violations.append(f"{path}:{lineno}: {message}")
305
+
306
+ return "\n".join(violations)