sarj-python-lint 0.15.1__tar.gz → 0.16.0__tar.gz

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.
Files changed (54) hide show
  1. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +2 -6
  4. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_comment_cruft.py +53 -6
  5. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +1 -2
  6. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +1 -3
  7. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_offset_pagination.py +1 -3
  8. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +1 -2
  9. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +17 -7
  10. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +6 -17
  11. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +36 -1
  12. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/zero_assertion_test.py +42 -5
  13. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/.gitignore +0 -0
  14. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/README.md +0 -0
  15. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/__init__.py +0 -0
  16. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/__main__.py +0 -0
  17. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/_secret_names.py +0 -0
  18. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/_version.py +0 -0
  19. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/py.typed +0 -0
  20. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rule_base.py +0 -0
  21. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  23. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  24. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  25. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  26. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  27. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  28. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  29. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  30. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  31. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  32. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  33. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  34. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  35. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  36. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  37. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  38. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  39. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  40. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  41. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  42. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  43. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  44. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  45. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  46. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  47. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  48. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  49. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  50. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  51. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  52. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  53. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  54. {sarj_python_lint-0.15.1 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.15.1
3
+ Version: 0.16.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
6
  Project-URL: Repository, https://github.com/sarj-ai/standards
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.15.1"
3
+ version = "0.16.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -82,9 +82,7 @@ _PRIMITIVES = frozenset({"str", "int", "float", "bool"})
82
82
 
83
83
  _EXEMPT_DECORATORS = frozenset({"override", "overload", "abstractmethod"})
84
84
 
85
- _HTTP_ROUTE_METHODS = frozenset(
86
- {"get", "post", "put", "patch", "delete", "head", "options", "websocket"}
87
- )
85
+ _HTTP_ROUTE_METHODS = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "websocket"})
88
86
 
89
87
  _EXEMPT_NAME_PREFIXES = ("visit_", "test_")
90
88
 
@@ -229,9 +227,7 @@ def _value_referenced_names(tree: ast.AST) -> frozenset[str]:
229
227
  The set of names loaded outside call position.
230
228
 
