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,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")