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.
- src/__init__.py +1 -0
- src/analyzers/__init__.py +4 -3
- src/analyzers/ast_utils.py +54 -0
- src/analyzers/rust_base.py +155 -0
- src/analyzers/rust_context.py +141 -0
- src/analyzers/typescript_base.py +4 -0
- src/cli/__init__.py +30 -0
- src/cli/__main__.py +22 -0
- src/cli/config.py +480 -0
- src/cli/config_merge.py +241 -0
- src/cli/linters/__init__.py +67 -0
- src/cli/linters/code_patterns.py +270 -0
- src/cli/linters/code_smells.py +342 -0
- src/cli/linters/documentation.py +83 -0
- src/cli/linters/performance.py +287 -0
- src/cli/linters/shared.py +331 -0
- src/cli/linters/structure.py +327 -0
- src/cli/linters/structure_quality.py +328 -0
- src/cli/main.py +120 -0
- src/cli/utils.py +395 -0
- src/cli_main.py +37 -0
- src/config.py +38 -25
- src/core/base.py +7 -2
- src/core/cli_utils.py +19 -2
- src/core/config_parser.py +5 -2
- src/core/constants.py +54 -0
- src/core/linter_utils.py +95 -6
- src/core/python_lint_rule.py +101 -0
- src/core/registry.py +1 -1
- src/core/rule_discovery.py +147 -84
- src/core/types.py +13 -0
- src/core/violation_builder.py +78 -15
- src/core/violation_utils.py +69 -0
- src/formatters/__init__.py +22 -0
- src/formatters/sarif.py +202 -0
- src/linter_config/directive_markers.py +109 -0
- src/linter_config/ignore.py +254 -395
- src/linter_config/loader.py +45 -12
- src/linter_config/pattern_utils.py +65 -0
- src/linter_config/rule_matcher.py +89 -0
- src/linters/collection_pipeline/__init__.py +90 -0
- src/linters/collection_pipeline/any_all_analyzer.py +281 -0
- src/linters/collection_pipeline/ast_utils.py +40 -0
- src/linters/collection_pipeline/config.py +75 -0
- src/linters/collection_pipeline/continue_analyzer.py +94 -0
- src/linters/collection_pipeline/detector.py +360 -0
- src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
- src/linters/collection_pipeline/linter.py +420 -0
- src/linters/collection_pipeline/suggestion_builder.py +130 -0
- src/linters/cqs/__init__.py +54 -0
- src/linters/cqs/config.py +55 -0
- src/linters/cqs/function_analyzer.py +201 -0
- src/linters/cqs/input_detector.py +139 -0
- src/linters/cqs/linter.py +159 -0
- src/linters/cqs/output_detector.py +84 -0
- src/linters/cqs/python_analyzer.py +54 -0
- src/linters/cqs/types.py +82 -0
- src/linters/cqs/typescript_cqs_analyzer.py +61 -0
- src/linters/cqs/typescript_function_analyzer.py +192 -0
- src/linters/cqs/typescript_input_detector.py +203 -0
- src/linters/cqs/typescript_output_detector.py +117 -0
- src/linters/cqs/violation_builder.py +94 -0
- src/linters/dry/base_token_analyzer.py +16 -9
- src/linters/dry/block_filter.py +120 -20
- src/linters/dry/block_grouper.py +4 -0
- src/linters/dry/cache.py +104 -10
- src/linters/dry/cache_query.py +4 -0
- src/linters/dry/config.py +54 -11
- src/linters/dry/constant.py +92 -0
- src/linters/dry/constant_matcher.py +223 -0
- src/linters/dry/constant_violation_builder.py +98 -0
- src/linters/dry/duplicate_storage.py +5 -4
- src/linters/dry/file_analyzer.py +4 -2
- src/linters/dry/inline_ignore.py +7 -16
- src/linters/dry/linter.py +183 -48
- src/linters/dry/python_analyzer.py +60 -439
- src/linters/dry/python_constant_extractor.py +100 -0
- src/linters/dry/single_statement_detector.py +417 -0
- src/linters/dry/token_hasher.py +116 -112
- src/linters/dry/typescript_analyzer.py +68 -382
- src/linters/dry/typescript_constant_extractor.py +138 -0
- src/linters/dry/typescript_statement_detector.py +255 -0
- src/linters/dry/typescript_value_extractor.py +70 -0
- src/linters/dry/violation_builder.py +4 -0
- src/linters/dry/violation_filter.py +5 -4
- src/linters/dry/violation_generator.py +71 -14
- src/linters/file_header/atemporal_detector.py +68 -50
- src/linters/file_header/base_parser.py +93 -0
- src/linters/file_header/bash_parser.py +66 -0
- src/linters/file_header/config.py +90 -16
- src/linters/file_header/css_parser.py +70 -0
- src/linters/file_header/field_validator.py +36 -33
- src/linters/file_header/linter.py +140 -144
- src/linters/file_header/markdown_parser.py +130 -0
- src/linters/file_header/python_parser.py +14 -58
- src/linters/file_header/typescript_parser.py +73 -0
- src/linters/file_header/violation_builder.py +13 -12
- src/linters/file_placement/config_loader.py +3 -1
- src/linters/file_placement/directory_matcher.py +4 -0
- src/linters/file_placement/linter.py +66 -34
- src/linters/file_placement/pattern_matcher.py +41 -6
- src/linters/file_placement/pattern_validator.py +31 -12
- src/linters/file_placement/rule_checker.py +12 -7
- src/linters/lazy_ignores/__init__.py +43 -0
- src/linters/lazy_ignores/config.py +74 -0
- src/linters/lazy_ignores/directive_utils.py +164 -0
- src/linters/lazy_ignores/header_parser.py +177 -0
- src/linters/lazy_ignores/linter.py +158 -0
- src/linters/lazy_ignores/matcher.py +168 -0
- src/linters/lazy_ignores/python_analyzer.py +209 -0
- src/linters/lazy_ignores/rule_id_utils.py +180 -0
- src/linters/lazy_ignores/skip_detector.py +298 -0
- src/linters/lazy_ignores/types.py +71 -0
- src/linters/lazy_ignores/typescript_analyzer.py +146 -0
- src/linters/lazy_ignores/violation_builder.py +135 -0
- src/linters/lbyl/__init__.py +31 -0
- src/linters/lbyl/config.py +63 -0
- src/linters/lbyl/linter.py +67 -0
- src/linters/lbyl/pattern_detectors/__init__.py +53 -0
- src/linters/lbyl/pattern_detectors/base.py +63 -0
- src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
- src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
- src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
- src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
- src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
- src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
- src/linters/lbyl/python_analyzer.py +215 -0
- src/linters/lbyl/violation_builder.py +354 -0
- src/linters/magic_numbers/context_analyzer.py +227 -225
- src/linters/magic_numbers/linter.py +28 -82
- src/linters/magic_numbers/python_analyzer.py +4 -16
- src/linters/magic_numbers/typescript_analyzer.py +9 -12
- src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
- src/linters/method_property/__init__.py +49 -0
- src/linters/method_property/config.py +138 -0
- src/linters/method_property/linter.py +414 -0
- src/linters/method_property/python_analyzer.py +473 -0
- src/linters/method_property/violation_builder.py +119 -0
- src/linters/nesting/linter.py +24 -16
- src/linters/nesting/python_analyzer.py +4 -0
- src/linters/nesting/typescript_analyzer.py +6 -12
- src/linters/nesting/violation_builder.py +1 -0
- src/linters/performance/__init__.py +91 -0
- src/linters/performance/config.py +43 -0
- src/linters/performance/constants.py +49 -0
- src/linters/performance/linter.py +149 -0
- src/linters/performance/python_analyzer.py +365 -0
- src/linters/performance/regex_analyzer.py +312 -0
- src/linters/performance/regex_linter.py +139 -0
- src/linters/performance/typescript_analyzer.py +236 -0
- src/linters/performance/violation_builder.py +160 -0
- src/linters/print_statements/config.py +7 -12
- src/linters/print_statements/linter.py +26 -43
- src/linters/print_statements/python_analyzer.py +91 -93
- src/linters/print_statements/typescript_analyzer.py +15 -25
- src/linters/print_statements/violation_builder.py +12 -14
- src/linters/srp/class_analyzer.py +11 -7
- src/linters/srp/heuristics.py +56 -22
- src/linters/srp/linter.py +15 -16
- src/linters/srp/python_analyzer.py +55 -20
- src/linters/srp/typescript_metrics_calculator.py +110 -50
- src/linters/stateless_class/__init__.py +25 -0
- src/linters/stateless_class/config.py +58 -0
- src/linters/stateless_class/linter.py +349 -0
- src/linters/stateless_class/python_analyzer.py +290 -0
- src/linters/stringly_typed/__init__.py +36 -0
- src/linters/stringly_typed/config.py +189 -0
- src/linters/stringly_typed/context_filter.py +451 -0
- src/linters/stringly_typed/function_call_violation_builder.py +135 -0
- src/linters/stringly_typed/ignore_checker.py +100 -0
- src/linters/stringly_typed/ignore_utils.py +51 -0
- src/linters/stringly_typed/linter.py +376 -0
- src/linters/stringly_typed/python/__init__.py +33 -0
- src/linters/stringly_typed/python/analyzer.py +348 -0
- src/linters/stringly_typed/python/call_tracker.py +175 -0
- src/linters/stringly_typed/python/comparison_tracker.py +257 -0
- src/linters/stringly_typed/python/condition_extractor.py +134 -0
- src/linters/stringly_typed/python/conditional_detector.py +179 -0
- src/linters/stringly_typed/python/constants.py +21 -0
- src/linters/stringly_typed/python/match_analyzer.py +94 -0
- src/linters/stringly_typed/python/validation_detector.py +189 -0
- src/linters/stringly_typed/python/variable_extractor.py +96 -0
- src/linters/stringly_typed/storage.py +620 -0
- src/linters/stringly_typed/storage_initializer.py +45 -0
- src/linters/stringly_typed/typescript/__init__.py +28 -0
- src/linters/stringly_typed/typescript/analyzer.py +157 -0
- src/linters/stringly_typed/typescript/call_tracker.py +335 -0
- src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
- src/linters/stringly_typed/violation_generator.py +419 -0
- src/orchestrator/core.py +252 -14
- src/orchestrator/language_detector.py +5 -3
- src/templates/thailint_config_template.yaml +196 -0
- src/utils/project_root.py +3 -0
- thailint-0.15.3.dist-info/METADATA +187 -0
- thailint-0.15.3.dist-info/RECORD +226 -0
- thailint-0.15.3.dist-info/entry_points.txt +4 -0
- src/cli.py +0 -1665
- thailint-0.5.0.dist-info/METADATA +0 -1286
- thailint-0.5.0.dist-info/RECORD +0 -96
- thailint-0.5.0.dist-info/entry_points.txt +0 -4
- {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +0 -0
- {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
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 ==
|
|
180
|
+
if context.language == Language.PYTHON:
|
|
176
181
|
return self._check_python(context, config)
|
|
177
182
|
|
|
178
|
-
if context.language in (
|
|
183
|
+
if context.language in (Language.TYPESCRIPT, Language.JAVASCRIPT):
|
|
179
184
|
return self._check_typescript(context, config)
|
|
180
185
|
|
|
181
186
|
return []
|