231
229
  """
232
- call_funcs = {
233
- id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)
234
- }
230
+ call_funcs = {id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)}
235
231
  return frozenset(
236
232
  node.id
237
233
  for node in ast.walk(tree)
@@ -53,6 +53,7 @@ from sarj_python_lint.rules._paths import is_generated_source
53
53
 
54
54
 
55
55
  if TYPE_CHECKING:
56
+ from collections.abc import Sequence
56
57
  from pathlib import Path
57
58
 
58
59
 
@@ -144,6 +145,55 @@ def _is_word_char(ch: str) -> bool:
144
145
  return ch.isalnum() or ch == "_"
145
146
 
146
147
 
148
+ _CODING_COOKIE_RE = re.compile(r"coding[:=]\s*[-_.a-zA-Z0-9]+")
149
+
150
+ # Only `>>>` arms a doctest block. A bare `...` cannot: an ASCII banner of
151
+ # dots starts with it, and exempting on that alone silently disabled the
152
+ # banner check.
153
+ _DOCTEST_PROMPT = ">>>"
154
+
155
+
156
+ def _is_coding_cookie(body: str) -> bool:
157
+ """Report whether the comment is a PEP 263 source-encoding declaration.
158
+
159
+ `# encoding=utf-8` / `# -*- coding: utf-8 -*-` are read by the interpreter,
160
+ not commentary, and `rich` carries them at the top of test modules.
161
+
162
+ Returns:
163
+ True when the body declares a source encoding.
164
+
165
+ """
166
+ return bool(_CODING_COOKIE_RE.search(body))
167
+
168
+
169
+ def _doctest_block_lines(standalone: Sequence[tuple[int, int, str]]) -> set[int]:
170
+ """Collect every line of a contiguous comment run that contains a doctest prompt.
171
+
172
+ A commented doctest is documentation, not dead code, but its *expected
173
+ output* lines look exactly like commented-out code — `# URL('https://...')`
174
+ in httpx's `_client.py` is the canonical shape. Exempting only the `>>>`
175
+ lines would still flag the output beneath them, so the whole run goes.
176
+
177
+ Returns:
178
+ The line numbers belonging to a doctest comment block.
179
+
180
+ """
181
+ exempt: set[int] = set()
182
+ block: list[tuple[int, str]] = []
183
+
184
+ def flush() -> None:
185
+ if any(body.startswith(_DOCTEST_PROMPT) for _, body in block):
186
+ exempt.update(line for line, _ in block)
187
+ block.clear()
188
+
189
+ for line, _, body in sorted(standalone):
190
+ if block and line != block[-1][0] + 1:
191
+ flush()
192
+ block.append((line, body))
193
+ flush()
194
+ return exempt
195
+
196
+
147
197
  def _is_directive(body: str) -> bool:
148
198
  low = body.lower()
149
199
  for prefix in _DIRECTIVE_PREFIXES:
@@ -186,11 +236,7 @@ def _is_heading_underline(body: str, prev_body: str | None) -> bool:
186
236
  return False
187
237
  if prev_body is None:
188
238
  return False
189
- return (
190
- any(_is_word_char(ch) for ch in prev_body)
191
- and not _is_banner(prev_body)
192
- and not _looks_like_code(prev_body)
193
- )
239
+ return any(_is_word_char(ch) for ch in prev_body) and not _is_banner(prev_body) and not _looks_like_code(prev_body)
194
240
 
195
241
 
196
242
  def _is_banner(body: str) -> bool:
@@ -288,8 +334,9 @@ class NoCommentCruft(Rule):
288
334
  return []
289
335
  diags: dict[int, Diagnostic] = {}
290
336
  by_line = {line: body for line, _, body in standalone}
337
+ doctest_lines = _doctest_block_lines(standalone)
291
338
  for line, col, body in standalone:
292
- if _is_directive(body):
339
+ if _is_directive(body) or _is_coding_cookie(body) or line in doctest_lines:
293
340
  continue
294
341
  prev_body = by_line.get(line - 1)
295
342
  msg = self._classify(body, prev_body)
@@ -36,8 +36,7 @@ class NoCorsWildcardWithCredentials(Rule):
36
36
  id: str = "no-cors-wildcard-with-credentials"
37
37
  code: str = "SARJ028"
38
38
  description: str = (
39
- 'CORS `allow_credentials=True` with `"*"` in `allow_origins` lets any '
40
- "site read authenticated responses."
39
+ 'CORS `allow_credentials=True` with `"*"` in `allow_origins` lets any site read authenticated responses.'
41
40
  )
42
41
 
43
42
  @override
@@ -126,9 +126,7 @@ class NoIsinstanceUnionChain(Rule):
126
126
  tree = parse_or_none(path, source)
127
127
  if tree is None:
128
128
  return []
129
- local_classes = frozenset(
130
- node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
131
- )
129
+ local_classes = frozenset(node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef))
132
130
  elif_nodes: set[int] = set()
133
131
  diags: list[Diagnostic] = []
134
132
  for node in ast.walk(tree):
@@ -44,9 +44,7 @@ if TYPE_CHECKING:
44
44
  # `OFFSET` followed by a value/param token — the real pagination construct. This
45
45
  # excludes the English word ("no base offset"), `'offset'` dict keys, and BigQuery
46
46
  # `UNNEST(...) WITH OFFSET AS col` (array indexing, no value token after OFFSET).
47
- _OFFSET_PAGINATION = re.compile(
48
- r"\bOFFSET\s+(?:%s|%\(\w+\)s|:\w+|@\w+|\$\d+|\d+)", re.IGNORECASE
49
- )
47
+ _OFFSET_PAGINATION = re.compile(r"\bOFFSET\s+(?:%s|%\(\w+\)s|:\w+|@\w+|\$\d+|\d+)", re.IGNORECASE)
50
48
 
51
49
 
52
50
  class NoOffsetPagination(Rule):
@@ -123,8 +123,7 @@ class NoSentinelReturnOnExcept(Rule):
123
123
  col=handler.col_offset + 1,
124
124
  code=self.code,
125
125
  message=(
126
- "Bare `except: pass` silently swallows the exception — "
127
- "re-raise, log it, or handle it explicitly."
126
+ "Bare `except: pass` silently swallows the exception — re-raise, log it, or handle it explicitly."
128
127
  ),
129
128
  )
130
129
  return None
@@ -15,10 +15,18 @@ Fires when ALL of these hold:
15
15
  bare `parametrize` imported from pytest),
16
16
  * the decorator does **not** pass `ids=` — one `ids=` covers the whole table, so
17
17
  its presence exempts every case,
18
- * and a case value is opaque to pytest's id generation: a `dict`, `set`,
19
- comprehension, or a constructor/factory `Call`. For a multi-argument case the
20
- check descends into the tuple, since one opaque column is enough to poison the
21
- generated id,
18
+ * and **every** column of the case is opaque to pytest's id generation: a
19
+ `dict`, `set`, comprehension, or a constructor/factory `Call`.
20
+
21
+ Requiring *every* column, not any, is the difference between a useful rule and
22
+ a noisy one. pytest builds an id by joining the per-argument ids with `-`, so a
23
+ single nameable column still distinguishes the case: `("0.0", Decimal("0.0"))`
24
+ reports as `0.0-value1`, which a reader can find. A third-party sweep over
25
+ pydantic, flask, httpx, requests and rich flagged 372 tables under the
26
+ any-column reading; the overwhelming majority paired an opaque value with a
27
+ perfectly nameable string or number — `Decimal('0.0')`, `datetime(2012, 4, 9)`,
28
+ `UUID(...)`, `timedelta(hours=10)`, `Err('...')` — and were false positives.
29
+ Only a case whose columns are *all* opaque degenerates to `case0`, `case1`,
22
30
  * and that specific case is not individually named by `pytest.param(..., id=...)`.
23
31
 
24
32
  The `pytest.param` unwrap is the load-bearing false-positive guard. A first pass
@@ -142,12 +150,14 @@ def _is_unnameable(case: ast.expr) -> bool:
142
150
  # An explicitly named case is fine however opaque its payload is.
143
151
  if _has_keyword(case, "id"):
144
152
  return False
145
- return any(_is_opaque_value(arg) for arg in case.args)
153
+ return bool(case.args) and all(_is_opaque_value(arg) for arg in case.args)
146
154
  return _is_opaque_value(case)
147
155
 
148
156
 
149
157
  def _is_opaque_value(value: ast.expr) -> bool:
150
- # A multi-column case is a tuple; one opaque column poisons the whole id.
158
+ # A multi-column case is a tuple. pytest joins the per-column ids with `-`,
159
+ # so one nameable column is enough to tell the case apart — only an
160
+ # all-opaque case degenerates to `case0`.
151
161
  if isinstance(value, ast.Tuple):
152
- return any(_is_opaque_value(elt) for elt in value.elts)
162
+ return bool(value.elts) and all(_is_opaque_value(elt) for elt in value.elts)
153
163
  return isinstance(value, _OPAQUE_NODES)
@@ -244,9 +244,7 @@ def _module_scope_classdefs(tree: ast.Module) -> list[ast.ClassDef]:
244
244
  return found
245
245
 
246
246
 
247
- def _enum_member_names(
248
- classdefs: list[ast.ClassDef], local_enums: frozenset[str]
249
- ) -> dict[str, frozenset[str]]:
247
+ def _enum_member_names(classdefs: list[ast.ClassDef], local_enums: frozenset[str]) -> dict[str, frozenset[str]]:
250
248
  """Map each module-scope enum's name to the member names it declares.
