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
mutation/check_mutmut.py
ADDED
|
@@ -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
|
+
[](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)
|
preferences/__init__.py
ADDED
|
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)
|