thailint 0.1.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/.ai/layout.yaml +48 -0
- src/__init__.py +49 -0
- src/api.py +118 -0
- src/cli.py +698 -0
- src/config.py +386 -0
- src/core/__init__.py +17 -0
- src/core/base.py +122 -0
- src/core/registry.py +170 -0
- src/core/types.py +83 -0
- src/linter_config/__init__.py +13 -0
- src/linter_config/ignore.py +403 -0
- src/linter_config/loader.py +77 -0
- src/linters/__init__.py +4 -0
- src/linters/file_placement/__init__.py +31 -0
- src/linters/file_placement/linter.py +621 -0
- src/linters/nesting/__init__.py +87 -0
- src/linters/nesting/config.py +50 -0
- src/linters/nesting/linter.py +257 -0
- src/linters/nesting/python_analyzer.py +89 -0
- src/linters/nesting/typescript_analyzer.py +180 -0
- src/orchestrator/__init__.py +9 -0
- src/orchestrator/core.py +188 -0
- src/orchestrator/language_detector.py +81 -0
- thailint-0.1.0.dist-info/LICENSE +21 -0
- thailint-0.1.0.dist-info/METADATA +601 -0
- thailint-0.1.0.dist-info/RECORD +28 -0
- thailint-0.1.0.dist-info/WHEEL +4 -0
- thailint-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: File placement linter implementation
|
|
3
|
+
Scope: Validate file organization against allow/deny patterns
|
|
4
|
+
Overview: Implements file placement validation using regex patterns from JSON/YAML config.
|
|
5
|
+
Supports directory-specific rules, global patterns, and generates helpful suggestions.
|
|
6
|
+
Dependencies: src.core (base classes, types), pathlib, json, re
|
|
7
|
+
Exports: FilePlacementLinter, FilePlacementRule
|
|
8
|
+
Implementation: Pattern matching with deny-takes-precedence logic
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from src.core.base import BaseLintContext, BaseLintRule
|
|
19
|
+
from src.core.types import Severity, Violation
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PatternMatcher:
|
|
23
|
+
"""Handles regex pattern matching for file paths."""
|
|
24
|
+
|
|
25
|
+
def match_deny_patterns(
|
|
26
|
+
self, path_str: str, deny_patterns: list[dict[str, str]]
|
|
27
|
+
) -> tuple[bool, str | None]:
|
|
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
|
|
43
|
+
|
|
44
|
+
def match_allow_patterns(self, path_str: str, allow_patterns: list[str]) -> bool:
|
|
45
|
+
"""Check if path matches any allow patterns.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
path_str: File path to check
|
|
49
|
+
allow_patterns: List of regex patterns
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if path matches any pattern
|
|
53
|
+
"""
|
|
54
|
+
return any(re.search(pattern, path_str, re.IGNORECASE) for pattern in allow_patterns)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FilePlacementLinter:
|
|
58
|
+
"""File placement linter for validating file organization."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
config_file: str | None = None,
|
|
63
|
+
config_obj: dict[str, Any] | None = None,
|
|
64
|
+
project_root: Path | None = None,
|
|
65
|
+
):
|
|
66
|
+
"""Initialize file placement linter.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
config_file: Path to layout config file (JSON/YAML)
|
|
70
|
+
config_obj: Config object (alternative to config_file)
|
|
71
|
+
project_root: Project root directory
|
|
72
|
+
"""
|
|
73
|
+
self.project_root = project_root or Path.cwd()
|
|
74
|
+
self.pattern_matcher = PatternMatcher()
|
|
75
|
+
|
|
76
|
+
# Load config
|
|
77
|
+
if config_obj:
|
|
78
|
+
self.config = config_obj
|
|
79
|
+
elif config_file:
|
|
80
|
+
self.config = self._load_config_file(config_file)
|
|
81
|
+
else:
|
|
82
|
+
self.config = {}
|
|
83
|
+
|
|
84
|
+
# Validate regex patterns in config
|
|
85
|
+
self._validate_regex_patterns()
|
|
86
|
+
|
|
87
|
+
def _validate_pattern(self, pattern: str) -> None:
|
|
88
|
+
"""Validate a single regex pattern.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
pattern: Regex pattern to validate
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: If pattern is invalid
|
|
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
|
|
139
|
+
"""
|
|
140
|
+
fp_config = self.config.get("file-placement", {})
|
|
141
|
+
|
|
142
|
+
self._validate_directory_patterns(fp_config)
|
|
143
|
+
self._validate_global_patterns(fp_config)
|
|
144
|
+
self._validate_global_deny_patterns(fp_config)
|
|
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}")
|
|
161
|
+
|
|
162
|
+
def _load_config_file(self, config_file: str) -> dict[str, Any]:
|
|
163
|
+
"""Load configuration from file.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
config_file: Path to config file
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Loaded configuration dict
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
Exception: If file cannot be loaded or parsed
|
|
173
|
+
"""
|
|
174
|
+
config_path = self._resolve_config_path(config_file)
|
|
175
|
+
if not config_path.exists():
|
|
176
|
+
return {}
|
|
177
|
+
return self._parse_config_file(config_path)
|
|
178
|
+
|
|
179
|
+
def _get_relative_path(self, file_path: Path) -> Path:
|
|
180
|
+
"""Get path relative to project root, or return as-is."""
|
|
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)
|
|
201
|
+
|
|
202
|
+
if "global_deny" in fp_config:
|
|
203
|
+
deny_violations = self._check_global_deny(path_str, rel_path, fp_config["global_deny"])
|
|
204
|
+
violations.extend(deny_violations)
|
|
205
|
+
|
|
206
|
+
if "global_patterns" in fp_config:
|
|
207
|
+
global_violations = self._check_global_patterns(
|
|
208
|
+
path_str, rel_path, fp_config["global_patterns"]
|
|
209
|
+
)
|
|
210
|
+
violations.extend(global_violations)
|
|
211
|
+
|
|
212
|
+
return violations
|
|
213
|
+
|
|
214
|
+
def lint_path(self, file_path: Path) -> list[Violation]:
|
|
215
|
+
"""Lint a single file path.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
file_path: File to lint
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
List of violations found
|
|
222
|
+
"""
|
|
223
|
+
rel_path = self._get_relative_path(file_path)
|
|
224
|
+
path_str = str(rel_path).replace("\\", "/")
|
|
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 []
|
|
262
|
+
|
|
263
|
+
is_denied, reason = self.pattern_matcher.match_deny_patterns(path_str, dir_rule["deny"])
|
|
264
|
+
if is_denied:
|
|
265
|
+
return [self._create_deny_violation(rel_path, matched_path, reason or "Pattern denied")]
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
def _check_allow_patterns(
|
|
269
|
+
self, path_str: str, rel_path: Path, dir_rule: dict[str, Any], matched_path: str
|
|
270
|
+
) -> list[Violation]:
|
|
271
|
+
"""Check allow patterns and return violations if not allowed."""
|
|
272
|
+
if "allow" not in dir_rule:
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
if not self.pattern_matcher.match_allow_patterns(path_str, dir_rule["allow"]):
|
|
276
|
+
return [self._create_allow_violation(rel_path, matched_path)]
|
|
277
|
+
return []
|
|
278
|
+
|
|
279
|
+
def _check_directory_rules(
|
|
280
|
+
self, path_str: str, rel_path: Path, directories: dict[str, Any]
|
|
281
|
+
) -> list[Violation]:
|
|
282
|
+
"""Check file against directory-specific rules.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
path_str: File path string
|
|
286
|
+
rel_path: Relative path
|
|
287
|
+
directories: Directory rules config
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
List of violations
|
|
291
|
+
"""
|
|
292
|
+
dir_rule, matched_path = self._find_matching_directory_rule(path_str, directories)
|
|
293
|
+
if not dir_rule or not matched_path:
|
|
294
|
+
return []
|
|
295
|
+
|
|
296
|
+
deny_violations = self._check_deny_patterns(path_str, rel_path, dir_rule, matched_path)
|
|
297
|
+
if deny_violations:
|
|
298
|
+
return deny_violations
|
|
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
|
|
316
|
+
|
|
317
|
+
def _find_matching_directory_rule(
|
|
318
|
+
self, path_str: str, directories: dict[str, Any]
|
|
319
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
320
|
+
"""Find most specific directory rule matching the path.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
path_str: File path string
|
|
324
|
+
directories: Directory rules
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Tuple of (rule_dict, matched_path)
|
|
328
|
+
"""
|
|
329
|
+
best_match = None
|
|
330
|
+
best_path = None
|
|
331
|
+
best_depth = -1
|
|
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
|
|
339
|
+
|
|
340
|
+
return best_match, best_path
|
|
341
|
+
|
|
342
|
+
def _check_global_deny(
|
|
343
|
+
self, path_str: str, rel_path: Path, global_deny: list[dict[str, str]]
|
|
344
|
+
) -> list[Violation]:
|
|
345
|
+
"""Check file against global deny patterns.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
path_str: File path string
|
|
349
|
+
rel_path: Relative path
|
|
350
|
+
global_deny: Global deny patterns
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
List of violations
|
|
354
|
+
"""
|
|
355
|
+
violations = []
|
|
356
|
+
is_denied, reason = self.pattern_matcher.match_deny_patterns(path_str, global_deny)
|
|
357
|
+
if is_denied:
|
|
358
|
+
violations.append(
|
|
359
|
+
Violation(
|
|
360
|
+
rule_id="file-placement",
|
|
361
|
+
file_path=str(rel_path),
|
|
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
|
|
370
|
+
|
|
371
|
+
def _check_global_deny_patterns(
|
|
372
|
+
self, path_str: str, rel_path: Path, global_patterns: dict[str, Any]
|
|
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.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
path_str: File path string
|
|
423
|
+
rel_path: Relative path
|
|
424
|
+
global_patterns: Global patterns config
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
List of violations
|
|
428
|
+
"""
|
|
429
|
+
deny_violations = self._check_global_deny_patterns(path_str, rel_path, global_patterns)
|
|
430
|
+
if deny_violations:
|
|
431
|
+
return deny_violations
|
|
432
|
+
|
|
433
|
+
return self._check_global_allow_patterns(path_str, rel_path, global_patterns)
|
|
434
|
+
|
|
435
|
+
def _suggest_for_test_file(self, filename: str) -> str | None:
|
|
436
|
+
"""Get suggestion for test files."""
|
|
437
|
+
if "test" in filename.lower():
|
|
438
|
+
return "Move to tests/ directory"
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
def _suggest_for_typescript_file(self, filename: str) -> str | None:
|
|
442
|
+
"""Get suggestion for TypeScript/JSX files."""
|
|
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
|
|
448
|
+
|
|
449
|
+
def _suggest_for_other_files(self, filename: str) -> str:
|
|
450
|
+
"""Get suggestion for other file types."""
|
|
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.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
filename: File name
|
|
464
|
+
current_location: Current directory location
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Suggestion string
|
|
468
|
+
"""
|
|
469
|
+
suggestion = self._suggest_for_test_file(filename)
|
|
470
|
+
if suggestion:
|
|
471
|
+
return suggestion
|
|
472
|
+
|
|
473
|
+
suggestion = self._suggest_for_typescript_file(filename)
|
|
474
|
+
if suggestion:
|
|
475
|
+
return suggestion
|
|
476
|
+
|
|
477
|
+
return self._suggest_for_other_files(filename)
|
|
478
|
+
|
|
479
|
+
def check_file_allowed(self, file_path: Path) -> bool:
|
|
480
|
+
"""Check if file is allowed (no violations).
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
file_path: File to check
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
True if file is allowed (no violations)
|
|
487
|
+
"""
|
|
488
|
+
violations = self.lint_path(file_path)
|
|
489
|
+
return len(violations) == 0
|
|
490
|
+
|
|
491
|
+
def lint_directory(self, dir_path: Path, recursive: bool = True) -> list[Violation]:
|
|
492
|
+
"""Lint all files in directory.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
dir_path: Directory to scan
|
|
496
|
+
recursive: Scan recursively
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
List of all violations found
|
|
500
|
+
"""
|
|
501
|
+
from src.linter_config.ignore import IgnoreDirectiveParser
|
|
502
|
+
|
|
503
|
+
ignore_parser = IgnoreDirectiveParser(self.project_root)
|
|
504
|
+
pattern = "**/*" if recursive else "*"
|
|
505
|
+
|
|
506
|
+
violations = []
|
|
507
|
+
for file_path in dir_path.glob(pattern):
|
|
508
|
+
if not file_path.is_file():
|
|
509
|
+
continue
|
|
510
|
+
file_violations = self._lint_file_if_not_ignored(file_path, ignore_parser)
|
|
511
|
+
violations.extend(file_violations)
|
|
512
|
+
|
|
513
|
+
return violations
|
|
514
|
+
|
|
515
|
+
def _lint_file_if_not_ignored(self, file_path: Path, ignore_parser: Any) -> list[Violation]:
|
|
516
|
+
"""Lint file if not ignored."""
|
|
517
|
+
if ignore_parser.is_ignored(file_path):
|
|
518
|
+
return []
|
|
519
|
+
return self.lint_path(file_path)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class FilePlacementRule(BaseLintRule):
|
|
523
|
+
"""File placement linting rule (integrates with framework)."""
|
|
524
|
+
|
|
525
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
526
|
+
"""Initialize rule with config.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
config: Rule configuration
|
|
530
|
+
"""
|
|
531
|
+
self.config = config or {}
|
|
532
|
+
self._linter_cache: dict[Path, FilePlacementLinter] = {}
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def rule_id(self) -> str:
|
|
536
|
+
"""Return rule ID."""
|
|
537
|
+
return "file-placement"
|
|
538
|
+
|
|
539
|
+
@property
|
|
540
|
+
def rule_name(self) -> str:
|
|
541
|
+
"""Return rule name."""
|
|
542
|
+
return "File Placement"
|
|
543
|
+
|
|
544
|
+
@property
|
|
545
|
+
def description(self) -> str:
|
|
546
|
+
"""Return rule description."""
|
|
547
|
+
return "Validate file organization against project structure rules"
|
|
548
|
+
|
|
549
|
+
def _get_layout_path(self, project_root: Path) -> Path:
|
|
550
|
+
"""Get layout config file path."""
|
|
551
|
+
layout_file = self.config.get("layout_file")
|
|
552
|
+
if layout_file:
|
|
553
|
+
return project_root / layout_file
|
|
554
|
+
|
|
555
|
+
yaml_path = project_root / ".ai" / "layout.yaml"
|
|
556
|
+
json_path = project_root / ".ai" / "layout.json"
|
|
557
|
+
if yaml_path.exists():
|
|
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 {}
|
|
569
|
+
|
|
570
|
+
def _parse_layout_file(self, layout_path: Path) -> dict[str, Any]:
|
|
571
|
+
"""Parse layout file based on extension."""
|
|
572
|
+
with layout_path.open(encoding="utf-8") as f:
|
|
573
|
+
if str(layout_path).endswith((".yaml", ".yml")):
|
|
574
|
+
return yaml.safe_load(f) or {}
|
|
575
|
+
return json.load(f)
|
|
576
|
+
|
|
577
|
+
def _get_or_create_linter(self, project_root: Path) -> FilePlacementLinter:
|
|
578
|
+
"""Get cached linter or create new one."""
|
|
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]
|
|
586
|
+
|
|
587
|
+
def check(self, context: BaseLintContext) -> list[Violation]:
|
|
588
|
+
"""Check file placement.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
context: Lint context
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
List of violations
|
|
595
|
+
"""
|
|
596
|
+
if not context.file_path:
|
|
597
|
+
return []
|
|
598
|
+
|
|
599
|
+
project_root = self._find_project_root(context.file_path)
|
|
600
|
+
linter = self._get_or_create_linter(project_root)
|
|
601
|
+
return linter.lint_path(context.file_path)
|
|
602
|
+
|
|
603
|
+
def _find_project_root(self, file_path: Path) -> Path:
|
|
604
|
+
"""Find project root by looking for .ai directory.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
file_path: File being linted
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
Project root directory
|
|
611
|
+
"""
|
|
612
|
+
current = file_path.parent if file_path.is_file() else file_path
|
|
613
|
+
|
|
614
|
+
# Walk up directory tree looking for .ai directory
|
|
615
|
+
while current != current.parent:
|
|
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()
|