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.
- src/__init__.py +1 -0
- src/cli/__init__.py +27 -0
- src/cli/__main__.py +22 -0
- src/cli/config.py +478 -0
- src/cli/linters/__init__.py +58 -0
- src/cli/linters/code_patterns.py +372 -0
- src/cli/linters/code_smells.py +343 -0
- src/cli/linters/documentation.py +155 -0
- src/cli/linters/shared.py +89 -0
- src/cli/linters/structure.py +313 -0
- src/cli/linters/structure_quality.py +316 -0
- src/cli/main.py +120 -0
- src/cli/utils.py +375 -0
- src/cli_main.py +34 -0
- src/config.py +2 -3
- src/core/rule_discovery.py +43 -10
- src/core/types.py +13 -0
- src/core/violation_utils.py +69 -0
- src/linter_config/ignore.py +32 -16
- src/linters/collection_pipeline/__init__.py +90 -0
- src/linters/collection_pipeline/config.py +63 -0
- src/linters/collection_pipeline/continue_analyzer.py +100 -0
- src/linters/collection_pipeline/detector.py +130 -0
- src/linters/collection_pipeline/linter.py +437 -0
- src/linters/collection_pipeline/suggestion_builder.py +63 -0
- src/linters/dry/block_filter.py +99 -9
- src/linters/dry/cache.py +94 -6
- src/linters/dry/config.py +47 -10
- src/linters/dry/constant.py +92 -0
- src/linters/dry/constant_matcher.py +214 -0
- src/linters/dry/constant_violation_builder.py +98 -0
- src/linters/dry/linter.py +89 -48
- src/linters/dry/python_analyzer.py +44 -431
- src/linters/dry/python_constant_extractor.py +101 -0
- src/linters/dry/single_statement_detector.py +415 -0
- src/linters/dry/token_hasher.py +5 -5
- src/linters/dry/typescript_analyzer.py +63 -382
- src/linters/dry/typescript_constant_extractor.py +134 -0
- src/linters/dry/typescript_statement_detector.py +255 -0
- src/linters/dry/typescript_value_extractor.py +66 -0
- src/linters/file_header/linter.py +9 -13
- src/linters/file_placement/linter.py +30 -10
- src/linters/file_placement/pattern_matcher.py +19 -5
- src/linters/magic_numbers/linter.py +8 -67
- src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
- src/linters/nesting/linter.py +12 -9
- src/linters/print_statements/linter.py +7 -24
- src/linters/srp/class_analyzer.py +9 -9
- src/linters/srp/heuristics.py +6 -5
- src/linters/srp/linter.py +4 -5
- src/linters/stateless_class/linter.py +2 -2
- src/linters/stringly_typed/__init__.py +23 -0
- src/linters/stringly_typed/config.py +165 -0
- src/linters/stringly_typed/python/__init__.py +29 -0
- src/linters/stringly_typed/python/analyzer.py +198 -0
- src/linters/stringly_typed/python/condition_extractor.py +131 -0
- src/linters/stringly_typed/python/conditional_detector.py +176 -0
- src/linters/stringly_typed/python/constants.py +21 -0
- src/linters/stringly_typed/python/match_analyzer.py +88 -0
- src/linters/stringly_typed/python/validation_detector.py +186 -0
- src/linters/stringly_typed/python/variable_extractor.py +96 -0
- src/orchestrator/core.py +241 -12
- {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/METADATA +116 -3
- {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/RECORD +67 -29
- thailint-0.11.0.dist-info/entry_points.txt +4 -0
- src/cli.py +0 -2014
- thailint-0.9.0.dist-info/entry_points.txt +0 -4
- {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/WHEEL +0 -0
- {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
|
|
107
|
-
|
|
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
|
src/core/rule_discovery.py
CHANGED
|
@@ -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
|
-
|
|
107
|
+
return importlib.import_module(module_path)
|
|
92
108
|
except (ImportError, AttributeError):
|
|
93
|
-
return
|
|
109
|
+
return None
|
|
94
110
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|