sarj-python-lint 0.43.0__tar.gz → 0.43.2__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 (105) hide show
  1. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/.gitignore +1 -0
  2. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/PKG-INFO +4 -6
  3. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/README.md +3 -5
  4. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/pyproject.toml +1 -1
  5. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/__main__.py +3 -17
  6. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/_secret_names.py +2 -6
  7. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/ratchet.py +8 -71
  8. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rule_base.py +5 -22
  9. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_ast_index.py +7 -66
  10. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_comments.py +34 -144
  11. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_docstrings.py +9 -74
  12. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_first_party.py +11 -110
  13. sarj_python_lint-0.43.2/src/sarj_python_lint/rules/_logging.py +48 -0
  14. sarj_python_lint-0.43.2/src/sarj_python_lint/rules/_paths.py +93 -0
  15. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_prose_budget.py +47 -12
  16. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_pytest.py +3 -25
  17. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_registry.py +0 -2
  18. sarj_python_lint-0.43.2/src/sarj_python_lint/rules/_suppression_comments.py +42 -0
  19. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +10 -42
  20. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/duplicate_test_body.py +61 -37
  21. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +1 -15
  22. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/interaction_only_test.py +3 -8
  23. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/kwonly_same_type_params.py +8 -62
  24. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/mock_without_spec.py +4 -13
  25. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +8 -36
  26. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_comment_cruft.py +19 -75
  27. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_fat_try_blocks.py +3 -17
  28. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +4 -8
  29. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_file_level_suppression.py +5 -11
  30. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_first_party_private_import.py +1 -4
  31. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_fstring_in_log.py +2 -16
  32. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +1 -4
  33. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +3 -9
  34. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_restated_comment.py +1 -3
  35. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +2 -4
  36. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_sequential_await.py +3 -13
  37. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_stdlib_logging.py +2 -7
  38. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_tautological_expect.py +1 -3
  39. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +4 -20
  40. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/over_mocked_test.py +12 -14
  41. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/phase_label_comment.py +1 -4
  42. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +7 -26
  43. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +5 -21
  44. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_library_fake.py +3 -10
  45. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +11 -83
  46. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_module_level_constant.py +23 -16
  47. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +1 -3
  48. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +7 -9
  49. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_or_pattern.py +18 -17
  50. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +3 -13
  51. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +1 -1
  52. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_str_enum.py +4 -18
  53. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +4 -42
  54. sarj_python_lint-0.43.2/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +124 -0
  55. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +2 -5
  56. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/redundant_class_docstring.py +1 -6
  57. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/require_port_for_service.py +8 -32
  58. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/restated_test_docstring.py +54 -24
  59. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/single_public_export.py +2 -10
  60. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/stepdown.py +14 -29
  61. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +2 -4
  62. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/tautological_mock_assertion.py +4 -12
  63. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +2 -8
  64. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/trivially_true_assertion.py +4 -14
  65. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/unused_mock_setup.py +3 -7
  66. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/xfail_requires_strict.py +3 -5
  67. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/zero_assertion_test.py +29 -32
  68. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/_logging.py +0 -77
  69. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/_paths.py +0 -153
  70. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/_suppression_comments.py +0 -55
  71. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -279
  72. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -108
  73. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/__init__.py +0 -0
  74. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  75. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/_version.py +0 -0
  76. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/py.typed +0 -0
  77. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/__init__.py +0 -0
  78. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_sql.py +0 -0
  79. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  80. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  81. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  82. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  83. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  84. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  85. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  86. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  87. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  88. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  89. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  90. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  91. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  92. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  93. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  94. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  95. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  96. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  97. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  98. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  99. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  100. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  101. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  102. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  103. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  104. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  105. {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
@@ -35,3 +35,4 @@ Thumbs.db
35
35
  .claude/*
36
36
  !.claude/settings.json
37
37
  .worktrees/
38
+ .sarj-private-refs.toml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.43.0
3
+ Version: 0.43.2
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
@@ -47,8 +47,7 @@ uv tool install sarj-python-lint
47
47
 
48
48
  Mined from an AST audit of ~7,500 test functions across two production repos.
49
49
  Every one is scoped to test files and carries the false-positive guard that made
50
- it shippable; the module docstring for each records the population it was
51
- measured against.
50
+ it shippable; `sarj-python-lint explain` links each rule to its behavioral tests.
52
51
 
53
52
  ```yaml
54
53
  - id: sarj-mock-without-spec # SARJ040
@@ -90,8 +89,8 @@ Attribute access (`session._stt`) is out of scope and stays with ruff's
90
89
 
91
90
  From a 37,918-comment, nine-repo measurement study. All three are
92
91
  deletion-class, so each was validated against pydantic / trio / attrs as well as
93
- the maintained repos before shipping the counts and the false-positive classes
94
- each guard was built from are recorded in the rule module docstrings.
92
+ the maintained repos before shipping, with each false-positive guard encoded in
93
+ the behavioral tests linked by `sarj-python-lint explain`.
95
94
 
96
95
  ```yaml
97
96
  - id: sarj-no-restated-comment # SARJ049
@@ -278,7 +277,6 @@ duplicated ruff.
278
277
 
279
278
  ```yaml
280
279
  - id: sarj-prefer-fstring-over-concat # SARJ068
281
- - id: sarj-prefer-match-pattern-destructuring # SARJ069
282
280
  - id: sarj-prefer-or-pattern # SARJ070
283
281
  - id: sarj-require-port-for-service # SARJ071
284
282
  ```
@@ -29,8 +29,7 @@ uv tool install sarj-python-lint
29
29
 
30
30
  Mined from an AST audit of ~7,500 test functions across two production repos.
31
31
  Every one is scoped to test files and carries the false-positive guard that made
32
- it shippable; the module docstring for each records the population it was
33
- measured against.
32
+ it shippable; `sarj-python-lint explain` links each rule to its behavioral tests.
34
33
 
35
34
  ```yaml
36
35
  - id: sarj-mock-without-spec # SARJ040
@@ -72,8 +71,8 @@ Attribute access (`session._stt`) is out of scope and stays with ruff's
72
71
 
73
72
  From a 37,918-comment, nine-repo measurement study. All three are
74
73
  deletion-class, so each was validated against pydantic / trio / attrs as well as
75
- the maintained repos before shipping the counts and the false-positive classes
76
- each guard was built from are recorded in the rule module docstrings.
74
+ the maintained repos before shipping, with each false-positive guard encoded in
75
+ the behavioral tests linked by `sarj-python-lint explain`.
77
76
 
78
77
  ```yaml
79
78
  - id: sarj-no-restated-comment # SARJ049
@@ -260,7 +259,6 @@ duplicated ruff.
260
259
 
261
260
  ```yaml
262
261
  - id: sarj-prefer-fstring-over-concat # SARJ068
263
- - id: sarj-prefer-match-pattern-destructuring # SARJ069
264
262
  - id: sarj-prefer-or-pattern # SARJ070
265
263
  - id: sarj-require-port-for-service # SARJ071
266
264
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.43.0"
3
+ version = "0.43.2"
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" }]
@@ -102,21 +102,13 @@ class _Args(argparse.Namespace):
102
102
 
103
103
 
104
104
  def _explain(wanted: str) -> int:
105
- """Print a rule's summary plus the derived links to its examples and evidence.
106
-
107
- This is the runtime consumer of the one-line module docstring: it is what
108
- makes an inaccurate summary visible rather than merely unread.
109
-
110
- """
105
+ """Print a rule's description and its derived examples link."""
111
106
  key = wanted.strip()
112
107
  cls = REGISTRY.get(key) or next((c for c in REGISTRY.values() if c.code.upper() == key.upper()), None)
113
108
  if cls is None:
114
109
  sys.stderr.write(f"unknown rule: {wanted}\navailable: {', '.join(sorted(REGISTRY))}\n")
115
110
  return 2
116
- summary = (sys.modules[cls.__module__].__doc__ or "").strip().splitlines()
117
- sys.stdout.write(
118
- f"{cls.code} {cls.id}\n{summary[0] if summary else cls.description}\nexamples: {cls.examples_url()}\n"
119
- )
111
+ sys.stdout.write(f"{cls.code} {cls.id}\n{cls.description}\nexamples: {cls.examples_url()}\n")
120
112
  return 0
121
113
 
122
114
 
@@ -131,13 +123,7 @@ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
131
123
 
132
124
 
133
125
  def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
134
- """Load a baseline file, keeping only well-formed `{path: {CODE: count}}` entries.
135
-
136
- `json.loads` returns `Any`, so the shape is narrowed here rather than
137
- asserted — a hand-edited baseline should degrade to "not baselined" rather
138
- than crash the run or silently suppress on a malformed entry.
139
-
140
- """
126
+ """Load a baseline file, keeping only well-formed `{path: {CODE: count}}` entries."""
141
127
  raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
142
128
  path.read_text(encoding="utf-8")
143
129
  )
@@ -1,7 +1,4 @@
1
- """Recognize secret-bearing identifiers for SARJ011 and SARJ012.
2
-
3
- Matching uses whole words and excludes metadata and boolean-state names.
4
- """
1
+ """Recognize secret-bearing identifiers for SARJ011 and SARJ012."""
5
2
 
6
3
  from __future__ import annotations
7
4
 
@@ -64,8 +61,7 @@ _INNOCUOUS_WORDS = frozenset(
64
61
  # A leading predicate makes the identifier a boolean flag, not a credential.
65
62
  _FLAG_PREFIXES = frozenset({"is", "has", "was", "are", "can", "should"})
66
63
 
67
- # camelCase / PascalCase / ALLCAPS / digit run splitter, applied to each
68
- # snake/kebab segment. `APIKey` -> ["API", "Key"], `authToken` -> ["auth", "Token"].
64
+ # Split camel case, capitals, and digit runs within each underscore-delimited component.
69
65
  _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
70
66
  _SEGMENT_RE = re.compile(r"[^A-Za-z0-9]+")
71
67
 
@@ -1,41 +1,4 @@
1
- """Suppression ratchet: count every escape hatch in a tree and let the count only shrink.
2
-
3
- A suppression comment is a decision to stop a checker from doing its job on one
4
- line. Individually each is often right; collectively they are the only number
5
- that says whether the codebase is getting more or less checked, and nothing in
6
- review shows that number. A ratchet makes it visible and one-directional:
7
- snapshot today's counts into a baseline, then fail any commit that raises one.
8
-
9
- Three ceilings apply at once, because each catches a shape the others miss:
10
-
11
- * **per code** — `# noqa: TID251` growing from 40 to 41 is a real regression
12
- even when the repo total falls, and a code-blind total lets a cleanup
13
- elsewhere pay for it.
14
- * **per package** — a monorepo where one package is well-typed and another is
15
- not needs separate floors, or the good package's headroom finances the bad
16
- one's debt.
17
- * **per file** — a global ceiling on any single file, so new suppressions
18
- cannot pile into one hot spot while the package total stays flat. Files that
19
- already exceeded it when the ceiling landed are grandfathered at their
20
- then-current counts and may only shrink.
21
-
22
- All four suppression dialects are counted, each under its own key prefix, so
23
- migrating a suppression from one spelling to another can never hide it:
24
-
25
- | key | comment form |
26
- |-------------------------|-------------------------------------------------|
27
- | `noqa:CODE` | `x = f() # noqa: E501` |
28
- | `sarj-noqa:CODE` | `x = f() # sarj-noqa: SARJ016 — reason` |
29
- | `pyright:CODE` | `x = f() # pyright: ignore[reportAny]` |
30
- | `type-ignore:CODE` | `x = f() # type: ignore[attr-defined]` |
31
- | `type-ignore` | `x = f() # type: ignore` (bare) |
32
- | `file-noqa:CODE` | `# ruff: noqa: E501` at file level |
33
- | `file-noqa:<blanket>` | `# ruff: noqa` at file level, no codes |
34
- | `file-pyright:RULE` | `# pyright: reportAny=false` file header |
35
-
36
- Every occurrence on a line counts, not just the first: `f() # noqa: A # noqa: B`
37
- is two suppressions.
38
- """
1
+ """Suppression ratchet: count every escape hatch in a tree and let the count only shrink."""
39
2
 
40
3
  from __future__ import annotations
41
4
 
@@ -100,7 +63,7 @@ class Measurement:
100
63
 
101
64
  @dataclass(frozen=True, slots=True)
102
65
  class Baseline:
103
- """The checked-in ceilings. A key absent from `codes`/`packages` has a ceiling of 0."""
66
+ """Ceilings by code, package, and file so cleanup in one dimension cannot finance debt in another."""
104
67
 
105
68
  codes: dict[str, int] = field(default_factory=dict[str, int])
106
69
  packages: dict[str, int] = field(default_factory=dict[str, int])
@@ -148,13 +111,7 @@ def measure(
148
111
  excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES,
149
112
  excluded_subtrees: Iterable[str] = (),
150
113
  ) -> Measurement:
151
- """Count every suppression under `root`, bucketed by code, package and file.
152
-
153
- `packages` are paths relative to `root`; a package that does not exist
154
- contributes 0 rather than raising, so a baseline outliving a rename fails
155
- loudly on the package's disappearance rather than crashing.
156
-
157
- """
114
+ """Count every suppression under `root`, bucketed by code, package and file."""
158
115
  codes: Counter[str] = Counter()
159
116
  package_counts: Counter[str] = Counter()
160
117
  files: dict[str, int] = {}
@@ -214,13 +171,8 @@ def improvements(measurement: Measurement, baseline: Baseline) -> dict[str, tupl
214
171
 
215
172
 
216
173
  def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
217
- """Build the baseline that `--update` would write from a measurement.
218
-
219
- Per-file grandfathering is preserved only where it is still needed: a file
220
- that dropped to or below the global ceiling loses its exception, so the
221
- allowance cannot outlive the debt it was granted for.
222
-
223
- """
174
+ """Build the baseline that `--update` would write from a measurement."""
175
+ # Recompute exceptions so a file loses grandfathering as soon as it reaches the ceiling.
224
176
  exceptions = {path: n for path, n in measurement.files.items() if n > baseline.per_file_ceiling}
225
177
  return Baseline(
226
178
  codes=dict(sorted(measurement.codes.items())),
@@ -231,12 +183,7 @@ def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
231
183
 
232
184
 
233
185
  def load_baseline(path: Path) -> Baseline:
234
- """Read a baseline JSON file, ignoring entries of the wrong shape.
235
-
236
- A hand-edited baseline degrades to "not baselined" (ceiling 0) for the
237
- malformed entry rather than crashing the gate or silently passing.
238
-
239
- """
186
+ """Read a baseline JSON file, ignoring entries of the wrong shape."""
240
187
  raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; every read below narrows
241
188
  path.read_text(encoding="utf-8")
242
189
  )
@@ -281,12 +228,7 @@ def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
281
228
 
282
229
 
283
230
  def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES) -> list[str]:
284
- """List the top-level directories under `root` that contain Python files.
285
-
286
- Used when neither `--package` nor a baseline names the packages, so the
287
- first run needs no configuration.
288
-
289
- """
231
+ """List the top-level directories under `root` that contain Python files."""
290
232
  return sorted(
291
233
  name
292
234
  for child in root.iterdir()
@@ -326,12 +268,7 @@ def _read(path: Path) -> str:
326
268
 
327
269
 
328
270
  def _count_line(line: str, counts: Counter[str]) -> None:
329
- """Add one line's suppressions to `counts`.
330
-
331
- The two file-level forms return early: a `# ruff: noqa: X` header is not
332
- also an inline `noqa`, and double-counting it would make the two key
333
- prefixes overlap.
334
- """
271
+ """Add one line's suppressions to `counts`."""
335
272
  file_noqa = _FILE_NOQA_RE.match(line)
336
273
  if file_noqa:
337
274
  listed = file_noqa.group(1)
@@ -20,13 +20,7 @@ REPO_BLOB: Final = "https://github.com/sarj-ai/standards/blob/main"
20
20
  TESTS_DIR: Final = "packages/python/tests/rules"
21
21
 
22
22
 
23
- # Suppression syntax. Two forms supported:
24
- # # sarj-noqa: SARJ001 — reason
25
- # # sarj-noqa: SARJ001, SARJ002 — reason
26
- # We deliberately do NOT reuse ruff's own suppression comment because ruff
27
- # aggressively cleans unrecognized codes (RUF100/RUF102) even with `external`
28
- # set, which silently breaks suppressions across runs. A distinct prefix
29
- # (sarj-noqa) shares no syntax with ruff, so the two never collide.
23
+ # Keep SARJ suppressions separate because Ruff removes unknown `noqa` codes.
30
24
  _SARJ_NOQA_RE = re.compile(
31
25
  r"#\s*sarj-noqa(?::\s*([A-Za-z0-9_, ]+))?",
32
26
  re.IGNORECASE,
@@ -34,11 +28,7 @@ _SARJ_NOQA_RE = re.compile(
34
28
 
35
29
 
36
30
  def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
37
- """Report whether the diagnostic's line carries a `# sarj-noqa[: CODE]` comment.
38
-
39
- `line` is 1-based to match Diagnostic.line.
40
-
41
- """
31
+ """Report whether the diagnostic's line carries a `# sarj-noqa[: CODE]` comment."""
42
32
  if line < 1 or line > len(source_lines):
43
33
  return False
44
34
  text = source_lines[line - 1]
@@ -47,7 +37,7 @@ def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
47
37
  return False
48
38
  codes_str = m.group(1)
49
39
  if not codes_str:
50
- # Bare `# sarj-noqa` suppresses every SARJ code on the line
40
+ # A bare sarj-noqa intentionally suppresses every SARJ code on its line.
51
41
  return True
52
42
  codes = {val.upper() for c in codes_str.split(",") if (val := c.strip())}
53
43
  return code.upper() in codes
@@ -78,14 +68,7 @@ class Diagnostic:
78
68
 
79
69
 
80
70
  class Rule(ABC):
81
- """Base class for a single lint rule.
82
-
83
- Subclasses set `id` (kebab-case) and `code` (e.g. SARJ001) as class
84
- attributes and implement `check(path, source) -> list[Diagnostic]`.
85
-
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.
88
- """
71
+ """Base class for a single lint rule."""
89
72
 
90
73
  id: str
91
74
  code: str
@@ -93,7 +76,7 @@ class Rule(ABC):
93
76
 
94
77
  @abstractmethod
95
78
  def check(self, path: Path, source: str) -> list[Diagnostic]:
96
- """Inspect the given source. Return zero or more diagnostics."""
79
+ """Inspect the given source."""
97
80
  raise NotImplementedError
98
81
 
99
82
  @classmethod
@@ -1,45 +1,6 @@
1
- """One tree traversal per file, shared by every rule that needs node lookups.
2
-
3
- Profiling the whole registry over a 1,920-line module showed `ast.walk` and its
4
- helpers (`iter_child_nodes` / `iter_fields`) accounting for ~60% of all rule
5
- time. The cause is structural rather than any one rule's fault: the dominant
6
- shape of a rule is
7
-
8
- for node in ast.walk(tree):
9
- if isinstance(node, ast.Something):
10
- ...
11
-
12
- so a file with N nodes and R rules asking that question pays R full traversals,
13
- each one re-deriving the same partition of the tree by node type.
14
-
15
- `nodes()` derives that partition **once per file** and answers every subsequent
16
- type query from it. The index is memoized in a single slot keyed on tree
17
- identity, mirroring `parse_or_none`: the CLI iterates files on the outer loop
18
- and rules on the inner one, so one slot is all that is ever live. The slot holds
19
- a strong reference to the tree, so a recycled `id()` can never alias a stale
20
- index.
21
-
22
- Two invariants make the substitution finding-preserving, not merely faster:
23
-
24
- * **Order.** The index is built by breadth-first traversal, the same order
25
- `ast.walk` yields, and every query preserves it. `nodes(tree, T)` is therefore
26
- elementwise equal to `[n for n in ast.walk(tree) if isinstance(n, T)]`, so a
27
- rule that takes the *first* match, or reports in discovery order, is
28
- unaffected.
29
- * **Subclasses.** Queries match by `isinstance`, not by exact type: the buckets
30
- are keyed on exact class, and a query resolves to the set of present classes
31
- that are subclasses of the requested types. Asking for `ast.stmt` still
32
- returns every statement.
33
-
34
- `walk()` is the same breadth-first order for the cases the index cannot serve —
35
- traversing a *subtree* rather than the module, where containment is not
36
- something a whole-module partition can answer.
37
-
38
- No rule may mutate the tree. That was already required — every rule shares one
39
- memoized parse, and `duplicate_test_body` deep-copies before normalizing — and
40
- the index inherits the requirement, since it holds references into the tree it
41
- was built from.
42
- """
1
+ """One tree traversal per file, shared by every rule that needs node lookups."""
2
+
3
+ # Breadth-first order and isinstance semantics intentionally match ast.walk because rules rely on first-match order.
43
4
 
44
5
  from __future__ import annotations
45
6
 
@@ -55,13 +16,7 @@ _AST = ast.AST
55
16
 
56
17
 
57
18
  def children(node: ast.AST) -> list[ast.AST]:
58
- """`node`'s direct children, in the same order as `ast.iter_child_nodes`.
59
-
60
- A list rather than a generator: every caller either iterates it once or
61
- extends a stack with it, and the hand-rolled walkers in this package call
62
- this once per node, where the generator's setup cost dominates the work.
63
-
64
- """
19
+ """`node`'s direct children, in the same order as `ast.iter_child_nodes`."""
65
20
  out: list[ast.AST] = []
66
21
  for name in node._fields:
67
22
  value: object = getattr(node, name, None)
@@ -73,12 +28,7 @@ def children(node: ast.AST) -> list[ast.AST]:
73
28
 
74
29
 
75
30
  def walk(node: ast.AST) -> Iterator[ast.AST]:
76
- """Yield `node` and every descendant, breadth-first.
77
-
78
- Identical in order to `ast.walk`, but reads each node's fields directly
79
- instead of routing every node through two intermediate generators.
80
-
81
- """
31
+ """Yield `node` and every descendant, breadth-first."""
82
32
  queue: list[ast.AST] = [node]
83
33
  i = 0
84
34
  while i < len(queue):
@@ -144,22 +94,13 @@ class _NodeIndex:
144
94
 
145
95
 
146
96
  _last_index: tuple[ast.AST, _NodeIndex] | None = None
97
+ # The strong tree reference prevents a recycled object id from ever reusing a stale index.
147
98
 
148
99
 
149
100
  def nodes[NodeT: ast.AST](tree: ast.AST, *types: type[NodeT]) -> list[NodeT]:
150
- """Every node of `tree` matching `types`, in `ast.walk` order.
151
-
152
- A drop-in, memoized replacement for the ubiquitous
153
- `[n for n in ast.walk(tree) if isinstance(n, types)]`. Pass the *module*
154
- tree: the index is per-file, so calling this with a subtree builds a fresh
155
- index and evicts the module's. Use `walk` for subtrees.
156
-
157
- """
101
+ """Index a whole-file tree; use `walk` for subtrees and never mutate indexed nodes."""
158
102
  global _last_index # ruff: ignore[global-statement] — single-slot memo, mirroring `parse_or_none`
159
103
  if _last_index is None or _last_index[0] is not tree:
160
104
  _last_index = (tree, _NodeIndex(tree))
161
105
  # The `isinstance` pass is what narrows `list[ast.AST]` to `list[NodeT]`
162
- # honestly, rather than asserting it with a `cast` the house style bans. It
163
- # runs over the matches only — never the tree — and every element passes by
164
- # construction, so it costs one type check per node actually returned.
165
106
  return [node for node in _last_index[1].query(types) if isinstance(node, types)]