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
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Tests for AST-based structural style checks (preferences.preferences).
|
|
2
|
+
|
|
3
|
+
The preferences API is a registry of single-node `Check` functions (each takes one `ast.AST`
|
|
4
|
+
node and returns a complaint string or None), plus `preferences_violations`, which walks a file
|
|
5
|
+
once and returns a dict grouping every complaint by check kind. These tests exercise that API
|
|
6
|
+
with real code that trips — and real code that must not trip — each check.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import inspect
|
|
13
|
+
import keyword
|
|
14
|
+
import string
|
|
15
|
+
from unittest.mock import Mock
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
from hypothesis import assume, given, strategies
|
|
19
|
+
|
|
20
|
+
preferences = pytest.importorskip("preferences.preferences")
|
|
21
|
+
preferences_violations = preferences.preferences_violations
|
|
22
|
+
CHECKS = preferences.CHECKS
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def complaints(check_name: str, source: str) -> list[str]:
|
|
26
|
+
"""Run one registered single-node check over every node in source, collecting its complaints."""
|
|
27
|
+
check = CHECKS[check_name]
|
|
28
|
+
found: list[str] = []
|
|
29
|
+
for node in ast.walk(ast.parse(source)):
|
|
30
|
+
message = check(node)
|
|
31
|
+
if message is not None:
|
|
32
|
+
found.append(message)
|
|
33
|
+
return found
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------- registry integrity
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_every_check_key_matches_its_function_name() -> None:
|
|
40
|
+
"""Each CHECKS key must equal its check function's __name__, so the registry label never lies about
|
|
41
|
+
(or drifts from) the rule it runs. Guards renames and copy-paste key mistakes for every entry.
|
|
42
|
+
"""
|
|
43
|
+
mismatched = {key: fn.__name__ for key, fn in CHECKS.items() if key != fn.__name__}
|
|
44
|
+
assert mismatched == {}, f"CHECKS keys must match their function names; mismatches: {mismatched}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_every_check_shaped_function_is_registered() -> None:
|
|
48
|
+
"""Every check-shaped function in preferences.py (one param, returns `str | None`) must be in CHECKS.
|
|
49
|
+
Catches a check that is defined but never wired up -- e.g. dropping
|
|
50
|
+
`named_with_underscore_and_not_in_class_or_dunder` from the registry would silently stop enforcing it.
|
|
51
|
+
Helpers like `preferences_violations` (two args) and mutmut's generated clones are excluded.
|
|
52
|
+
"""
|
|
53
|
+
registered = set(CHECKS.values())
|
|
54
|
+
unregistered = [
|
|
55
|
+
name
|
|
56
|
+
for name, fn in inspect.getmembers(preferences, inspect.isfunction)
|
|
57
|
+
if fn.__module__ == preferences.__name__
|
|
58
|
+
and "mutmut" not in name # mutmut clones every check; only the trampoline keeps the name
|
|
59
|
+
and list(inspect.signature(fn).parameters) == ["node"]
|
|
60
|
+
and fn.__annotations__.get("return") == "str | None"
|
|
61
|
+
and fn not in registered
|
|
62
|
+
]
|
|
63
|
+
assert unregistered == [], f"check-shaped functions defined but not registered in CHECKS: {unregistered}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --------------------------------------------------------------------------- single-node checks
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_underscore_names_flagged() -> None:
|
|
70
|
+
"""Private-style underscore names are flagged while the exact discard name is exempt."""
|
|
71
|
+
flagged_source = "def _hidden(_arg):\n _value = 1\n return _value\n"
|
|
72
|
+
found = complaints("named_with_underscore_and_not_in_class_or_dunder", flagged_source)
|
|
73
|
+
assert len(found) == 3
|
|
74
|
+
assert any("'_hidden'" in message for message in found)
|
|
75
|
+
assert any("'_arg'" in message for message in found)
|
|
76
|
+
assert any("'_value'" in message for message in found)
|
|
77
|
+
allowed_source = "_ = 1\nvalue, _ = pair\nfor _ in values:\n pass\n\ndef consume(_):\n return None\n"
|
|
78
|
+
assert complaints("named_with_underscore_and_not_in_class_or_dunder", allowed_source) == []
|
|
79
|
+
assert not preferences_violations("harness/cli.py", "env_bin, _ = infer_env_manager()\n")
|
|
80
|
+
class_source = "class Box:\n def _private(self):\n _value = 1\n return _value\n"
|
|
81
|
+
assert "starts with underscore" not in preferences_violations("m.py", class_source)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_dunder_names_exempt() -> None:
|
|
85
|
+
"""Dunder names like __all__ and __init__ are not flagged."""
|
|
86
|
+
source = "__all__ = []\n\n\nclass Box(dict):\n def __init__(self):\n super().__init__()\n"
|
|
87
|
+
assert complaints("named_with_underscore_and_not_in_class_or_dunder", source) == []
|
|
88
|
+
assert complaints("named_with_underscore_and_not_in_class_or_dunder", "__private = 1\n") == [
|
|
89
|
+
"Name '__private starts with a dunder, rename it"
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_hidden_signature_star_args_flagged() -> None:
|
|
94
|
+
"""A def declaring *args or **kwargs hides its signature and is flagged (strict, no exemption)."""
|
|
95
|
+
assert len(complaints("hidden_signature_star_args", "def f(*args, **kwargs):\n return args\n")) == 1
|
|
96
|
+
assert len(complaints("hidden_signature_star_args", "def g(*args):\n return args\n")) == 1
|
|
97
|
+
assert len(complaints("hidden_signature_star_args", "def h(**kwargs):\n return kwargs\n")) == 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_hidden_signature_flags_even_decorated_and_inner_wrappers() -> None:
|
|
101
|
+
"""No wrapper/decorator exemption: intent is not AST-detectable, so decorated and inner *args/**kwargs
|
|
102
|
+
defs are flagged too. This is a strict, optional house-style rule.
|
|
103
|
+
"""
|
|
104
|
+
assert complaints("hidden_signature_star_args", "@deco\ndef w(*args, **kwargs):\n return 1\n")
|
|
105
|
+
inner = "def deco(fn):\n def wrapper(*args):\n return fn(*args)\n return wrapper\n"
|
|
106
|
+
assert complaints("hidden_signature_star_args", inner)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_explicit_signature_not_flagged() -> None:
|
|
110
|
+
"""A def with explicit parameters (no *args/**kwargs) is not flagged."""
|
|
111
|
+
assert complaints("hidden_signature_star_args", "def f(x, y):\n return x\n") == []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_hidden_signature_flags_async_def() -> None:
|
|
115
|
+
"""An async def with *args is flagged too (AsyncFunctionDef, not just FunctionDef)."""
|
|
116
|
+
assert len(complaints("hidden_signature_star_args", "async def f(*args):\n return args\n")) == 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_bare_star_and_slash_separators_not_allowed() -> None:
|
|
120
|
+
"""The '*' in a def using them with named parameters is unallowed - obfuscates types."""
|
|
121
|
+
message = "'*args', '**kwargs', '*', and '/' hide the function signature, use explicit parameters"
|
|
122
|
+
assert complaints("hidden_signature_star_args", "def f(a, *, b):\n return b\n") == [message] # kw-only
|
|
123
|
+
assert complaints("hidden_signature_star_args", "def f(a, b, /):\n return a\n") == [message] # pos-only
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_dynamic_star_call_flagged() -> None:
|
|
127
|
+
"""Splatting a non-literal sequence (a name, or a literal containing a '*') into a call is flagged."""
|
|
128
|
+
assert complaints("dynamic_star_call", "f(*my_list)\n") == [
|
|
129
|
+
"Dynamic '*' call hides positional arguments; pass explicit arguments"
|
|
130
|
+
]
|
|
131
|
+
assert len(complaints("dynamic_star_call", "f(*[1, *items])\n")) == 1
|
|
132
|
+
assert len(complaints("dynamic_star_call", "f(*(1, *items))\n")) == 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_literal_star_call_and_double_star_not_flagged() -> None:
|
|
136
|
+
"""A fixed-shape list/tuple literal splat is fine, and '**' keyword unpacking in a call is allowed."""
|
|
137
|
+
assert complaints("dynamic_star_call", "f(*[1, 2, 3])\n") == []
|
|
138
|
+
assert complaints("dynamic_star_call", "f(*(1, 2))\n") == []
|
|
139
|
+
assert complaints("dynamic_star_call", "f(**kwargs)\n") == []
|
|
140
|
+
assert complaints("dynamic_star_call", "f(a, b)\n") == []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_dynamic_star_flagged_alongside_normal_args_and_on_methods() -> None:
|
|
144
|
+
"""The '*' splat is judged on its own: a normal argument beside it does not excuse it, and method
|
|
145
|
+
calls (obj.m(*x)) are calls too. Two splats in one call still report (the first one hit).
|
|
146
|
+
"""
|
|
147
|
+
assert len(complaints("dynamic_star_call", "f(a, *rest)\n")) == 1 # normal arg + dynamic splat
|
|
148
|
+
assert len(complaints("dynamic_star_call", "obj.method(*rest)\n")) == 1 # attribute call
|
|
149
|
+
assert len(complaints("dynamic_star_call", "f(*xs, *ys)\n")) == 1 # returns on the first splat
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_empty_literal_splat_not_flagged() -> None:
|
|
153
|
+
"""An empty list/tuple literal is a fixed (zero) length, so f(*[]) is not flagged."""
|
|
154
|
+
assert complaints("dynamic_star_call", "f(*[])\n") == []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_pointless_class_flagged() -> None:
|
|
158
|
+
"""A class with no base, decorator, and one method is flagged."""
|
|
159
|
+
found = complaints("pointless_class", "class Holder:\n def get(self):\n return 1\n")
|
|
160
|
+
assert len(found) == 1
|
|
161
|
+
assert "'Holder'" in found[0]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_useful_classes_pass() -> None:
|
|
165
|
+
"""Dataclasses, subclasses, keyword-based classes, and stateful classes pass."""
|
|
166
|
+
source = (
|
|
167
|
+
"from dataclasses import dataclass\n\n\n"
|
|
168
|
+
"@dataclass\n"
|
|
169
|
+
"class Point:\n x: int\n\n\n"
|
|
170
|
+
"class CustomError(Exception):\n pass\n\n\n"
|
|
171
|
+
"class Meta(metaclass=type):\n pass\n\n\n"
|
|
172
|
+
"class Machine:\n"
|
|
173
|
+
" def start(self):\n return 1\n\n"
|
|
174
|
+
" def stop(self):\n return 0\n"
|
|
175
|
+
)
|
|
176
|
+
assert complaints("pointless_class", source) == []
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_pointless_class_exempt_by_any_single_signal() -> None:
|
|
180
|
+
"""Any one of a base, a decorator, or a class keyword exempts an otherwise-bare class -- the rule
|
|
181
|
+
only fires when all three are absent.
|
|
182
|
+
"""
|
|
183
|
+
assert complaints("pointless_class", "class C(Base):\n x = 1\n") == [] # base only
|
|
184
|
+
assert complaints("pointless_class", "@deco\nclass C:\n x = 1\n") == [] # decorator only
|
|
185
|
+
assert complaints("pointless_class", "class C(metaclass=M):\n x = 1\n") == [] # keyword only
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_pointless_class_with_two_methods_not_flagged() -> None:
|
|
189
|
+
"""A bare class earns its keep once it has more than one method (real behavior)."""
|
|
190
|
+
source = "class C:\n def a(self):\n return 1\n def b(self):\n return 2\n"
|
|
191
|
+
assert complaints("pointless_class", source) == []
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_bare_class_with_zero_methods_flagged() -> None:
|
|
195
|
+
"""A bare class with only data and no methods is still pointless (use a function or Pydantic)."""
|
|
196
|
+
assert len(complaints("pointless_class", "class C:\n x = 1\n")) == 1
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_lambda_flagged() -> None:
|
|
200
|
+
"""Every lambda is flagged, not only the E731 name-assignment case ruff catches."""
|
|
201
|
+
assert len(complaints("lambda_found", "sorted(xs, key=lambda item: item.rank)\n")) == 1
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_lazy_any_type_hint_flagged() -> None:
|
|
205
|
+
"""An argument annotated Any (bare or typing.Any) is flagged."""
|
|
206
|
+
assert len(complaints("lazy_any_type_hints", "def f(x: Any):\n return x\n")) == 1
|
|
207
|
+
assert len(complaints("lazy_any_type_hints", "def g(x: typing.Any):\n return x\n")) == 1
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_continue_in_while_loop_flagged() -> None:
|
|
211
|
+
"""A continue inside a while loop is flagged (infinite-freeze risk)."""
|
|
212
|
+
assert complaints("chaotic_continue_statements", "while True:\n if x:\n continue\n") == [
|
|
213
|
+
"'continue' inside a while loop banned to prevent infinite freezes"
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_continue_nested_in_stacked_ifs_flagged() -> None:
|
|
218
|
+
"""A continue nested under two if-statements is flagged on its own line (line 4 here), proving the
|
|
219
|
+
parent links let the check see the grandparent If and that the reported line number is real.
|
|
220
|
+
"""
|
|
221
|
+
source = "for i in items:\n if a:\n if b:\n continue\n"
|
|
222
|
+
violations = preferences_violations("m.py", source)
|
|
223
|
+
assert "m.py:4: Overly-nested 'continue'" in violations
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_continue_in_elif_is_flagged_as_nested() -> None:
|
|
227
|
+
"""KNOWN BEHAVIOR (arguably a false positive): a continue in an `elif` trips the nested-if check,
|
|
228
|
+
because `elif` desugars to an If in the outer If's orelse, so parent.parent is an If. This test
|
|
229
|
+
pins the current source behavior so a change to it is a deliberate, visible decision.
|
|
230
|
+
"""
|
|
231
|
+
source = "for i in x:\n if a:\n pass\n elif b:\n continue\n"
|
|
232
|
+
assert "Overly-nested" in preferences_violations("m.py", source)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_plain_continue_in_for_loop_not_flagged() -> None:
|
|
236
|
+
"""A continue in a simple for loop (not a while loop, not nested in ifs) is allowed."""
|
|
237
|
+
assert complaints("chaotic_continue_statements", "for i in items:\n continue\n") == []
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_continue_deeply_nested_in_loops_flagged() -> None:
|
|
241
|
+
"""A continue three for-loops deep trips the nested rule: its parent and grandparent are both For."""
|
|
242
|
+
source = "for i in x:\n for j in y:\n for k in z:\n continue\n"
|
|
243
|
+
assert "Overly-nested" in preferences_violations("m.py", source)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_continue_in_two_nested_loops_flagged() -> None:
|
|
247
|
+
"""Two nested loops are the minimum prohibited continue depth."""
|
|
248
|
+
source = "for outer in xs:\n for inner in ys:\n continue\n"
|
|
249
|
+
assert preferences_violations("m.py", source) == (
|
|
250
|
+
"m.py:3: Overly-nested 'continue' detected inside multiple if/for blocks"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_while_continue_reports_the_while_message_not_the_nested_one() -> None:
|
|
255
|
+
"""When a continue sits in an if inside a while, the while-loop ban is reported (that branch runs
|
|
256
|
+
first and returns), not the nested-if message.
|
|
257
|
+
"""
|
|
258
|
+
violations = preferences_violations("m.py", "while cond:\n if a:\n continue\n")
|
|
259
|
+
assert "while loop banned" in violations
|
|
260
|
+
assert "Overly-nested" not in violations
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_lazy_assert_flagged() -> None:
|
|
264
|
+
"""An assert on a constant or literal container tests nothing and is flagged."""
|
|
265
|
+
assert complaints("lazy_assert", "assert True\n") == ["Lazy test assertion detected"] # constant
|
|
266
|
+
assert complaints("lazy_assert", "assert []\n") # literal container
|
|
267
|
+
assert complaints("lazy_assert", "assert real_condition\n") == [] # a real check passes
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_globals_and_locals_injection_flagged() -> None:
|
|
271
|
+
"""Calling globals()/locals() to poke the runtime registry is flagged; a plain call is not."""
|
|
272
|
+
assert complaints("objects_injected_into_runtime_memory", "globals()['x'] = 1\n")
|
|
273
|
+
assert complaints("objects_injected_into_runtime_memory", "locals()\n") == [
|
|
274
|
+
"Dynamic injection of memory registry spotted"
|
|
275
|
+
]
|
|
276
|
+
assert complaints("objects_injected_into_runtime_memory", "sorted(items)\n") == []
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_complex_multi_generator_comprehension_flagged() -> None:
|
|
280
|
+
"""A comprehension with multiple generators AND a filter is flagged; a simple one is not."""
|
|
281
|
+
assert complaints("complex_comprehension", "[a for row in grid for a in row if a]\n") == [
|
|
282
|
+
"Overly complex comprehension, use a loop or type Set math"
|
|
283
|
+
]
|
|
284
|
+
assert complaints("complex_comprehension", "[a for a in row if a]\n") == []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# --------------------------------------------------------------------- preferences_violations (the walk)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_preferences_violations_returns_grouped_str() -> None:
|
|
291
|
+
"""The walk returns a string; a clean file produces the empty string (no groups)."""
|
|
292
|
+
violations = preferences_violations("m.py", "VALUE = 1\n")
|
|
293
|
+
assert isinstance(violations, str)
|
|
294
|
+
assert not violations
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_locationless_node_violation_reports_unknown_line(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
298
|
+
"""A `preferences` check reports '?' for missing lineno when its AST node has no parser-provided line.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
monkeypatch: Sets/restores the AST parser for the test.
|
|
302
|
+
"""
|
|
303
|
+
source = "value = lambda: None\n"
|
|
304
|
+
tree = ast.parse(source)
|
|
305
|
+
lambda_node = next(node for node in ast.walk(tree) if isinstance(node, ast.Lambda))
|
|
306
|
+
del lambda_node.lineno
|
|
307
|
+
monkeypatch.setattr(ast, "parse", Mock(return_value=tree))
|
|
308
|
+
|
|
309
|
+
expected = "m.py:?: Lambda found hurting readability and adding complexity, prefer map() or filter()"
|
|
310
|
+
assert preferences_violations("m.py", source) == expected
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_clean_file_has_no_complaints() -> None:
|
|
314
|
+
"""A compliant module produces no complaints (the empty string)."""
|
|
315
|
+
source = (
|
|
316
|
+
'"""Module."""\n\n'
|
|
317
|
+
"VALUE = 1\n\n\n"
|
|
318
|
+
"def double(number: int) -> int:\n"
|
|
319
|
+
' """Double the number."""\n'
|
|
320
|
+
" return number * 2\n"
|
|
321
|
+
)
|
|
322
|
+
assert not preferences_violations("m.py", source)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_a_clean_file_reports_only_the_kind_that_fired() -> None:
|
|
326
|
+
"""A file that trips exactly one check reports that check's message and no other: only lambda_found
|
|
327
|
+
fires here, so the underscore/star messages must be absent.
|
|
328
|
+
"""
|
|
329
|
+
violations = preferences_violations("m.py", "value = lambda a: a\n") # only lambda_found fires
|
|
330
|
+
assert violations == ("m.py:1: Lambda found hurting readability and adding complexity, prefer map() or filter()")
|
|
331
|
+
assert "starts with underscore" not in violations
|
|
332
|
+
assert "Star unpacking" not in violations
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_dirty_file_lists_each_violation_on_its_own_line() -> None:
|
|
336
|
+
"""Two checks trip on line 1; each is rendered as one `m.py:1: <message>` line with the real line
|
|
337
|
+
number, and the two are newline-joined into a single string — pinning the exact flat format.
|
|
338
|
+
"""
|
|
339
|
+
violations = preferences_violations("m.py", "_x = lambda a: a\n")
|
|
340
|
+
assert violations == (
|
|
341
|
+
"m.py:1: Name '_x' starts with underscore and is not in a class\n"
|
|
342
|
+
"m.py:1: Lambda found hurting readability and adding complexity, prefer map() or filter()"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_line_number_in_message_is_accurate() -> None:
|
|
347
|
+
"""The reported line number is the violation's real line, not always 1: a lambda on line 3
|
|
348
|
+
reports :3:, proving the walk carries each node's lineno into its message.
|
|
349
|
+
"""
|
|
350
|
+
source = "value = 1\n\n\nother = lambda a: a\n" # lambda is on line 4
|
|
351
|
+
assert "m.py:4: Lambda found" in preferences_violations("m.py", source)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_repeated_violations_of_one_kind_each_get_a_line() -> None:
|
|
355
|
+
"""Multiple hits of the same check each produce their own line (not collapsed): two lambdas on two
|
|
356
|
+
lines yield two `Lambda found` messages on two separate lines.
|
|
357
|
+
"""
|
|
358
|
+
violations = preferences_violations("m.py", "a = lambda x: x\nb = lambda y: y\n")
|
|
359
|
+
assert violations.count("Lambda found") == 2
|
|
360
|
+
assert violations.count("\n") == 1 # two messages, one joining newline
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_lambda_in_a_test_file_is_flagged() -> None:
|
|
364
|
+
"""A test file gets no path-based exemption: a lambda in test_gate.py is flagged like any other file.
|
|
365
|
+
Regression guard — the gate unstages harness test files, so preferences never scanned them there and
|
|
366
|
+
lambdas slipped in; this proves preferences_violations itself flags them regardless of the path.
|
|
367
|
+
"""
|
|
368
|
+
source = "def test_x() -> None:\n fake(lambda command: 0)\n"
|
|
369
|
+
violations = preferences_violations("harness/tests/test_gate.py", source)
|
|
370
|
+
assert "harness/tests/test_gate.py:2: Lambda found" in violations
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_syntax_error_raises() -> None:
|
|
374
|
+
"""Unparseable source raises SyntaxError; preferences does not swallow it."""
|
|
375
|
+
with pytest.raises(SyntaxError):
|
|
376
|
+
preferences_violations("m.py", "def broken(:\n")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# --------------------------------------------------------------------- generated behavior
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
IDENTIFIER_START = string.ascii_letters + "_"
|
|
383
|
+
IDENTIFIER_REST = IDENTIFIER_START + string.digits
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@strategies.composite
|
|
387
|
+
def identifiers(draw: strategies.DrawFn) -> str:
|
|
388
|
+
"""Draw a valid ASCII Python identifier that is not a keyword."""
|
|
389
|
+
first = draw(strategies.sampled_from(IDENTIFIER_START))
|
|
390
|
+
rest = draw(strategies.text(alphabet=IDENTIFIER_REST, max_size=24))
|
|
391
|
+
name = first + rest
|
|
392
|
+
assume(not keyword.iskeyword(name))
|
|
393
|
+
return name
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def flags(source: str, needle: str) -> bool:
|
|
397
|
+
"""Return whether the preferences output contains the expected text."""
|
|
398
|
+
checker = preferences_violations
|
|
399
|
+
return needle in checker("m.py", source) if checker else False
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@given(name=identifiers())
|
|
403
|
+
def test_underscore_lead_flagged_iff_leading_underscore_not_dunder(name: str) -> None:
|
|
404
|
+
"""Assignment targets are flagged exactly when they use a non-dunder leading underscore."""
|
|
405
|
+
expected = name != "_" and name.startswith("_") and not name.startswith("__") and not name.endswith("__")
|
|
406
|
+
assert flags(f"{name} = 1\n", "starts with underscore") is expected
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@given(name=identifiers())
|
|
410
|
+
def test_underscore_rule_holds_for_function_and_argument_names(name: str) -> None:
|
|
411
|
+
"""The underscore rule applies equally to function and argument names."""
|
|
412
|
+
assume(not name.endswith("__"))
|
|
413
|
+
expected = name != "_" and name.startswith("_") and not name.startswith("__")
|
|
414
|
+
assert flags(f"def {name}():\n return 1\n", "starts with underscore") is expected
|
|
415
|
+
assert flags(f"def f({name}):\n return {name}\n", "starts with underscore") is expected
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@strategies.composite
|
|
419
|
+
def class_source(draw: strategies.DrawFn) -> tuple[str, bool]:
|
|
420
|
+
"""Draw a class and whether the pointless-class rule should flag it."""
|
|
421
|
+
has_base = draw(strategies.booleans())
|
|
422
|
+
has_decorator = draw(strategies.booleans())
|
|
423
|
+
has_keyword = draw(strategies.booleans())
|
|
424
|
+
method_count = draw(strategies.integers(min_value=0, max_value=3))
|
|
425
|
+
|
|
426
|
+
decorator = "@deco\n" if has_decorator else ""
|
|
427
|
+
header_bits = (["Base"] if has_base else []) + (["metaclass=type"] if has_keyword else [])
|
|
428
|
+
header = f"({', '.join(header_bits)})" if header_bits else ""
|
|
429
|
+
body = "".join(f" def m{i}(self):\n return {i}\n" for i in range(method_count)) or " x = 1\n"
|
|
430
|
+
source = f"{decorator}class C{header}:\n{body}"
|
|
431
|
+
should_flag = not has_base and not has_decorator and not has_keyword and method_count <= 1
|
|
432
|
+
return source, should_flag
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@given(case=class_source())
|
|
436
|
+
def test_pointless_class_flagged_iff_plain_and_at_most_one_method(case: tuple[str, bool]) -> None:
|
|
437
|
+
"""Only plain classes with at most one method are pointless."""
|
|
438
|
+
source, should_flag = case
|
|
439
|
+
assert flags(source, "no base, decorator, or behavior") is should_flag
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@strategies.composite
|
|
443
|
+
def comprehension_source(draw: strategies.DrawFn) -> tuple[str, bool]:
|
|
444
|
+
"""Draw a comprehension and whether its generators make it too complex."""
|
|
445
|
+
generator_count = draw(strategies.integers(min_value=1, max_value=3))
|
|
446
|
+
if_on = draw(strategies.integers(min_value=-1, max_value=generator_count - 1))
|
|
447
|
+
|
|
448
|
+
clauses: list[str] = []
|
|
449
|
+
for index in range(generator_count):
|
|
450
|
+
clause = f"for v{index} in xs{index}"
|
|
451
|
+
if index == if_on:
|
|
452
|
+
clause += f" if v{index}"
|
|
453
|
+
clauses.append(clause)
|
|
454
|
+
source = f"[v0 {' '.join(clauses)}]\n"
|
|
455
|
+
return source, generator_count > 1 and if_on != -1
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@given(case=comprehension_source())
|
|
459
|
+
def test_complex_comprehension_flagged_iff_multi_generator_with_filter(case: tuple[str, bool]) -> None:
|
|
460
|
+
"""Comprehensions are complex only with multiple generators and a filter."""
|
|
461
|
+
source, should_flag = case
|
|
462
|
+
assert flags(source, "Overly complex comprehension") is should_flag
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@strategies.composite
|
|
466
|
+
def nested_continue_source(draw: strategies.DrawFn) -> str:
|
|
467
|
+
"""Draw a continue nested beneath an outer loop and two to four more blocks."""
|
|
468
|
+
depth = draw(strategies.integers(min_value=2, max_value=4))
|
|
469
|
+
blocks = draw(strategies.lists(strategies.sampled_from(["if cond", "for i in xs"]), min_size=depth, max_size=depth))
|
|
470
|
+
|
|
471
|
+
lines = ["for outer in items:"]
|
|
472
|
+
indent = " "
|
|
473
|
+
for block in blocks:
|
|
474
|
+
lines.append(f"{indent}{block}:")
|
|
475
|
+
indent += " "
|
|
476
|
+
lines.append(f"{indent}continue")
|
|
477
|
+
return "\n".join(lines) + "\n"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@given(source=nested_continue_source())
|
|
481
|
+
def test_continue_nested_under_stacked_blocks_is_flagged(source: str) -> None:
|
|
482
|
+
"""Mixed nested if/for blocks always trigger the continue nesting rule."""
|
|
483
|
+
assert flags(source, "Overly-nested 'continue'")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_single_if_guard_in_a_loop_is_not_flagged() -> None:
|
|
487
|
+
"""A single if guard directly inside a loop remains readable."""
|
|
488
|
+
assert not flags("for i in items:\n if skip:\n continue\n", "Overly-nested 'continue'")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def test_shallow_continue_in_single_loop_is_not_flagged() -> None:
|
|
492
|
+
"""A continue directly inside one for loop is allowed."""
|
|
493
|
+
assert not flags("for x in items:\n continue\n", "Overly-nested 'continue'")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def test_continue_in_while_loop_is_flagged() -> None:
|
|
497
|
+
"""A continue inside a while loop is banned as a freeze risk."""
|
|
498
|
+
assert flags("while cond:\n continue\n", "while loop banned")
|