thailint 0.1.6__py3-none-any.whl → 0.2.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 +498 -141
- src/config.py +6 -31
- src/core/base.py +12 -0
- src/core/cli_utils.py +206 -0
- src/core/config_parser.py +99 -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 +262 -0
- src/linters/dry/block_grouper.py +59 -0
- src/linters/dry/cache.py +218 -0
- src/linters/dry/cache_query.py +61 -0
- src/linters/dry/config.py +130 -0
- src/linters/dry/config_loader.py +44 -0
- src/linters/dry/deduplicator.py +120 -0
- src/linters/dry/duplicate_storage.py +126 -0
- src/linters/dry/file_analyzer.py +127 -0
- src/linters/dry/inline_ignore.py +140 -0
- src/linters/dry/linter.py +170 -0
- src/linters/dry/python_analyzer.py +517 -0
- src/linters/dry/storage_initializer.py +51 -0
- src/linters/dry/token_hasher.py +115 -0
- src/linters/dry/typescript_analyzer.py +590 -0
- src/linters/dry/violation_builder.py +74 -0
- src/linters/dry/violation_filter.py +91 -0
- src/linters/dry/violation_generator.py +174 -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 +252 -472
- 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/nesting/config.py +13 -3
- src/linters/nesting/linter.py +76 -152
- src/linters/nesting/typescript_analyzer.py +38 -102
- src/linters/nesting/typescript_function_extractor.py +130 -0
- src/linters/nesting/violation_builder.py +139 -0
- src/linters/srp/__init__.py +99 -0
- src/linters/srp/class_analyzer.py +113 -0
- src/linters/srp/config.py +76 -0
- src/linters/srp/heuristics.py +89 -0
- src/linters/srp/linter.py +225 -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 +42 -7
- src/utils/__init__.py +4 -0
- src/utils/project_root.py +84 -0
- {thailint-0.1.6.dist-info → thailint-0.2.0.dist-info}/METADATA +414 -63
- thailint-0.2.0.dist-info/RECORD +75 -0
- src/.ai/layout.yaml +0 -48
- thailint-0.1.6.dist-info/RECORD +0 -28
- {thailint-0.1.6.dist-info → thailint-0.2.0.dist-info}/LICENSE +0 -0
- {thailint-0.1.6.dist-info → thailint-0.2.0.dist-info}/WHEEL +0 -0
- {thailint-0.1.6.dist-info → thailint-0.2.0.dist-info}/entry_points.txt +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
|
|
|
@@ -103,7 +105,7 @@ def _load_from_default_locations() -> dict[str, Any]:
|
|
|
103
105
|
loaded_config = _try_load_from_location(location)
|
|
104
106
|
if loaded_config:
|
|
105
107
|
return loaded_config
|
|
106
|
-
logger.
|
|
108
|
+
logger.debug("No CLI config file found, using defaults")
|
|
107
109
|
return DEFAULT_CONFIG.copy()
|
|
108
110
|
|
|
109
111
|
|
|
@@ -134,23 +136,6 @@ def load_config(config_path: Path | None = None) -> dict[str, Any]:
|
|
|
134
136
|
return _load_from_default_locations()
|
|
135
137
|
|
|
136
138
|
|
|
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
139
|
def _load_config_file(path: Path) -> dict[str, Any]:
|
|
155
140
|
"""
|
|
156
141
|
Load config from YAML or JSON file based on extension.
|
|
@@ -165,23 +150,13 @@ def _load_config_file(path: Path) -> dict[str, Any]:
|
|
|
165
150
|
ConfigError: If file cannot be parsed.
|
|
166
151
|
"""
|
|
167
152
|
try:
|
|
168
|
-
return
|
|
169
|
-
except
|
|
170
|
-
raise
|
|
153
|
+
return parse_config_file(path)
|
|
154
|
+
except ConfigParseError as e:
|
|
155
|
+
raise ConfigError(str(e)) from e
|
|
171
156
|
except Exception as e:
|
|
172
157
|
raise ConfigError(f"Failed to load config from {path}: {e}") from e
|
|
173
158
|
|
|
174
159
|
|
|
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
160
|
def _validate_before_save(config: dict[str, Any]) -> None:
|
|
186
161
|
"""Validate config before saving."""
|
|
187
162
|
is_valid, errors = validate_config(config)
|
src/core/base.py
CHANGED
|
@@ -120,3 +120,15 @@ class BaseLintRule(ABC):
|
|
|
120
120
|
List of violations found. Empty list if no violations.
|
|
121
121
|
"""
|
|
122
122
|
raise NotImplementedError("Subclasses must implement check")
|
|
123
|
+
|
|
124
|
+
def finalize(self) -> list[Violation]:
|
|
125
|
+
"""Finalize analysis after all files processed.
|
|
126
|
+
|
|
127
|
+
Optional hook called after all files have been processed via check().
|
|
128
|
+
Useful for rules that need to perform cross-file analysis or aggregate
|
|
129
|
+
results (e.g., DRY linter querying for duplicates across all files).
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
List of violations found during finalization. Empty list by default.
|
|
133
|
+
"""
|
|
134
|
+
return []
|
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,99 @@
|
|
|
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 parse_config_file(path: Path, encoding: str = "utf-8") -> dict[str, Any]:
|
|
76
|
+
"""Parse configuration file based on extension.
|
|
77
|
+
|
|
78
|
+
Supports .yaml, .yml, and .json formats. Automatically detects format
|
|
79
|
+
from file extension and uses appropriate parser.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
path: Path to configuration file.
|
|
83
|
+
encoding: File encoding (default: utf-8).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Parsed configuration dictionary.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ConfigParseError: If file format is unsupported or parsing fails.
|
|
90
|
+
"""
|
|
91
|
+
suffix = path.suffix.lower()
|
|
92
|
+
|
|
93
|
+
if suffix not in [".yaml", ".yml", ".json"]:
|
|
94
|
+
raise ConfigParseError(f"Unsupported config format: {suffix}")
|
|
95
|
+
|
|
96
|
+
with path.open(encoding=encoding) as f:
|
|
97
|
+
if suffix in [".yaml", ".yml"]:
|
|
98
|
+
return parse_yaml(f, path)
|
|
99
|
+
return parse_json(f, path)
|
src/core/linter_utils.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Shared utility functions for linter framework patterns
|
|
3
|
+
|
|
4
|
+
Scope: Common config loading, metadata access, and context validation utilities for all linters
|
|
5
|
+
|
|
6
|
+
Overview: Provides reusable helper functions to eliminate duplication across linter implementations.
|
|
7
|
+
Includes utilities for loading configuration from context metadata with language-specific overrides,
|
|
8
|
+
extracting metadata fields safely with type validation, and validating context state. Standardizes
|
|
9
|
+
common patterns used by srp, nesting, dry, and file_placement linters. Reduces boilerplate code
|
|
10
|
+
while maintaining type safety and proper error handling.
|
|
11
|
+
|
|
12
|
+
Dependencies: BaseLintContext from src.core.base
|
|
13
|
+
|
|
14
|
+
Exports: get_metadata, get_metadata_value, load_linter_config, has_file_content
|
|
15
|
+
|
|
16
|
+
Interfaces: All functions take BaseLintContext and return typed values (dict, str, bool, Any)
|
|
17
|
+
|
|
18
|
+
Implementation: Type-safe metadata access with fallbacks, generic config loading with language support
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Any, Protocol, TypeVar
|
|
22
|
+
|
|
23
|
+
from src.core.base import BaseLintContext
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Protocol for config classes that support from_dict
|
|
27
|
+
class ConfigProtocol(Protocol):
|
|
28
|
+
"""Protocol for configuration classes with from_dict class method."""
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(
|
|
32
|
+
cls, config_dict: dict[str, Any], language: str | None = None
|
|
33
|
+
) -> "ConfigProtocol":
|
|
34
|
+
"""Create config instance from dictionary."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Type variable for config classes
|
|
38
|
+
ConfigType = TypeVar("ConfigType", bound=ConfigProtocol) # pylint: disable=invalid-name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_metadata(context: BaseLintContext) -> dict[str, Any]:
|
|
42
|
+
"""Get metadata dictionary from context with safe fallback.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
context: Lint context containing optional metadata
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Metadata dictionary, or empty dict if not available
|
|
49
|
+
"""
|
|
50
|
+
metadata = getattr(context, "metadata", None)
|
|
51
|
+
if metadata is None or not isinstance(metadata, dict):
|
|
52
|
+
return {}
|
|
53
|
+
return dict(metadata) # Explicit cast to satisfy type checker
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_metadata_value(context: BaseLintContext, key: str, default: Any = None) -> Any:
|
|
57
|
+
"""Get specific value from context metadata with safe fallback.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
context: Lint context containing optional metadata
|
|
61
|
+
key: Metadata key to retrieve
|
|
62
|
+
default: Default value if key not found
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Metadata value or default
|
|
66
|
+
"""
|
|
67
|
+
metadata = get_metadata(context)
|
|
68
|
+
return metadata.get(key, default)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_language(context: BaseLintContext) -> str | None:
|
|
72
|
+
"""Get language from context.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
context: Lint context containing optional language
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Language string or None
|
|
79
|
+
"""
|
|
80
|
+
return getattr(context, "language", None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_project_root(context: BaseLintContext) -> str | None:
|
|
84
|
+
"""Get project root from context metadata.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
context: Lint context containing optional metadata
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Project root path or None
|
|
91
|
+
"""
|
|
92
|
+
metadata = get_metadata(context)
|
|
93
|
+
project_root = metadata.get("project_root")
|
|
94
|
+
return str(project_root) if project_root is not None else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_linter_config(
|
|
98
|
+
context: BaseLintContext,
|
|
99
|
+
config_key: str,
|
|
100
|
+
config_class: type[ConfigType],
|
|
101
|
+
) -> ConfigType:
|
|
102
|
+
"""Load linter configuration from context metadata with language-specific overrides.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
context: Lint context containing metadata
|
|
106
|
+
config_key: Key to look up in metadata (e.g., "srp", "nesting", "dry")
|
|
107
|
+
config_class: Configuration class with from_dict() class method
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Configuration instance (uses default config if metadata unavailable)
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
config = load_linter_config(context, "srp", SRPConfig)
|
|
114
|
+
"""
|
|
115
|
+
metadata = get_metadata(context)
|
|
116
|
+
config_dict = metadata.get(config_key, {})
|
|
117
|
+
|
|
118
|
+
if not isinstance(config_dict, dict):
|
|
119
|
+
return config_class()
|
|
120
|
+
|
|
121
|
+
# Get language for language-specific thresholds
|
|
122
|
+
language = get_language(context)
|
|
123
|
+
|
|
124
|
+
# Call from_dict with language if config class supports it
|
|
125
|
+
# This works for SRPConfig, NestingConfig, etc.
|
|
126
|
+
try:
|
|
127
|
+
result = config_class.from_dict(config_dict, language=language)
|
|
128
|
+
return result # type: ignore[return-value]
|
|
129
|
+
except TypeError:
|
|
130
|
+
# Fallback for config classes that don't support language parameter
|
|
131
|
+
result_fallback = config_class.from_dict(config_dict)
|
|
132
|
+
return result_fallback # type: ignore[return-value]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def has_file_content(context: BaseLintContext) -> bool:
|
|
136
|
+
"""Check if context has file content available.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
context: Lint context to check
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if file_content is not None
|
|
143
|
+
"""
|
|
144
|
+
return context.file_content is not None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def has_file_path(context: BaseLintContext) -> bool:
|
|
148
|
+
"""Check if context has file path available.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
context: Lint context to check
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if file_path is not None
|
|
155
|
+
"""
|
|
156
|
+
return context.file_path is not None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def should_process_file(context: BaseLintContext) -> bool:
|
|
160
|
+
"""Check if file should be processed (has both content and path).
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
context: Lint context to check
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
True if file has both content and path available
|
|
167
|
+
"""
|
|
168
|
+
return has_file_content(context) and has_file_path(context)
|