thailint 0.5.0__py3-none-any.whl → 0.15.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. src/__init__.py +1 -0
  2. src/analyzers/__init__.py +4 -3
  3. src/analyzers/ast_utils.py +54 -0
  4. src/analyzers/rust_base.py +155 -0
  5. src/analyzers/rust_context.py +141 -0
  6. src/analyzers/typescript_base.py +4 -0
  7. src/cli/__init__.py +30 -0
  8. src/cli/__main__.py +22 -0
  9. src/cli/config.py +480 -0
  10. src/cli/config_merge.py +241 -0
  11. src/cli/linters/__init__.py +67 -0
  12. src/cli/linters/code_patterns.py +270 -0
  13. src/cli/linters/code_smells.py +342 -0
  14. src/cli/linters/documentation.py +83 -0
  15. src/cli/linters/performance.py +287 -0
  16. src/cli/linters/shared.py +331 -0
  17. src/cli/linters/structure.py +327 -0
  18. src/cli/linters/structure_quality.py +328 -0
  19. src/cli/main.py +120 -0
  20. src/cli/utils.py +395 -0
  21. src/cli_main.py +37 -0
  22. src/config.py +38 -25
  23. src/core/base.py +7 -2
  24. src/core/cli_utils.py +19 -2
  25. src/core/config_parser.py +5 -2
  26. src/core/constants.py +54 -0
  27. src/core/linter_utils.py +95 -6
  28. src/core/python_lint_rule.py +101 -0
  29. src/core/registry.py +1 -1
  30. src/core/rule_discovery.py +147 -84
  31. src/core/types.py +13 -0
  32. src/core/violation_builder.py +78 -15
  33. src/core/violation_utils.py +69 -0
  34. src/formatters/__init__.py +22 -0
  35. src/formatters/sarif.py +202 -0
  36. src/linter_config/directive_markers.py +109 -0
  37. src/linter_config/ignore.py +254 -395
  38. src/linter_config/loader.py +45 -12
  39. src/linter_config/pattern_utils.py +65 -0
  40. src/linter_config/rule_matcher.py +89 -0
  41. src/linters/collection_pipeline/__init__.py +90 -0
  42. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  43. src/linters/collection_pipeline/ast_utils.py +40 -0
  44. src/linters/collection_pipeline/config.py +75 -0
  45. src/linters/collection_pipeline/continue_analyzer.py +94 -0
  46. src/linters/collection_pipeline/detector.py +360 -0
  47. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  48. src/linters/collection_pipeline/linter.py +420 -0
  49. src/linters/collection_pipeline/suggestion_builder.py +130 -0
  50. src/linters/cqs/__init__.py +54 -0
  51. src/linters/cqs/config.py +55 -0
  52. src/linters/cqs/function_analyzer.py +201 -0
  53. src/linters/cqs/input_detector.py +139 -0
  54. src/linters/cqs/linter.py +159 -0
  55. src/linters/cqs/output_detector.py +84 -0
  56. src/linters/cqs/python_analyzer.py +54 -0
  57. src/linters/cqs/types.py +82 -0
  58. src/linters/cqs/typescript_cqs_analyzer.py +61 -0
  59. src/linters/cqs/typescript_function_analyzer.py +192 -0
  60. src/linters/cqs/typescript_input_detector.py +203 -0
  61. src/linters/cqs/typescript_output_detector.py +117 -0
  62. src/linters/cqs/violation_builder.py +94 -0
  63. src/linters/dry/base_token_analyzer.py +16 -9
  64. src/linters/dry/block_filter.py +120 -20
  65. src/linters/dry/block_grouper.py +4 -0
  66. src/linters/dry/cache.py +104 -10
  67. src/linters/dry/cache_query.py +4 -0
  68. src/linters/dry/config.py +54 -11
  69. src/linters/dry/constant.py +92 -0
  70. src/linters/dry/constant_matcher.py +223 -0
  71. src/linters/dry/constant_violation_builder.py +98 -0
  72. src/linters/dry/duplicate_storage.py +5 -4
  73. src/linters/dry/file_analyzer.py +4 -2
  74. src/linters/dry/inline_ignore.py +7 -16
  75. src/linters/dry/linter.py +183 -48
  76. src/linters/dry/python_analyzer.py +60 -439
  77. src/linters/dry/python_constant_extractor.py +100 -0
  78. src/linters/dry/single_statement_detector.py +417 -0
  79. src/linters/dry/token_hasher.py +116 -112
  80. src/linters/dry/typescript_analyzer.py +68 -382
  81. src/linters/dry/typescript_constant_extractor.py +138 -0
  82. src/linters/dry/typescript_statement_detector.py +255 -0
  83. src/linters/dry/typescript_value_extractor.py +70 -0
  84. src/linters/dry/violation_builder.py +4 -0
  85. src/linters/dry/violation_filter.py +5 -4
  86. src/linters/dry/violation_generator.py +71 -14
  87. src/linters/file_header/atemporal_detector.py +68 -50
  88. src/linters/file_header/base_parser.py +93 -0
  89. src/linters/file_header/bash_parser.py +66 -0
  90. src/linters/file_header/config.py +90 -16
  91. src/linters/file_header/css_parser.py +70 -0
  92. src/linters/file_header/field_validator.py +36 -33
  93. src/linters/file_header/linter.py +140 -144
  94. src/linters/file_header/markdown_parser.py +130 -0
  95. src/linters/file_header/python_parser.py +14 -58
  96. src/linters/file_header/typescript_parser.py +73 -0
  97. src/linters/file_header/violation_builder.py +13 -12
  98. src/linters/file_placement/config_loader.py +3 -1
  99. src/linters/file_placement/directory_matcher.py +4 -0
  100. src/linters/file_placement/linter.py +66 -34
  101. src/linters/file_placement/pattern_matcher.py +41 -6
  102. src/linters/file_placement/pattern_validator.py +31 -12
  103. src/linters/file_placement/rule_checker.py +12 -7
  104. src/linters/lazy_ignores/__init__.py +43 -0
  105. src/linters/lazy_ignores/config.py +74 -0
  106. src/linters/lazy_ignores/directive_utils.py +164 -0
  107. src/linters/lazy_ignores/header_parser.py +177 -0
  108. src/linters/lazy_ignores/linter.py +158 -0
  109. src/linters/lazy_ignores/matcher.py +168 -0
  110. src/linters/lazy_ignores/python_analyzer.py +209 -0
  111. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  112. src/linters/lazy_ignores/skip_detector.py +298 -0
  113. src/linters/lazy_ignores/types.py +71 -0
  114. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  115. src/linters/lazy_ignores/violation_builder.py +135 -0
  116. src/linters/lbyl/__init__.py +31 -0
  117. src/linters/lbyl/config.py +63 -0
  118. src/linters/lbyl/linter.py +67 -0
  119. src/linters/lbyl/pattern_detectors/__init__.py +53 -0
  120. src/linters/lbyl/pattern_detectors/base.py +63 -0
  121. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  122. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  123. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  124. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  125. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  126. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  127. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  128. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  129. src/linters/lbyl/python_analyzer.py +215 -0
  130. src/linters/lbyl/violation_builder.py +354 -0
  131. src/linters/magic_numbers/context_analyzer.py +227 -225
  132. src/linters/magic_numbers/linter.py +28 -82
  133. src/linters/magic_numbers/python_analyzer.py +4 -16
  134. src/linters/magic_numbers/typescript_analyzer.py +9 -12
  135. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  136. src/linters/method_property/__init__.py +49 -0
  137. src/linters/method_property/config.py +138 -0
  138. src/linters/method_property/linter.py +414 -0
  139. src/linters/method_property/python_analyzer.py +473 -0
  140. src/linters/method_property/violation_builder.py +119 -0
  141. src/linters/nesting/linter.py +24 -16
  142. src/linters/nesting/python_analyzer.py +4 -0
  143. src/linters/nesting/typescript_analyzer.py +6 -12
  144. src/linters/nesting/violation_builder.py +1 -0
  145. src/linters/performance/__init__.py +91 -0
  146. src/linters/performance/config.py +43 -0
  147. src/linters/performance/constants.py +49 -0
  148. src/linters/performance/linter.py +149 -0
  149. src/linters/performance/python_analyzer.py +365 -0
  150. src/linters/performance/regex_analyzer.py +312 -0
  151. src/linters/performance/regex_linter.py +139 -0
  152. src/linters/performance/typescript_analyzer.py +236 -0
  153. src/linters/performance/violation_builder.py +160 -0
  154. src/linters/print_statements/config.py +7 -12
  155. src/linters/print_statements/linter.py +26 -43
  156. src/linters/print_statements/python_analyzer.py +91 -93
  157. src/linters/print_statements/typescript_analyzer.py +15 -25
  158. src/linters/print_statements/violation_builder.py +12 -14
  159. src/linters/srp/class_analyzer.py +11 -7
  160. src/linters/srp/heuristics.py +56 -22
  161. src/linters/srp/linter.py +15 -16
  162. src/linters/srp/python_analyzer.py +55 -20
  163. src/linters/srp/typescript_metrics_calculator.py +110 -50
  164. src/linters/stateless_class/__init__.py +25 -0
  165. src/linters/stateless_class/config.py +58 -0
  166. src/linters/stateless_class/linter.py +349 -0
  167. src/linters/stateless_class/python_analyzer.py +290 -0
  168. src/linters/stringly_typed/__init__.py +36 -0
  169. src/linters/stringly_typed/config.py +189 -0
  170. src/linters/stringly_typed/context_filter.py +451 -0
  171. src/linters/stringly_typed/function_call_violation_builder.py +135 -0
  172. src/linters/stringly_typed/ignore_checker.py +100 -0
  173. src/linters/stringly_typed/ignore_utils.py +51 -0
  174. src/linters/stringly_typed/linter.py +376 -0
  175. src/linters/stringly_typed/python/__init__.py +33 -0
  176. src/linters/stringly_typed/python/analyzer.py +348 -0
  177. src/linters/stringly_typed/python/call_tracker.py +175 -0
  178. src/linters/stringly_typed/python/comparison_tracker.py +257 -0
  179. src/linters/stringly_typed/python/condition_extractor.py +134 -0
  180. src/linters/stringly_typed/python/conditional_detector.py +179 -0
  181. src/linters/stringly_typed/python/constants.py +21 -0
  182. src/linters/stringly_typed/python/match_analyzer.py +94 -0
  183. src/linters/stringly_typed/python/validation_detector.py +189 -0
  184. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  185. src/linters/stringly_typed/storage.py +620 -0
  186. src/linters/stringly_typed/storage_initializer.py +45 -0
  187. src/linters/stringly_typed/typescript/__init__.py +28 -0
  188. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  189. src/linters/stringly_typed/typescript/call_tracker.py +335 -0
  190. src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
  191. src/linters/stringly_typed/violation_generator.py +419 -0
  192. src/orchestrator/core.py +252 -14
  193. src/orchestrator/language_detector.py +5 -3
  194. src/templates/thailint_config_template.yaml +196 -0
  195. src/utils/project_root.py +3 -0
  196. thailint-0.15.3.dist-info/METADATA +187 -0
  197. thailint-0.15.3.dist-info/RECORD +226 -0
  198. thailint-0.15.3.dist-info/entry_points.txt +4 -0
  199. src/cli.py +0 -1665
  200. thailint-0.5.0.dist-info/METADATA +0 -1286
  201. thailint-0.5.0.dist-info/RECORD +0 -96
  202. thailint-0.5.0.dist-info/entry_points.txt +0 -4
  203. {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +0 -0
  204. {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/licenses/LICENSE +0 -0
src/core/cli_utils.py CHANGED
@@ -26,6 +26,8 @@ from typing import Any
26
26
 
27
27
  import click
28
28
 
29
+ from src.core.constants import CONFIG_EXTENSIONS
30
+
29
31
 
30
32
  def common_linter_options(func: Callable) -> Callable:
31
33
  """Add common linter CLI options to command.
@@ -103,7 +105,7 @@ def _load_config_by_format(config_file: Path) -> dict[str, Any]:
103
105
  Returns:
104
106
  Loaded configuration dictionary
105
107
  """
106
- if config_file.suffix in {".yaml", ".yml"}:
108
+ if config_file.suffix in CONFIG_EXTENSIONS:
107
109
  return _load_yaml_config(config_file)
108
110
  if config_file.suffix == ".json":
109
111
  return _load_json_config(config_file)
@@ -146,10 +148,12 @@ def format_violations(violations: list, output_format: str) -> None:
146
148
 
147
149
  Args:
148
150
  violations: List of violation objects with rule_id, file_path, line, column, message, severity
149
- output_format: Output format ("text" or "json")
151
+ output_format: Output format ("text", "json", or "sarif")
150
152
  """
151
153
  if output_format == "json":
152
154
  _output_json(violations)
155
+ elif output_format == "sarif":
156
+ _output_sarif(violations)
153
157
  else:
154
158
  _output_text(violations)
155
159
 
@@ -177,6 +181,19 @@ def _output_json(violations: list) -> None:
177
181
  click.echo(json.dumps(output, indent=2))
178
182
 
179
183
 
184
+ def _output_sarif(violations: list) -> None:
185
+ """Output violations in SARIF v2.1.0 format.
186
+
187
+ Args:
188
+ violations: List of violation objects
189
+ """
190
+ from src.formatters.sarif import SarifFormatter
191
+
192
+ formatter = SarifFormatter()
193
+ sarif_doc = formatter.format(violations)
194
+ click.echo(json.dumps(sarif_doc, indent=2))
195
+
196
+
180
197
  def _output_text(violations: list) -> None:
181
198
  """Output violations in human-readable text format.
182
199
 
src/core/config_parser.py CHANGED
@@ -27,6 +27,8 @@ from typing import Any, TextIO
27
27
 
28
28
  import yaml
29
29
 
30
+ from src.core.constants import CONFIG_EXTENSIONS
31
+
30
32
 
31
33
  class ConfigParseError(Exception):
32
34
  """Configuration file parsing errors."""
@@ -113,11 +115,12 @@ def parse_config_file(path: Path, encoding: str = "utf-8") -> dict[str, Any]:
113
115
  """
114
116
  suffix = path.suffix.lower()
115
117
 
116
- if suffix not in [".yaml", ".yml", ".json"]:
118
+ valid_suffixes = (*CONFIG_EXTENSIONS, ".json")
119
+ if suffix not in valid_suffixes:
117
120
  raise ConfigParseError(f"Unsupported config format: {suffix}")
118
121
 
119
122
  with path.open(encoding=encoding) as f:
120
- if suffix in [".yaml", ".yml"]:
123
+ if suffix in CONFIG_EXTENSIONS:
121
124
  config = parse_yaml(f, path)
122
125
  else:
123
126
  config = parse_json(f, path)
src/core/constants.py ADDED
@@ -0,0 +1,54 @@
1
+ """
2
+ Purpose: Core constants and enums used across the thai-lint codebase
3
+
4
+ Scope: Centralized definitions for language names, storage modes, config extensions
5
+
6
+ Overview: Provides type-safe enums and constants for consistent stringly-typed patterns
7
+ across the codebase. Includes Language enum for programming language detection,
8
+ StorageMode for cache storage options, and CONFIG_EXTENSIONS for config file
9
+ discovery. Using enums ensures compile-time safety and IDE autocompletion.
10
+
11
+ Dependencies: enum module
12
+
13
+ Exports: Language enum, StorageMode enum, CONFIG_EXTENSIONS, IgnoreDirective enum,
14
+ HEADER_SCAN_LINES, MAX_ATTRIBUTE_CHAIN_DEPTH
15
+
16
+ Interfaces: Use enum values instead of string literals throughout codebase
17
+
18
+ Implementation: Standard Python enums with string values for compatibility
19
+ """
20
+
21
+ from enum import Enum
22
+
23
+
24
+ class Language(str, Enum):
25
+ """Supported programming languages for linting."""
26
+
27
+ PYTHON = "python"
28
+ TYPESCRIPT = "typescript"
29
+ JAVASCRIPT = "javascript"
30
+ MARKDOWN = "markdown"
31
+
32
+
33
+ class StorageMode(str, Enum):
34
+ """Storage modes for DRY linter cache."""
35
+
36
+ MEMORY = "memory"
37
+ TEMPFILE = "tempfile"
38
+
39
+
40
+ class IgnoreDirective(str, Enum):
41
+ """Inline ignore directive types."""
42
+
43
+ IGNORE = "ignore"
44
+ IGNORE_FILE = "ignore-file"
45
+
46
+
47
+ # Valid config file extensions
48
+ CONFIG_EXTENSIONS: tuple[str, str] = (".yaml", ".yml")
49
+
50
+ # Number of lines to scan at file start for ignore directives and headers
51
+ HEADER_SCAN_LINES: int = 10
52
+
53
+ # Maximum depth for attribute chain traversal (e.g., obj.attr.attr2.attr3)
54
+ MAX_ATTRIBUTE_CHAIN_DEPTH: int = 3
src/core/linter_utils.py CHANGED
@@ -1,26 +1,48 @@
1
1
  """
2
2
  Purpose: Shared utility functions for linter framework patterns
3
3
 
4
- Scope: Common config loading, metadata access, and context validation utilities for all linters
4
+ Scope: Common config loading, metadata access, context validation, and AST parsing utilities
5
5
 
6
6
  Overview: Provides reusable helper functions to eliminate duplication across linter implementations.
7
7
  Includes utilities for loading configuration from context metadata with language-specific overrides,
8
- extracting metadata fields safely with type validation, and validating context state. Standardizes
9
- common patterns used by srp, nesting, dry, and file_placement linters. Reduces boilerplate code
10
- while maintaining type safety and proper error handling.
8
+ extracting metadata fields safely with type validation, validating context state, and parsing
9
+ Python AST with syntax error handling. Standardizes common patterns used by srp, nesting, dry,
10
+ performance, and file_placement linters. Reduces boilerplate code while maintaining type safety
11
+ and proper error handling.
11
12
 
12
- Dependencies: BaseLintContext from src.core.base
13
+ Dependencies: BaseLintContext from src.core.base, ast for Python parsing
13
14
 
14
- Exports: get_metadata, get_metadata_value, load_linter_config, has_file_content
15
+ Exports: get_metadata, get_metadata_value, load_linter_config, has_file_content, parse_python_ast,
16
+ with_parsed_python
15
17
 
16
18
  Interfaces: All functions take BaseLintContext and return typed values (dict, str, bool, Any)
17
19
 
18
20
  Implementation: Type-safe metadata access with fallbacks, generic config loading with language support
21
+
22
+ Suppressions:
23
+ - invalid-name: T type variable follows Python generic naming convention
24
+ - type:ignore[return-value]: Generic config factory with runtime type checking
25
+ - unnecessary-ellipsis: Protocol method bodies use ellipsis per PEP 544
26
+ - B101: Assert used to narrow type after parse_python_ast returns non-None tree
19
27
  """
20
28
 
29
+ import ast
30
+ from collections.abc import Callable
21
31
  from typing import Any, Protocol, TypeVar
22
32
 
23
33
  from src.core.base import BaseLintContext
34
+ from src.core.types import Violation
35
+
36
+
37
+ # Protocol for violation builders that support syntax error handling
38
+ class SyntaxErrorViolationBuilder(Protocol):
39
+ """Protocol for violation builders that can create syntax error violations."""
40
+
41
+ def create_syntax_error_violation(
42
+ self, error: SyntaxError, context: BaseLintContext
43
+ ) -> Violation:
44
+ """Create a violation for a syntax error."""
45
+ ... # pylint: disable=unnecessary-ellipsis
24
46
 
25
47
 
26
48
  # Protocol for config classes that support from_dict
@@ -166,3 +188,70 @@ def should_process_file(context: BaseLintContext) -> bool:
166
188
  True if file has both content and path available
167
189
  """
168
190
  return has_file_content(context) and has_file_path(context)
191
+
192
+
193
+ def parse_python_ast(
194
+ context: BaseLintContext,
195
+ violation_builder: SyntaxErrorViolationBuilder,
196
+ ) -> tuple[ast.Module | None, list[Violation]]:
197
+ """Parse Python AST from context, handling syntax errors gracefully.
198
+
199
+ Provides a standard pattern for Python linters to parse AST and handle
200
+ syntax errors by returning a violation instead of crashing.
201
+
202
+ Args:
203
+ context: Lint context containing file content
204
+ violation_builder: Builder to create syntax error violations
205
+
206
+ Returns:
207
+ Tuple of (ast_tree, violations):
208
+ - On success: (ast.Module, [])
209
+ - On syntax error: (None, [syntax_error_violation])
210
+
211
+ Example:
212
+ tree, errors = parse_python_ast(context, self._violation_builder)
213
+ if errors:
214
+ return errors
215
+ # ... use tree for analysis
216
+ """
217
+ try:
218
+ tree = ast.parse(context.file_content or "")
219
+ return tree, []
220
+ except SyntaxError as e:
221
+ violation = violation_builder.create_syntax_error_violation(e, context)
222
+ return None, [violation]
223
+
224
+
225
+ def with_parsed_python(
226
+ context: BaseLintContext,
227
+ violation_builder: SyntaxErrorViolationBuilder,
228
+ on_success: Callable[[ast.Module], list[Violation]],
229
+ ) -> list[Violation]:
230
+ """Parse Python and call on_success with the AST, or return parse errors.
231
+
232
+ Eliminates the repeated parse-check-assert pattern across Python linters.
233
+ On parse success, calls on_success with a guaranteed non-None AST tree.
234
+ On parse failure, returns syntax error violations.
235
+
236
+ Args:
237
+ context: Lint context containing file content
238
+ violation_builder: Builder to create syntax error violations
239
+ on_success: Callback receiving the parsed AST tree, returns violations
240
+
241
+ Returns:
242
+ Violations from on_success callback, or syntax error violations
243
+
244
+ Example:
245
+ def _check_python(self, context, config):
246
+ return with_parsed_python(
247
+ context,
248
+ self._violation_builder,
249
+ lambda tree: self._analyze_tree(tree, config, context),
250
+ )
251
+ """
252
+ tree, errors = parse_python_ast(context, violation_builder)
253
+ if errors:
254
+ return errors
255
+ # tree is guaranteed non-None when errors is empty (parse_python_ast contract)
256
+ assert tree is not None # nosec B101
257
+ return on_success(tree)
@@ -0,0 +1,101 @@
1
+ """
2
+ Purpose: Base class for Python-only linters with common boilerplate
3
+
4
+ Scope: Shared infrastructure for Python-only lint rules
5
+
6
+ Overview: Provides PythonOnlyLintRule abstract base class that handles common boilerplate
7
+ for Python-only linters. Subclasses implement the abstract properties and analysis
8
+ method while the base class handles language checking, config loading, and enabled
9
+ checking. This eliminates duplicate code across Python-only linters like CQS and LBYL.
10
+
11
+ Dependencies: BaseLintRule, BaseLintContext, Language, load_linter_config, has_file_content
12
+
13
+ Exports: PythonOnlyLintRule
14
+
15
+ Interfaces: Subclasses implement _config_key, _config_class, _analyze, and rule metadata
16
+
17
+ Implementation: Template method pattern for Python linter boilerplate
18
+ """
19
+
20
+ from abc import abstractmethod
21
+ from typing import Any, Generic
22
+
23
+ from .base import BaseLintContext, BaseLintRule
24
+ from .constants import Language
25
+ from .linter_utils import ConfigType, has_file_content, load_linter_config
26
+ from .types import Violation
27
+
28
+
29
+ class PythonOnlyLintRule(BaseLintRule, Generic[ConfigType]):
30
+ """Base class for Python-only linters with common boilerplate.
31
+
32
+ Handles language checking, config loading, and enabled checking.
33
+ Subclasses provide the config key, config class, and analysis logic.
34
+ """
35
+
36
+ def __init__(self, config: ConfigType | None = None) -> None:
37
+ """Initialize with optional config override.
38
+
39
+ Args:
40
+ config: Optional configuration override for testing
41
+ """
42
+ self._config_override = config
43
+
44
+ @property
45
+ @abstractmethod
46
+ def _config_key(self) -> str:
47
+ """Configuration key in metadata (e.g., 'cqs', 'lbyl')."""
48
+ raise NotImplementedError
49
+
50
+ @property
51
+ @abstractmethod
52
+ def _config_class(self) -> type[ConfigType]:
53
+ """Configuration class type."""
54
+ raise NotImplementedError
55
+
56
+ @abstractmethod
57
+ def _analyze(self, code: str, file_path: str, config: ConfigType) -> list[Violation]:
58
+ """Perform linter-specific analysis.
59
+
60
+ Args:
61
+ code: Python source code
62
+ file_path: Path to the file
63
+ config: Loaded configuration
64
+
65
+ Returns:
66
+ List of violations found
67
+ """
68
+ raise NotImplementedError
69
+
70
+ def check(self, context: BaseLintContext) -> list[Violation]:
71
+ """Check for violations in the given context.
72
+
73
+ Args:
74
+ context: The lint context containing file information.
75
+
76
+ Returns:
77
+ List of violations found.
78
+ """
79
+ if not self._should_analyze(context):
80
+ return []
81
+
82
+ config = self._get_config(context)
83
+ if not self._is_enabled(config):
84
+ return []
85
+
86
+ file_path = str(context.file_path) if context.file_path else "unknown"
87
+ return self._analyze(context.file_content or "", file_path, config)
88
+
89
+ def _should_analyze(self, context: BaseLintContext) -> bool:
90
+ """Check if context should be analyzed."""
91
+ return context.language == Language.PYTHON and has_file_content(context)
92
+
93
+ def _get_config(self, context: BaseLintContext) -> ConfigType:
94
+ """Get configuration, using override if provided."""
95
+ if self._config_override is not None:
96
+ return self._config_override
97
+ return load_linter_config(context, self._config_key, self._config_class)
98
+
99
+ def _is_enabled(self, config: Any) -> bool:
100
+ """Check if linter is enabled in config."""
101
+ return getattr(config, "enabled", True)
src/core/registry.py CHANGED
@@ -6,7 +6,7 @@ Scope: Dynamic rule management and discovery across all linter plugin packages
6
6
  Overview: Implements rule registry that maintains a collection of registered linting rules indexed
7
7
  by rule_id. Provides methods to register individual rules, retrieve rules by identifier, list
8
8
  all available rules, and discover rules from packages using the RuleDiscovery helper. Enables
9
- the extensible plugin architecture by allowing rules to be added dynamically without framework
9
+ the extensible plugin architecture by allowing dynamic rule registration without framework
10
10
  modifications. Validates rule uniqueness and handles registration errors gracefully.
11
11
 
12
12
  Dependencies: BaseLintRule, RuleDiscovery
@@ -10,7 +10,7 @@ Overview: Provides automatic rule discovery functionality for the linter framewo
10
10
 
11
11
  Dependencies: importlib, inspect, pkgutil, BaseLintRule
12
12
 
13
- Exports: RuleDiscovery
13
+ Exports: discover_from_package function, RuleDiscovery class (compat)
14
14
 
15
15
  Interfaces: discover_from_package(package_path) -> list[BaseLintRule]
16
16
 
@@ -19,114 +19,177 @@ Implementation: Package traversal with pkgutil, class introspection with inspect
19
19
 
20
20
  import importlib
21
21
  import inspect
22
+ import logging
22
23
  import pkgutil
24
+ from types import ModuleType
23
25
  from typing import Any
24
26
 
25
27
  from .base import BaseLintRule
26
28
 
29
+ logger = logging.getLogger(__name__)
27
30
 
28
- class RuleDiscovery:
29
- """Discovers linting rules from Python packages."""
30
31
 
31
- def discover_from_package(self, package_path: str) -> list[BaseLintRule]:
32
- """Discover rules from a package and its modules.
32
+ def discover_from_package(package_path: str) -> list[BaseLintRule]:
33
+ """Discover rules from a package and its modules.
33
34
 
34
- Args:
35
- package_path: Python package path (e.g., 'src.linters')
35
+ Args:
36
+ package_path: Python package path (e.g., 'src.linters')
36
37
 
37
- Returns:
38
- List of discovered rule instances
39
- """
40
- try:
41
- package = importlib.import_module(package_path)
42
- except ImportError:
43
- return []
38
+ Returns:
39
+ List of discovered rule instances
40
+ """
41
+ try:
42
+ package = importlib.import_module(package_path)
43
+ except ImportError as e:
44
+ logger.debug("Failed to import package %s: %s", package_path, e)
45
+ return []
44
46
 
45
- if not hasattr(package, "__path__"):
46
- return self._discover_from_module(package_path)
47
+ if not hasattr(package, "__path__"):
48
+ return _discover_from_module(package_path)
47
49
 
48
- return self._discover_from_package_modules(package_path, package)
50
+ return _discover_from_package_modules(package_path, package)
49
51
 
50
- def _discover_from_package_modules(self, package_path: str, package: Any) -> list[BaseLintRule]:
51
- """Discover rules from all modules in a package.
52
52
 
53
- Args:
54
- package_path: Package path
55
- package: Imported package object
53
+ def _discover_from_package_modules(package_path: str, package: Any) -> list[BaseLintRule]:
54
+ """Discover rules from all modules in a package.
56
55
 
57
- Returns:
58
- List of discovered rules
59
- """
60
- rules = []
61
- for _, module_name, _ in pkgutil.iter_modules(package.__path__):
62
- full_module_name = f"{package_path}.{module_name}"
63
- module_rules = self._try_discover_from_module(full_module_name)
64
- rules.extend(module_rules)
65
- return rules
56
+ Args:
57
+ package_path: Package path
58
+ package: Imported package object
66
59
 
67
- def _try_discover_from_module(self, module_name: str) -> list[BaseLintRule]:
68
- """Try to discover rules from a module, return empty list on error.
60
+ Returns:
61
+ List of discovered rules
62
+ """
63
+ rules = []
64
+ for _, module_name, _ in pkgutil.iter_modules(package.__path__):
65
+ full_module_name = f"{package_path}.{module_name}"
66
+ module_rules = _try_discover_from_module(full_module_name)
67
+ rules.extend(module_rules)
68
+ return rules
69
69
 
70
- Args:
71
- module_name: Full module name
72
70
 
73
- Returns:
74
- List of discovered rules (empty on error)
75
- """
76
- try:
77
- return self._discover_from_module(module_name)
78
- except (ImportError, AttributeError):
79
- return []
71
+ def _try_discover_from_module(module_name: str) -> list[BaseLintRule]:
72
+ """Try to discover rules from a module, return empty list on error.
80
73
 
81
- def _discover_from_module(self, module_path: str) -> list[BaseLintRule]:
82
- """Discover rules from a specific module.
74
+ Args:
75
+ module_name: Full module name
83
76
 
84
- Args:
85
- module_path: Full module path to search
77
+ Returns:
78
+ List of discovered rules (empty on error)
79
+ """
80
+ try:
81
+ return _discover_from_module(module_name)
82
+ except (ImportError, AttributeError):
83
+ return []
86
84
 
87
- Returns:
88
- List of discovered rule instances
89
- """
90
- try:
91
- module = importlib.import_module(module_path)
92
- except (ImportError, AttributeError):
93
- return []
94
-
95
- rules = []
96
- for _name, obj in inspect.getmembers(module):
97
- if not self._is_rule_class(obj):
98
- continue
99
- rule_instance = self._try_instantiate_rule(obj)
100
- if rule_instance:
101
- rules.append(rule_instance)
102
- return rules
103
-
104
- def _try_instantiate_rule(self, rule_class: type[BaseLintRule]) -> BaseLintRule | None:
105
- """Try to instantiate a rule class.
106
85
 
107
- Args:
108
- rule_class: Rule class to instantiate
86
+ def _discover_from_module(module_path: str) -> list[BaseLintRule]:
87
+ """Discover rules from a specific module.
109
88
 
110
- Returns:
111
- Rule instance or None on error
112
- """
113
- try:
114
- return rule_class()
115
- except (TypeError, AttributeError):
116
- return None
89
+ Args:
90
+ module_path: Full module path to search
91
+
92
+ Returns:
93
+ List of discovered rule instances
94
+ """
95
+ module = _try_import_module(module_path)
96
+ if module is None:
97
+ return []
98
+ return _extract_rules_from_module(module)
99
+
100
+
101
+ def _try_import_module(module_path: str) -> ModuleType | None:
102
+ """Try to import a module, returning None on failure.
103
+
104
+ Args:
105
+ module_path: Full module path to import
106
+
107
+ Returns:
108
+ Module object or None if import fails
109
+ """
110
+ try:
111
+ return importlib.import_module(module_path)
112
+ except (ImportError, AttributeError):
113
+ return None
117
114
 
118
- def _is_rule_class(self, obj: Any) -> bool:
119
- """Check if an object is a valid rule class.
115
+
116
+ def _extract_rules_from_module(module: ModuleType) -> list[BaseLintRule]:
117
+ """Extract rule instances from a module.
118
+
119
+ Args:
120
+ module: Imported module to scan
121
+
122
+ Returns:
123
+ List of discovered rule instances
124
+ """
125
+ rule_classes = [obj for _name, obj in inspect.getmembers(module) if _is_rule_class(obj)]
126
+ return _instantiate_rules(rule_classes)
127
+
128
+
129
+ def _instantiate_rules(rule_classes: list[type[BaseLintRule]]) -> list[BaseLintRule]:
130
+ """Instantiate a list of rule classes.
131
+
132
+ Args:
133
+ rule_classes: List of rule classes to instantiate
134
+
135
+ Returns:
136
+ List of successfully instantiated rules
137
+ """
138
+ instances = (_try_instantiate_rule(cls) for cls in rule_classes)
139
+ return [inst for inst in instances if inst is not None]
140
+
141
+
142
+ def _try_instantiate_rule(rule_class: type[BaseLintRule]) -> BaseLintRule | None:
143
+ """Try to instantiate a rule class.
144
+
145
+ Args:
146
+ rule_class: Rule class to instantiate
147
+
148
+ Returns:
149
+ Rule instance or None on error
150
+ """
151
+ try:
152
+ return rule_class()
153
+ except (TypeError, AttributeError):
154
+ return None
155
+
156
+
157
+ def _is_rule_class(obj: Any) -> bool:
158
+ """Check if an object is a valid rule class.
159
+
160
+ Args:
161
+ obj: Object to check
162
+
163
+ Returns:
164
+ True if obj is a concrete BaseLintRule subclass
165
+ """
166
+ return (
167
+ inspect.isclass(obj)
168
+ and issubclass(obj, BaseLintRule)
169
+ and obj is not BaseLintRule
170
+ and not inspect.isabstract(obj)
171
+ )
172
+
173
+
174
+ # Legacy class wrapper for backward compatibility
175
+ class RuleDiscovery:
176
+ """Discovers linting rules from Python packages.
177
+
178
+ Note: This class is a thin wrapper around module-level functions
179
+ for backward compatibility.
180
+ """
181
+
182
+ def __init__(self) -> None:
183
+ """Initialize the discovery service."""
184
+ pass # No state needed
185
+
186
+ def discover_from_package(self, package_path: str) -> list[BaseLintRule]:
187
+ """Discover rules from a package and its modules.
120
188
 
121
189
  Args:
122
- obj: Object to check
190
+ package_path: Python package path (e.g., 'src.linters')
123
191
 
124
192
  Returns:
125
- True if obj is a concrete BaseLintRule subclass
193
+ List of discovered rule instances
126
194
  """
127
- return (
128
- inspect.isclass(obj)
129
- and issubclass(obj, BaseLintRule)
130
- and obj is not BaseLintRule
131
- and not inspect.isabstract(obj)
132
- )
195
+ return discover_from_package(package_path)
src/core/types.py CHANGED
@@ -81,3 +81,16 @@ class Violation:
81
81
  "severity": self.severity.value,
82
82
  "suggestion": self.suggestion,
83
83
  }
84
+
85
+ @classmethod
86
+ def from_dict(cls, data: dict) -> "Violation":
87
+ """Reconstruct Violation from dictionary (for parallel processing)."""
88
+ return cls(
89
+ rule_id=data["rule_id"],
90
+ file_path=data["file_path"],
91
+ line=data["line"],
92
+ column=data["column"],
93
+ message=data["message"],
94
+ severity=Severity(data["severity"]),
95
+ suggestion=data.get("suggestion"),
96
+ )