sarj-python-lint 0.15.0__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.0 → sarj_python_lint-0.16.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/_secret_names.py +56 -5
  4. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +2 -6
  5. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_comment_cruft.py +53 -6
  6. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +1 -2
  7. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +1 -3
  8. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_offset_pagination.py +1 -3
  9. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +1 -2
  10. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +17 -7
  11. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +11 -10
  12. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +6 -17
  13. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +36 -1
  14. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/zero_assertion_test.py +42 -5
  15. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/.gitignore +0 -0
  16. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/README.md +0 -0
  17. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/__init__.py +0 -0
  18. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/__main__.py +0 -0
  19. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/_version.py +0 -0
  20. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/py.typed +0 -0
  21. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rule_base.py +0 -0
  22. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  23. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  24. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  25. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  26. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  27. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  28. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  29. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  30. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  31. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  32. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  33. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  34. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  35. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  36. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  37. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  38. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  39. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  40. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  41. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  42. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  43. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  44. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  45. {sarj_python_lint-0.15.0 → 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.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  47. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  48. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  49. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  50. {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  51. {sarj_python_lint-0.15.0 → 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.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  53. {sarj_python_lint-0.15.0 → 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.0 → 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.0
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.0"
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" }]
@@ -11,13 +11,15 @@ misfired on a large false-positive class observed in a real audit:
11
11
  `tokenize`, `tokenizer`, `token_budget`.
12
12
  - Row-id / handle names: `api_key_id`, `*_key_id` — the id of a key row, not the
13
13
  key material.
14
- - Boolean feature / presence / state flags: `password_enabled`,
15
- `token_present`, `password_set`, `password_configured` — a boolean answering
16
- "is it there / was it set", not the credential itself. A `type` discriminator
17
- is the same: `token_type` is `"Bearer"`, `credential_type` is a class name.
14
+ - Boolean feature / presence / state flags, in both word orders: trailing
15
+ (`password_enabled`, `token_present`, `password_set`, `password_configured`)
16
+ and leading (`has_secret`, `hasSecret`, `is_token`, `isToken`) — a boolean
17
+ answering "is it there / was it set", not the credential itself. A `type`
18
+ discriminator is the same: `token_type` is `"Bearer"`, `credential_type` is a
19
+ class name.
18
20
  - Innocent words embedding a secret word: `secretary` (embeds `secret`).
19
21
 
20
- We fix this with two changes:
22
+ We fix this with three changes:
21
23
 
22
24
  1. Match a secret word only as a WHOLE token (after snake_case / camelCase
23
25
  splitting), never a substring. This alone clears `tokenize`, `tokenizer`,
@@ -28,6 +30,10 @@ We fix this with two changes:
28
30
  also present — this clears `token_count`, `api_key_id`, `password_enabled`,
29
31
  while still catching a credential that merely leads with such a word
30
32
  (`valid_token`, `present_token` are secrets, not flags).
33
+ 3. Disqualify an identifier whose LEADING WORD is a boolean predicate (`is`,
34
+ `has`, `was`, ...) — the mirror image of (2), clearing `has_secret` /
35
+ `hasSecret` / `is_token` / `isToken`, which name a boolean answering "does a
36
+ secret exist?" and are neither a leak nor a timing surface.
31
37
  """
32
38
 
33
39
  from __future__ import annotations
@@ -89,6 +95,28 @@ _INNOCUOUS_WORDS = frozenset(
89
95
  }
90
96
  )
91
97
 
98
+ # A LEADING boolean-predicate word marks a flag, not the credential itself:
99
+ # `has_secret`, `hasSecret`, `is_token`, `isToken`, `should_rotate_token`.
100
+ #
101
+ # WHY THE SHARED PREDICATE (both SARJ011 and SARJ012), not just the SARJ011-only
102
+ # auth narrowing the TS port uses: this is the exact mirror of the TRAILING
103
+ # `_INNOCUOUS_WORDS` check above, which already exempts both rules. `has_token`
104
+ # and `token_present` are the same boolean; word order must not decide whether a
105
+ # name counts as a credential. And the SARJ012 case stands on its own — a boolean
106
+ # answering "does a secret exist?" leaks nothing when logged, so the rule was
107
+ # reporting a pure false positive. (As with the trailing form, the exemption keys
108
+ # on the NAME: someone who writes `has_secret=the_actual_secret` still leaks, but
109
+ # that hole predates this and is inherent to a name-only rule.)
110
+ #
111
+ # WHY IT IS SAFE IN THE PERMISSIVE DIRECTION: every member is a copula/auxiliary
112
+ # verb that is never the head noun of a credential. Real secret names are noun
113
+ # phrases — `auth_token`, `api_key`, `signing_secret`, `INTERNAL_ADMIN_TOKEN` —
114
+ # and none begins with `is`/`has`/`was`/`are`/`can`/`should`. Matching is on the
115
+ # whole leading WORD, never a prefix of one, so names that merely start with
116
+ # those letters keep firing: `hash_secret` (`hash` != `has`), `issuer_token`
117
+ # (`issuer` != `is`), `canary_token` (`canary` != `can`).
118
+ _FLAG_PREFIXES = frozenset({"is", "has", "was", "are", "can", "should"})
119
+
92
120
  # camelCase / PascalCase / ALLCAPS / digit run splitter, applied to each
93
121
  # snake/kebab segment. `APIKey` -> ["API", "Key"], `authToken` -> ["auth", "Token"].
94
122
  _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
@@ -115,6 +143,27 @@ def identifier_tokens(identifier: str) -> list[str]:
115
143
  return tokens
116
144
 
117
145
 
146
+ def leading_word(identifier: str) -> str | None:
147
+ """Return the first *word* of `identifier`, lowercased, or None if it has none.
148
+
149
+ `identifier_tokens` deliberately emits each whole snake/kebab segment before
150
+ its camel parts, so its first entry for the camelCase `hasSecret` is the
151
+ useless `hassecret` rather than `has`. Splitting the leading segment with the
152
+ same camel regex makes `has_secret` and `hasSecret` both yield `has`.
153
+
154
+ Returns:
155
+ The lowercased leading word, or None when `identifier` has no word
156
+ characters at all.
157
+
158
+ """
159
+ for segment in _SEGMENT_RE.split(identifier):
160
+ if not segment:
161
+ continue
162
+ parts = _CAMEL_RE.findall(segment)
163
+ return parts[0].lower() if parts else segment.lower()
164
+ return None
165
+
166
+
118
167
  def is_secret_name(identifier: str) -> bool:
119
168
  """Report whether `identifier` names raw secret material (a credential, not metadata).
120
169
 
@@ -125,6 +174,8 @@ def is_secret_name(identifier: str) -> bool:
125
174
  tokens = identifier_tokens(identifier)
126
175
  if tokens and tokens[-1] in _INNOCUOUS_WORDS:
127
176
  return False
177
+ if leading_word(identifier) in _FLAG_PREFIXES:
178
+ return False
128
179
  if any(tok in _SECRET_WORDS for tok in tokens):
129
180
  return True
130
181
  return _has_api_key(tokens)
@@ -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)
@@ -49,10 +49,6 @@ _DESCRIPTOR_WORDS = frozenset({"type", "types", "name", "names", "id", "ids", "k
49
49
  # credential: `TOKEN_TYPE_SYSTEM`, `credential_type`, `grant_kind`.
50
50
  _CATEGORY_WORDS = frozenset({"type", "types", "kind", "kinds"})
51
51
 
52
- # A leading boolean-predicate token marks a flag, not the credential itself:
53
- # `is_token`, `has_secret`, `is_token_strategy`.
54
- _FLAG_PREFIXES = frozenset({"is", "has", "was", "are", "can", "should"})
55
-
56
52
  # Words that make an identifier a secret *only* via an integrity/content hash
57
53
  # (`content_hash`, `metadata_hash`, `row_hash`) rather than an authenticator.
58
54
  # A name that ALSO carries one of these keeps firing (`password_hash`,
@@ -182,10 +178,17 @@ def _is_auth_secret_name(identifier: str, *, crypto_module: bool) -> bool:
182
178
  """Report whether `identifier` names an authenticator (an access-gating secret).
183
179
 
184
180
  Narrows the shared `is_secret_name` for SARJ011: strips category/handle
185
- descriptors, `type`/`kind` discriminators, boolean flags, and integrity-only
186
- hashes, none of which are a timing-attack surface. A name whose only auth
187
- token is the polysemous `signature` needs the module to import crypto
188
- machinery — otherwise it is a function signature, not a MAC.
181
+ descriptors, `type`/`kind` discriminators, and integrity-only hashes, none
182
+ of which are a timing-attack surface. A name whose only auth token is the
183
+ polysemous `signature` needs the module to import crypto machinery —
184
+ otherwise it is a function signature, not a MAC.
185
+
186
+ Boolean flags (`is_token`, `hasSecret`) are NOT handled here: the shared
187
+ `is_secret_name` now rejects a leading flag word for both SARJ011 and
188
+ SARJ012, so the gate above has already returned. A duplicate local check
189
+ used to live here and was dead once that landed — worse, it read
190
+ `tokens[0]`, which is the whole snake segment (`"hassecret"`), so it never
191
+ matched a camelCase flag in the first place.
189
192
 
190
193
  Returns:
191
194
  True when comparing `identifier` in non-constant time leaks an auth secret.
@@ -194,8 +197,6 @@ def _is_auth_secret_name(identifier: str, *, crypto_module: bool) -> bool:
194
197
  if not is_secret_name(identifier):
195
198
  return False
196
199
  tokens = identifier_tokens(identifier)
197
- if tokens and tokens[0] in _FLAG_PREFIXES:
198
- return False
199
200
  if tokens and tokens[-1] in _DESCRIPTOR_WORDS:
200
201
  return False
201
202
  if any(tok in _CATEGORY_WORDS for tok in tokens):
@@ -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