thailint 0.9.0__py3-none-any.whl → 0.11.0__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 (69) hide show
  1. src/__init__.py +1 -0
  2. src/cli/__init__.py +27 -0
  3. src/cli/__main__.py +22 -0
  4. src/cli/config.py +478 -0
  5. src/cli/linters/__init__.py +58 -0
  6. src/cli/linters/code_patterns.py +372 -0
  7. src/cli/linters/code_smells.py +343 -0
  8. src/cli/linters/documentation.py +155 -0
  9. src/cli/linters/shared.py +89 -0
  10. src/cli/linters/structure.py +313 -0
  11. src/cli/linters/structure_quality.py +316 -0
  12. src/cli/main.py +120 -0
  13. src/cli/utils.py +375 -0
  14. src/cli_main.py +34 -0
  15. src/config.py +2 -3
  16. src/core/rule_discovery.py +43 -10
  17. src/core/types.py +13 -0
  18. src/core/violation_utils.py +69 -0
  19. src/linter_config/ignore.py +32 -16
  20. src/linters/collection_pipeline/__init__.py +90 -0
  21. src/linters/collection_pipeline/config.py +63 -0
  22. src/linters/collection_pipeline/continue_analyzer.py +100 -0
  23. src/linters/collection_pipeline/detector.py +130 -0
  24. src/linters/collection_pipeline/linter.py +437 -0
  25. src/linters/collection_pipeline/suggestion_builder.py +63 -0
  26. src/linters/dry/block_filter.py +99 -9
  27. src/linters/dry/cache.py +94 -6
  28. src/linters/dry/config.py +47 -10
  29. src/linters/dry/constant.py +92 -0
  30. src/linters/dry/constant_matcher.py +214 -0
  31. src/linters/dry/constant_violation_builder.py +98 -0
  32. src/linters/dry/linter.py +89 -48
  33. src/linters/dry/python_analyzer.py +44 -431
  34. src/linters/dry/python_constant_extractor.py +101 -0
  35. src/linters/dry/single_statement_detector.py +415 -0
  36. src/linters/dry/token_hasher.py +5 -5
  37. src/linters/dry/typescript_analyzer.py +63 -382
  38. src/linters/dry/typescript_constant_extractor.py +134 -0
  39. src/linters/dry/typescript_statement_detector.py +255 -0
  40. src/linters/dry/typescript_value_extractor.py +66 -0
  41. src/linters/file_header/linter.py +9 -13
  42. src/linters/file_placement/linter.py +30 -10
  43. src/linters/file_placement/pattern_matcher.py +19 -5
  44. src/linters/magic_numbers/linter.py +8 -67
  45. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  46. src/linters/nesting/linter.py +12 -9
  47. src/linters/print_statements/linter.py +7 -24
  48. src/linters/srp/class_analyzer.py +9 -9
  49. src/linters/srp/heuristics.py +6 -5
  50. src/linters/srp/linter.py +4 -5
  51. src/linters/stateless_class/linter.py +2 -2
  52. src/linters/stringly_typed/__init__.py +23 -0
  53. src/linters/stringly_typed/config.py +165 -0
  54. src/linters/stringly_typed/python/__init__.py +29 -0
  55. src/linters/stringly_typed/python/analyzer.py +198 -0
  56. src/linters/stringly_typed/python/condition_extractor.py +131 -0
  57. src/linters/stringly_typed/python/conditional_detector.py +176 -0
  58. src/linters/stringly_typed/python/constants.py +21 -0
  59. src/linters/stringly_typed/python/match_analyzer.py +88 -0
  60. src/linters/stringly_typed/python/validation_detector.py +186 -0
  61. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  62. src/orchestrator/core.py +241 -12
  63. {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/METADATA +116 -3
  64. {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/RECORD +67 -29
  65. thailint-0.11.0.dist-info/entry_points.txt +4 -0
  66. src/cli.py +0 -2014
  67. thailint-0.9.0.dist-info/entry_points.txt +0 -4
  68. {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/WHEEL +0 -0
  69. {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/licenses/LICENSE +0 -0
src/cli/main.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ Purpose: Main CLI group definition and core setup for thai-lint command-line interface
3
+
4
+ Scope: Core Click group configuration, version handling, global options, and context setup
5
+
6
+ Overview: Defines the root CLI command group using Click framework with version option and global
7
+ options (verbose, config, project-root). Handles context initialization, logging setup, and
8
+ configuration loading. Serves as the central entry point that other CLI modules register
9
+ commands against. Provides the foundation for modular CLI architecture where commands are
10
+ defined in separate modules but registered to this main group.
11
+
12
+ Dependencies: click for CLI framework, src.config for configuration loading, src.__version__ for
13
+ version info
14
+
15
+ Exports: cli (main Click command group), setup_logging function
16
+
17
+ Interfaces: Click context object with config, verbose, project_root options stored in ctx.obj
18
+
19
+ Implementation: Uses Click decorators for group definition, stores parsed options in context
20
+ for child commands to access. Defers project root determination to avoid import issues
21
+ in test environments.
22
+ """
23
+
24
+ import logging
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ import click
29
+
30
+ from src import __version__
31
+ from src.config import ConfigError, load_config
32
+
33
+ # Configure module logger
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def setup_logging(verbose: bool = False) -> None:
38
+ """Configure logging for the CLI application.
39
+
40
+ Args:
41
+ verbose: Enable DEBUG level logging if True, INFO otherwise.
42
+ """
43
+ level = logging.DEBUG if verbose else logging.INFO
44
+
45
+ logging.basicConfig(
46
+ level=level,
47
+ format="%(asctime)s | %(levelname)-8s | %(message)s",
48
+ datefmt="%Y-%m-%d %H:%M:%S",
49
+ stream=sys.stdout,
50
+ )
51
+
52
+
53
+ @click.group()
54
+ @click.version_option(version=__version__)
55
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
56
+ @click.option("--config", "-c", type=click.Path(), help="Path to config file")
57
+ @click.option(
58
+ "--project-root",
59
+ type=click.Path(),
60
+ help="Explicitly specify project root directory (overrides auto-detection)",
61
+ )
62
+ @click.pass_context
63
+ def cli(ctx: click.Context, verbose: bool, config: str | None, project_root: str | None) -> None:
64
+ """thai-lint - AI code linter and governance tool
65
+
66
+ Lint and governance for AI-generated code across multiple languages.
67
+ Identifies common mistakes, anti-patterns, and security issues.
68
+
69
+ Examples:
70
+
71
+ \b
72
+ # Check for duplicate code (DRY violations)
73
+ thai-lint dry .
74
+
75
+ \b
76
+ # Lint current directory for file placement issues
77
+ thai-lint file-placement .
78
+
79
+ \b
80
+ # Lint with custom config
81
+ thai-lint file-placement --config .thailint.yaml src/
82
+
83
+ \b
84
+ # Specify project root explicitly (useful in Docker)
85
+ thai-lint --project-root /workspace/root magic-numbers backend/
86
+
87
+ \b
88
+ # Get JSON output
89
+ thai-lint file-placement --format json .
90
+
91
+ \b
92
+ # Show help
93
+ thai-lint --help
94
+ """
95
+ # Ensure context object exists
96
+ ctx.ensure_object(dict)
97
+
98
+ # Setup logging
99
+ setup_logging(verbose)
100
+
101
+ # Store CLI options for later project root determination
102
+ # (deferred to avoid pyprojroot import issues in test environments)
103
+ ctx.obj["cli_project_root"] = project_root
104
+ ctx.obj["cli_config_path"] = config
105
+
106
+ # Load configuration
107
+ try:
108
+ if config:
109
+ ctx.obj["config"] = load_config(Path(config))
110
+ ctx.obj["config_path"] = Path(config)
111
+ else:
112
+ ctx.obj["config"] = load_config()
113
+ ctx.obj["config_path"] = None
114
+
115
+ logger.debug("Configuration loaded successfully")
116
+ except ConfigError as e:
117
+ click.echo(f"Error loading configuration: {e}", err=True)
118
+ sys.exit(2)
119
+
120
+ ctx.obj["verbose"] = verbose
src/cli/utils.py ADDED
@@ -0,0 +1,375 @@
1
+ """
2
+ Purpose: Shared CLI utilities and helper functions for thai-lint commands
3
+
4
+ Scope: Project root resolution, path validation, common decorators, and orchestrator setup
5
+
6
+ Overview: Provides reusable utilities for CLI commands including project root determination with
7
+ precedence rules (explicit > config-inferred > auto-detected), path existence validation,
8
+ common Click option decorators (format, project-root), and orchestrator setup helpers.
9
+ Centralizes shared logic to reduce duplication across linter command modules while
10
+ maintaining consistent behavior for all CLI operations.
11
+
12
+ Dependencies: click for CLI framework, pathlib for file paths, logging for debug output,
13
+ src.orchestrator for linting execution, src.utils.project_root for auto-detection
14
+
15
+ Exports: format_option decorator, get_project_root_from_context, validate_paths_exist,
16
+ setup_base_orchestrator, execute_linting_on_paths, handle_linting_error
17
+
18
+ Interfaces: Click context integration via ctx.obj, Path objects for file operations
19
+
20
+ Implementation: Uses Click decorators for option definitions, deferred imports for orchestrator
21
+ to support test environments, caches project root in context for efficiency
22
+ """
23
+
24
+ import logging
25
+ import sys
26
+ from collections.abc import Callable
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING, Any, TypeVar
29
+
30
+ import click
31
+
32
+ if TYPE_CHECKING:
33
+ from src.orchestrator.core import Orchestrator
34
+
35
+ # Configure module logger
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ # =============================================================================
40
+ # Common Option Decorators
41
+ # =============================================================================
42
+
43
+
44
+ F = TypeVar("F", bound=Callable[..., object])
45
+
46
+
47
+ def format_option(func: F) -> F:
48
+ """Add --format option to a command for output format selection."""
49
+ return click.option(
50
+ "--format",
51
+ "-f",
52
+ type=click.Choice(["text", "json", "sarif"]),
53
+ default="text",
54
+ help="Output format",
55
+ )(func)
56
+
57
+
58
+ def parallel_option(func: F) -> F:
59
+ """Add --parallel option to enable multi-core file processing."""
60
+ return click.option(
61
+ "--parallel",
62
+ "-p",
63
+ is_flag=True,
64
+ default=False,
65
+ help="Enable parallel file processing (uses multiple CPU cores)",
66
+ )(func)
67
+
68
+
69
+ # =============================================================================
70
+ # Project Root Determination
71
+ # =============================================================================
72
+
73
+
74
+ def _determine_project_root(
75
+ explicit_root: str | None, config_path: str | None, verbose: bool
76
+ ) -> Path:
77
+ """Determine project root with precedence rules.
78
+
79
+ Precedence order:
80
+ 1. Explicit --project-root (highest priority)
81
+ 2. Inferred from --config path directory
82
+ 3. Auto-detection via get_project_root() (fallback)
83
+
84
+ Args:
85
+ explicit_root: Explicitly specified project root path (from --project-root)
86
+ config_path: Config file path (from --config)
87
+ verbose: Whether verbose logging is enabled
88
+
89
+ Returns:
90
+ Path to determined project root
91
+
92
+ Raises:
93
+ SystemExit: If explicit_root doesn't exist or is not a directory
94
+ """
95
+ from src.utils.project_root import get_project_root
96
+
97
+ # Priority 1: Explicit --project-root
98
+ if explicit_root:
99
+ return _resolve_explicit_project_root(explicit_root, verbose)
100
+
101
+ # Priority 2: Infer from --config path
102
+ if config_path:
103
+ return _infer_root_from_config(config_path, verbose)
104
+
105
+ # Priority 3: Auto-detection (fallback)
106
+ return _autodetect_project_root(verbose, get_project_root)
107
+
108
+
109
+ def _resolve_explicit_project_root(explicit_root: str, verbose: bool) -> Path:
110
+ """Resolve and validate explicitly specified project root.
111
+
112
+ Args:
113
+ explicit_root: Explicitly specified project root path
114
+ verbose: Whether verbose logging is enabled
115
+
116
+ Returns:
117
+ Resolved project root path
118
+
119
+ Raises:
120
+ SystemExit: If explicit_root doesn't exist or is not a directory
121
+ """
122
+ root = Path(explicit_root)
123
+ # Check existence before resolving to handle relative paths in test environments
124
+ if not root.exists():
125
+ click.echo(f"Error: Project root does not exist: {explicit_root}", err=True)
126
+ sys.exit(2)
127
+ if not root.is_dir():
128
+ click.echo(f"Error: Project root must be a directory: {explicit_root}", err=True)
129
+ sys.exit(2)
130
+
131
+ # Now resolve after validation
132
+ root = root.resolve()
133
+
134
+ if verbose:
135
+ logger.debug(f"Using explicit project root: {root}")
136
+ return root
137
+
138
+
139
+ def _infer_root_from_config(config_path: str, verbose: bool) -> Path:
140
+ """Infer project root from config file path.
141
+
142
+ Args:
143
+ config_path: Config file path
144
+ verbose: Whether verbose logging is enabled
145
+
146
+ Returns:
147
+ Inferred project root (parent directory of config file)
148
+ """
149
+ config_file = Path(config_path).resolve()
150
+ inferred_root = config_file.parent
151
+
152
+ if verbose:
153
+ logger.debug(f"Inferred project root from config path: {inferred_root}")
154
+ return inferred_root
155
+
156
+
157
+ def _autodetect_project_root(
158
+ verbose: bool, get_project_root: Callable[[Path | None], Path]
159
+ ) -> Path:
160
+ """Auto-detect project root using project root detection.
161
+
162
+ Args:
163
+ verbose: Whether verbose logging is enabled
164
+ get_project_root: Function to detect project root
165
+
166
+ Returns:
167
+ Auto-detected project root
168
+ """
169
+ auto_root = get_project_root(None)
170
+ if verbose:
171
+ logger.debug(f"Auto-detected project root: {auto_root}")
172
+ return auto_root
173
+
174
+
175
+ def get_project_root_from_context(ctx: click.Context) -> Path:
176
+ """Get or determine project root from Click context.
177
+
178
+ This function defers the actual determination until needed to avoid
179
+ importing pyprojroot in test environments where it may not be available.
180
+
181
+ Args:
182
+ ctx: Click context containing CLI options
183
+
184
+ Returns:
185
+ Path to determined project root
186
+ """
187
+ # Check if already determined and cached
188
+ if "project_root" in ctx.obj:
189
+ cached_root: Path = ctx.obj["project_root"]
190
+ return cached_root
191
+
192
+ # Determine project root using stored CLI options
193
+ explicit_root = ctx.obj.get("cli_project_root")
194
+ config_path = ctx.obj.get("cli_config_path")
195
+ verbose = ctx.obj.get("verbose", False)
196
+
197
+ project_root = _determine_project_root(explicit_root, config_path, verbose)
198
+
199
+ # Cache for future use
200
+ ctx.obj["project_root"] = project_root
201
+
202
+ return project_root
203
+
204
+
205
+ # =============================================================================
206
+ # Path Validation
207
+ # =============================================================================
208
+
209
+
210
+ def validate_paths_exist(path_objs: list[Path]) -> None:
211
+ """Validate that all provided paths exist.
212
+
213
+ Args:
214
+ path_objs: List of Path objects to validate
215
+
216
+ Raises:
217
+ SystemExit: If any path doesn't exist (exit code 2)
218
+ """
219
+ for path in path_objs:
220
+ if not path.exists():
221
+ click.echo(f"Error: Path does not exist: {path}", err=True)
222
+ click.echo("", err=True)
223
+ click.echo(
224
+ "Hint: When using Docker, ensure paths are inside the mounted volume:", err=True
225
+ )
226
+ click.echo(
227
+ " docker run -v $(pwd):/data thailint <command> /data/your-file.py", err=True
228
+ )
229
+ sys.exit(2)
230
+
231
+
232
+ # =============================================================================
233
+ # Error Handling
234
+ # =============================================================================
235
+
236
+
237
+ def handle_linting_error(error: Exception, verbose: bool) -> None:
238
+ """Handle linting errors.
239
+
240
+ Args:
241
+ error: The exception that occurred
242
+ verbose: Whether verbose logging is enabled
243
+ """
244
+ click.echo(f"Error during linting: {error}", err=True)
245
+ if verbose:
246
+ logger.exception("Linting failed with exception")
247
+ sys.exit(2)
248
+
249
+
250
+ # =============================================================================
251
+ # Orchestrator Setup
252
+ # =============================================================================
253
+
254
+
255
+ def get_or_detect_project_root(path_objs: list[Path], project_root: Path | None) -> Path:
256
+ """Get provided project root or auto-detect from paths.
257
+
258
+ Args:
259
+ path_objs: List of path objects
260
+ project_root: Optionally provided project root
261
+
262
+ Returns:
263
+ Project root path
264
+ """
265
+ if project_root is not None:
266
+ return project_root
267
+
268
+ from src.utils.project_root import get_project_root
269
+
270
+ # Find actual project root (where .git or pyproject.toml exists)
271
+ first_path = path_objs[0] if path_objs else Path.cwd()
272
+ search_start = first_path if first_path.is_dir() else first_path.parent
273
+ return get_project_root(search_start)
274
+
275
+
276
+ def setup_base_orchestrator(
277
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
278
+ ) -> "Orchestrator":
279
+ """Set up orchestrator for linter commands.
280
+
281
+ Args:
282
+ path_objs: List of path objects to lint
283
+ config_file: Optional config file path
284
+ verbose: Whether verbose logging is enabled
285
+ project_root: Optional explicit project root
286
+
287
+ Returns:
288
+ Configured Orchestrator instance
289
+ """
290
+ from src.orchestrator.core import Orchestrator
291
+
292
+ root = get_or_detect_project_root(path_objs, project_root)
293
+ orchestrator = Orchestrator(project_root=root)
294
+
295
+ if config_file:
296
+ load_config_file(orchestrator, config_file, verbose)
297
+
298
+ return orchestrator
299
+
300
+
301
+ def load_config_file(orchestrator: "Orchestrator", config_file: str, verbose: bool) -> None:
302
+ """Load configuration from external file.
303
+
304
+ Args:
305
+ orchestrator: Orchestrator instance
306
+ config_file: Path to config file
307
+ verbose: Whether verbose logging is enabled
308
+ """
309
+ config_path = Path(config_file)
310
+ if not config_path.exists():
311
+ click.echo(f"Error: Config file not found: {config_file}", err=True)
312
+ sys.exit(2)
313
+
314
+ # Load config into orchestrator
315
+ orchestrator.config = orchestrator.config_loader.load(config_path)
316
+
317
+ if verbose:
318
+ logger.debug(f"Loaded config from: {config_file}")
319
+
320
+
321
+ # =============================================================================
322
+ # Linting Execution
323
+ # =============================================================================
324
+
325
+
326
+ def separate_files_and_dirs(path_objs: list[Path]) -> tuple[list[Path], list[Path]]:
327
+ """Separate file paths from directory paths.
328
+
329
+ Args:
330
+ path_objs: List of Path objects
331
+
332
+ Returns:
333
+ Tuple of (files, directories)
334
+ """
335
+ files = [p for p in path_objs if p.is_file()]
336
+ dirs = [p for p in path_objs if p.is_dir()]
337
+ return files, dirs
338
+
339
+
340
+ def execute_linting_on_paths(
341
+ orchestrator: "Orchestrator",
342
+ path_objs: list[Path],
343
+ recursive: bool,
344
+ parallel: bool = False,
345
+ ) -> list[Any]:
346
+ """Execute linting on list of file/directory paths.
347
+
348
+ Args:
349
+ orchestrator: Orchestrator instance
350
+ path_objs: List of Path objects (files or directories)
351
+ recursive: Whether to scan directories recursively
352
+ parallel: Whether to use parallel processing for multiple files
353
+
354
+ Returns:
355
+ List of violations from all paths
356
+ """
357
+ files, dirs = separate_files_and_dirs(path_objs)
358
+
359
+ violations = []
360
+
361
+ # Lint files
362
+ if files:
363
+ if parallel:
364
+ violations.extend(orchestrator.lint_files_parallel(files))
365
+ else:
366
+ violations.extend(orchestrator.lint_files(files))
367
+
368
+ # Lint directories
369
+ for dir_path in dirs:
370
+ if parallel:
371
+ violations.extend(orchestrator.lint_directory_parallel(dir_path, recursive=recursive))
372
+ else:
373
+ violations.extend(orchestrator.lint_directory(dir_path, recursive=recursive))
374
+
375
+ return violations
src/cli_main.py ADDED
@@ -0,0 +1,34 @@
1
+ """
2
+ Purpose: Main CLI entrypoint for thai-lint command-line interface
3
+
4
+ Scope: CLI package initialization and command registration via module imports
5
+
6
+ Overview: Thin entry point that imports and re-exports the fully configured CLI from the modular
7
+ src.cli package. All linter commands are registered via decorator side effects when their
8
+ modules are imported. Configuration commands (hello, config group, init-config) are in
9
+ src.cli.config, and linter commands (nesting, srp, dry, magic-numbers, file-placement,
10
+ print-statements, file-header, method-property, stateless-class, pipeline) are in
11
+ src.cli.linters submodules.
12
+
13
+ Dependencies: click for CLI framework, src.cli for modular CLI package
14
+
15
+ Exports: cli (main command group with all commands registered)
16
+
17
+ Interfaces: Click CLI commands, integration with Orchestrator for linting execution
18
+
19
+ Implementation: Module imports trigger command registration via Click decorator side effects
20
+ """
21
+
22
+ # Import the main CLI group from the modular package
23
+ # Import config module to register configuration commands
24
+ # (hello, config group, init-config)
25
+ from src.cli import config as _config_module # noqa: F401
26
+
27
+ # Import linters package to register all linter commands
28
+ # (nesting, srp, dry, magic-numbers, file-placement, print-statements,
29
+ # file-header, method-property, stateless-class, pipeline)
30
+ from src.cli import linters as _linters_module # noqa: F401
31
+ from src.cli.main import cli # noqa: F401
32
+
33
+ if __name__ == "__main__":
34
+ cli()
src/config.py CHANGED
@@ -103,9 +103,8 @@ def _load_from_explicit_path(config_path: Path) -> dict[str, Any]:
103
103
 
104
104
  def _load_from_default_locations() -> dict[str, Any]:
105
105
  """Load config from default locations."""
106
- for location in CONFIG_LOCATIONS:
107
- if not location.exists():
108
- continue
106
+ existing_locations = (loc for loc in CONFIG_LOCATIONS if loc.exists())
107
+ for location in existing_locations:
109
108
  loaded_config = _try_load_from_location(location)
110
109
  if loaded_config:
111
110
  return loaded_config
@@ -20,6 +20,7 @@ Implementation: Package traversal with pkgutil, class introspection with inspect
20
20
  import importlib
21
21
  import inspect
22
22
  import pkgutil
23
+ from types import ModuleType
23
24
  from typing import Any
24
25
 
25
26
  from .base import BaseLintRule
@@ -87,19 +88,51 @@ def _discover_from_module(module_path: str) -> list[BaseLintRule]:
87
88
  Returns:
88
89
  List of discovered rule instances
89
90
  """
91
+ module = _try_import_module(module_path)
92
+ if module is None:
93
+ return []
94
+ return _extract_rules_from_module(module)
95
+
96
+
97
+ def _try_import_module(module_path: str) -> ModuleType | None:
98
+ """Try to import a module, returning None on failure.
99
+
100
+ Args:
101
+ module_path: Full module path to import
102
+
103
+ Returns:
104
+ Module object or None if import fails
105
+ """
90
106
  try:
91
- module = importlib.import_module(module_path)
107
+ return importlib.import_module(module_path)
92
108
  except (ImportError, AttributeError):
93
- return []
109
+ return None
94
110
 
95
- rules = []
96
- for _name, obj in inspect.getmembers(module):
97
- if not _is_rule_class(obj):
98
- continue
99
- rule_instance = _try_instantiate_rule(obj)
100
- if rule_instance:
101
- rules.append(rule_instance)
102
- return rules
111
+
112
+ def _extract_rules_from_module(module: ModuleType) -> list[BaseLintRule]:
113
+ """Extract rule instances from a module.
114
+
115
+ Args:
116
+ module: Imported module to scan
117
+
118
+ Returns:
119
+ List of discovered rule instances
120
+ """
121
+ rule_classes = [obj for _name, obj in inspect.getmembers(module) if _is_rule_class(obj)]
122
+ return _instantiate_rules(rule_classes)
123
+
124
+
125
+ def _instantiate_rules(rule_classes: list[type[BaseLintRule]]) -> list[BaseLintRule]:
126
+ """Instantiate a list of rule classes.
127
+
128
+ Args:
129
+ rule_classes: List of rule classes to instantiate
130
+
131
+ Returns:
132
+ List of successfully instantiated rules
133
+ """
134
+ instances = (_try_instantiate_rule(cls) for cls in rule_classes)
135
+ return [inst for inst in instances if inst is not None]
103
136
 
104
137
 
105
138
  def _try_instantiate_rule(rule_class: type[BaseLintRule]) -> BaseLintRule | None:
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
+ )
@@ -0,0 +1,69 @@
1
+ """
2
+ Purpose: Shared utility functions for violation processing across linters
3
+
4
+ Scope: Common violation-related operations used by multiple linters
5
+
6
+ Overview: Provides shared utility functions for working with violations, including
7
+ extracting line text and checking for ignore directives. These patterns were
8
+ previously duplicated across multiple linter modules (magic_numbers, print_statements,
9
+ method_property). Centralizing them here improves maintainability and ensures
10
+ consistent behavior across all linters.
11
+
12
+ Dependencies: BaseLintContext, Violation types
13
+
14
+ Exports: get_violation_line, has_python_noqa, has_typescript_noqa
15
+
16
+ Interfaces:
17
+ get_violation_line(violation, context) -> str | None
18
+ has_python_noqa(line_text) -> bool
19
+ has_typescript_noqa(line_text) -> bool
20
+
21
+ Implementation: Simple text extraction and pattern matching
22
+ """
23
+
24
+ from src.core.base import BaseLintContext
25
+ from src.core.types import Violation
26
+
27
+
28
+ def get_violation_line(violation: Violation, context: BaseLintContext) -> str | None:
29
+ """Get the line text for a violation, lowercased.
30
+
31
+ Args:
32
+ violation: Violation to get line for
33
+ context: Lint context with file content
34
+
35
+ Returns:
36
+ Lowercased line text, or None if not available
37
+ """
38
+ if not context.file_content:
39
+ return None
40
+
41
+ lines = context.file_content.splitlines()
42
+ if violation.line <= 0 or violation.line > len(lines):
43
+ return None
44
+
45
+ return lines[violation.line - 1].lower()
46
+
47
+
48
+ def has_python_noqa(line_text: str) -> bool:
49
+ """Check if line has Python-style noqa directive.
50
+
51
+ Args:
52
+ line_text: Lowercased line text
53
+
54
+ Returns:
55
+ True if line has # noqa comment
56
+ """
57
+ return "# noqa" in line_text
58
+
59
+
60
+ def has_typescript_noqa(line_text: str) -> bool:
61
+ """Check if line has TypeScript-style noqa directive.
62
+
63
+ Args:
64
+ line_text: Lowercased line text
65
+
66
+ Returns:
67
+ True if line has // noqa comment
68
+ """
69
+ return "// noqa" in line_text