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/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,395 @@
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 contextlib import suppress
28
+ from pathlib import Path
29
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
30
+
31
+ import click
32
+
33
+ if TYPE_CHECKING:
34
+ from src.orchestrator.core import Orchestrator
35
+
36
+ # Configure module logger
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ # =============================================================================
41
+ # Common Option Decorators
42
+ # =============================================================================
43
+
44
+
45
+ F = TypeVar("F", bound=Callable[..., object])
46
+
47
+
48
+ def format_option(func: F) -> F:
49
+ """Add --format option to a command for output format selection."""
50
+ return click.option(
51
+ "--format",
52
+ "-f",
53
+ type=click.Choice(["text", "json", "sarif"]),
54
+ default="text",
55
+ help="Output format",
56
+ )(func)
57
+
58
+
59
+ def parallel_option(func: F) -> F:
60
+ """Add --parallel option to enable multi-core file processing."""
61
+ return click.option(
62
+ "--parallel",
63
+ "-p",
64
+ is_flag=True,
65
+ default=False,
66
+ help="Enable parallel file processing (uses multiple CPU cores)",
67
+ )(func)
68
+
69
+
70
+ # =============================================================================
71
+ # Project Root Determination
72
+ # =============================================================================
73
+
74
+
75
+ def _determine_project_root(
76
+ explicit_root: str | None, config_path: str | None, verbose: bool
77
+ ) -> Path:
78
+ """Determine project root with precedence rules.
79
+
80
+ Precedence order:
81
+ 1. Explicit --project-root (highest priority)
82
+ 2. Inferred from --config path directory
83
+ 3. Auto-detection via get_project_root() (fallback)
84
+
85
+ Args:
86
+ explicit_root: Explicitly specified project root path (from --project-root)
87
+ config_path: Config file path (from --config)
88
+ verbose: Whether verbose logging is enabled
89
+
90
+ Returns:
91
+ Path to determined project root
92
+
93
+ Raises:
94
+ SystemExit: If explicit_root doesn't exist or is not a directory
95
+ """
96
+ from src.utils.project_root import get_project_root
97
+
98
+ # Priority 1: Explicit --project-root
99
+ if explicit_root:
100
+ return _resolve_explicit_project_root(explicit_root, verbose)
101
+
102
+ # Priority 2: Infer from --config path
103
+ if config_path:
104
+ return _infer_root_from_config(config_path, verbose)
105
+
106
+ # Priority 3: Auto-detection (fallback)
107
+ return _autodetect_project_root(verbose, get_project_root)
108
+
109
+
110
+ def _resolve_explicit_project_root(explicit_root: str, verbose: bool) -> Path:
111
+ """Resolve and validate explicitly specified project root.
112
+
113
+ Args:
114
+ explicit_root: Explicitly specified project root path
115
+ verbose: Whether verbose logging is enabled
116
+
117
+ Returns:
118
+ Resolved project root path
119
+
120
+ Raises:
121
+ SystemExit: If explicit_root doesn't exist or is not a directory
122
+ """
123
+ root = Path(explicit_root)
124
+ # Check existence before resolving to handle relative paths in test environments
125
+ if not root.exists():
126
+ click.echo(f"Error: Project root does not exist: {explicit_root}", err=True)
127
+ sys.exit(2)
128
+ if not root.is_dir():
129
+ click.echo(f"Error: Project root must be a directory: {explicit_root}", err=True)
130
+ sys.exit(2)
131
+
132
+ # Now resolve after validation
133
+ root = root.resolve()
134
+
135
+ if verbose:
136
+ logger.debug(f"Using explicit project root: {root}")
137
+ return root
138
+
139
+
140
+ def _infer_root_from_config(config_path: str, verbose: bool) -> Path:
141
+ """Infer project root from config file path.
142
+
143
+ Args:
144
+ config_path: Config file path
145
+ verbose: Whether verbose logging is enabled
146
+
147
+ Returns:
148
+ Inferred project root (parent directory of config file)
149
+ """
150
+ config_file = Path(config_path).resolve()
151
+ inferred_root = config_file.parent
152
+
153
+ if verbose:
154
+ logger.debug(f"Inferred project root from config path: {inferred_root}")
155
+ return inferred_root
156
+
157
+
158
+ def _autodetect_project_root(
159
+ verbose: bool, get_project_root: Callable[[Path | None], Path]
160
+ ) -> Path:
161
+ """Auto-detect project root using project root detection.
162
+
163
+ Args:
164
+ verbose: Whether verbose logging is enabled
165
+ get_project_root: Function to detect project root
166
+
167
+ Returns:
168
+ Auto-detected project root
169
+ """
170
+ auto_root = get_project_root(None)
171
+ if verbose:
172
+ logger.debug(f"Auto-detected project root: {auto_root}")
173
+ return auto_root
174
+
175
+
176
+ def get_project_root_from_context(ctx: click.Context) -> Path | None:
177
+ """Get or determine project root from Click context.
178
+
179
+ This function defers the actual determination until needed to avoid
180
+ importing pyprojroot in test environments where it may not be available.
181
+
182
+ Returns None when no explicit root is specified (via --project-root or --config),
183
+ allowing the orchestrator to auto-detect from target paths instead of CWD.
184
+
185
+ Args:
186
+ ctx: Click context containing CLI options
187
+
188
+ Returns:
189
+ Path to determined project root, or None for auto-detection from target paths
190
+ """
191
+ # Check if already determined and cached
192
+ with suppress(KeyError):
193
+ return cast(Path | None, ctx.obj["project_root"])
194
+
195
+ project_root = _determine_project_root_for_context(ctx)
196
+ ctx.obj["project_root"] = project_root
197
+ return project_root
198
+
199
+
200
+ def _determine_project_root_for_context(ctx: click.Context) -> Path | None:
201
+ """Determine project root from context options.
202
+
203
+ Args:
204
+ ctx: Click context containing CLI options
205
+
206
+ Returns:
207
+ Path if explicit root or config specified, None for auto-detection
208
+ """
209
+ explicit_root = ctx.obj.get("cli_project_root")
210
+ config_path = ctx.obj.get("cli_config_path")
211
+ verbose = ctx.obj.get("verbose", False)
212
+
213
+ if explicit_root:
214
+ return _resolve_explicit_project_root(explicit_root, verbose)
215
+
216
+ if config_path:
217
+ return _infer_root_from_config(config_path, verbose)
218
+
219
+ # No explicit root - return None for auto-detection from target paths
220
+ if verbose:
221
+ logger.debug("No explicit project root, will auto-detect from target paths")
222
+ return None
223
+
224
+
225
+ # =============================================================================
226
+ # Path Validation
227
+ # =============================================================================
228
+
229
+
230
+ def validate_paths_exist(path_objs: list[Path]) -> None:
231
+ """Validate that all provided paths exist.
232
+
233
+ Args:
234
+ path_objs: List of Path objects to validate
235
+
236
+ Raises:
237
+ SystemExit: If any path doesn't exist (exit code 2)
238
+ """
239
+ for path in path_objs:
240
+ if not path.exists():
241
+ click.echo(f"Error: Path does not exist: {path}", err=True)
242
+ click.echo("", err=True)
243
+ click.echo(
244
+ "Hint: When using Docker, ensure paths are inside the mounted volume:", err=True
245
+ )
246
+ click.echo(
247
+ " docker run -v $(pwd):/data thailint <command> /data/your-file.py", err=True
248
+ )
249
+ sys.exit(2)
250
+
251
+
252
+ # =============================================================================
253
+ # Error Handling
254
+ # =============================================================================
255
+
256
+
257
+ def handle_linting_error(error: Exception, verbose: bool) -> None:
258
+ """Handle linting errors.
259
+
260
+ Args:
261
+ error: The exception that occurred
262
+ verbose: Whether verbose logging is enabled
263
+ """
264
+ click.echo(f"Error during linting: {error}", err=True)
265
+ if verbose:
266
+ logger.exception("Linting failed with exception")
267
+ sys.exit(2)
268
+
269
+
270
+ # =============================================================================
271
+ # Orchestrator Setup
272
+ # =============================================================================
273
+
274
+
275
+ def get_or_detect_project_root(path_objs: list[Path], project_root: Path | None) -> Path:
276
+ """Get provided project root or auto-detect from paths.
277
+
278
+ Args:
279
+ path_objs: List of path objects
280
+ project_root: Optionally provided project root
281
+
282
+ Returns:
283
+ Project root path
284
+ """
285
+ if project_root is not None:
286
+ return project_root
287
+
288
+ from src.utils.project_root import get_project_root
289
+
290
+ # Find actual project root (where .git or pyproject.toml exists)
291
+ first_path = path_objs[0] if path_objs else Path.cwd()
292
+ search_start = first_path if first_path.is_dir() else first_path.parent
293
+ return get_project_root(search_start)
294
+
295
+
296
+ def setup_base_orchestrator(
297
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
298
+ ) -> "Orchestrator":
299
+ """Set up orchestrator for linter commands.
300
+
301
+ Args:
302
+ path_objs: List of path objects to lint
303
+ config_file: Optional config file path
304
+ verbose: Whether verbose logging is enabled
305
+ project_root: Optional explicit project root
306
+
307
+ Returns:
308
+ Configured Orchestrator instance
309
+ """
310
+ from src.orchestrator.core import Orchestrator
311
+
312
+ root = get_or_detect_project_root(path_objs, project_root)
313
+ orchestrator = Orchestrator(project_root=root)
314
+
315
+ if config_file:
316
+ load_config_file(orchestrator, config_file, verbose)
317
+
318
+ return orchestrator
319
+
320
+
321
+ def load_config_file(orchestrator: "Orchestrator", config_file: str, verbose: bool) -> None:
322
+ """Load configuration from external file.
323
+
324
+ Args:
325
+ orchestrator: Orchestrator instance
326
+ config_file: Path to config file
327
+ verbose: Whether verbose logging is enabled
328
+ """
329
+ config_path = Path(config_file)
330
+ if not config_path.exists():
331
+ click.echo(f"Error: Config file not found: {config_file}", err=True)
332
+ sys.exit(2)
333
+
334
+ # Load config into orchestrator
335
+ orchestrator.config = orchestrator.config_loader.load(config_path)
336
+
337
+ if verbose:
338
+ logger.debug(f"Loaded config from: {config_file}")
339
+
340
+
341
+ # =============================================================================
342
+ # Linting Execution
343
+ # =============================================================================
344
+
345
+
346
+ def separate_files_and_dirs(path_objs: list[Path]) -> tuple[list[Path], list[Path]]:
347
+ """Separate file paths from directory paths.
348
+
349
+ Args:
350
+ path_objs: List of Path objects
351
+
352
+ Returns:
353
+ Tuple of (files, directories)
354
+ """
355
+ files = [p for p in path_objs if p.is_file()]
356
+ dirs = [p for p in path_objs if p.is_dir()]
357
+ return files, dirs
358
+
359
+
360
+ def execute_linting_on_paths(
361
+ orchestrator: "Orchestrator",
362
+ path_objs: list[Path],
363
+ recursive: bool,
364
+ parallel: bool = False,
365
+ ) -> list[Any]:
366
+ """Execute linting on list of file/directory paths.
367
+
368
+ Args:
369
+ orchestrator: Orchestrator instance
370
+ path_objs: List of Path objects (files or directories)
371
+ recursive: Whether to scan directories recursively
372
+ parallel: Whether to use parallel processing for multiple files
373
+
374
+ Returns:
375
+ List of violations from all paths
376
+ """
377
+ files, dirs = separate_files_and_dirs(path_objs)
378
+
379
+ violations = []
380
+
381
+ # Lint files
382
+ if files:
383
+ if parallel:
384
+ violations.extend(orchestrator.lint_files_parallel(files))
385
+ else:
386
+ violations.extend(orchestrator.lint_files(files))
387
+
388
+ # Lint directories
389
+ for dir_path in dirs:
390
+ if parallel:
391
+ violations.extend(orchestrator.lint_directory_parallel(dir_path, recursive=recursive))
392
+ else:
393
+ violations.extend(orchestrator.lint_directory(dir_path, recursive=recursive))
394
+
395
+ return violations
src/cli_main.py ADDED
@@ -0,0 +1,37 @@
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
+ Suppressions:
22
+ - F401: Module re-exports and imports trigger Click command registration via decorator side effects
23
+ """
24
+
25
+ # Import the main CLI group from the modular package
26
+ # Import config module to register configuration commands
27
+ # (hello, config group, init-config)
28
+ from src.cli import config as _config_module # noqa: F401
29
+
30
+ # Import linters package to register all linter commands
31
+ # (nesting, srp, dry, magic-numbers, file-placement, print-statements,
32
+ # file-header, method-property, stateless-class, pipeline)
33
+ from src.cli import linters as _linters_module # noqa: F401
34
+ from src.cli.main import cli # noqa: F401
35
+
36
+ if __name__ == "__main__":
37
+ cli()
src/config.py CHANGED
@@ -26,6 +26,7 @@ from typing import Any
26
26
  import yaml
27
27
 
28
28
  from src.core.config_parser import ConfigParseError, parse_config_file
29
+ from src.core.constants import CONFIG_EXTENSIONS
29
30
 
30
31
  logger = logging.getLogger(__name__)
31
32
 
@@ -103,9 +104,8 @@ def _load_from_explicit_path(config_path: Path) -> dict[str, Any]:
103
104
 
104
105
  def _load_from_default_locations() -> dict[str, Any]:
105
106
  """Load config from default locations."""
106
- for location in CONFIG_LOCATIONS:
107
- if not location.exists():
108
- continue
107
+ existing_locations = (loc for loc in CONFIG_LOCATIONS if loc.exists())
108
+ for location in existing_locations:
109
109
  loaded_config = _try_load_from_location(location)
110
110
  if loaded_config:
111
111
  return loaded_config
@@ -171,7 +171,7 @@ def _validate_before_save(config: dict[str, Any]) -> None:
171
171
 
172
172
  def _write_config_file(config: dict[str, Any], path: Path) -> None:
173
173
  """Write config to file based on extension."""
174
- if path.suffix in [".yaml", ".yml"]:
174
+ if path.suffix in CONFIG_EXTENSIONS:
175
175
  _write_yaml_config(config, path)
176
176
  elif path.suffix == ".json":
177
177
  _write_json_config(config, path)
@@ -237,37 +237,47 @@ def _validate_required_keys(config: dict[str, Any], errors: list[str]) -> None:
237
237
  def _validate_log_level(config: dict[str, Any], errors: list[str]) -> None:
238
238
  """Validate log level is a valid value."""
239
239
  valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
240
- if "log_level" in config:
241
- if config["log_level"] not in valid_log_levels:
242
- errors.append(
243
- f"Invalid log_level: {config['log_level']}. "
244
- f"Must be one of: {', '.join(valid_log_levels)}"
245
- )
240
+ try:
241
+ log_level = config["log_level"]
242
+ except KeyError:
243
+ return # Optional key not present
244
+ if log_level not in valid_log_levels:
245
+ errors.append(
246
+ f"Invalid log_level: {log_level}. Must be one of: {', '.join(valid_log_levels)}"
247
+ )
246
248
 
247
249
 
248
250
  def _validate_output_format(config: dict[str, Any], errors: list[str]) -> None:
249
251
  """Validate output format is a valid value."""
250
252
  valid_formats = ["text", "json", "yaml"]
251
- if "output_format" in config:
252
- if config["output_format"] not in valid_formats:
253
- errors.append(
254
- f"Invalid output_format: {config['output_format']}. "
255
- f"Must be one of: {', '.join(valid_formats)}"
256
- )
253
+ try:
254
+ output_format = config["output_format"]
255
+ except KeyError:
256
+ return # Optional key not present
257
+ if output_format not in valid_formats:
258
+ errors.append(
259
+ f"Invalid output_format: {output_format}. Must be one of: {', '.join(valid_formats)}"
260
+ )
257
261
 
258
262
 
259
263
  def _validate_max_retries(config: dict[str, Any], errors: list[str]) -> None:
260
264
  """Validate max_retries configuration value."""
261
- if "max_retries" in config:
262
- if not isinstance(config["max_retries"], int) or config["max_retries"] < 0:
263
- errors.append("max_retries must be a non-negative integer")
265
+ try:
266
+ max_retries = config["max_retries"]
267
+ except KeyError:
268
+ return # Optional key not present
269
+ if not isinstance(max_retries, int) or max_retries < 0:
270
+ errors.append("max_retries must be a non-negative integer")
264
271
 
265
272
 
266
273
  def _validate_timeout(config: dict[str, Any], errors: list[str]) -> None:
267
274
  """Validate timeout configuration value."""
268
- if "timeout" in config:
269
- if not isinstance(config["timeout"], (int, float)) or config["timeout"] <= 0:
270
- errors.append("timeout must be a positive number")
275
+ try:
276
+ timeout = config["timeout"]
277
+ except KeyError:
278
+ return # Optional key not present
279
+ if not isinstance(timeout, (int, float)) or timeout <= 0:
280
+ errors.append("timeout must be a positive number")
271
281
 
272
282
 
273
283
  def _validate_numeric_values(config: dict[str, Any], errors: list[str]) -> None:
@@ -278,9 +288,12 @@ def _validate_numeric_values(config: dict[str, Any], errors: list[str]) -> None:
278
288
 
279
289
  def _validate_string_values(config: dict[str, Any], errors: list[str]) -> None:
280
290
  """Validate string configuration values."""
281
- if "app_name" in config:
282
- if not isinstance(config["app_name"], str) or not config["app_name"].strip():
283
- errors.append("app_name must be a non-empty string")
291
+ try:
292
+ app_name = config["app_name"]
293
+ except KeyError:
294
+ return # Optional key not present
295
+ if not isinstance(app_name, str) or not app_name.strip():
296
+ errors.append("app_name must be a non-empty string")
284
297
 
285
298
 
286
299
  def validate_config(config: dict[str, Any]) -> tuple[bool, list[str]]:
src/core/base.py CHANGED
@@ -31,6 +31,7 @@ from abc import ABC, abstractmethod
31
31
  from pathlib import Path
32
32
  from typing import Any
33
33
 
34
+ from .constants import Language
34
35
  from .types import Violation
35
36
 
36
37
 
@@ -151,6 +152,10 @@ class MultiLanguageLintRule(BaseLintRule):
151
152
  - _load_config(context) for configuration loading
152
153
  """
153
154
 
155
+ def __init__(self) -> None:
156
+ """Initialize the multi-language lint rule."""
157
+ pass # Base class for multi-language linters
158
+
154
159
  def check(self, context: BaseLintContext) -> list[Violation]:
155
160
  """Check for violations with automatic language dispatch.
156
161
 
@@ -172,10 +177,10 @@ class MultiLanguageLintRule(BaseLintRule):
172
177
  if not config.enabled:
173
178
  return []
174
179
 
175
- if context.language == "python":
180
+ if context.language == Language.PYTHON:
176
181
  return self._check_python(context, config)
177
182
 
178
- if context.language in ("typescript", "javascript"):
183
+ if context.language in (Language.TYPESCRIPT, Language.JAVASCRIPT):
179
184
  return self._check_typescript(context, config)
180
185
 
181
186
  return []