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
|
@@ -1,57 +1,56 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Purpose: File placement linter implementation
|
|
3
|
+
|
|
3
4
|
Scope: Validate file organization against allow/deny patterns
|
|
5
|
+
|
|
4
6
|
Overview: Implements file placement validation using regex patterns from JSON/YAML config.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
Orchestrates configuration loading, pattern validation, path resolution, rule checking,
|
|
8
|
+
and violation creation through focused helper classes. Supports directory-specific rules,
|
|
9
|
+
global patterns, and generates helpful suggestions. Main linter class acts as coordinator.
|
|
10
|
+
|
|
11
|
+
Dependencies: src.core (base classes, types), pathlib, typing
|
|
12
|
+
|
|
7
13
|
Exports: FilePlacementLinter, FilePlacementRule
|
|
8
|
-
|
|
14
|
+
|
|
15
|
+
Implementation: Composition pattern with helper classes for each responsibility
|
|
16
|
+
|
|
17
|
+
SRP Exception: FilePlacementRule has 13 methods (exceeds max 8)
|
|
18
|
+
Justification: Framework adapter class that bridges BaseLintRule interface with
|
|
19
|
+
FilePlacementLinter implementation. Must handle multiple config sources (metadata vs file),
|
|
20
|
+
multiple config formats (wrapped vs unwrapped), project root detection with fallbacks,
|
|
21
|
+
and linter caching. This complexity is inherent to adapter pattern - splitting would
|
|
22
|
+
create unnecessary indirection between framework and implementation without improving
|
|
23
|
+
maintainability. All methods are focused on the single responsibility of integrating
|
|
24
|
+
file placement validation with the linting framework.
|
|
9
25
|
"""
|
|
10
26
|
|
|
11
27
|
import json
|
|
12
|
-
import re
|
|
13
28
|
from pathlib import Path
|
|
14
29
|
from typing import Any
|
|
15
30
|
|
|
16
31
|
import yaml
|
|
17
32
|
|
|
18
33
|
from src.core.base import BaseLintContext, BaseLintRule
|
|
19
|
-
from src.core.types import
|
|
20
|
-
|
|
34
|
+
from src.core.types import Violation
|
|
21
35
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"""Check if path matches any deny patterns.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
path_str: File path to check
|
|
32
|
-
deny_patterns: List of deny pattern dicts with 'pattern' and 'reason'
|
|
33
|
-
|
|
34
|
-
Returns:
|
|
35
|
-
Tuple of (is_denied, reason)
|
|
36
|
-
"""
|
|
37
|
-
for deny_item in deny_patterns:
|
|
38
|
-
pattern = deny_item["pattern"]
|
|
39
|
-
if re.search(pattern, path_str, re.IGNORECASE):
|
|
40
|
-
reason = deny_item.get("reason", "File not allowed in this location")
|
|
41
|
-
return True, reason
|
|
42
|
-
return False, None
|
|
36
|
+
from .config_loader import ConfigLoader
|
|
37
|
+
from .path_resolver import PathResolver
|
|
38
|
+
from .pattern_matcher import PatternMatcher
|
|
39
|
+
from .pattern_validator import PatternValidator
|
|
40
|
+
from .rule_checker import RuleChecker
|
|
41
|
+
from .violation_factory import ViolationFactory
|
|
43
42
|
|
|
44
|
-
def match_allow_patterns(self, path_str: str, allow_patterns: list[str]) -> bool:
|
|
45
|
-
"""Check if path matches any allow patterns.
|
|
46
43
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
allow_patterns: List of regex patterns
|
|
44
|
+
class _Components:
|
|
45
|
+
"""Container for linter components to reduce instance attributes."""
|
|
50
46
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
def __init__(self, project_root: Path):
|
|
48
|
+
self.config_loader = ConfigLoader(project_root)
|
|
49
|
+
self.path_resolver = PathResolver(project_root)
|
|
50
|
+
self.pattern_matcher = PatternMatcher()
|
|
51
|
+
self.pattern_validator = PatternValidator()
|
|
52
|
+
self.violation_factory = ViolationFactory()
|
|
53
|
+
self.rule_checker = RuleChecker(self.pattern_matcher, self.violation_factory)
|
|
55
54
|
|
|
56
55
|
|
|
57
56
|
class FilePlacementLinter:
|
|
@@ -71,551 +70,343 @@ class FilePlacementLinter:
|
|
|
71
70
|
project_root: Project root directory
|
|
72
71
|
"""
|
|
73
72
|
self.project_root = project_root or Path.cwd()
|
|
74
|
-
self.
|
|
73
|
+
self._components = _Components(self.project_root)
|
|
75
74
|
|
|
76
|
-
# Load config
|
|
75
|
+
# Load and validate config
|
|
77
76
|
if config_obj:
|
|
78
|
-
|
|
77
|
+
# Handle both wrapped and unwrapped config formats
|
|
78
|
+
# Wrapped: {"file-placement": {...}} or {"file_placement": {...}}
|
|
79
|
+
# Unwrapped: {"directories": {...}, "global_deny": [...], ...}
|
|
80
|
+
# Try both hyphenated and underscored keys for backward compatibility
|
|
81
|
+
self.config = config_obj.get(
|
|
82
|
+
"file-placement", config_obj.get("file_placement", config_obj)
|
|
83
|
+
)
|
|
79
84
|
elif config_file:
|
|
80
|
-
self.config = self.
|
|
85
|
+
self.config = self._components.config_loader.load_config_file(config_file)
|
|
81
86
|
else:
|
|
82
87
|
self.config = {}
|
|
83
88
|
|
|
84
89
|
# Validate regex patterns in config
|
|
85
|
-
self.
|
|
90
|
+
self._components.pattern_validator.validate_config(self.config)
|
|
86
91
|
|
|
87
|
-
def
|
|
88
|
-
"""
|
|
92
|
+
def lint_path(self, file_path: Path) -> list[Violation]:
|
|
93
|
+
"""Lint a single file path.
|
|
89
94
|
|
|
90
95
|
Args:
|
|
91
|
-
|
|
96
|
+
file_path: File to lint
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"""
|
|
96
|
-
try:
|
|
97
|
-
re.compile(pattern)
|
|
98
|
-
except re.error as e:
|
|
99
|
-
raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
|
|
100
|
-
|
|
101
|
-
def _validate_allow_patterns(self, rules: dict[str, Any]) -> None:
|
|
102
|
-
"""Validate allow patterns in a rules dict."""
|
|
103
|
-
if "allow" in rules:
|
|
104
|
-
for pattern in rules["allow"]:
|
|
105
|
-
self._validate_pattern(pattern)
|
|
106
|
-
|
|
107
|
-
def _validate_deny_patterns(self, rules: dict[str, Any]) -> None:
|
|
108
|
-
"""Validate deny patterns in a rules dict."""
|
|
109
|
-
if "deny" in rules:
|
|
110
|
-
for deny_item in rules["deny"]:
|
|
111
|
-
pattern = deny_item.get("pattern", "")
|
|
112
|
-
self._validate_pattern(pattern)
|
|
113
|
-
|
|
114
|
-
def _validate_directory_patterns(self, fp_config: dict[str, Any]) -> None:
|
|
115
|
-
"""Validate all directory-specific patterns."""
|
|
116
|
-
if "directories" in fp_config:
|
|
117
|
-
for _dir_path, rules in fp_config["directories"].items():
|
|
118
|
-
self._validate_allow_patterns(rules)
|
|
119
|
-
self._validate_deny_patterns(rules)
|
|
120
|
-
|
|
121
|
-
def _validate_global_patterns(self, fp_config: dict[str, Any]) -> None:
|
|
122
|
-
"""Validate global patterns section."""
|
|
123
|
-
if "global_patterns" in fp_config:
|
|
124
|
-
self._validate_allow_patterns(fp_config["global_patterns"])
|
|
125
|
-
self._validate_deny_patterns(fp_config["global_patterns"])
|
|
126
|
-
|
|
127
|
-
def _validate_global_deny_patterns(self, fp_config: dict[str, Any]) -> None:
|
|
128
|
-
"""Validate global_deny patterns."""
|
|
129
|
-
if "global_deny" in fp_config:
|
|
130
|
-
for deny_item in fp_config["global_deny"]:
|
|
131
|
-
pattern = deny_item.get("pattern", "")
|
|
132
|
-
self._validate_pattern(pattern)
|
|
133
|
-
|
|
134
|
-
def _validate_regex_patterns(self) -> None:
|
|
135
|
-
"""Validate all regex patterns in config.
|
|
136
|
-
|
|
137
|
-
Raises:
|
|
138
|
-
re.error: If any regex pattern is invalid
|
|
98
|
+
Returns:
|
|
99
|
+
List of violations found
|
|
139
100
|
"""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
self.
|
|
144
|
-
self.
|
|
145
|
-
|
|
146
|
-
def _resolve_config_path(self, config_file: str) -> Path:
|
|
147
|
-
"""Resolve config file path relative to project root."""
|
|
148
|
-
config_path = Path(config_file)
|
|
149
|
-
if not config_path.is_absolute():
|
|
150
|
-
config_path = self.project_root / config_path
|
|
151
|
-
return config_path
|
|
152
|
-
|
|
153
|
-
def _parse_config_file(self, config_path: Path) -> dict[str, Any]:
|
|
154
|
-
"""Parse config file based on extension."""
|
|
155
|
-
with config_path.open(encoding="utf-8") as f:
|
|
156
|
-
if config_path.suffix in [".yaml", ".yml"]:
|
|
157
|
-
return yaml.safe_load(f) or {}
|
|
158
|
-
if config_path.suffix == ".json":
|
|
159
|
-
return json.load(f)
|
|
160
|
-
raise ValueError(f"Unsupported config format: {config_path.suffix}")
|
|
101
|
+
rel_path = self._components.path_resolver.get_relative_path(file_path)
|
|
102
|
+
path_str = self._components.path_resolver.normalize_path_string(rel_path)
|
|
103
|
+
# Config is already unwrapped from file-placement key in _load_layout_config
|
|
104
|
+
fp_config = self.config
|
|
105
|
+
return self._components.rule_checker.check_all_rules(path_str, rel_path, fp_config)
|
|
161
106
|
|
|
162
|
-
def
|
|
163
|
-
"""
|
|
107
|
+
def check_file_allowed(self, file_path: Path) -> bool:
|
|
108
|
+
"""Check if file is allowed (no violations).
|
|
164
109
|
|
|
165
110
|
Args:
|
|
166
|
-
|
|
111
|
+
file_path: File to check
|
|
167
112
|
|
|
168
113
|
Returns:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
Raises:
|
|
172
|
-
Exception: If file cannot be loaded or parsed
|
|
114
|
+
True if file is allowed (no violations)
|
|
173
115
|
"""
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return {}
|
|
177
|
-
return self._parse_config_file(config_path)
|
|
116
|
+
violations = self.lint_path(file_path)
|
|
117
|
+
return len(violations) == 0
|
|
178
118
|
|
|
179
|
-
def
|
|
180
|
-
"""
|
|
181
|
-
try:
|
|
182
|
-
if file_path.is_absolute():
|
|
183
|
-
return file_path.relative_to(self.project_root)
|
|
184
|
-
return file_path
|
|
185
|
-
except ValueError:
|
|
186
|
-
# If path is outside project root, return it as-is
|
|
187
|
-
# This allows detection of absolute paths in global_deny patterns
|
|
188
|
-
return file_path
|
|
189
|
-
|
|
190
|
-
def _check_all_rules(
|
|
191
|
-
self, path_str: str, rel_path: Path, fp_config: dict[str, Any]
|
|
192
|
-
) -> list[Violation]:
|
|
193
|
-
"""Check all file placement rules."""
|
|
194
|
-
violations: list[Violation] = []
|
|
195
|
-
|
|
196
|
-
if "directories" in fp_config:
|
|
197
|
-
dir_violations = self._check_directory_rules(
|
|
198
|
-
path_str, rel_path, fp_config["directories"]
|
|
199
|
-
)
|
|
200
|
-
violations.extend(dir_violations)
|
|
119
|
+
def lint_directory(self, dir_path: Path, recursive: bool = True) -> list[Violation]:
|
|
120
|
+
"""Lint all files in directory.
|
|
201
121
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
122
|
+
Args:
|
|
123
|
+
dir_path: Directory to scan
|
|
124
|
+
recursive: Scan recursively
|
|
205
125
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
126
|
+
Returns:
|
|
127
|
+
List of all violations found
|
|
128
|
+
"""
|
|
129
|
+
from src.linter_config.ignore import IgnoreDirectiveParser
|
|
130
|
+
|
|
131
|
+
ignore_parser = IgnoreDirectiveParser(self.project_root)
|
|
132
|
+
pattern = "**/*" if recursive else "*"
|
|
133
|
+
|
|
134
|
+
violations = []
|
|
135
|
+
for file_path in dir_path.glob(pattern):
|
|
136
|
+
if not file_path.is_file():
|
|
137
|
+
continue
|
|
138
|
+
if ignore_parser.is_ignored(file_path):
|
|
139
|
+
continue
|
|
140
|
+
file_violations = self.lint_path(file_path)
|
|
141
|
+
violations.extend(file_violations)
|
|
211
142
|
|
|
212
143
|
return violations
|
|
213
144
|
|
|
214
|
-
def lint_path(self, file_path: Path) -> list[Violation]:
|
|
215
|
-
"""Lint a single file path.
|
|
216
145
|
|
|
217
|
-
|
|
218
|
-
|
|
146
|
+
class FilePlacementRule(BaseLintRule): # thailint: ignore[srp.violation]
|
|
147
|
+
"""File placement linting rule (integrates with framework).
|
|
219
148
|
|
|
220
|
-
|
|
221
|
-
|
|
149
|
+
SRP suppression: Framework adapter class requires 13 methods to bridge
|
|
150
|
+
BaseLintRule interface with FilePlacementLinter. See file header for justification.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
154
|
+
"""Initialize rule with config.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
config: Rule configuration
|
|
222
158
|
"""
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
fp_config = self.config.get("file-placement", {})
|
|
226
|
-
return self._check_all_rules(path_str, rel_path, fp_config)
|
|
227
|
-
|
|
228
|
-
def _create_deny_violation(self, rel_path: Path, matched_path: str, reason: str) -> Violation:
|
|
229
|
-
"""Create violation for denied file."""
|
|
230
|
-
message = f"File '{rel_path}' not allowed in {matched_path}: {reason}"
|
|
231
|
-
suggestion = self._get_suggestion(rel_path.name, matched_path)
|
|
232
|
-
return Violation(
|
|
233
|
-
rule_id="file-placement",
|
|
234
|
-
file_path=str(rel_path),
|
|
235
|
-
line=1,
|
|
236
|
-
column=0,
|
|
237
|
-
message=message,
|
|
238
|
-
severity=Severity.ERROR,
|
|
239
|
-
suggestion=suggestion,
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
def _create_allow_violation(self, rel_path: Path, matched_path: str) -> Violation:
|
|
243
|
-
"""Create violation for file not matching allow patterns."""
|
|
244
|
-
message = f"File '{rel_path}' does not match allowed patterns for {matched_path}"
|
|
245
|
-
suggestion = f"Move to {matched_path} or ensure file type is allowed"
|
|
246
|
-
return Violation(
|
|
247
|
-
rule_id="file-placement",
|
|
248
|
-
file_path=str(rel_path),
|
|
249
|
-
line=1,
|
|
250
|
-
column=0,
|
|
251
|
-
message=message,
|
|
252
|
-
severity=Severity.ERROR,
|
|
253
|
-
suggestion=suggestion,
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
def _check_deny_patterns(
|
|
257
|
-
self, path_str: str, rel_path: Path, dir_rule: dict[str, Any], matched_path: str
|
|
258
|
-
) -> list[Violation]:
|
|
259
|
-
"""Check deny patterns and return violations if denied."""
|
|
260
|
-
if "deny" not in dir_rule:
|
|
261
|
-
return []
|
|
159
|
+
self.config = config or {}
|
|
160
|
+
self._linter_cache: dict[Path, FilePlacementLinter] = {}
|
|
262
161
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return
|
|
162
|
+
@property
|
|
163
|
+
def rule_id(self) -> str:
|
|
164
|
+
"""Return rule ID."""
|
|
165
|
+
return "file-placement"
|
|
267
166
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if "allow" not in dir_rule:
|
|
273
|
-
return []
|
|
167
|
+
@property
|
|
168
|
+
def rule_name(self) -> str:
|
|
169
|
+
"""Return rule name."""
|
|
170
|
+
return "File Placement"
|
|
274
171
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
172
|
+
@property
|
|
173
|
+
def description(self) -> str:
|
|
174
|
+
"""Return rule description."""
|
|
175
|
+
return "Validate file organization against project structure rules"
|
|
278
176
|
|
|
279
|
-
def
|
|
280
|
-
|
|
281
|
-
) -> list[Violation]:
|
|
282
|
-
"""Check file against directory-specific rules.
|
|
177
|
+
def check(self, context: BaseLintContext) -> list[Violation]:
|
|
178
|
+
"""Check file placement.
|
|
283
179
|
|
|
284
180
|
Args:
|
|
285
|
-
|
|
286
|
-
rel_path: Relative path
|
|
287
|
-
directories: Directory rules config
|
|
181
|
+
context: Lint context
|
|
288
182
|
|
|
289
183
|
Returns:
|
|
290
184
|
List of violations
|
|
291
185
|
"""
|
|
292
|
-
|
|
293
|
-
if not dir_rule or not matched_path:
|
|
186
|
+
if not context.file_path:
|
|
294
187
|
return []
|
|
295
188
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return self._check_allow_patterns(path_str, rel_path, dir_rule, matched_path)
|
|
301
|
-
|
|
302
|
-
def _check_root_match(self, dir_path: str, path_str: str) -> tuple[bool, int]:
|
|
303
|
-
"""Check if path matches root directory rule."""
|
|
304
|
-
if dir_path == "/" and "/" not in path_str:
|
|
305
|
-
return True, 0
|
|
306
|
-
return False, -1
|
|
307
|
-
|
|
308
|
-
def _check_path_match(self, dir_path: str, path_str: str) -> tuple[bool, int]:
|
|
309
|
-
"""Check if path matches directory rule."""
|
|
310
|
-
if dir_path == "/":
|
|
311
|
-
return self._check_root_match(dir_path, path_str)
|
|
312
|
-
if path_str.startswith(dir_path):
|
|
313
|
-
depth = len(dir_path.split("/"))
|
|
314
|
-
return True, depth
|
|
315
|
-
return False, -1
|
|
189
|
+
project_root = self._get_project_root(context)
|
|
190
|
+
linter = self._get_or_create_linter(project_root, context)
|
|
191
|
+
return linter.lint_path(context.file_path)
|
|
316
192
|
|
|
317
|
-
def
|
|
318
|
-
|
|
319
|
-
) -> tuple[dict[str, Any] | None, str | None]:
|
|
320
|
-
"""Find most specific directory rule matching the path.
|
|
193
|
+
def _get_project_root(self, context: BaseLintContext) -> Path:
|
|
194
|
+
"""Get project root from context or detect it.
|
|
321
195
|
|
|
322
196
|
Args:
|
|
323
|
-
|
|
324
|
-
directories: Directory rules
|
|
197
|
+
context: Lint context
|
|
325
198
|
|
|
326
199
|
Returns:
|
|
327
|
-
|
|
200
|
+
Project root directory path
|
|
328
201
|
"""
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
for dir_path, rules in directories.items():
|
|
334
|
-
matches, depth = self._check_path_match(dir_path, path_str)
|
|
335
|
-
if matches and depth > best_depth:
|
|
336
|
-
best_match = rules
|
|
337
|
-
best_path = dir_path
|
|
338
|
-
best_depth = depth
|
|
202
|
+
# Use project root from orchestrator metadata if available
|
|
203
|
+
metadata_root = self._get_root_from_metadata(context)
|
|
204
|
+
if metadata_root is not None:
|
|
205
|
+
return metadata_root
|
|
339
206
|
|
|
340
|
-
|
|
207
|
+
# Otherwise detect it from file path
|
|
208
|
+
return self._detect_project_root(context)
|
|
341
209
|
|
|
342
|
-
def
|
|
343
|
-
|
|
344
|
-
) -> list[Violation]:
|
|
345
|
-
"""Check file against global deny patterns.
|
|
210
|
+
def _get_root_from_metadata(self, context: BaseLintContext) -> Path | None:
|
|
211
|
+
"""Extract project root from context metadata.
|
|
346
212
|
|
|
347
213
|
Args:
|
|
348
|
-
|
|
349
|
-
rel_path: Relative path
|
|
350
|
-
global_deny: Global deny patterns
|
|
214
|
+
context: Lint context
|
|
351
215
|
|
|
352
216
|
Returns:
|
|
353
|
-
|
|
217
|
+
Project root from metadata, or None if not available
|
|
354
218
|
"""
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
line=1,
|
|
363
|
-
column=0,
|
|
364
|
-
message=reason or f"File '{rel_path}' matches denied pattern",
|
|
365
|
-
severity=Severity.ERROR,
|
|
366
|
-
suggestion=self._get_suggestion(rel_path.name, None),
|
|
367
|
-
)
|
|
368
|
-
)
|
|
369
|
-
return violations
|
|
219
|
+
if not hasattr(context, "metadata"):
|
|
220
|
+
return None
|
|
221
|
+
if not context.metadata:
|
|
222
|
+
return None
|
|
223
|
+
if "_project_root" not in context.metadata:
|
|
224
|
+
return None
|
|
225
|
+
return context.metadata["_project_root"]
|
|
370
226
|
|
|
371
|
-
def
|
|
372
|
-
|
|
373
|
-
) -> list[Violation]:
|
|
374
|
-
"""Check global deny patterns."""
|
|
375
|
-
if "deny" not in global_patterns:
|
|
376
|
-
return []
|
|
377
|
-
|
|
378
|
-
is_denied, reason = self.pattern_matcher.match_deny_patterns(
|
|
379
|
-
path_str, global_patterns["deny"]
|
|
380
|
-
)
|
|
381
|
-
if is_denied:
|
|
382
|
-
return [
|
|
383
|
-
Violation(
|
|
384
|
-
rule_id="file-placement",
|
|
385
|
-
file_path=str(rel_path),
|
|
386
|
-
line=1,
|
|
387
|
-
column=0,
|
|
388
|
-
message=reason or f"File '{rel_path}' matches denied pattern",
|
|
389
|
-
severity=Severity.ERROR,
|
|
390
|
-
suggestion=self._get_suggestion(rel_path.name, None),
|
|
391
|
-
)
|
|
392
|
-
]
|
|
393
|
-
return []
|
|
394
|
-
|
|
395
|
-
def _check_global_allow_patterns(
|
|
396
|
-
self, path_str: str, rel_path: Path, global_patterns: dict[str, Any]
|
|
397
|
-
) -> list[Violation]:
|
|
398
|
-
"""Check global allow patterns."""
|
|
399
|
-
if "allow" not in global_patterns:
|
|
400
|
-
return []
|
|
401
|
-
|
|
402
|
-
if not self.pattern_matcher.match_allow_patterns(path_str, global_patterns["allow"]):
|
|
403
|
-
return [
|
|
404
|
-
Violation(
|
|
405
|
-
rule_id="file-placement",
|
|
406
|
-
file_path=str(rel_path),
|
|
407
|
-
line=1,
|
|
408
|
-
column=0,
|
|
409
|
-
message=f"File '{rel_path}' does not match any allowed patterns",
|
|
410
|
-
severity=Severity.ERROR,
|
|
411
|
-
suggestion="Ensure file matches project structure patterns",
|
|
412
|
-
)
|
|
413
|
-
]
|
|
414
|
-
return []
|
|
415
|
-
|
|
416
|
-
def _check_global_patterns(
|
|
417
|
-
self, path_str: str, rel_path: Path, global_patterns: dict[str, Any]
|
|
418
|
-
) -> list[Violation]:
|
|
419
|
-
"""Check file against global patterns.
|
|
227
|
+
def _detect_project_root(self, context: BaseLintContext) -> Path:
|
|
228
|
+
"""Detect project root from file path.
|
|
420
229
|
|
|
421
230
|
Args:
|
|
422
|
-
|
|
423
|
-
rel_path: Relative path
|
|
424
|
-
global_patterns: Global patterns config
|
|
231
|
+
context: Lint context
|
|
425
232
|
|
|
426
233
|
Returns:
|
|
427
|
-
|
|
234
|
+
Detected project root directory path
|
|
428
235
|
"""
|
|
429
|
-
|
|
430
|
-
if deny_violations:
|
|
431
|
-
return deny_violations
|
|
236
|
+
from src.utils.project_root import get_project_root
|
|
432
237
|
|
|
433
|
-
|
|
238
|
+
if context.file_path is None:
|
|
239
|
+
return Path.cwd()
|
|
434
240
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if "test" in filename.lower():
|
|
438
|
-
return "Move to tests/ directory"
|
|
439
|
-
return None
|
|
241
|
+
start_path = context.file_path.parent if context.file_path.is_file() else context.file_path
|
|
242
|
+
return get_project_root(start_path)
|
|
440
243
|
|
|
441
|
-
def
|
|
442
|
-
"""
|
|
443
|
-
if filename.endswith((".ts", ".tsx", ".jsx")):
|
|
444
|
-
if "component" in filename.lower():
|
|
445
|
-
return "Move to src/components/"
|
|
446
|
-
return "Move to src/"
|
|
447
|
-
return None
|
|
244
|
+
def _extract_inline_config(self, context: BaseLintContext | None) -> dict[str, Any] | None:
|
|
245
|
+
"""Extract file-placement config from context metadata.
|
|
448
246
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if filename.endswith(".py"):
|
|
452
|
-
return "Move to src/"
|
|
453
|
-
if filename.startswith(("debug", "temp")):
|
|
454
|
-
return "Move to debug/ or remove if not needed"
|
|
455
|
-
if filename.endswith(".log"):
|
|
456
|
-
return "Move to logs/ or add to .gitignore"
|
|
457
|
-
return "Review file organization and move to appropriate directory"
|
|
458
|
-
|
|
459
|
-
def _get_suggestion(self, filename: str, current_location: str | None) -> str:
|
|
460
|
-
"""Get suggestion for file placement.
|
|
247
|
+
Handles both wrapped format: {"file-placement": {...}}
|
|
248
|
+
and unwrapped format: {"global_deny": [...], "directories": {...}, ...}
|
|
461
249
|
|
|
462
250
|
Args:
|
|
463
|
-
|
|
464
|
-
current_location: Current directory location
|
|
251
|
+
context: Lint context
|
|
465
252
|
|
|
466
253
|
Returns:
|
|
467
|
-
|
|
254
|
+
File placement config dict, or None if no config in metadata
|
|
468
255
|
"""
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
return suggestion
|
|
256
|
+
if not self._has_valid_metadata(context):
|
|
257
|
+
return None
|
|
472
258
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
259
|
+
# Type narrowing: _has_valid_metadata ensures context is not None
|
|
260
|
+
# by checking: context and hasattr(context, "metadata") and context.metadata
|
|
261
|
+
if context is None:
|
|
262
|
+
return None # Should never happen after _has_valid_metadata check
|
|
476
263
|
|
|
477
|
-
|
|
264
|
+
# Check for wrapped format first
|
|
265
|
+
wrapped_config = self._get_wrapped_config(context)
|
|
266
|
+
if wrapped_config is not None:
|
|
267
|
+
return wrapped_config
|
|
478
268
|
|
|
479
|
-
|
|
480
|
-
|
|
269
|
+
# Check for unwrapped format
|
|
270
|
+
return self._get_unwrapped_config(context)
|
|
271
|
+
|
|
272
|
+
def _has_valid_metadata(self, context: BaseLintContext | None) -> bool:
|
|
273
|
+
"""Check if context has valid metadata.
|
|
481
274
|
|
|
482
275
|
Args:
|
|
483
|
-
|
|
276
|
+
context: Lint context
|
|
484
277
|
|
|
485
278
|
Returns:
|
|
486
|
-
True if
|
|
279
|
+
True if context has metadata dict
|
|
487
280
|
"""
|
|
488
|
-
|
|
489
|
-
return len(violations) == 0
|
|
281
|
+
return bool(context and hasattr(context, "metadata") and context.metadata)
|
|
490
282
|
|
|
491
|
-
|
|
492
|
-
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _get_wrapped_config(context: BaseLintContext) -> dict[str, Any] | None:
|
|
285
|
+
"""Get config from wrapped format: {"file-placement": {...}} or {"file_placement": {...}}.
|
|
286
|
+
|
|
287
|
+
Supports both hyphenated and underscored keys for backward compatibility.
|
|
493
288
|
|
|
494
289
|
Args:
|
|
495
|
-
|
|
496
|
-
recursive: Scan recursively
|
|
290
|
+
context: Lint context with metadata
|
|
497
291
|
|
|
498
292
|
Returns:
|
|
499
|
-
|
|
293
|
+
Config dict or None if not in wrapped format
|
|
500
294
|
"""
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
file_violations = self._lint_file_if_not_ignored(file_path, ignore_parser)
|
|
511
|
-
violations.extend(file_violations)
|
|
295
|
+
if not hasattr(context, "metadata"):
|
|
296
|
+
return None
|
|
297
|
+
# Try hyphenated format first (original format)
|
|
298
|
+
if "file-placement" in context.metadata:
|
|
299
|
+
return context.metadata["file-placement"]
|
|
300
|
+
# Try underscored format (normalized format)
|
|
301
|
+
if "file_placement" in context.metadata:
|
|
302
|
+
return context.metadata["file_placement"]
|
|
303
|
+
return None
|
|
512
304
|
|
|
513
|
-
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _get_unwrapped_config(context: BaseLintContext) -> dict[str, Any] | None:
|
|
307
|
+
"""Get config from unwrapped format: {"directories": {...}, ...}.
|
|
514
308
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if ignore_parser.is_ignored(file_path):
|
|
518
|
-
return []
|
|
519
|
-
return self.lint_path(file_path)
|
|
309
|
+
Args:
|
|
310
|
+
context: Lint context with metadata
|
|
520
311
|
|
|
312
|
+
Returns:
|
|
313
|
+
Config dict or None if not in unwrapped format
|
|
314
|
+
"""
|
|
315
|
+
if not hasattr(context, "metadata"):
|
|
316
|
+
return None
|
|
521
317
|
|
|
522
|
-
|
|
523
|
-
|
|
318
|
+
config_keys = {"directories", "global_deny", "global_allow", "global_patterns"}
|
|
319
|
+
matching_keys = {k: v for k, v in context.metadata.items() if k in config_keys}
|
|
320
|
+
return matching_keys if matching_keys else None
|
|
524
321
|
|
|
525
|
-
def
|
|
526
|
-
|
|
322
|
+
def _get_or_create_linter(
|
|
323
|
+
self, project_root: Path, context: BaseLintContext | None = None
|
|
324
|
+
) -> FilePlacementLinter:
|
|
325
|
+
"""Get cached linter or create new one.
|
|
527
326
|
|
|
528
327
|
Args:
|
|
529
|
-
|
|
328
|
+
project_root: Project root directory
|
|
329
|
+
context: Lint context (to extract inline config if present)
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
FilePlacementLinter instance
|
|
530
333
|
"""
|
|
531
|
-
|
|
532
|
-
self._linter_cache:
|
|
334
|
+
# Check if cached linter exists for this project root
|
|
335
|
+
if project_root in self._linter_cache:
|
|
336
|
+
return self._linter_cache[project_root]
|
|
533
337
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
"""Return rule ID."""
|
|
537
|
-
return "file-placement"
|
|
338
|
+
# Try to get config from context metadata (orchestrator passes config here)
|
|
339
|
+
config_from_metadata = self._extract_inline_config(context) if context else None
|
|
538
340
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
341
|
+
if config_from_metadata:
|
|
342
|
+
# Use config from orchestrator's metadata
|
|
343
|
+
linter = FilePlacementLinter(config_obj=config_from_metadata, project_root=project_root)
|
|
344
|
+
else:
|
|
345
|
+
# Fall back to loading from file
|
|
346
|
+
layout_path = self._get_layout_path(project_root)
|
|
347
|
+
layout_config = self._load_layout_config(layout_path)
|
|
348
|
+
linter = FilePlacementLinter(config_obj=layout_config, project_root=project_root)
|
|
543
349
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
return "Validate file organization against project structure rules"
|
|
350
|
+
# Cache the linter
|
|
351
|
+
self._linter_cache[project_root] = linter
|
|
352
|
+
return linter
|
|
548
353
|
|
|
549
354
|
def _get_layout_path(self, project_root: Path) -> Path:
|
|
550
|
-
"""Get layout config file path.
|
|
355
|
+
"""Get layout config file path.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
project_root: Project root directory
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Path to layout config file
|
|
362
|
+
"""
|
|
551
363
|
layout_file = self.config.get("layout_file")
|
|
552
364
|
if layout_file:
|
|
553
365
|
return project_root / layout_file
|
|
554
366
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
return yaml_path
|
|
559
|
-
if json_path.exists():
|
|
560
|
-
return json_path
|
|
561
|
-
return yaml_path
|
|
562
|
-
|
|
563
|
-
def _load_layout_config(self, layout_path: Path) -> dict[str, Any]:
|
|
564
|
-
"""Load layout configuration from file."""
|
|
565
|
-
try:
|
|
566
|
-
return self._parse_layout_file(layout_path)
|
|
567
|
-
except Exception:
|
|
568
|
-
return {}
|
|
367
|
+
# Check for standard config files at project root
|
|
368
|
+
thailint_yaml = project_root / ".thailint.yaml"
|
|
369
|
+
thailint_json = project_root / ".thailint.json"
|
|
569
370
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if str(layout_path).endswith((".yaml", ".yml")):
|
|
574
|
-
return yaml.safe_load(f) or {}
|
|
575
|
-
return json.load(f)
|
|
371
|
+
for path in [thailint_yaml, thailint_json]:
|
|
372
|
+
if path.exists():
|
|
373
|
+
return path
|
|
576
374
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
if project_root not in self._linter_cache:
|
|
580
|
-
layout_path = self._get_layout_path(project_root)
|
|
581
|
-
layout_config = self._load_layout_config(layout_path)
|
|
582
|
-
self._linter_cache[project_root] = FilePlacementLinter(
|
|
583
|
-
config_obj=layout_config, project_root=project_root
|
|
584
|
-
)
|
|
585
|
-
return self._linter_cache[project_root]
|
|
375
|
+
# Return default path if no config exists
|
|
376
|
+
return thailint_yaml
|
|
586
377
|
|
|
587
|
-
def
|
|
588
|
-
"""
|
|
378
|
+
def _load_layout_config(self, layout_path: Path) -> dict[str, Any]:
|
|
379
|
+
"""Load layout configuration from file.
|
|
589
380
|
|
|
590
381
|
Args:
|
|
591
|
-
|
|
382
|
+
layout_path: Path to layout file
|
|
592
383
|
|
|
593
384
|
Returns:
|
|
594
|
-
|
|
385
|
+
Layout configuration dict (unwrapped from file-placement key), or empty dict on error
|
|
595
386
|
"""
|
|
596
|
-
|
|
597
|
-
|
|
387
|
+
try:
|
|
388
|
+
config = self._parse_layout_file(layout_path)
|
|
598
389
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
390
|
+
# Unwrap file-placement key if present (try both formats for backward compatibility)
|
|
391
|
+
if "file-placement" in config:
|
|
392
|
+
return config["file-placement"]
|
|
393
|
+
if "file_placement" in config:
|
|
394
|
+
return config["file_placement"]
|
|
395
|
+
|
|
396
|
+
return config
|
|
397
|
+
except Exception:
|
|
398
|
+
return {}
|
|
602
399
|
|
|
603
|
-
def
|
|
604
|
-
"""
|
|
400
|
+
def _parse_layout_file(self, layout_path: Path) -> dict[str, Any]:
|
|
401
|
+
"""Parse layout file based on extension.
|
|
605
402
|
|
|
606
403
|
Args:
|
|
607
|
-
|
|
404
|
+
layout_path: Path to layout file
|
|
608
405
|
|
|
609
406
|
Returns:
|
|
610
|
-
|
|
407
|
+
Parsed configuration dict
|
|
611
408
|
"""
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (current / ".ai").exists():
|
|
617
|
-
return current
|
|
618
|
-
current = current.parent
|
|
619
|
-
|
|
620
|
-
# Fallback to current directory if no .ai found
|
|
621
|
-
return Path.cwd()
|
|
409
|
+
with layout_path.open(encoding="utf-8") as f:
|
|
410
|
+
if str(layout_path).endswith((".yaml", ".yml")):
|
|
411
|
+
return yaml.safe_load(f) or {}
|
|
412
|
+
return json.load(f)
|