thailint 0.2.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 (214) 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 +44 -27
  23. src/core/base.py +95 -5
  24. src/core/cli_utils.py +19 -2
  25. src/core/config_parser.py +36 -6
  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 +125 -22
  65. src/linters/dry/block_grouper.py +4 -0
  66. src/linters/dry/cache.py +142 -94
  67. src/linters/dry/cache_query.py +4 -0
  68. src/linters/dry/config.py +68 -21
  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 +20 -82
  73. src/linters/dry/file_analyzer.py +15 -50
  74. src/linters/dry/inline_ignore.py +7 -16
  75. src/linters/dry/linter.py +182 -54
  76. src/linters/dry/python_analyzer.py +108 -336
  77. src/linters/dry/python_constant_extractor.py +100 -0
  78. src/linters/dry/single_statement_detector.py +417 -0
  79. src/linters/dry/storage_initializer.py +9 -18
  80. src/linters/dry/token_hasher.py +129 -71
  81. src/linters/dry/typescript_analyzer.py +68 -380
  82. src/linters/dry/typescript_constant_extractor.py +138 -0
  83. src/linters/dry/typescript_statement_detector.py +255 -0
  84. src/linters/dry/typescript_value_extractor.py +70 -0
  85. src/linters/dry/violation_builder.py +4 -0
  86. src/linters/dry/violation_filter.py +9 -5
  87. src/linters/dry/violation_generator.py +71 -14
  88. src/linters/file_header/__init__.py +24 -0
  89. src/linters/file_header/atemporal_detector.py +105 -0
  90. src/linters/file_header/base_parser.py +93 -0
  91. src/linters/file_header/bash_parser.py +66 -0
  92. src/linters/file_header/config.py +140 -0
  93. src/linters/file_header/css_parser.py +70 -0
  94. src/linters/file_header/field_validator.py +72 -0
  95. src/linters/file_header/linter.py +309 -0
  96. src/linters/file_header/markdown_parser.py +130 -0
  97. src/linters/file_header/python_parser.py +42 -0
  98. src/linters/file_header/typescript_parser.py +73 -0
  99. src/linters/file_header/violation_builder.py +79 -0
  100. src/linters/file_placement/config_loader.py +3 -1
  101. src/linters/file_placement/directory_matcher.py +4 -0
  102. src/linters/file_placement/linter.py +74 -31
  103. src/linters/file_placement/pattern_matcher.py +41 -6
  104. src/linters/file_placement/pattern_validator.py +31 -12
  105. src/linters/file_placement/rule_checker.py +12 -7
  106. src/linters/lazy_ignores/__init__.py +43 -0
  107. src/linters/lazy_ignores/config.py +74 -0
  108. src/linters/lazy_ignores/directive_utils.py +164 -0
  109. src/linters/lazy_ignores/header_parser.py +177 -0
  110. src/linters/lazy_ignores/linter.py +158 -0
  111. src/linters/lazy_ignores/matcher.py +168 -0
  112. src/linters/lazy_ignores/python_analyzer.py +209 -0
  113. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  114. src/linters/lazy_ignores/skip_detector.py +298 -0
  115. src/linters/lazy_ignores/types.py +71 -0
  116. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  117. src/linters/lazy_ignores/violation_builder.py +135 -0
  118. src/linters/lbyl/__init__.py +31 -0
  119. src/linters/lbyl/config.py +63 -0
  120. src/linters/lbyl/linter.py +67 -0
  121. src/linters/lbyl/pattern_detectors/__init__.py +53 -0
  122. src/linters/lbyl/pattern_detectors/base.py +63 -0
  123. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  124. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  125. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  126. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  127. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  128. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  129. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  130. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  131. src/linters/lbyl/python_analyzer.py +215 -0
  132. src/linters/lbyl/violation_builder.py +354 -0
  133. src/linters/magic_numbers/__init__.py +48 -0
  134. src/linters/magic_numbers/config.py +82 -0
  135. src/linters/magic_numbers/context_analyzer.py +249 -0
  136. src/linters/magic_numbers/linter.py +462 -0
  137. src/linters/magic_numbers/python_analyzer.py +64 -0
  138. src/linters/magic_numbers/typescript_analyzer.py +215 -0
  139. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  140. src/linters/magic_numbers/violation_builder.py +98 -0
  141. src/linters/method_property/__init__.py +49 -0
  142. src/linters/method_property/config.py +138 -0
  143. src/linters/method_property/linter.py +414 -0
  144. src/linters/method_property/python_analyzer.py +473 -0
  145. src/linters/method_property/violation_builder.py +119 -0
  146. src/linters/nesting/__init__.py +6 -2
  147. src/linters/nesting/config.py +6 -3
  148. src/linters/nesting/linter.py +31 -34
  149. src/linters/nesting/python_analyzer.py +4 -0
  150. src/linters/nesting/typescript_analyzer.py +6 -11
  151. src/linters/nesting/violation_builder.py +1 -0
  152. src/linters/performance/__init__.py +91 -0
  153. src/linters/performance/config.py +43 -0
  154. src/linters/performance/constants.py +49 -0
  155. src/linters/performance/linter.py +149 -0
  156. src/linters/performance/python_analyzer.py +365 -0
  157. src/linters/performance/regex_analyzer.py +312 -0
  158. src/linters/performance/regex_linter.py +139 -0
  159. src/linters/performance/typescript_analyzer.py +236 -0
  160. src/linters/performance/violation_builder.py +160 -0
  161. src/linters/print_statements/__init__.py +53 -0
  162. src/linters/print_statements/config.py +78 -0
  163. src/linters/print_statements/linter.py +413 -0
  164. src/linters/print_statements/python_analyzer.py +153 -0
  165. src/linters/print_statements/typescript_analyzer.py +125 -0
  166. src/linters/print_statements/violation_builder.py +96 -0
  167. src/linters/srp/__init__.py +3 -3
  168. src/linters/srp/class_analyzer.py +11 -7
  169. src/linters/srp/config.py +12 -6
  170. src/linters/srp/heuristics.py +56 -22
  171. src/linters/srp/linter.py +47 -39
  172. src/linters/srp/python_analyzer.py +55 -20
  173. src/linters/srp/typescript_metrics_calculator.py +110 -50
  174. src/linters/stateless_class/__init__.py +25 -0
  175. src/linters/stateless_class/config.py +58 -0
  176. src/linters/stateless_class/linter.py +349 -0
  177. src/linters/stateless_class/python_analyzer.py +290 -0
  178. src/linters/stringly_typed/__init__.py +36 -0
  179. src/linters/stringly_typed/config.py +189 -0
  180. src/linters/stringly_typed/context_filter.py +451 -0
  181. src/linters/stringly_typed/function_call_violation_builder.py +135 -0
  182. src/linters/stringly_typed/ignore_checker.py +100 -0
  183. src/linters/stringly_typed/ignore_utils.py +51 -0
  184. src/linters/stringly_typed/linter.py +376 -0
  185. src/linters/stringly_typed/python/__init__.py +33 -0
  186. src/linters/stringly_typed/python/analyzer.py +348 -0
  187. src/linters/stringly_typed/python/call_tracker.py +175 -0
  188. src/linters/stringly_typed/python/comparison_tracker.py +257 -0
  189. src/linters/stringly_typed/python/condition_extractor.py +134 -0
  190. src/linters/stringly_typed/python/conditional_detector.py +179 -0
  191. src/linters/stringly_typed/python/constants.py +21 -0
  192. src/linters/stringly_typed/python/match_analyzer.py +94 -0
  193. src/linters/stringly_typed/python/validation_detector.py +189 -0
  194. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  195. src/linters/stringly_typed/storage.py +620 -0
  196. src/linters/stringly_typed/storage_initializer.py +45 -0
  197. src/linters/stringly_typed/typescript/__init__.py +28 -0
  198. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  199. src/linters/stringly_typed/typescript/call_tracker.py +335 -0
  200. src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
  201. src/linters/stringly_typed/violation_generator.py +419 -0
  202. src/orchestrator/core.py +264 -16
  203. src/orchestrator/language_detector.py +5 -3
  204. src/templates/thailint_config_template.yaml +354 -0
  205. src/utils/project_root.py +138 -16
  206. thailint-0.15.3.dist-info/METADATA +187 -0
  207. thailint-0.15.3.dist-info/RECORD +226 -0
  208. {thailint-0.2.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +1 -1
  209. thailint-0.15.3.dist-info/entry_points.txt +4 -0
  210. src/cli.py +0 -1055
  211. thailint-0.2.0.dist-info/METADATA +0 -980
  212. thailint-0.2.0.dist-info/RECORD +0 -75
  213. thailint-0.2.0.dist-info/entry_points.txt +0 -4
  214. {thailint-0.2.0.dist-info → thailint-0.15.3.dist-info/licenses}/LICENSE +0 -0
src/cli.py DELETED
@@ -1,1055 +0,0 @@
1
- """
2
- Purpose: Main CLI entrypoint with Click framework for command-line interface
3
-
4
- Scope: CLI command definitions, option parsing, and command execution coordination
5
-
6
- Overview: Provides the main CLI application using Click decorators for command definition, option
7
- parsing, and help text generation. Includes example commands (hello, config management) that
8
- demonstrate best practices for CLI design including error handling, logging configuration,
9
- context management, and user-friendly output. Serves as the entry point for the installed
10
- CLI tool and coordinates between user input and application logic.
11
-
12
- Dependencies: click for CLI framework, logging for structured output, pathlib for file paths
13
-
14
- Exports: cli (main command group), hello command, config command group, file_placement command, dry command
15
-
16
- Interfaces: Click CLI commands, configuration context via Click ctx, logging integration
17
-
18
- Implementation: Click decorators for commands, context passing for shared state, comprehensive help text
19
- """
20
- # pylint: disable=too-many-lines
21
- # Justification: CLI modules naturally have many commands and helper functions
22
-
23
- import logging
24
- import sys
25
- from pathlib import Path
26
-
27
- import click
28
-
29
- from src import __version__
30
- from src.config import ConfigError, load_config, save_config, validate_config
31
- from src.core.cli_utils import format_violations
32
-
33
- # Configure module logger
34
- logger = logging.getLogger(__name__)
35
-
36
-
37
- # Shared Click option decorators for common CLI options
38
- def format_option(func):
39
- """Add --format option to a command for output format selection."""
40
- return click.option(
41
- "--format", "-f", type=click.Choice(["text", "json"]), default="text", help="Output format"
42
- )(func)
43
-
44
-
45
- def setup_logging(verbose: bool = False):
46
- """
47
- Configure logging for the CLI application.
48
-
49
- Args:
50
- verbose: Enable DEBUG level logging if True, INFO otherwise.
51
- """
52
- level = logging.DEBUG if verbose else logging.INFO
53
-
54
- logging.basicConfig(
55
- level=level,
56
- format="%(asctime)s | %(levelname)-8s | %(message)s",
57
- datefmt="%Y-%m-%d %H:%M:%S",
58
- stream=sys.stdout,
59
- )
60
-
61
-
62
- @click.group()
63
- @click.version_option(version=__version__)
64
- @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
65
- @click.option("--config", "-c", type=click.Path(), help="Path to config file")
66
- @click.pass_context
67
- def cli(ctx, verbose: bool, config: str | None):
68
- """
69
- thai-lint - AI code linter and governance tool
70
-
71
- Lint and governance for AI-generated code across multiple languages.
72
- Identifies common mistakes, anti-patterns, and security issues.
73
-
74
- Examples:
75
-
76
- \b
77
- # Check for duplicate code (DRY violations)
78
- thai-lint dry .
79
-
80
- \b
81
- # Lint current directory for file placement issues
82
- thai-lint file-placement .
83
-
84
- \b
85
- # Lint with custom config
86
- thai-lint file-placement --config .thailint.yaml src/
87
-
88
- \b
89
- # Get JSON output
90
- thai-lint file-placement --format json .
91
-
92
- \b
93
- # Show help
94
- thai-lint --help
95
- """
96
- # Ensure context object exists
97
- ctx.ensure_object(dict)
98
-
99
- # Setup logging
100
- setup_logging(verbose)
101
-
102
- # Load configuration
103
- try:
104
- if config:
105
- ctx.obj["config"] = load_config(Path(config))
106
- ctx.obj["config_path"] = Path(config)
107
- else:
108
- ctx.obj["config"] = load_config()
109
- ctx.obj["config_path"] = None
110
-
111
- logger.debug("Configuration loaded successfully")
112
- except ConfigError as e:
113
- click.echo(f"Error loading configuration: {e}", err=True)
114
- sys.exit(2)
115
-
116
- ctx.obj["verbose"] = verbose
117
-
118
-
119
- @cli.command()
120
- @click.option("--name", "-n", default="World", help="Name to greet")
121
- @click.option("--uppercase", "-u", is_flag=True, help="Convert greeting to uppercase")
122
- @click.pass_context
123
- def hello(ctx, name: str, uppercase: bool):
124
- """
125
- Print a greeting message.
126
-
127
- This is a simple example command demonstrating CLI basics.
128
-
129
- Examples:
130
-
131
- \b
132
- # Basic greeting
133
- thai-lint hello
134
-
135
- \b
136
- # Custom name
137
- thai-lint hello --name Alice
138
-
139
- \b
140
- # Uppercase output
141
- thai-lint hello --name Bob --uppercase
142
- """
143
- config = ctx.obj["config"]
144
- verbose = ctx.obj.get("verbose", False)
145
-
146
- # Get greeting from config or use default
147
- greeting_template = config.get("greeting", "Hello")
148
-
149
- # Build greeting message
150
- message = f"{greeting_template}, {name}!"
151
-
152
- if uppercase:
153
- message = message.upper()
154
-
155
- # Output greeting
156
- click.echo(message)
157
-
158
- if verbose:
159
- logger.info(f"Greeted {name} with template '{greeting_template}'")
160
-
161
-
162
- @cli.group()
163
- def config():
164
- """Configuration management commands."""
165
- pass
166
-
167
-
168
- @config.command("show")
169
- @click.option(
170
- "--format",
171
- "-f",
172
- type=click.Choice(["text", "json", "yaml"]),
173
- default="text",
174
- help="Output format",
175
- )
176
- @click.pass_context
177
- def config_show(ctx, format: str):
178
- """
179
- Display current configuration.
180
-
181
- Shows all configuration values in the specified format.
182
-
183
- Examples:
184
-
185
- \b
186
- # Show as text
187
- thai-lint config show
188
-
189
- \b
190
- # Show as JSON
191
- thai-lint config show --format json
192
-
193
- \b
194
- # Show as YAML
195
- thai-lint config show --format yaml
196
- """
197
- cfg = ctx.obj["config"]
198
-
199
- formatters = {
200
- "json": _format_config_json,
201
- "yaml": _format_config_yaml,
202
- "text": _format_config_text,
203
- }
204
- formatters[format](cfg)
205
-
206
-
207
- def _format_config_json(cfg: dict) -> None:
208
- """Format configuration as JSON."""
209
- import json
210
-
211
- click.echo(json.dumps(cfg, indent=2))
212
-
213
-
214
- def _format_config_yaml(cfg: dict) -> None:
215
- """Format configuration as YAML."""
216
- import yaml
217
-
218
- click.echo(yaml.dump(cfg, default_flow_style=False, sort_keys=False))
219
-
220
-
221
- def _format_config_text(cfg: dict) -> None:
222
- """Format configuration as text."""
223
- click.echo("Current Configuration:")
224
- click.echo("-" * 40)
225
- for key, value in cfg.items():
226
- click.echo(f"{key:20} : {value}")
227
-
228
-
229
- @config.command("get")
230
- @click.argument("key")
231
- @click.pass_context
232
- def config_get(ctx, key: str):
233
- """
234
- Get specific configuration value.
235
-
236
- KEY: Configuration key to retrieve
237
-
238
- Examples:
239
-
240
- \b
241
- # Get log level
242
- thai-lint config get log_level
243
-
244
- \b
245
- # Get greeting template
246
- thai-lint config get greeting
247
- """
248
- cfg = ctx.obj["config"]
249
-
250
- if key not in cfg:
251
- click.echo(f"Configuration key not found: {key}", err=True)
252
- sys.exit(1)
253
-
254
- click.echo(cfg[key])
255
-
256
-
257
- def _convert_value_type(value: str):
258
- """Convert string value to appropriate type."""
259
- if value.lower() in ["true", "false"]:
260
- return value.lower() == "true"
261
- if value.isdigit():
262
- return int(value)
263
- if value.replace(".", "", 1).isdigit() and value.count(".") == 1:
264
- return float(value)
265
- return value
266
-
267
-
268
- def _validate_and_report_errors(cfg: dict):
269
- """Validate configuration and report errors."""
270
- is_valid, errors = validate_config(cfg)
271
- if not is_valid:
272
- click.echo("Invalid configuration:", err=True)
273
- for error in errors:
274
- click.echo(f" - {error}", err=True)
275
- sys.exit(1)
276
-
277
-
278
- def _save_and_report_success(cfg: dict, key: str, value, config_path, verbose: bool):
279
- """Save configuration and report success."""
280
- save_config(cfg, config_path)
281
- click.echo(f"✓ Set {key} = {value}")
282
- if verbose:
283
- logger.info(f"Configuration updated: {key}={value}")
284
-
285
-
286
- @config.command("set")
287
- @click.argument("key")
288
- @click.argument("value")
289
- @click.pass_context
290
- def config_set(ctx, key: str, value: str):
291
- """
292
- Set configuration value.
293
-
294
- KEY: Configuration key to set
295
-
296
- VALUE: New value for the key
297
-
298
- Examples:
299
-
300
- \b
301
- # Set log level
302
- thai-lint config set log_level DEBUG
303
-
304
- \b
305
- # Set greeting template
306
- thai-lint config set greeting "Hi"
307
-
308
- \b
309
- # Set numeric value
310
- thai-lint config set max_retries 5
311
- """
312
- cfg = ctx.obj["config"]
313
- converted_value = _convert_value_type(value)
314
- cfg[key] = converted_value
315
-
316
- try:
317
- _validate_and_report_errors(cfg)
318
- except Exception as e:
319
- click.echo(f"Validation error: {e}", err=True)
320
- sys.exit(1)
321
-
322
- try:
323
- config_path = ctx.obj.get("config_path")
324
- verbose = ctx.obj.get("verbose", False)
325
- _save_and_report_success(cfg, key, converted_value, config_path, verbose)
326
- except ConfigError as e:
327
- click.echo(f"Error saving configuration: {e}", err=True)
328
- sys.exit(1)
329
-
330
-
331
- @config.command("reset")
332
- @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
333
- @click.pass_context
334
- def config_reset(ctx, yes: bool):
335
- """
336
- Reset configuration to defaults.
337
-
338
- Examples:
339
-
340
- \b
341
- # Reset with confirmation
342
- thai-lint config reset
343
-
344
- \b
345
- # Reset without confirmation
346
- thai-lint config reset --yes
347
- """
348
- if not yes:
349
- click.confirm("Reset configuration to defaults?", abort=True)
350
-
351
- from src.config import DEFAULT_CONFIG
352
-
353
- try:
354
- config_path = ctx.obj.get("config_path")
355
- save_config(DEFAULT_CONFIG.copy(), config_path)
356
- click.echo("✓ Configuration reset to defaults")
357
-
358
- if ctx.obj.get("verbose"):
359
- logger.info("Configuration reset to defaults")
360
- except ConfigError as e:
361
- click.echo(f"Error resetting configuration: {e}", err=True)
362
- sys.exit(1)
363
-
364
-
365
- @cli.command("file-placement")
366
- @click.argument("paths", nargs=-1, type=click.Path(exists=True))
367
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
368
- @click.option("--rules", "-r", help="Inline JSON rules configuration")
369
- @format_option
370
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
371
- @click.pass_context
372
- def file_placement( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements
373
- ctx,
374
- paths: tuple[str, ...],
375
- config_file: str | None,
376
- rules: str | None,
377
- format: str,
378
- recursive: bool,
379
- ):
380
- # Justification for Pylint disables:
381
- # - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 4 options = 6 params
382
- # - too-many-locals/statements: Complex CLI logic for config, linting, and output formatting
383
- # All parameters and logic are necessary for flexible CLI usage.
384
- """
385
- Lint files for proper file placement.
386
-
387
- Checks that files are placed in appropriate directories according to
388
- configured rules and patterns.
389
-
390
- PATHS: Files or directories to lint (defaults to current directory if none provided)
391
-
392
- Examples:
393
-
394
- \b
395
- # Lint current directory (all files recursively)
396
- thai-lint file-placement
397
-
398
- \b
399
- # Lint specific directory
400
- thai-lint file-placement src/
401
-
402
- \b
403
- # Lint single file
404
- thai-lint file-placement src/app.py
405
-
406
- \b
407
- # Lint multiple files
408
- thai-lint file-placement src/app.py src/utils.py tests/test_app.py
409
-
410
- \b
411
- # Use custom config
412
- thai-lint file-placement --config rules.json .
413
-
414
- \b
415
- # Inline JSON rules
416
- thai-lint file-placement --rules '{"allow": [".*\\.py$"]}' .
417
- """
418
- verbose = ctx.obj.get("verbose", False)
419
-
420
- if not paths:
421
- paths = (".",)
422
-
423
- path_objs = [Path(p) for p in paths]
424
-
425
- try:
426
- _execute_file_placement_lint(path_objs, config_file, rules, format, recursive, verbose)
427
- except Exception as e:
428
- _handle_linting_error(e, verbose)
429
-
430
-
431
- def _execute_file_placement_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
432
- path_objs, config_file, rules, format, recursive, verbose
433
- ):
434
- """Execute file placement linting."""
435
- orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose)
436
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
437
-
438
- # Filter to only file-placement violations
439
- violations = [v for v in all_violations if v.rule_id.startswith("file-placement")]
440
-
441
- if verbose:
442
- logger.info(f"Found {len(violations)} violation(s)")
443
-
444
- format_violations(violations, format)
445
- sys.exit(1 if violations else 0)
446
-
447
-
448
- def _handle_linting_error(error: Exception, verbose: bool) -> None:
449
- """Handle linting errors."""
450
- click.echo(f"Error during linting: {error}", err=True)
451
- if verbose:
452
- logger.exception("Linting failed with exception")
453
- sys.exit(2)
454
-
455
-
456
- def _find_project_root(start_path: Path) -> Path:
457
- """Find project root by looking for .git or pyproject.toml.
458
-
459
- DEPRECATED: Use src.utils.project_root.get_project_root() instead.
460
-
461
- Args:
462
- start_path: Directory to start searching from
463
-
464
- Returns:
465
- Path to project root, or start_path if no markers found
466
- """
467
- from src.utils.project_root import get_project_root
468
-
469
- return get_project_root(start_path)
470
-
471
-
472
- def _setup_orchestrator(path_objs, config_file, rules, verbose):
473
- """Set up and configure the orchestrator."""
474
- from src.orchestrator.core import Orchestrator
475
- from src.utils.project_root import get_project_root
476
-
477
- # Find actual project root (where .git or pyproject.toml exists)
478
- # This ensures .artifacts/ is always created at project root, not in subdirectories
479
- first_path = path_objs[0] if path_objs else Path.cwd()
480
- search_start = first_path if first_path.is_dir() else first_path.parent
481
- project_root = get_project_root(search_start)
482
-
483
- orchestrator = Orchestrator(project_root=project_root)
484
-
485
- if rules:
486
- _apply_inline_rules(orchestrator, rules, verbose)
487
- elif config_file:
488
- _load_config_file(orchestrator, config_file, verbose)
489
-
490
- return orchestrator
491
-
492
-
493
- def _apply_inline_rules(orchestrator, rules, verbose):
494
- """Parse and apply inline JSON rules."""
495
- rules_config = _parse_json_rules(rules)
496
- orchestrator.config.update(rules_config)
497
- _log_applied_rules(rules_config, verbose)
498
-
499
-
500
- def _parse_json_rules(rules: str) -> dict:
501
- """Parse JSON rules string, exit on error."""
502
- import json
503
-
504
- try:
505
- return json.loads(rules)
506
- except json.JSONDecodeError as e:
507
- click.echo(f"Error: Invalid JSON in --rules: {e}", err=True)
508
- sys.exit(2)
509
-
510
-
511
- def _log_applied_rules(rules_config: dict, verbose: bool) -> None:
512
- """Log applied rules if verbose."""
513
- if verbose:
514
- logger.debug(f"Applied inline rules: {rules_config}")
515
-
516
-
517
- def _load_config_file(orchestrator, config_file, verbose):
518
- """Load configuration from external file."""
519
- config_path = Path(config_file)
520
- if not config_path.exists():
521
- click.echo(f"Error: Config file not found: {config_file}", err=True)
522
- sys.exit(2)
523
-
524
- # Load config into orchestrator
525
- orchestrator.config = orchestrator.config_loader.load(config_path)
526
-
527
- if verbose:
528
- logger.debug(f"Loaded config from: {config_file}")
529
-
530
-
531
- def _execute_linting(orchestrator, path_obj, recursive):
532
- """Execute linting on file or directory."""
533
- if path_obj.is_file():
534
- return orchestrator.lint_file(path_obj)
535
- return orchestrator.lint_directory(path_obj, recursive=recursive)
536
-
537
-
538
- def _separate_files_and_dirs(path_objs: list[Path]) -> tuple[list[Path], list[Path]]:
539
- """Separate file paths from directory paths.
540
-
541
- Args:
542
- path_objs: List of Path objects
543
-
544
- Returns:
545
- Tuple of (files, directories)
546
- """
547
- files = [p for p in path_objs if p.is_file()]
548
- dirs = [p for p in path_objs if p.is_dir()]
549
- return files, dirs
550
-
551
-
552
- def _lint_files_if_any(orchestrator, files: list[Path]) -> list:
553
- """Lint files if list is non-empty.
554
-
555
- Args:
556
- orchestrator: Orchestrator instance
557
- files: List of file paths
558
-
559
- Returns:
560
- List of violations
561
- """
562
- if files:
563
- return orchestrator.lint_files(files)
564
- return []
565
-
566
-
567
- def _lint_directories(orchestrator, dirs: list[Path], recursive: bool) -> list:
568
- """Lint all directories.
569
-
570
- Args:
571
- orchestrator: Orchestrator instance
572
- dirs: List of directory paths
573
- recursive: Whether to scan recursively
574
-
575
- Returns:
576
- List of violations from all directories
577
- """
578
- violations = []
579
- for dir_path in dirs:
580
- violations.extend(orchestrator.lint_directory(dir_path, recursive=recursive))
581
- return violations
582
-
583
-
584
- def _execute_linting_on_paths(orchestrator, path_objs: list[Path], recursive: bool) -> list:
585
- """Execute linting on list of file/directory paths.
586
-
587
- Args:
588
- orchestrator: Orchestrator instance
589
- path_objs: List of Path objects (files or directories)
590
- recursive: Whether to scan directories recursively
591
-
592
- Returns:
593
- List of violations from all paths
594
- """
595
- files, dirs = _separate_files_and_dirs(path_objs)
596
-
597
- violations = []
598
- violations.extend(_lint_files_if_any(orchestrator, files))
599
- violations.extend(_lint_directories(orchestrator, dirs, recursive))
600
-
601
- return violations
602
-
603
-
604
- def _setup_nesting_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
605
- """Set up orchestrator for nesting command."""
606
- # Use first path to determine project root
607
- first_path = path_objs[0] if path_objs else Path.cwd()
608
- project_root = first_path if first_path.is_dir() else first_path.parent
609
-
610
- from src.orchestrator.core import Orchestrator
611
-
612
- orchestrator = Orchestrator(project_root=project_root)
613
-
614
- if config_file:
615
- _load_config_file(orchestrator, config_file, verbose)
616
-
617
- return orchestrator
618
-
619
-
620
- def _apply_nesting_config_override(orchestrator, max_depth: int | None, verbose: bool):
621
- """Apply max_depth override to orchestrator config."""
622
- if max_depth is None:
623
- return
624
-
625
- if "nesting" not in orchestrator.config:
626
- orchestrator.config["nesting"] = {}
627
- orchestrator.config["nesting"]["max_nesting_depth"] = max_depth
628
-
629
- if verbose:
630
- logger.debug(f"Overriding max_nesting_depth to {max_depth}")
631
-
632
-
633
- def _run_nesting_lint(orchestrator, path_objs: list[Path], recursive: bool):
634
- """Execute nesting lint on files or directories."""
635
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
636
- return [v for v in all_violations if "nesting" in v.rule_id]
637
-
638
-
639
- @cli.command("nesting")
640
- @click.argument("paths", nargs=-1, type=click.Path(exists=True))
641
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
642
- @format_option
643
- @click.option("--max-depth", type=int, help="Override max nesting depth (default: 4)")
644
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
645
- @click.pass_context
646
- def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
647
- ctx,
648
- paths: tuple[str, ...],
649
- config_file: str | None,
650
- format: str,
651
- max_depth: int | None,
652
- recursive: bool,
653
- ):
654
- """Check for excessive nesting depth in code.
655
-
656
- Analyzes Python and TypeScript files for deeply nested code structures
657
- (if/for/while/try statements) and reports violations.
658
-
659
- PATHS: Files or directories to lint (defaults to current directory if none provided)
660
-
661
- Examples:
662
-
663
- \b
664
- # Check current directory (all files recursively)
665
- thai-lint nesting
666
-
667
- \b
668
- # Check specific directory
669
- thai-lint nesting src/
670
-
671
- \b
672
- # Check single file
673
- thai-lint nesting src/app.py
674
-
675
- \b
676
- # Check multiple files
677
- thai-lint nesting src/app.py src/utils.py tests/test_app.py
678
-
679
- \b
680
- # Check mix of files and directories
681
- thai-lint nesting src/app.py tests/
682
-
683
- \b
684
- # Use custom max depth
685
- thai-lint nesting --max-depth 3 src/
686
-
687
- \b
688
- # Get JSON output
689
- thai-lint nesting --format json .
690
-
691
- \b
692
- # Use custom config file
693
- thai-lint nesting --config .thailint.yaml src/
694
- """
695
- verbose = ctx.obj.get("verbose", False)
696
-
697
- # Default to current directory if no paths provided
698
- if not paths:
699
- paths = (".",)
700
-
701
- path_objs = [Path(p) for p in paths]
702
-
703
- try:
704
- _execute_nesting_lint(path_objs, config_file, format, max_depth, recursive, verbose)
705
- except Exception as e:
706
- _handle_linting_error(e, verbose)
707
-
708
-
709
- def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
710
- path_objs, config_file, format, max_depth, recursive, verbose
711
- ):
712
- """Execute nesting lint."""
713
- orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose)
714
- _apply_nesting_config_override(orchestrator, max_depth, verbose)
715
- nesting_violations = _run_nesting_lint(orchestrator, path_objs, recursive)
716
-
717
- if verbose:
718
- logger.info(f"Found {len(nesting_violations)} nesting violation(s)")
719
-
720
- format_violations(nesting_violations, format)
721
- sys.exit(1 if nesting_violations else 0)
722
-
723
-
724
- def _setup_srp_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
725
- """Set up orchestrator for SRP command."""
726
- first_path = path_objs[0] if path_objs else Path.cwd()
727
- project_root = first_path if first_path.is_dir() else first_path.parent
728
-
729
- from src.orchestrator.core import Orchestrator
730
-
731
- orchestrator = Orchestrator(project_root=project_root)
732
-
733
- if config_file:
734
- _load_config_file(orchestrator, config_file, verbose)
735
-
736
- return orchestrator
737
-
738
-
739
- def _apply_srp_config_override(
740
- orchestrator, max_methods: int | None, max_loc: int | None, verbose: bool
741
- ):
742
- """Apply max_methods and max_loc overrides to orchestrator config."""
743
- if max_methods is None and max_loc is None:
744
- return
745
-
746
- if "srp" not in orchestrator.config:
747
- orchestrator.config["srp"] = {}
748
-
749
- _apply_srp_max_methods(orchestrator, max_methods, verbose)
750
- _apply_srp_max_loc(orchestrator, max_loc, verbose)
751
-
752
-
753
- def _apply_srp_max_methods(orchestrator, max_methods: int | None, verbose: bool):
754
- """Apply max_methods override."""
755
- if max_methods is not None:
756
- orchestrator.config["srp"]["max_methods"] = max_methods
757
- if verbose:
758
- logger.debug(f"Overriding max_methods to {max_methods}")
759
-
760
-
761
- def _apply_srp_max_loc(orchestrator, max_loc: int | None, verbose: bool):
762
- """Apply max_loc override."""
763
- if max_loc is not None:
764
- orchestrator.config["srp"]["max_loc"] = max_loc
765
- if verbose:
766
- logger.debug(f"Overriding max_loc to {max_loc}")
767
-
768
-
769
- def _run_srp_lint(orchestrator, path_objs: list[Path], recursive: bool):
770
- """Execute SRP lint on files or directories."""
771
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
772
- return [v for v in all_violations if "srp" in v.rule_id]
773
-
774
-
775
- @cli.command("srp")
776
- @click.argument("paths", nargs=-1, type=click.Path(exists=True))
777
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
778
- @format_option
779
- @click.option("--max-methods", type=int, help="Override max methods per class (default: 7)")
780
- @click.option("--max-loc", type=int, help="Override max lines of code per class (default: 200)")
781
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
782
- @click.pass_context
783
- def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
784
- ctx,
785
- paths: tuple[str, ...],
786
- config_file: str | None,
787
- format: str,
788
- max_methods: int | None,
789
- max_loc: int | None,
790
- recursive: bool,
791
- ):
792
- """Check for Single Responsibility Principle violations.
793
-
794
- Analyzes Python and TypeScript classes for SRP violations using heuristics:
795
- - Method count exceeding threshold (default: 7)
796
- - Lines of code exceeding threshold (default: 200)
797
- - Responsibility keywords in class names (Manager, Handler, Processor, etc.)
798
-
799
- PATHS: Files or directories to lint (defaults to current directory if none provided)
800
-
801
- Examples:
802
-
803
- \b
804
- # Check current directory (all files recursively)
805
- thai-lint srp
806
-
807
- \b
808
- # Check specific directory
809
- thai-lint srp src/
810
-
811
- \b
812
- # Check single file
813
- thai-lint srp src/app.py
814
-
815
- \b
816
- # Check multiple files
817
- thai-lint srp src/app.py src/service.py tests/test_app.py
818
-
819
- \b
820
- # Use custom thresholds
821
- thai-lint srp --max-methods 10 --max-loc 300 src/
822
-
823
- \b
824
- # Get JSON output
825
- thai-lint srp --format json .
826
-
827
- \b
828
- # Use custom config file
829
- thai-lint srp --config .thailint.yaml src/
830
- """
831
- verbose = ctx.obj.get("verbose", False)
832
-
833
- if not paths:
834
- paths = (".",)
835
-
836
- path_objs = [Path(p) for p in paths]
837
-
838
- try:
839
- _execute_srp_lint(path_objs, config_file, format, max_methods, max_loc, recursive, verbose)
840
- except Exception as e:
841
- _handle_linting_error(e, verbose)
842
-
843
-
844
- def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
845
- path_objs, config_file, format, max_methods, max_loc, recursive, verbose
846
- ):
847
- """Execute SRP lint."""
848
- orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose)
849
- _apply_srp_config_override(orchestrator, max_methods, max_loc, verbose)
850
- srp_violations = _run_srp_lint(orchestrator, path_objs, recursive)
851
-
852
- if verbose:
853
- logger.info(f"Found {len(srp_violations)} SRP violation(s)")
854
-
855
- format_violations(srp_violations, format)
856
- sys.exit(1 if srp_violations else 0)
857
-
858
-
859
- @cli.command("dry")
860
- @click.argument("paths", nargs=-1, type=click.Path(exists=True))
861
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
862
- @format_option
863
- @click.option("--min-lines", type=int, help="Override min duplicate lines threshold")
864
- @click.option("--no-cache", is_flag=True, help="Disable SQLite cache (force rehash)")
865
- @click.option("--clear-cache", is_flag=True, help="Clear cache before running")
866
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
867
- @click.pass_context
868
- def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
869
- ctx,
870
- paths: tuple[str, ...],
871
- config_file: str | None,
872
- format: str,
873
- min_lines: int | None,
874
- no_cache: bool,
875
- clear_cache: bool,
876
- recursive: bool,
877
- ):
878
- # Justification for Pylint disables:
879
- # - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 6 options = 8 params
880
- # All parameters are necessary for flexible DRY linter CLI usage.
881
- """
882
- Check for duplicate code (DRY principle violations).
883
-
884
- Detects duplicate code blocks across your project using token-based hashing
885
- with SQLite caching for fast incremental scans.
886
-
887
- PATHS: Files or directories to lint (defaults to current directory if none provided)
888
-
889
- Examples:
890
-
891
- \b
892
- # Check current directory (all files recursively)
893
- thai-lint dry
894
-
895
- \b
896
- # Check specific directory
897
- thai-lint dry src/
898
-
899
- \b
900
- # Check single file
901
- thai-lint dry src/app.py
902
-
903
- \b
904
- # Check multiple files
905
- thai-lint dry src/app.py src/service.py tests/test_app.py
906
-
907
- \b
908
- # Use custom config file
909
- thai-lint dry --config .thailint.yaml src/
910
-
911
- \b
912
- # Override minimum duplicate lines threshold
913
- thai-lint dry --min-lines 5 .
914
-
915
- \b
916
- # Disable cache (force re-analysis)
917
- thai-lint dry --no-cache .
918
-
919
- \b
920
- # Clear cache before running
921
- thai-lint dry --clear-cache .
922
-
923
- \b
924
- # Get JSON output
925
- thai-lint dry --format json .
926
- """
927
- verbose = ctx.obj.get("verbose", False)
928
-
929
- if not paths:
930
- paths = (".",)
931
-
932
- path_objs = [Path(p) for p in paths]
933
-
934
- try:
935
- _execute_dry_lint(
936
- path_objs, config_file, format, min_lines, no_cache, clear_cache, recursive, verbose
937
- )
938
- except Exception as e:
939
- _handle_linting_error(e, verbose)
940
-
941
-
942
- def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
943
- path_objs, config_file, format, min_lines, no_cache, clear_cache, recursive, verbose
944
- ):
945
- """Execute DRY linting."""
946
- orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose)
947
- _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose)
948
-
949
- if clear_cache:
950
- _clear_dry_cache(orchestrator, verbose)
951
-
952
- dry_violations = _run_dry_lint(orchestrator, path_objs, recursive)
953
-
954
- if verbose:
955
- logger.info(f"Found {len(dry_violations)} DRY violation(s)")
956
-
957
- format_violations(dry_violations, format)
958
- sys.exit(1 if dry_violations else 0)
959
-
960
-
961
- def _setup_dry_orchestrator(path_objs, config_file, verbose):
962
- """Set up orchestrator for DRY linting."""
963
- from src.orchestrator.core import Orchestrator
964
- from src.utils.project_root import get_project_root
965
-
966
- first_path = path_objs[0] if path_objs else Path.cwd()
967
- search_start = first_path if first_path.is_dir() else first_path.parent
968
- project_root = get_project_root(search_start)
969
-
970
- orchestrator = Orchestrator(project_root=project_root)
971
-
972
- if config_file:
973
- _load_dry_config_file(orchestrator, config_file, verbose)
974
-
975
- return orchestrator
976
-
977
-
978
- def _load_dry_config_file(orchestrator, config_file, verbose):
979
- """Load DRY configuration from file."""
980
- import yaml
981
-
982
- config_path = Path(config_file)
983
- if not config_path.exists():
984
- click.echo(f"Error: Config file not found: {config_file}", err=True)
985
- sys.exit(2)
986
-
987
- with config_path.open("r", encoding="utf-8") as f:
988
- config = yaml.safe_load(f)
989
-
990
- if "dry" in config:
991
- orchestrator.config.update({"dry": config["dry"]})
992
-
993
- if verbose:
994
- logger.info(f"Loaded DRY config from {config_file}")
995
-
996
-
997
- def _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose):
998
- """Apply CLI option overrides to DRY config."""
999
- _ensure_dry_config_exists(orchestrator)
1000
- _apply_min_lines_override(orchestrator, min_lines, verbose)
1001
- _apply_cache_override(orchestrator, no_cache, verbose)
1002
-
1003
-
1004
- def _ensure_dry_config_exists(orchestrator):
1005
- """Ensure dry config section exists."""
1006
- if "dry" not in orchestrator.config:
1007
- orchestrator.config["dry"] = {}
1008
-
1009
-
1010
- def _apply_min_lines_override(orchestrator, min_lines, verbose):
1011
- """Apply min_lines override if provided."""
1012
- if min_lines is None:
1013
- return
1014
-
1015
- orchestrator.config["dry"]["min_duplicate_lines"] = min_lines
1016
- if verbose:
1017
- logger.info(f"Override: min_duplicate_lines = {min_lines}")
1018
-
1019
-
1020
- def _apply_cache_override(orchestrator, no_cache, verbose):
1021
- """Apply cache override if requested."""
1022
- if not no_cache:
1023
- return
1024
-
1025
- orchestrator.config["dry"]["cache_enabled"] = False
1026
- if verbose:
1027
- logger.info("Override: cache_enabled = False")
1028
-
1029
-
1030
- def _clear_dry_cache(orchestrator, verbose):
1031
- """Clear DRY cache before running."""
1032
- cache_path_str = orchestrator.config.get("dry", {}).get("cache_path", ".thailint-cache/dry.db")
1033
- cache_path = orchestrator.project_root / cache_path_str
1034
-
1035
- if cache_path.exists():
1036
- cache_path.unlink()
1037
- if verbose:
1038
- logger.info(f"Cleared cache: {cache_path}")
1039
- else:
1040
- if verbose:
1041
- logger.info("Cache file does not exist, nothing to clear")
1042
-
1043
-
1044
- def _run_dry_lint(orchestrator, path_objs, recursive):
1045
- """Run DRY linting and return violations."""
1046
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1047
-
1048
- # Filter to only DRY violations
1049
- dry_violations = [v for v in all_violations if v.rule_id.startswith("dry.")]
1050
-
1051
- return dry_violations
1052
-
1053
-
1054
- if __name__ == "__main__":
1055
- cli()