sarj-python-lint 0.41.0__tar.gz → 0.43.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 (103) hide show
  1. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/PKG-INFO +3 -3
  2. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/README.md +2 -2
  3. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/__main__.py +7 -4
  5. sarj_python_lint-0.43.0/src/sarj_python_lint/_secret_names.py +109 -0
  6. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rule_base.py +14 -28
  7. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_docstrings.py +1 -1
  8. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/_prose_budget.py +151 -0
  9. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_registry.py +8 -0
  10. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/_sql.py +78 -0
  11. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -2
  12. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -2
  13. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -2
  14. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/duplicate_test_body.py +18 -5
  15. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -2
  16. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -2
  17. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -2
  18. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -2
  19. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +4 -39
  20. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -2
  21. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/mock_without_spec.py +22 -3
  22. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +2 -11
  23. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -2
  24. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -2
  25. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -2
  26. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -2
  27. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -2
  28. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +3 -16
  29. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -2
  30. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +2 -10
  31. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -2
  32. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/no_long_comment.py +31 -0
  33. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_offset_pagination.py +2 -11
  34. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +2 -2
  35. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -2
  36. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -2
  37. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -2
  38. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_restated_comment.py +10 -86
  39. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -2
  40. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_select_star.py +0 -2
  41. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -2
  42. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -2
  43. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -2
  44. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -2
  45. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -2
  46. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/no_typed_doc_sections.py +30 -0
  47. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -2
  48. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -2
  49. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -2
  50. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -2
  51. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -2
  52. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -2
  53. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -2
  54. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -2
  55. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -2
  56. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +2 -12
  57. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -2
  58. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -2
  59. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -2
  60. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/prefer_nominal_id_types.py +219 -0
  61. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -2
  62. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -2
  63. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +1 -3
  64. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +3 -5
  65. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +31 -0
  66. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -2
  67. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -2
  68. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -2
  69. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -2
  70. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -2
  71. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -2
  72. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -2
  73. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -2
  74. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/redundant_docstring.py +4 -19
  75. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/require_port_for_service.py +24 -11
  76. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -2
  77. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/single_public_export.py +7 -5
  78. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -2
  79. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/stepdown.py +0 -2
  80. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +5 -11
  81. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -2
  82. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -2
  83. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -2
  84. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -2
  85. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -2
  86. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -2
  87. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/zero_assertion_test.py +9 -96
  88. sarj_python_lint-0.41.0/src/sarj_python_lint/_secret_names.py +0 -182
  89. sarj_python_lint-0.41.0/src/sarj_python_lint/rules/_sql.py +0 -121
  90. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/.gitignore +0 -0
  91. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/__init__.py +0 -0
  92. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  93. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/_version.py +0 -0
  94. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/py.typed +0 -0
  95. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/ratchet.py +0 -0
  96. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  97. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  98. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  99. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  100. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  101. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  102. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  103. {sarj_python_lint-0.41.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.41.0
3
+ Version: 0.43.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
@@ -157,7 +157,7 @@ the shipped config the section goes and the summary stays.
157
157
  read against source gave **~2%** false positives, the whole of which was one
158
158
  family (`Returns: A new X` — whether the value is a copy is the one thing
159
159
  `-> Self` cannot say) now guarded. Three findings on this repo's own source, all
160
- true, all deleted. Measurements: [docs/rules/SARJ087.md](../../docs/rules/SARJ087.md).
160
+ true, all deleted. The guarded copy-return case is covered by the paired rule tests.
161
161
 
162
162
  ### Test ceremony, and the census it was chosen from (0.38.0)
163
163
 
@@ -169,7 +169,7 @@ true, all deleted. Measurements: [docs/rules/SARJ087.md](../../docs/rules/SARJ08
169
169
  Every comment GROUP in 19 repositories / 45,900 Python files was collected with
170
170
  its adjacent code and classified: **451,482 groups, 1,293,022 lines**, of which
171
171
  the seven comment/docstring rules that predate this release reached **4.9%**.
172
- The full census table is in [docs/rules/SARJ088.md](../../docs/rules/SARJ088.md).
172
+ The shipped predicate and its boundaries are recorded in the paired rule tests.
173
173
 
174
174
  The largest precisely-detectable class left in it is the **test docstring**:
175
175
  52,894 of them, 10.1% of every comment group, and SARJ050 reached 4.7%. It
@@ -139,7 +139,7 @@ the shipped config the section goes and the summary stays.
139
139
  read against source gave **~2%** false positives, the whole of which was one
140
140
  family (`Returns: A new X` — whether the value is a copy is the one thing
141
141
  `-> Self` cannot say) now guarded. Three findings on this repo's own source, all
142
- true, all deleted. Measurements: [docs/rules/SARJ087.md](../../docs/rules/SARJ087.md).
142
+ true, all deleted. The guarded copy-return case is covered by the paired rule tests.
143
143
 
144
144
  ### Test ceremony, and the census it was chosen from (0.38.0)
145
145
 
@@ -151,7 +151,7 @@ true, all deleted. Measurements: [docs/rules/SARJ087.md](../../docs/rules/SARJ08
151
151
  Every comment GROUP in 19 repositories / 45,900 Python files was collected with
152
152
  its adjacent code and classified: **451,482 groups, 1,293,022 lines**, of which
153
153
  the seven comment/docstring rules that predate this release reached **4.9%**.
154
- The full census table is in [docs/rules/SARJ088.md](../../docs/rules/SARJ088.md).
154
+ The shipped predicate and its boundaries are recorded in the paired rule tests.
155
155
 
156
156
  The largest precisely-detectable class left in it is the **test docstring**:
157
157
  52,894 of them, 10.1% of every comment group, and SARJ050 reached 4.7%. It
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.41.0"
3
+ version = "0.43.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" }]
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  import sys
10
10
 
11
11
  from sarj_python_lint import __version__
12
- from sarj_python_lint.rule_base import Diagnostic, is_suppressed
12
+ from sarj_python_lint.rule_base import Diagnostic, Severity, is_suppressed
13
13
  from sarj_python_lint.rules import REGISTRY
14
14
 
15
15
 
@@ -117,14 +117,14 @@ def _explain(wanted: str) -> int:
117
117
  sys.stdout.write(
118
118
  f"{cls.code} {cls.id}\n{summary[0] if summary else cls.description}\nexamples: {cls.examples_url()}\n"
119
119
  )
120
- if cls.has_evidence:
121
- sys.stdout.write(f"evidence: {cls.evidence_url()}\n")
122
120
  return 0
123
121
 
124
122
 
125
123
  def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
126
124
  counts: dict[str, dict[str, int]] = {}
127
125
  for d in diags:
126
+ if d.severity is Severity.WARNING:
127
+ continue
128
128
  counts.setdefault(str(d.path), {})
129
129
  counts[str(d.path)][d.code] = counts[str(d.path)].get(d.code, 0) + 1
130
130
  return counts
@@ -160,6 +160,9 @@ def _apply_baseline(diags: list[Diagnostic], baseline: dict[str, dict[str, int]]
160
160
  seen: Counter[tuple[str, str]] = Counter()
161
161
  out: list[Diagnostic] = []
162
162
  for d in diags:
163
+ if d.severity is Severity.WARNING:
164
+ out.append(d)
165
+ continue
163
166
  key = (str(d.path), d.code)
164
167
  seen[key] += 1
165
168
  if seen[key] > baseline.get(key[0], {}).get(key[1], 0):
@@ -221,7 +224,7 @@ def main(argv: list[str] | None = None) -> int:
221
224
  diags = _apply_baseline(diags, _read_baseline(args.baseline))
222
225
  for d in diags:
223
226
  sys.stdout.write(d.format() + "\n")
224
- return 1 if diags else 0
227
+ return 1 if any(d.severity is Severity.ERROR for d in diags) else 0
225
228
 
226
229
 
227
230
  if __name__ == "__main__":
@@ -0,0 +1,109 @@
1
+ """Recognize secret-bearing identifiers for SARJ011 and SARJ012.
2
+
3
+ Matching uses whole words and excludes metadata and boolean-state names.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from itertools import pairwise
9
+ import re
10
+
11
+
12
+ SECRET_WORDS = frozenset(
13
+ {
14
+ "token",
15
+ "secret",
16
+ "password",
17
+ "passwd",
18
+ "jwt",
19
+ "secrets",
20
+ "passwords",
21
+ "credential",
22
+ "credentials",
23
+ "authorization",
24
+ "signature",
25
+ "hmac",
26
+ "digest",
27
+ "hash",
28
+ "apikey",
29
+ # Keep this vocabulary aligned with the TypeScript helper.
30
+ "bearer",
31
+ }
32
+ )
33
+
34
+ _SECRET_WORDS = SECRET_WORDS
35
+
36
+ # A trailing word in this set makes the identifier metadata, not a credential.
37
+ _INNOCUOUS_WORDS = frozenset(
38
+ {
39
+ "count",
40
+ "counts",
41
+ "budget",
42
+ "limit",
43
+ "limits",
44
+ "id",
45
+ "ids",
46
+ "enabled",
47
+ "disabled",
48
+ "flag",
49
+ "flags",
50
+ "present",
51
+ "set",
52
+ "unset",
53
+ "configured",
54
+ "missing",
55
+ "required",
56
+ "valid",
57
+ "invalid",
58
+ "exists",
59
+ "type",
60
+ "types",
61
+ }
62
+ )
63
+
64
+ # A leading predicate makes the identifier a boolean flag, not a credential.
65
+ _FLAG_PREFIXES = frozenset({"is", "has", "was", "are", "can", "should"})
66
+
67
+ # camelCase / PascalCase / ALLCAPS / digit run splitter, applied to each
68
+ # snake/kebab segment. `APIKey` -> ["API", "Key"], `authToken` -> ["auth", "Token"].
69
+ _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
70
+ _SEGMENT_RE = re.compile(r"[^A-Za-z0-9]+")
71
+
72
+
73
+ def identifier_tokens(identifier: str) -> list[str]:
74
+ """Return lowercase whole segments and their camel-case words."""
75
+ tokens: list[str] = []
76
+ for segment in _SEGMENT_RE.split(identifier):
77
+ if not segment:
78
+ continue
79
+ tokens.append(segment.lower())
80
+ camel_parts: list[str] = _CAMEL_RE.findall(segment)
81
+ tokens.extend(part.lower() for part in camel_parts)
82
+ return tokens
83
+
84
+
85
+ def leading_word(identifier: str) -> str | None:
86
+ """Return the first camel- or delimiter-separated word, lowercased."""
87
+ for segment in _SEGMENT_RE.split(identifier):
88
+ if not segment:
89
+ continue
90
+ parts: list[str] = _CAMEL_RE.findall(segment)
91
+ return parts[0].lower() if parts else segment.lower()
92
+ return None
93
+
94
+
95
+ def is_secret_name(identifier: str) -> bool:
96
+ """Report whether `identifier` names raw secret material (a credential, not metadata)."""
97
+ tokens = identifier_tokens(identifier)
98
+ if tokens and tokens[-1] in _INNOCUOUS_WORDS:
99
+ return False
100
+ if leading_word(identifier) in _FLAG_PREFIXES:
101
+ return False
102
+ if any(tok in SECRET_WORDS for tok in tokens):
103
+ return True
104
+ return _has_api_key(tokens)
105
+
106
+
107
+ def _has_api_key(tokens: list[str]) -> bool:
108
+ """Report whether `api` is immediately followed by `key` (the split form of `api_key`)."""
109
+ return any(a == "api" and b == "key" for a, b in pairwise(tokens))
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from abc import ABC, abstractmethod
6
6
  import ast
7
7
  from dataclasses import dataclass
8
+ from enum import StrEnum
8
9
  from pathlib import Path
9
10
  import re
10
11
  from typing import TYPE_CHECKING, Final
@@ -14,15 +15,9 @@ if TYPE_CHECKING:
14
15
  from collections.abc import Sequence
15
16
 
16
17
 
17
- # The two locations a rule's documentation lives in, and the base of the links
18
- # that point at them. Named once here because `Rule.examples_path` /
19
- # `Rule.evidence_path` DERIVE every rule's links from these plus the rule's own
20
- # module name and code — no rule module writes a URL by hand, so a rename cannot
21
- # leave a dead link behind. `test_rule_meta.py` asserts every derived path
22
- # resolves on disk.
18
+ # Each rule points directly to its executable examples.
23
19
  REPO_BLOB: Final = "https://github.com/sarj-ai/standards/blob/main"
24
20
  TESTS_DIR: Final = "packages/python/tests/rules"
25
- EVIDENCE_DIR: Final = "docs/rules"
26
21
 
27
22
 
28
23
  # Suppression syntax. Two forms supported:
@@ -58,6 +53,13 @@ def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
58
53
  return code.upper() in codes
59
54
 
60
55
 
56
+ class Severity(StrEnum):
57
+ """Whether a diagnostic blocks the lint command."""
58
+
59
+ WARNING = "warning"
60
+ ERROR = "error"
61
+
62
+
61
63
  @dataclass(frozen=True, slots=True)
62
64
  class Diagnostic:
63
65
  """A single lint finding."""
@@ -67,10 +69,12 @@ class Diagnostic:
67
69
  col: int
68
70
  code: str
69
71
  message: str
72
+ severity: Severity = Severity.ERROR
70
73
 
71
74
  def format(self) -> str:
72
75
  """Render the finding ruff-compatibly as `path:line:col: CODE message`."""
73
- return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
76
+ label = "warning: " if self.severity is Severity.WARNING else ""
77
+ return f"{self.path}:{self.line}:{self.col}: {self.code} {label}{self.message}"
74
78
 
75
79
 
76
80
  class Rule(ABC):
@@ -79,24 +83,14 @@ class Rule(ABC):
79
83
  Subclasses set `id` (kebab-case) and `code` (e.g. SARJ001) as class
80
84
  attributes and implement `check(path, source) -> list[Diagnostic]`.
81
85
 
82
- A rule documents itself with a one-line summary plus two DERIVED links
83
- `examples_url()` (the examples) and `evidence_url()` (the measurements). Both
84
- are computed from `__module__` and `code`, never written by hand, and
85
- `test_rule_meta.py` asserts each resolves to a file that exists. A rename
86
- therefore fails the suite instead of leaving a dead link in a docstring.
86
+ A rule documents itself with a concise summary and a derived link to its
87
+ executable examples. The metadata tests require that paired test module.
87
88
  """
88
89
 
89
90
  id: str
90
91
  code: str
91
92
  description: str
92
93
 
93
- # True when `docs/rules/<code>.md` holds this rule's measured evidence — the
94
- # corpus census, the threshold sweeps and the false-positive families each
95
- # guard was built to stop. Declared rather than probed so the link still
96
- # prints from an installed wheel, where `docs/` is not shipped;
97
- # `test_evidence_flag_matches_the_filesystem` keeps the two honest.
98
- has_evidence: bool = False
99
-
100
94
  @abstractmethod
101
95
  def check(self, path: Path, source: str) -> list[Diagnostic]:
102
96
  """Inspect the given source. Return zero or more diagnostics."""
@@ -106,18 +100,10 @@ class Rule(ABC):
106
100
  def examples_path(cls) -> str:
107
101
  return f"{TESTS_DIR}/test_{cls.__module__.rpartition('.')[2]}.py"
108
102
 
109
- @classmethod
110
- def evidence_path(cls) -> str:
111
- return f"{EVIDENCE_DIR}/{cls.code}.md"
112
-
113
103
  @classmethod
114
104
  def examples_url(cls) -> str:
115
105
  return f"{REPO_BLOB}/{cls.examples_path()}"
116
106
 
117
- @classmethod
118
- def evidence_url(cls) -> str:
119
- return f"{REPO_BLOB}/{cls.evidence_path()}"
120
-
121
107
 
122
108
  _last_parse: tuple[str, str, ast.Module | None] | None = None
123
109
 
@@ -111,7 +111,7 @@ _BASE_STOPWORDS = frozenset(
111
111
  # config", "the entire widget" — strip the adjective and the sentence means
112
112
  # exactly what it meant. Kept as its own name because a single one of these
113
113
  # was the commonest reason a pure restatement survived the whole family; see
114
- # docs/rules/SARJ088.md for the measurement. `main`, `new`, `same` and `copy`
114
+ # The paired SARJ088 tests cover this vocabulary. `main`, `new`, `same` and `copy`
115
115
  # are NOT here: they name the thing or its identity, which is content.
116
116
  FILLER_QUALIFIERS = frozenset(
117
117
  {
@@ -0,0 +1,151 @@
1
+ """Shared extraction and sentence counting for SARJ090-092."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from dataclasses import dataclass
7
+ import io
8
+ import re
9
+ import tokenize
10
+ from typing import TYPE_CHECKING, Final
11
+
12
+ from sarj_python_lint.rule_base import parse_or_none
13
+ from sarj_python_lint.rules._docstrings import PROMPT_DECORATOR_MARKERS, decorator_markers, sections
14
+ from sarj_python_lint.rules._paths import is_generated
15
+
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+
21
+ _DIRECTIVE_RE: Final = re.compile(
22
+ r"^(?:!|noqa|sarj-noqa|type:|pragma|pyright|mypy|fmt:|isort|ruff|pylint|flake8|"
23
+ r"nosec|nosemgrep|todo|fixme|hack\b|xxx|coding[:=])",
24
+ re.IGNORECASE,
25
+ )
26
+ _LICENSE_RE: Final = re.compile(r"\b(?:copyright|spdx-license-identifier|licensed under)\b", re.IGNORECASE)
27
+ _BOUNDARY_RE: Final = re.compile(r"(?<=[.!?])[\"'`)\]]*\s+(?=[A-Z0-9`])")
28
+ _BULLET_RE: Final = re.compile(r"^\s*(?:[-*+] |\d+[.)] )")
29
+ _TYPED_SECTIONS: Final = frozenset(
30
+ {"Args", "Arguments", "Parameters", "Params", "Keyword Args", "Keyword Arguments", "Returns", "Return", "Yields", "Yield"}
31
+ )
32
+ _SCHEMA_BASES: Final = frozenset(
33
+ {"BaseModel", "BaseSettings", "RootModel", "TypedDict", "Enum", "IntEnum", "StrEnum"}
34
+ )
35
+ _SCHEMA_DECORATORS: Final = frozenset({"pydantic", "strawberry", "graphene", "msgspec"})
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ProseGroup:
40
+ line: int
41
+ col: int
42
+ text: str
43
+ kind: str
44
+ typed_sections: frozenset[str] = frozenset()
45
+
46
+
47
+ def sentence_units(text: str) -> int:
48
+ """Count prose sentences and unpunctuated list items deterministically."""
49
+ cleaned = re.sub(r"https?://\S+", "URL", text)
50
+ cleaned = re.sub(r"`[^`\n]+`", "CODE", cleaned)
51
+ cleaned = re.sub(r"\b\d+\.\d+\b", "NUMBER", cleaned)
52
+ cleaned = re.sub(r"\b(?:e\.g\.|i\.e\.|vs\.|etc\.)", "ABBREVIATION", cleaned, flags=re.IGNORECASE)
53
+ units = 0
54
+ prose: list[str] = []
55
+ for raw in cleaned.splitlines():
56
+ line = raw.strip().lstrip("*").strip()
57
+ if not line or re.fullmatch(r"[A-Za-z][A-Za-z ]+:", line):
58
+ continue
59
+ if _BULLET_RE.match(line):
60
+ units += 1
61
+ else:
62
+ prose.append(line)
63
+ paragraph = " ".join(prose).strip()
64
+ if paragraph:
65
+ units += len(_BOUNDARY_RE.split(paragraph))
66
+ return units
67
+
68
+
69
+ def groups(path: Path, source: str) -> list[ProseGroup]:
70
+ """Extract docstrings and contiguous own-line comment runs from one file."""
71
+ if is_generated(path, source):
72
+ return []
73
+ tree = parse_or_none(path, source)
74
+ if tree is None:
75
+ return []
76
+ out = _docstring_groups(tree)
77
+ lines = source.splitlines()
78
+ try:
79
+ comments = [token for token in tokenize.generate_tokens(io.StringIO(source).readline) if token.type == tokenize.COMMENT]
80
+ except tokenize.TokenError, IndentationError, SyntaxError:
81
+ return out
82
+ run: list[tokenize.TokenInfo] = []
83
+ for comment in comments:
84
+ body = comment.string.removeprefix("#").strip()
85
+ own_line = not lines[comment.start[0] - 1][: comment.start[1]].strip()
86
+ if not own_line or _DIRECTIVE_RE.match(body) or _LICENSE_RE.search(body):
87
+ if run:
88
+ out.append(_comment_run(run))
89
+ run = []
90
+ continue
91
+ if run and (comment.start[0] != run[-1].end[0] + 1 or comment.start[1] != run[-1].start[1]):
92
+ out.append(_comment_run(run))
93
+ run = []
94
+ run.append(comment)
95
+ if run:
96
+ out.append(_comment_run(run))
97
+ return out
98
+
99
+
100
+ def _comment_run(run: list[tokenize.TokenInfo]) -> ProseGroup:
101
+ return ProseGroup(
102
+ line=run[0].start[0],
103
+ col=run[0].start[1] + 1,
104
+ text="\n".join(token.string.removeprefix("#").strip() for token in run),
105
+ kind="comment",
106
+ )
107
+
108
+
109
+ def _docstring_groups(tree: ast.Module) -> list[ProseGroup]:
110
+ out: list[ProseGroup] = []
111
+ for node in ast.walk(tree):
112
+ if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) or not node.body:
113
+ continue
114
+ first = node.body[0]
115
+ if not (
116
+ isinstance(first, ast.Expr)
117
+ and isinstance(first.value, ast.Constant)
118
+ and isinstance(first.value.value, str)
119
+ ):
120
+ continue
121
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and (
122
+ decorator_markers(node) & PROMPT_DECORATOR_MARKERS
123
+ ):
124
+ continue
125
+ if isinstance(node, ast.ClassDef) and _is_schema_class(node):
126
+ continue
127
+ doc = first.value.value
128
+ found = frozenset(
129
+ name
130
+ for name in sections(doc)
131
+ if name in _TYPED_SECTIONS and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and _fully_typed(node)
132
+ )
133
+ out.append(ProseGroup(first.lineno, first.col_offset + 1, doc, "docstring", found))
134
+ return out
135
+
136
+
137
+ def _is_schema_class(node: ast.ClassDef) -> bool:
138
+ bases = {
139
+ target.attr if isinstance(target, ast.Attribute) else target.id
140
+ for base in node.bases
141
+ if isinstance((target := base.value if isinstance(base, ast.Subscript) else base), (ast.Attribute, ast.Name))
142
+ }
143
+ return bool(bases & _SCHEMA_BASES or decorator_markers(node) & _SCHEMA_DECORATORS)
144
+
145
+
146
+ def _fully_typed(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
147
+ args = node.args
148
+ parameters = (*args.posonlyargs, *args.args, *args.kwonlyargs)
149
+ return node.returns is not None and all(arg.arg in {"self", "cls"} or arg.annotation is not None for arg in parameters) and (
150
+ args.vararg is None or args.vararg.annotation is not None
151
+ ) and (args.kwarg is None or args.kwarg.annotation is not None)
@@ -37,6 +37,7 @@ from sarj_python_lint.rules.no_first_party_private_import import (
37
37
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
38
38
  from sarj_python_lint.rules.no_gen_random_uuid_in_sql import NoGenRandomUuidInSql
39
39
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
40
+ from sarj_python_lint.rules.no_long_comment import NoLongComment
40
41
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
41
42
  from sarj_python_lint.rules.no_optional_tenant_predicate import (
42
43
  NoOptionalTenantPredicate,
@@ -52,6 +53,7 @@ from sarj_python_lint.rules.no_sequential_await import NoSequentialAwait
52
53
  from sarj_python_lint.rules.no_sleep_in_test_body import NoSleepInTestBody
53
54
  from sarj_python_lint.rules.no_stdlib_logging import NoStdlibLogging
54
55
  from sarj_python_lint.rules.no_tautological_expect import NoTautologicalExpect
56
+ from sarj_python_lint.rules.no_typed_doc_sections import NoTypedDocSections
55
57
  from sarj_python_lint.rules.no_unreachable_after_terminal import (
56
58
  NoUnreachableAfterTerminal,
57
59
  )
@@ -73,12 +75,14 @@ from sarj_python_lint.rules.prefer_module_level_constant import (
73
75
  from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
74
76
  PreferNamedtupleOverTupleReturn,
75
77
  )
78
+ from sarj_python_lint.rules.prefer_nominal_id_types import PreferNominalIdTypes
76
79
  from sarj_python_lint.rules.prefer_non_nullable_collection import (
77
80
  PreferNonNullableCollection,
78
81
  )
79
82
  from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
80
83
  from sarj_python_lint.rules.prefer_real_store_in_tests import PreferRealStoreInTests
81
84
  from sarj_python_lint.rules.prefer_self_type_annotation import PreferSelfTypeAnnotation
85
+ from sarj_python_lint.rules.prefer_single_sentence_comment import PreferSingleSentenceComment
82
86
  from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
83
87
  from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
84
88
  PreferStructOverNamedtuple,
@@ -190,6 +194,10 @@ REGISTRY: dict[str, type[Rule]] = {
190
194
  DocstringReturnsRestateSignature.id: DocstringReturnsRestateSignature,
191
195
  RestatedTestDocstring.id: RestatedTestDocstring,
192
196
  TestPhaseLabelComment.id: TestPhaseLabelComment,
197
+ PreferSingleSentenceComment.id: PreferSingleSentenceComment,
198
+ NoLongComment.id: NoLongComment,
199
+ NoTypedDocSections.id: NoTypedDocSections,
200
+ PreferNominalIdTypes.id: PreferNominalIdTypes,
193
201
  }
194
202
 
195
203
  __all__ = ["REGISTRY"]
@@ -0,0 +1,78 @@
1
+ """Extract executable SQL while masking quoted values and comments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from typing import TYPE_CHECKING
7
+
8
+ from sarj_python_lint.rules._paths import is_test_path
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ def is_store_module(path: Path) -> bool:
16
+ """Return whether a non-test path belongs to the store layer."""
17
+ if is_test_path(path):
18
+ return False
19
+ return path.name.endswith("_store.py") or "stores" in path.parts
20
+
21
+
22
+ def sql_string_value(node: ast.expr) -> str | None:
23
+ """Reconstruct a (possibly `+`-concatenated) string literal, else None."""
24
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
25
+ return node.value
26
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
27
+ left = sql_string_value(node.left)
28
+ right = sql_string_value(node.right)
29
+ if left is not None and right is not None:
30
+ return left + right
31
+ return None
32
+
33
+
34
+ def strip_sql_noise(text: str) -> str:
35
+ """Mask SQL values and comments without changing text or line lengths."""
36
+ out = list(text)
37
+ n = len(text)
38
+ i = 0
39
+ while i < n:
40
+ ch = text[i]
41
+ if ch in {"'", '"'}:
42
+ out[i] = " "
43
+ i += 1
44
+ while i < n:
45
+ c = text[i]
46
+ if c == ch:
47
+ if i + 1 < n and text[i + 1] == ch:
48
+ out[i] = out[i + 1] = " "
49
+ i += 2
50
+ continue
51
+ out[i] = " "
52
+ i += 1
53
+ break
54
+ if c != "\n":
55
+ out[i] = " "
56
+ i += 1
57
+ continue
58
+ if ch == "-" and i + 1 < n and text[i + 1] == "-":
59
+ while i < n and text[i] != "\n":
60
+ out[i] = " "
61
+ i += 1
62
+ continue
63
+ if ch == "/" and i + 1 < n and text[i + 1] == "*":
64
+ out[i] = out[i + 1] = " "
65
+ i += 2
66
+ while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"):
67
+ if text[i] != "\n":
68
+ out[i] = " "
69
+ i += 1
70
+ if i < n:
71
+ out[i] = " "
72
+ i += 1
73
+ if i < n:
74
+ out[i] = " "
75
+ i += 1
76
+ continue
77
+ i += 1
78
+ return "".join(out)
@@ -1,7 +1,6 @@
1
1
  """SARJ065 — A test whose every assertion sits behind a branch or loop can pass asserting nothing.
2
2
 
3
3
  Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_conditional_assertion_in_test.py
4
- Evidence: https://github.com/sarj-ai/standards/blob/main/docs/rules/SARJ065.md
5
4
  """
6
5
 
7
6
  from __future__ import annotations
@@ -137,7 +136,6 @@ class _Facts:
137
136
  class ConditionalAssertionInTest(Rule):
138
137
  id: str = "conditional-assertion-in-test"
139
138
  code: str = "SARJ065"
140
- has_evidence: bool = True
141
139
  description: str = "Every assertion in the test is inside a conditional or loop — it can pass asserting nothing."
142
140
 
143
141
  @override
@@ -1,7 +1,6 @@
1
1
  """SARJ086 — An `Args:` block that only re-spells the parameter list.
2
2
 
3
3
  Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_docstring_args_restate_signature.py
4
- Evidence: https://github.com/sarj-ai/standards/blob/main/docs/rules/SARJ086.md
5
4
  """
6
5
 
7
6
  from __future__ import annotations
@@ -32,7 +31,6 @@ if TYPE_CHECKING:
32
31
  class DocstringArgsRestateSignature(Rule):
33
32
  id: str = "docstring-args-restate-signature"
34
33
  code: str = "SARJ086"
35
- has_evidence: bool = True
36
34
  description: str = (
37
35
  "`Args:` block adds nothing the signature does not already say — delete the section and keep the summary."
38
36
  )
@@ -1,7 +1,6 @@
1
1
  """SARJ087 — A `Returns:` block that only re-spells the name and the return annotation.
2
2
 
3
3
  Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_docstring_returns_restate_signature.py
4
- Evidence: https://github.com/sarj-ai/standards/blob/main/docs/rules/SARJ087.md
5
4
  """
6
5
 
7
6
  from __future__ import annotations
@@ -52,7 +51,6 @@ def _return_block(docstring: str) -> str | None:
52
51
  class DocstringReturnsRestateSignature(Rule):
53
52
  id: str = "docstring-returns-restate-signature"
54
53
  code: str = "SARJ087"
55
- has_evidence: bool = True
56
54
  description: str = (
57
55
  "`Returns:` block adds nothing the name and the return annotation do not "
58
56
  "already say — delete the section and keep the summary."