251
249
 
252
250
  A member is a plain `NAME = <value>` assignment in the class body. Methods,
@@ -296,16 +294,13 @@ def _grown_dict_names(tree: ast.Module) -> frozenset[str]:
296
294
  grown: set[str] = set()
297
295
  for node in ast.walk(tree):
298
296
  match node:
299
- case ast.Call(
300
- func=ast.Attribute(value=ast.Name(id=name), attr=attr)
301
- ) if attr in _DICT_GROWING_METHODS:
297
+ case ast.Call(func=ast.Attribute(value=ast.Name(id=name), attr=attr)) if attr in _DICT_GROWING_METHODS:
302
298
  grown.add(name)
303
299
  case ast.Assign(targets=targets):
304
300
  grown.update(
305
301
  subscript.value.id
306
302
  for subscript in targets
307
- if isinstance(subscript, ast.Subscript)
308
- and isinstance(subscript.value, ast.Name)
303
+ if isinstance(subscript, ast.Subscript) and isinstance(subscript.value, ast.Name)
309
304
  )
310
305
  case _:
311
306
  pass
@@ -347,9 +342,7 @@ def _incomplete_dispatch_map(
347
342
  if owner is None or owner not in enum_members:
348
343
  return None
349
344
  declared = enum_members[owner]
350
- covered = {
351
- key.attr for key in mapping.keys if isinstance(key, ast.Attribute) and key.attr in declared
352
- }
345
+ covered = {key.attr for key in mapping.keys if isinstance(key, ast.Attribute) and key.attr in declared}
353
346
  if len(covered) != len(mapping.keys) or not covered < declared:
354
347
  return None
355
348
  missing = ", ".join(f"{owner}.{name}" for name in sorted(declared - covered))
@@ -483,9 +476,7 @@ def _is_silent_body(body: list[ast.stmt]) -> bool:
483
476
  return False
484
477
 
485
478
 
486
- def _all_one_owner_member_arms(
487
- cases: list[ast.match_case], member_owners: frozenset[str]
488
- ) -> bool:
479
+ def _all_one_owner_member_arms(cases: list[ast.match_case], member_owners: frozenset[str]) -> bool:
489
480
  """Report whether every arm matches enum-member values of one owner class.
490
481
 
491
482
  The owner must be a name that can actually bind a class here: defined by a
@@ -535,9 +526,7 @@ def _is_local_class_pattern(pattern: ast.pattern, local_classes: frozenset[str])
535
526
  return False
536
527
 
537
528
 
538
- def _silent_enum_chain(
539
- head: ast.If, local_enums: frozenset[str], consumed_elifs: set[int]
540
- ) -> str | None:
529
+ def _silent_enum_chain(head: ast.If, local_enums: frozenset[str], consumed_elifs: set[int]) -> str | None:
541
530
  """Parse `head` as an ==/in chain over one local enum with a silent `else`.
542
531
 
543
532
  Each nested `elif` that genuinely continues the chain (same target, same
@@ -32,6 +32,14 @@ reason mentioning intermittence.
32
32
 
33
33
  Deliberately NOT flagged:
34
34
 
35
+ * **property-based and fuzz tests.** A `@given(...)` (hypothesis) or
36
+ `<schema>.parametrize()` (schemathesis) decorator means one test function
37
+ expands into many generated cases, and a documented bug is typically tripped
38
+ by only a subset of them — the rest legitimately XPASS. `strict=True` there
39
+ turns every passing generated input into a failure, which is why these suites
40
+ set `strict=False` deliberately. Found against bulbul's
41
+ `test_calls_fuzz_known_bugs`, where the unroutable-id shapes trip the bug and
42
+ the other generated ids do not,
35
43
  * `xfail` with no `reason=`, or a reason describing an environment gate rather
36
44
  than a defect ("no GPU on CI") — those are not bug pins,
37
45
  * `pytest.xfail(...)` called imperatively inside a body — that aborts the test
@@ -65,6 +73,14 @@ _NONDETERMINISM_RE = re.compile(r"intermittent|flak|sometimes|non-?deterministic
65
73
  # Sibling markers that declare a nondeterministic dependency.
66
74
  _NONDETERMINISTIC_MARKERS = frozenset({"real_llm", "flaky", "network", "integration"})
67
75
 
76
+ # Hypothesis' entry point. One `@given` expands into many generated inputs.
77
+ _PROPERTY_DECORATORS = frozenset({"given"})
78
+
79
+ # schemathesis binds `.parametrize()` on a schema object. `pytest.mark.parametrize`
80
+ # is a fixed table and is NOT this — it is excluded by checking the receiver.
81
+ _PARAMETRIZE_ATTR = "parametrize"
82
+ _PYTEST_MARK = "mark"
83
+
68
84
  _FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
69
85
 
70
86
 
@@ -119,7 +135,26 @@ def _rotting_bug_pins(tree: ast.Module) -> list[ast.Call]:
119
135
 
120
136
 
121
137
  def _has_nondeterministic_marker(decorators: list[ast.expr]) -> bool:
122
- return any(_marker_name(dec) in _NONDETERMINISTIC_MARKERS for dec in decorators)
138
+ return any(_marker_name(dec) in _NONDETERMINISTIC_MARKERS or _is_property_based(dec) for dec in decorators)
139
+
140
+
141
+ def _is_property_based(dec: ast.expr) -> bool:
142
+ """Report whether `dec` expands the test into many generated inputs.
143
+
144
+ Returns:
145
+ True for a hypothesis `@given(...)` or a schemathesis
146
+ `<schema>.parametrize()`, both of which make a partial XPASS normal.
147
+
148
+ """
149
+ target = dec.func if isinstance(dec, ast.Call) else dec
150
+ if isinstance(target, ast.Name):
151
+ return target.id in _PROPERTY_DECORATORS
152
+ if not isinstance(target, ast.Attribute) or target.attr != _PARAMETRIZE_ATTR:
153
+ return False
154
+ # `pytest.mark.parametrize` is a fixed table, not a generator — the receiver
155
+ # is `mark`. A schemathesis schema object is anything else.
156
+ receiver = target.value
157
+ return not (isinstance(receiver, ast.Attribute) and receiver.attr == _PYTEST_MARK)
123
158
 
124
159
 
125
160
  def _marker_name(dec: ast.expr) -> str | None:
@@ -39,7 +39,17 @@ Deliberately NOT flagged:
39
39
 
40
40
  * a test marked `@pytest.mark.skip`/`skipif`/`xfail` — it is not expected to
41
41
  verify anything right now,
42
- * a fixture, helper, or any function not named `test_*`,
42
+ * **a `test_*` function nested inside another function.** pytest only collects
43
+ module-level functions and methods of `Test*` classes, so a nested one is not
44
+ a test at all — it is a callback that happens to be named for what it does.
45
+ Flask route handlers are the canonical case: `def test_index()` registered
46
+ with `@app.route("/", subdomain="test")` inside a test that asserts on the
47
+ response afterwards. A third-party sweep found 36 such hits, every one a false
48
+ positive. Only functions whose parent is the module or a class are considered.
49
+ * **a `@pytest.fixture`**, whatever it is named. `flask/tests/conftest.py`
50
+ defines `def test_apps(monkeypatch)` as a fixture; it sets up `sys.path` and
51
+ yields, and asserting nothing is exactly right for it,
52
+ * a helper, or any function not named `test_*`,
43
53
  * an abstract or stub body (`...`, `pass`, docstring only) — an intentionally
44
54
  empty placeholder is a different problem from a half-written test,
45
55
  * anything under a `scripts/` directory. `digital-bank/banking-ai/chat/scripts/`
@@ -75,6 +85,8 @@ _FLUENT_ATTRS = frozenset({"expect"})
75
85
 
76
86
  _SKIP_MARKERS = frozenset({"skip", "skipif", "xfail"})
77
87
 
88
+ _FIXTURE = "fixture"
89
+
78
90
  _FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
79
91
 
80
92
  # Manual CLI probes live here under test_*.py names but are never collected.
@@ -126,15 +138,40 @@ def _is_uncollected(path: Path) -> bool:
126
138
 
127
139
  def _unverifying_tests(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
128
140
  hits: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
129
- for node in ast.walk(tree):
130
- if not isinstance(node, _FUNC_NODES) or not node.name.startswith("test_"):
131
- continue
132
- if _is_skipped(node) or _is_placeholder(node) or _verifies_something(node):
141
+ for node in _collectible_tests(tree):
142
+ if _is_skipped(node) or _is_fixture(node) or _is_placeholder(node) or _verifies_something(node):
133
143
  continue
134
144
  hits.append(node)
135
145
  return hits
136
146
 
137
147
 
148
+ def _collectible_tests(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
149
+ """Collect the `test_*` functions pytest would actually run.
150
+
151
+ Only module-level functions and methods of a class qualify. A `test_*`
152
+ nested inside another function is a callback, not a test — pytest never
153
+ collects it — so descending into function bodies would invent findings.
154
+
155
+ Returns:
156
+ The test functions in the order they appear.
157
+
158
+ """
159
+ found: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
160
+ containers: list[ast.Module | ast.ClassDef] = [tree]
161
+ while containers:
162
+ for stmt in containers.pop().body:
163
+ if isinstance(stmt, ast.ClassDef):
164
+ containers.append(stmt)
165
+ elif isinstance(stmt, _FUNC_NODES) and stmt.name.startswith("test_"):
166
+ found.append(stmt)
167
+ found.sort(key=lambda n: (n.lineno, n.col_offset))
168
+ return found
169
+
170
+
171
+ def _is_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
172
+ return any(_marker_name(dec) == _FIXTURE for dec in node.decorator_list)
173
+
174
+
138
175
  def _is_skipped(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
139
176
  return any(_marker_name(dec) in _SKIP_MARKERS for dec in node.decorator_list)
140
177