thailint 0.1.5__py3-none-any.whl → 0.5.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 +7 -2
- src/analyzers/__init__.py +23 -0
- src/analyzers/typescript_base.py +148 -0
- src/api.py +1 -1
- src/cli.py +1111 -144
- src/config.py +12 -33
- src/core/base.py +102 -5
- src/core/cli_utils.py +206 -0
- src/core/config_parser.py +126 -0
- src/core/linter_utils.py +168 -0
- src/core/registry.py +17 -92
- src/core/rule_discovery.py +132 -0
- src/core/violation_builder.py +122 -0
- src/linter_config/ignore.py +112 -40
- src/linter_config/loader.py +3 -13
- src/linters/dry/__init__.py +23 -0
- src/linters/dry/base_token_analyzer.py +76 -0
- src/linters/dry/block_filter.py +265 -0
- src/linters/dry/block_grouper.py +59 -0
- src/linters/dry/cache.py +172 -0
- src/linters/dry/cache_query.py +61 -0
- src/linters/dry/config.py +134 -0
- src/linters/dry/config_loader.py +44 -0
- src/linters/dry/deduplicator.py +120 -0
- src/linters/dry/duplicate_storage.py +63 -0
- src/linters/dry/file_analyzer.py +90 -0
- src/linters/dry/inline_ignore.py +140 -0
- src/linters/dry/linter.py +163 -0
- src/linters/dry/python_analyzer.py +668 -0
- src/linters/dry/storage_initializer.py +42 -0
- src/linters/dry/token_hasher.py +169 -0
- src/linters/dry/typescript_analyzer.py +592 -0
- src/linters/dry/violation_builder.py +74 -0
- src/linters/dry/violation_filter.py +94 -0
- src/linters/dry/violation_generator.py +174 -0
- src/linters/file_header/__init__.py +24 -0
- src/linters/file_header/atemporal_detector.py +87 -0
- src/linters/file_header/config.py +66 -0
- src/linters/file_header/field_validator.py +69 -0
- src/linters/file_header/linter.py +313 -0
- src/linters/file_header/python_parser.py +86 -0
- src/linters/file_header/violation_builder.py +78 -0
- src/linters/file_placement/config_loader.py +86 -0
- src/linters/file_placement/directory_matcher.py +80 -0
- src/linters/file_placement/linter.py +262 -471
- src/linters/file_placement/path_resolver.py +61 -0
- src/linters/file_placement/pattern_matcher.py +55 -0
- src/linters/file_placement/pattern_validator.py +106 -0
- src/linters/file_placement/rule_checker.py +229 -0
- src/linters/file_placement/violation_factory.py +177 -0
- src/linters/magic_numbers/__init__.py +48 -0
- src/linters/magic_numbers/config.py +82 -0
- src/linters/magic_numbers/context_analyzer.py +247 -0
- src/linters/magic_numbers/linter.py +516 -0
- src/linters/magic_numbers/python_analyzer.py +76 -0
- src/linters/magic_numbers/typescript_analyzer.py +218 -0
- src/linters/magic_numbers/violation_builder.py +98 -0
- src/linters/nesting/__init__.py +6 -2
- src/linters/nesting/config.py +17 -4
- src/linters/nesting/linter.py +81 -168
- src/linters/nesting/typescript_analyzer.py +39 -102
- src/linters/nesting/typescript_function_extractor.py +130 -0
- src/linters/nesting/violation_builder.py +139 -0
- src/linters/print_statements/__init__.py +53 -0
- src/linters/print_statements/config.py +83 -0
- src/linters/print_statements/linter.py +430 -0
- src/linters/print_statements/python_analyzer.py +155 -0
- src/linters/print_statements/typescript_analyzer.py +135 -0
- src/linters/print_statements/violation_builder.py +98 -0
- src/linters/srp/__init__.py +99 -0
- src/linters/srp/class_analyzer.py +113 -0
- src/linters/srp/config.py +82 -0
- src/linters/srp/heuristics.py +89 -0
- src/linters/srp/linter.py +234 -0
- src/linters/srp/metrics_evaluator.py +47 -0
- src/linters/srp/python_analyzer.py +72 -0
- src/linters/srp/typescript_analyzer.py +75 -0
- src/linters/srp/typescript_metrics_calculator.py +90 -0
- src/linters/srp/violation_builder.py +117 -0
- src/orchestrator/core.py +54 -9
- src/templates/thailint_config_template.yaml +158 -0
- src/utils/__init__.py +4 -0
- src/utils/project_root.py +203 -0
- thailint-0.5.0.dist-info/METADATA +1286 -0
- thailint-0.5.0.dist-info/RECORD +96 -0
- {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/WHEEL +1 -1
- src/.ai/layout.yaml +0 -48
- thailint-0.1.5.dist-info/METADATA +0 -629
- thailint-0.1.5.dist-info/RECORD +0 -28
- {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/entry_points.txt +0 -0
- {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info/licenses}/LICENSE +0 -0
src/config.py
CHANGED
|
@@ -25,6 +25,8 @@ from typing import Any
|
|
|
25
25
|
|
|
26
26
|
import yaml
|
|
27
27
|
|
|
28
|
+
from src.core.config_parser import ConfigParseError, parse_config_file
|
|
29
|
+
|
|
28
30
|
logger = logging.getLogger(__name__)
|
|
29
31
|
|
|
30
32
|
|
|
@@ -32,6 +34,10 @@ class ConfigError(Exception):
|
|
|
32
34
|
"""Configuration-related errors."""
|
|
33
35
|
|
|
34
36
|
|
|
37
|
+
# Default configuration constants
|
|
38
|
+
DEFAULT_MAX_RETRIES = 3
|
|
39
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
40
|
+
|
|
35
41
|
# Default configuration values
|
|
36
42
|
DEFAULT_CONFIG: dict[str, Any] = {
|
|
37
43
|
"app_name": "{{PROJECT_NAME}}",
|
|
@@ -39,8 +45,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
39
45
|
"log_level": "INFO",
|
|
40
46
|
"output_format": "text",
|
|
41
47
|
"greeting": "Hello",
|
|
42
|
-
"max_retries":
|
|
43
|
-
"timeout":
|
|
48
|
+
"max_retries": DEFAULT_MAX_RETRIES,
|
|
49
|
+
"timeout": DEFAULT_TIMEOUT_SECONDS,
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
# Configuration file search paths (in priority order)
|
|
@@ -103,7 +109,7 @@ def _load_from_default_locations() -> dict[str, Any]:
|
|
|
103
109
|
loaded_config = _try_load_from_location(location)
|
|
104
110
|
if loaded_config:
|
|
105
111
|
return loaded_config
|
|
106
|
-
logger.
|
|
112
|
+
logger.debug("No CLI config file found, using defaults")
|
|
107
113
|
return DEFAULT_CONFIG.copy()
|
|
108
114
|
|
|
109
115
|
|
|
@@ -134,23 +140,6 @@ def load_config(config_path: Path | None = None) -> dict[str, Any]:
|
|
|
134
140
|
return _load_from_default_locations()
|
|
135
141
|
|
|
136
142
|
|
|
137
|
-
def _parse_yaml_file(f, path: Path) -> dict[str, Any]:
|
|
138
|
-
"""Parse YAML file and return data."""
|
|
139
|
-
try:
|
|
140
|
-
data = yaml.safe_load(f)
|
|
141
|
-
return data if data is not None else {}
|
|
142
|
-
except yaml.YAMLError as e:
|
|
143
|
-
raise ConfigError(f"Invalid YAML in {path}: {e}") from e
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _parse_json_file(f, path: Path) -> dict[str, Any]:
|
|
147
|
-
"""Parse JSON file and return data."""
|
|
148
|
-
try:
|
|
149
|
-
return json.load(f)
|
|
150
|
-
except json.JSONDecodeError as e:
|
|
151
|
-
raise ConfigError(f"Invalid JSON in {path}: {e}") from e
|
|
152
|
-
|
|
153
|
-
|
|
154
143
|
def _load_config_file(path: Path) -> dict[str, Any]:
|
|
155
144
|
"""
|
|
156
145
|
Load config from YAML or JSON file based on extension.
|
|
@@ -165,23 +154,13 @@ def _load_config_file(path: Path) -> dict[str, Any]:
|
|
|
165
154
|
ConfigError: If file cannot be parsed.
|
|
166
155
|
"""
|
|
167
156
|
try:
|
|
168
|
-
return
|
|
169
|
-
except
|
|
170
|
-
raise
|
|
157
|
+
return parse_config_file(path)
|
|
158
|
+
except ConfigParseError as e:
|
|
159
|
+
raise ConfigError(str(e)) from e
|
|
171
160
|
except Exception as e:
|
|
172
161
|
raise ConfigError(f"Failed to load config from {path}: {e}") from e
|
|
173
162
|
|
|
174
163
|
|
|
175
|
-
def _parse_config_by_extension(path: Path) -> dict[str, Any]:
|
|
176
|
-
"""Parse config file based on its extension."""
|
|
177
|
-
with path.open() as f:
|
|
178
|
-
if path.suffix in [".yaml", ".yml"]:
|
|
179
|
-
return _parse_yaml_file(f, path)
|
|
180
|
-
if path.suffix == ".json":
|
|
181
|
-
return _parse_json_file(f, path)
|
|
182
|
-
raise ConfigError(f"Unsupported config format: {path.suffix}")
|
|
183
|
-
|
|
184
|
-
|
|
185
164
|
def _validate_before_save(config: dict[str, Any]) -> None:
|
|
186
165
|
"""Validate config before saving."""
|
|
187
166
|
is_valid, errors = validate_config(config)
|
src/core/base.py
CHANGED
|
@@ -8,14 +8,17 @@ Overview: Establishes the contract that all linting plugins must follow through
|
|
|
8
8
|
Defines BaseLintRule which all concrete linting rules inherit from, specifying required
|
|
9
9
|
properties (rule_id, rule_name, description) and the check() method for violation detection.
|
|
10
10
|
Provides BaseLintContext as the interface for accessing file information during analysis,
|
|
11
|
-
exposing file_path, file_content, and language properties.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
exposing file_path, file_content, and language properties. Includes MultiLanguageLintRule
|
|
12
|
+
intermediate class implementing template method pattern for language dispatch, eliminating
|
|
13
|
+
code duplication across multi-language linters (nesting, srp, magic_numbers). These
|
|
14
|
+
abstractions enable the rule registry to discover and instantiate rules dynamically without
|
|
15
|
+
tight coupling, supporting the extensible plugin system where new rules can be added by
|
|
16
|
+
simply placing them in the appropriate directory structure.
|
|
15
17
|
|
|
16
18
|
Dependencies: abc for abstract base class support, pathlib for Path types, Violation from types
|
|
17
19
|
|
|
18
|
-
Exports: BaseLintRule (abstract rule interface), BaseLintContext (abstract context interface)
|
|
20
|
+
Exports: BaseLintRule (abstract rule interface), BaseLintContext (abstract context interface),
|
|
21
|
+
MultiLanguageLintRule (template method base for multi-language linters)
|
|
19
22
|
|
|
20
23
|
Interfaces: BaseLintRule.check(context) -> list[Violation], BaseLintContext properties
|
|
21
24
|
(file_path, file_content, language), all abstract methods must be implemented by subclasses
|
|
@@ -26,6 +29,7 @@ Implementation: ABC-based interface definitions with @abstractmethod decorators,
|
|
|
26
29
|
|
|
27
30
|
from abc import ABC, abstractmethod
|
|
28
31
|
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
29
33
|
|
|
30
34
|
from .types import Violation
|
|
31
35
|
|
|
@@ -120,3 +124,96 @@ class BaseLintRule(ABC):
|
|
|
120
124
|
List of violations found. Empty list if no violations.
|
|
121
125
|
"""
|
|
122
126
|
raise NotImplementedError("Subclasses must implement check")
|
|
127
|
+
|
|
128
|
+
def finalize(self) -> list[Violation]:
|
|
129
|
+
"""Finalize analysis after all files processed.
|
|
130
|
+
|
|
131
|
+
Optional hook called after all files have been processed via check().
|
|
132
|
+
Useful for rules that need to perform cross-file analysis or aggregate
|
|
133
|
+
results (e.g., DRY linter querying for duplicates across all files).
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of violations found during finalization. Empty list by default.
|
|
137
|
+
"""
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class MultiLanguageLintRule(BaseLintRule):
|
|
142
|
+
"""Base class for linting rules that support multiple programming languages.
|
|
143
|
+
|
|
144
|
+
Provides language dispatch pattern to eliminate code duplication across multi-language
|
|
145
|
+
linters. Subclasses implement language-specific checking methods rather than handling
|
|
146
|
+
dispatch logic themselves.
|
|
147
|
+
|
|
148
|
+
Subclasses must implement:
|
|
149
|
+
- _check_python(context, config) for Python language support
|
|
150
|
+
- _check_typescript(context, config) for TypeScript/JavaScript support
|
|
151
|
+
- _load_config(context) for configuration loading
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def check(self, context: BaseLintContext) -> list[Violation]:
|
|
155
|
+
"""Check for violations with automatic language dispatch.
|
|
156
|
+
|
|
157
|
+
Dispatches to language-specific checking methods based on context.language.
|
|
158
|
+
Handles common patterns like file content validation and config loading.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
context: Lint context with file information
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of violations found
|
|
165
|
+
"""
|
|
166
|
+
from .linter_utils import has_file_content
|
|
167
|
+
|
|
168
|
+
if not has_file_content(context):
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
config = self._load_config(context)
|
|
172
|
+
if not config.enabled:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
if context.language == "python":
|
|
176
|
+
return self._check_python(context, config)
|
|
177
|
+
|
|
178
|
+
if context.language in ("typescript", "javascript"):
|
|
179
|
+
return self._check_typescript(context, config)
|
|
180
|
+
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
@abstractmethod
|
|
184
|
+
def _load_config(self, context: BaseLintContext) -> Any:
|
|
185
|
+
"""Load configuration from context.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
context: Lint context
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Configuration object with at minimum an 'enabled' attribute
|
|
192
|
+
"""
|
|
193
|
+
raise NotImplementedError("Subclasses must implement _load_config")
|
|
194
|
+
|
|
195
|
+
@abstractmethod
|
|
196
|
+
def _check_python(self, context: BaseLintContext, config: Any) -> list[Violation]:
|
|
197
|
+
"""Check Python code for violations.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
context: Lint context with Python file information
|
|
201
|
+
config: Loaded configuration
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of violations found in Python code
|
|
205
|
+
"""
|
|
206
|
+
raise NotImplementedError("Subclasses must implement _check_python")
|
|
207
|
+
|
|
208
|
+
@abstractmethod
|
|
209
|
+
def _check_typescript(self, context: BaseLintContext, config: Any) -> list[Violation]:
|
|
210
|
+
"""Check TypeScript/JavaScript code for violations.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
context: Lint context with TypeScript/JavaScript file information
|
|
214
|
+
config: Loaded configuration
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
List of violations found in TypeScript/JavaScript code
|
|
218
|
+
"""
|
|
219
|
+
raise NotImplementedError("Subclasses must implement _check_typescript")
|
src/core/cli_utils.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Shared CLI utilities for common Click command patterns across all linters
|
|
3
|
+
|
|
4
|
+
Scope: CLI command decorators, config loading, and violation output formatting
|
|
5
|
+
|
|
6
|
+
Overview: Provides reusable utilities for CLI commands to eliminate duplication across linter
|
|
7
|
+
commands (dry, srp, nesting, file-placement). Includes common option decorators for consistent
|
|
8
|
+
CLI interfaces, configuration file loading helpers, and violation output formatting for both
|
|
9
|
+
text and JSON formats. Standardizes CLI patterns across all linter commands for maintainability
|
|
10
|
+
and consistency.
|
|
11
|
+
|
|
12
|
+
Dependencies: click for CLI framework, pathlib for file paths, json for JSON output
|
|
13
|
+
|
|
14
|
+
Exports: common_linter_options decorator, load_linter_config, format_violations
|
|
15
|
+
|
|
16
|
+
Interfaces: Click decorators, config dict, violation list formatting
|
|
17
|
+
|
|
18
|
+
Implementation: Decorator composition for Click options, shared formatting logic for CLI output
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import click
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def common_linter_options(func: Callable) -> Callable:
|
|
31
|
+
"""Add common linter CLI options to command.
|
|
32
|
+
|
|
33
|
+
Decorator that adds standard options used by all linter commands:
|
|
34
|
+
- path argument (defaults to current directory)
|
|
35
|
+
- --config/-c option for custom config file
|
|
36
|
+
- --format/-f option for output format (text or json)
|
|
37
|
+
- --recursive/--no-recursive option for directory traversal
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
func: Click command function to decorate
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Decorated function with common CLI options added
|
|
44
|
+
"""
|
|
45
|
+
func = click.argument("path", type=click.Path(exists=True), default=".")(func)
|
|
46
|
+
func = click.option(
|
|
47
|
+
"--config", "-c", "config_file", type=click.Path(), help="Path to config file"
|
|
48
|
+
)(func)
|
|
49
|
+
func = click.option(
|
|
50
|
+
"--format",
|
|
51
|
+
"-f",
|
|
52
|
+
type=click.Choice(["text", "json"]),
|
|
53
|
+
default="text",
|
|
54
|
+
help="Output format",
|
|
55
|
+
)(func)
|
|
56
|
+
func = click.option(
|
|
57
|
+
"--recursive/--no-recursive", default=True, help="Scan directories recursively"
|
|
58
|
+
)(func)
|
|
59
|
+
return func
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_linter_config(config_path: str | None) -> dict[str, Any]:
|
|
63
|
+
"""Load linter configuration from file or return empty dict.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
config_path: Path to config file (optional)
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Configuration dictionary, empty dict if no config provided
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
SystemExit: If config file path provided but file not found
|
|
73
|
+
"""
|
|
74
|
+
if not config_path:
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
config_file = Path(config_path)
|
|
78
|
+
_validate_config_file_exists(config_file, config_path)
|
|
79
|
+
return _load_config_by_format(config_file)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _validate_config_file_exists(config_file: Path, config_path: str) -> None:
|
|
83
|
+
"""Validate config file exists, exit if not found.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
config_file: Path object for config file
|
|
87
|
+
config_path: Original config path string for error message
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
SystemExit: If config file not found
|
|
91
|
+
"""
|
|
92
|
+
if not config_file.exists():
|
|
93
|
+
click.echo(f"Error: Config file not found: {config_path}", err=True)
|
|
94
|
+
sys.exit(2)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_config_by_format(config_file: Path) -> dict[str, Any]:
|
|
98
|
+
"""Load config based on file extension.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
config_file: Path to config file
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Loaded configuration dictionary
|
|
105
|
+
"""
|
|
106
|
+
if config_file.suffix in {".yaml", ".yml"}:
|
|
107
|
+
return _load_yaml_config(config_file)
|
|
108
|
+
if config_file.suffix == ".json":
|
|
109
|
+
return _load_json_config(config_file)
|
|
110
|
+
# Fallback: attempt YAML
|
|
111
|
+
return _load_yaml_config(config_file)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _load_yaml_config(config_file: Path) -> dict[str, Any]:
|
|
115
|
+
"""Load YAML config file.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
config_file: Path to YAML file
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Loaded configuration, empty dict if null
|
|
122
|
+
"""
|
|
123
|
+
import yaml
|
|
124
|
+
|
|
125
|
+
with config_file.open("r", encoding="utf-8") as f:
|
|
126
|
+
result = yaml.safe_load(f)
|
|
127
|
+
return dict(result) if result is not None else {}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_json_config(config_file: Path) -> dict[str, Any]:
|
|
131
|
+
"""Load JSON config file.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
config_file: Path to JSON file
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Loaded configuration
|
|
138
|
+
"""
|
|
139
|
+
with config_file.open("r", encoding="utf-8") as f:
|
|
140
|
+
result = json.load(f)
|
|
141
|
+
return dict(result) if isinstance(result, dict) else {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def format_violations(violations: list, output_format: str) -> None:
|
|
145
|
+
"""Format and print violations to console.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
violations: List of violation objects with rule_id, file_path, line, column, message, severity
|
|
149
|
+
output_format: Output format ("text" or "json")
|
|
150
|
+
"""
|
|
151
|
+
if output_format == "json":
|
|
152
|
+
_output_json(violations)
|
|
153
|
+
else:
|
|
154
|
+
_output_text(violations)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _output_json(violations: list) -> None:
|
|
158
|
+
"""Output violations in JSON format.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
violations: List of violation objects
|
|
162
|
+
"""
|
|
163
|
+
output = {
|
|
164
|
+
"violations": [
|
|
165
|
+
{
|
|
166
|
+
"rule_id": v.rule_id,
|
|
167
|
+
"file_path": str(v.file_path),
|
|
168
|
+
"line": v.line,
|
|
169
|
+
"column": v.column,
|
|
170
|
+
"message": v.message,
|
|
171
|
+
"severity": v.severity.name,
|
|
172
|
+
}
|
|
173
|
+
for v in violations
|
|
174
|
+
],
|
|
175
|
+
"total": len(violations),
|
|
176
|
+
}
|
|
177
|
+
click.echo(json.dumps(output, indent=2))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _output_text(violations: list) -> None:
|
|
181
|
+
"""Output violations in human-readable text format.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
violations: List of violation objects
|
|
185
|
+
"""
|
|
186
|
+
if not violations:
|
|
187
|
+
click.echo("✓ No violations found")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
click.echo(f"Found {len(violations)} violation(s):\n")
|
|
191
|
+
for v in violations:
|
|
192
|
+
_print_violation(v)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _print_violation(v: Any) -> None:
|
|
196
|
+
"""Print single violation in text format.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
v: Violation object with file_path, line, column, severity, rule_id, message
|
|
200
|
+
"""
|
|
201
|
+
location = f"{v.file_path}:{v.line}" if v.line else str(v.file_path)
|
|
202
|
+
if v.column:
|
|
203
|
+
location += f":{v.column}"
|
|
204
|
+
click.echo(f" {location}")
|
|
205
|
+
click.echo(f" [{v.severity.name}] {v.rule_id}: {v.message}")
|
|
206
|
+
click.echo()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Shared YAML/JSON configuration file parsing utilities
|
|
3
|
+
|
|
4
|
+
Scope: Common parsing logic for configuration files across the project
|
|
5
|
+
|
|
6
|
+
Overview: Provides reusable utilities for parsing YAML and JSON configuration files with
|
|
7
|
+
consistent error handling and format detection. Eliminates duplication between src/config.py
|
|
8
|
+
and src/linter_config/loader.py by centralizing the parsing logic in one place. Supports
|
|
9
|
+
extension-based format detection (.yaml, .yml, .json), safe YAML loading with yaml.safe_load(),
|
|
10
|
+
and proper null handling. Returns empty dictionaries for null YAML content to ensure consistent
|
|
11
|
+
behavior across all config loaders.
|
|
12
|
+
|
|
13
|
+
Dependencies: PyYAML for YAML parsing, json (stdlib) for JSON parsing, pathlib for file operations
|
|
14
|
+
|
|
15
|
+
Exports: parse_config_file(), parse_yaml(), parse_json()
|
|
16
|
+
|
|
17
|
+
Interfaces: parse_config_file(path: Path) -> dict[str, Any] for extension-based parsing,
|
|
18
|
+
parse_yaml(file_obj, path: Path) -> dict[str, Any] for YAML parsing,
|
|
19
|
+
parse_json(file_obj, path: Path) -> dict[str, Any] for JSON parsing
|
|
20
|
+
|
|
21
|
+
Implementation: yaml.safe_load() for security, json.load() for JSON, ConfigParseError for errors
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, TextIO
|
|
27
|
+
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigParseError(Exception):
|
|
32
|
+
"""Configuration file parsing errors."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_yaml(file_obj: TextIO, path: Path) -> dict[str, Any]:
|
|
36
|
+
"""Parse YAML file content.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
file_obj: Open file object to read from.
|
|
40
|
+
path: Path to file (for error messages).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Parsed YAML data as dictionary.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ConfigParseError: If YAML is malformed.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
data = yaml.safe_load(file_obj)
|
|
50
|
+
return data if data is not None else {}
|
|
51
|
+
except yaml.YAMLError as e:
|
|
52
|
+
raise ConfigParseError(f"Invalid YAML in {path}: {e}") from e
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_json(file_obj: TextIO, path: Path) -> dict[str, Any]:
|
|
56
|
+
"""Parse JSON file content.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
file_obj: Open file object to read from.
|
|
60
|
+
path: Path to file (for error messages).
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Parsed JSON data as dictionary.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ConfigParseError: If JSON is malformed.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
result: dict[str, Any] = json.load(file_obj)
|
|
70
|
+
return result
|
|
71
|
+
except json.JSONDecodeError as e:
|
|
72
|
+
raise ConfigParseError(f"Invalid JSON in {path}: {e}") from e
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalize_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
|
76
|
+
"""Normalize configuration keys from hyphens to underscores.
|
|
77
|
+
|
|
78
|
+
Converts top-level keys like "magic-numbers" to "magic_numbers" to match
|
|
79
|
+
internal linter expectations while maintaining backward compatibility with
|
|
80
|
+
both formats in config files.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
config: Configuration dictionary with potentially hyphenated keys
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Configuration dictionary with normalized (underscored) keys
|
|
87
|
+
"""
|
|
88
|
+
normalized = {}
|
|
89
|
+
for key, value in config.items():
|
|
90
|
+
# Replace hyphens with underscores in keys
|
|
91
|
+
normalized_key = key.replace("-", "_")
|
|
92
|
+
normalized[normalized_key] = value
|
|
93
|
+
return normalized
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_config_file(path: Path, encoding: str = "utf-8") -> dict[str, Any]:
|
|
97
|
+
"""Parse configuration file based on extension.
|
|
98
|
+
|
|
99
|
+
Supports .yaml, .yml, and .json formats. Automatically detects format
|
|
100
|
+
from file extension and uses appropriate parser. Normalizes hyphenated
|
|
101
|
+
keys (e.g., "magic-numbers") to underscored keys (e.g., "magic_numbers")
|
|
102
|
+
for internal consistency.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
path: Path to configuration file.
|
|
106
|
+
encoding: File encoding (default: utf-8).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Parsed configuration dictionary with normalized keys.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ConfigParseError: If file format is unsupported or parsing fails.
|
|
113
|
+
"""
|
|
114
|
+
suffix = path.suffix.lower()
|
|
115
|
+
|
|
116
|
+
if suffix not in [".yaml", ".yml", ".json"]:
|
|
117
|
+
raise ConfigParseError(f"Unsupported config format: {suffix}")
|
|
118
|
+
|
|
119
|
+
with path.open(encoding=encoding) as f:
|
|
120
|
+
if suffix in [".yaml", ".yml"]:
|
|
121
|
+
config = parse_yaml(f, path)
|
|
122
|
+
else:
|
|
123
|
+
config = parse_json(f, path)
|
|
124
|
+
|
|
125
|
+
# Normalize keys from hyphens to underscores
|
|
126
|
+
return _normalize_config_keys(config)
|