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.
Files changed (91) hide show
  1. src/__init__.py +7 -2
  2. src/analyzers/__init__.py +23 -0
  3. src/analyzers/typescript_base.py +148 -0
  4. src/api.py +1 -1
  5. src/cli.py +1111 -144
  6. src/config.py +12 -33
  7. src/core/base.py +102 -5
  8. src/core/cli_utils.py +206 -0
  9. src/core/config_parser.py +126 -0
  10. src/core/linter_utils.py +168 -0
  11. src/core/registry.py +17 -92
  12. src/core/rule_discovery.py +132 -0
  13. src/core/violation_builder.py +122 -0
  14. src/linter_config/ignore.py +112 -40
  15. src/linter_config/loader.py +3 -13
  16. src/linters/dry/__init__.py +23 -0
  17. src/linters/dry/base_token_analyzer.py +76 -0
  18. src/linters/dry/block_filter.py +265 -0
  19. src/linters/dry/block_grouper.py +59 -0
  20. src/linters/dry/cache.py +172 -0
  21. src/linters/dry/cache_query.py +61 -0
  22. src/linters/dry/config.py +134 -0
  23. src/linters/dry/config_loader.py +44 -0
  24. src/linters/dry/deduplicator.py +120 -0
  25. src/linters/dry/duplicate_storage.py +63 -0
  26. src/linters/dry/file_analyzer.py +90 -0
  27. src/linters/dry/inline_ignore.py +140 -0
  28. src/linters/dry/linter.py +163 -0
  29. src/linters/dry/python_analyzer.py +668 -0
  30. src/linters/dry/storage_initializer.py +42 -0
  31. src/linters/dry/token_hasher.py +169 -0
  32. src/linters/dry/typescript_analyzer.py +592 -0
  33. src/linters/dry/violation_builder.py +74 -0
  34. src/linters/dry/violation_filter.py +94 -0
  35. src/linters/dry/violation_generator.py +174 -0
  36. src/linters/file_header/__init__.py +24 -0
  37. src/linters/file_header/atemporal_detector.py +87 -0
  38. src/linters/file_header/config.py +66 -0
  39. src/linters/file_header/field_validator.py +69 -0
  40. src/linters/file_header/linter.py +313 -0
  41. src/linters/file_header/python_parser.py +86 -0
  42. src/linters/file_header/violation_builder.py +78 -0
  43. src/linters/file_placement/config_loader.py +86 -0
  44. src/linters/file_placement/directory_matcher.py +80 -0
  45. src/linters/file_placement/linter.py +262 -471
  46. src/linters/file_placement/path_resolver.py +61 -0
  47. src/linters/file_placement/pattern_matcher.py +55 -0
  48. src/linters/file_placement/pattern_validator.py +106 -0
  49. src/linters/file_placement/rule_checker.py +229 -0
  50. src/linters/file_placement/violation_factory.py +177 -0
  51. src/linters/magic_numbers/__init__.py +48 -0
  52. src/linters/magic_numbers/config.py +82 -0
  53. src/linters/magic_numbers/context_analyzer.py +247 -0
  54. src/linters/magic_numbers/linter.py +516 -0
  55. src/linters/magic_numbers/python_analyzer.py +76 -0
  56. src/linters/magic_numbers/typescript_analyzer.py +218 -0
  57. src/linters/magic_numbers/violation_builder.py +98 -0
  58. src/linters/nesting/__init__.py +6 -2
  59. src/linters/nesting/config.py +17 -4
  60. src/linters/nesting/linter.py +81 -168
  61. src/linters/nesting/typescript_analyzer.py +39 -102
  62. src/linters/nesting/typescript_function_extractor.py +130 -0
  63. src/linters/nesting/violation_builder.py +139 -0
  64. src/linters/print_statements/__init__.py +53 -0
  65. src/linters/print_statements/config.py +83 -0
  66. src/linters/print_statements/linter.py +430 -0
  67. src/linters/print_statements/python_analyzer.py +155 -0
  68. src/linters/print_statements/typescript_analyzer.py +135 -0
  69. src/linters/print_statements/violation_builder.py +98 -0
  70. src/linters/srp/__init__.py +99 -0
  71. src/linters/srp/class_analyzer.py +113 -0
  72. src/linters/srp/config.py +82 -0
  73. src/linters/srp/heuristics.py +89 -0
  74. src/linters/srp/linter.py +234 -0
  75. src/linters/srp/metrics_evaluator.py +47 -0
  76. src/linters/srp/python_analyzer.py +72 -0
  77. src/linters/srp/typescript_analyzer.py +75 -0
  78. src/linters/srp/typescript_metrics_calculator.py +90 -0
  79. src/linters/srp/violation_builder.py +117 -0
  80. src/orchestrator/core.py +54 -9
  81. src/templates/thailint_config_template.yaml +158 -0
  82. src/utils/__init__.py +4 -0
  83. src/utils/project_root.py +203 -0
  84. thailint-0.5.0.dist-info/METADATA +1286 -0
  85. thailint-0.5.0.dist-info/RECORD +96 -0
  86. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/WHEEL +1 -1
  87. src/.ai/layout.yaml +0 -48
  88. thailint-0.1.5.dist-info/METADATA +0 -629
  89. thailint-0.1.5.dist-info/RECORD +0 -28
  90. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/entry_points.txt +0 -0
  91. {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
- Supports directory-specific rules, global patterns, and generates helpful suggestions.
6
- Dependencies: src.core (base classes, types), pathlib, json, re
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
- Implementation: Pattern matching with deny-takes-precedence logic
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 Severity, Violation
20
-
34
+ from src.core.types import Violation
21
35
 
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
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
- Args:
48
- path_str: File path to check
49
- allow_patterns: List of regex patterns
44
+ class _Components:
45
+ """Container for linter components to reduce instance attributes."""
50
46
 
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)
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.pattern_matcher = PatternMatcher()
73
+ self._components = _Components(self.project_root)
75
74
 
76
- # Load config
75
+ # Load and validate config
77
76
  if config_obj:
78
- self.config = config_obj
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._load_config_file(config_file)
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._validate_regex_patterns()
90
+ self._components.pattern_validator.validate_config(self.config)
86
91
 
87
- def _validate_pattern(self, pattern: str) -> None:
88
- """Validate a single regex pattern.
92
+ def lint_path(self, file_path: Path) -> list[Violation]:
93
+ """Lint a single file path.
89
94
 
90
95
  Args:
91
- pattern: Regex pattern to validate
96
+ file_path: File to lint
92
97
 
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
98
+ Returns:
99
+ List of violations found
139
100
  """
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}")
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 _load_config_file(self, config_file: str) -> dict[str, Any]:
163
- """Load configuration from file.
107
+ def check_file_allowed(self, file_path: Path) -> bool:
108
+ """Check if file is allowed (no violations).
164
109
 
165
110
  Args:
166
- config_file: Path to config file
111
+ file_path: File to check
167
112
 
168
113
  Returns:
169
- Loaded configuration dict
170
-
171
- Raises:
172
- Exception: If file cannot be loaded or parsed
114
+ True if file is allowed (no violations)
173
115
  """
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)
116
+ violations = self.lint_path(file_path)
117
+ return len(violations) == 0
178
118
 
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)
119
+ def lint_directory(self, dir_path: Path, recursive: bool = True) -> list[Violation]:
120
+ """Lint all files in directory.
201
121
 
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)
122
+ Args:
123
+ dir_path: Directory to scan
124
+ recursive: Scan recursively
205
125
 
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)
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
- Args:
218
- file_path: File to lint
146
+ class FilePlacementRule(BaseLintRule): # thailint: ignore[srp.violation]
147
+ """File placement linting rule (integrates with framework).
219
148
 
220
- Returns:
221
- List of violations found
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
- 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 []
159
+ self.config = config or {}
160
+ self._linter_cache: dict[Path, FilePlacementLinter] = {}
262
161
 
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 []
162
+ @property
163
+ def rule_id(self) -> str:
164
+ """Return rule ID."""
165
+ return "file-placement"
267
166
 
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 []
167
+ @property
168
+ def rule_name(self) -> str:
169
+ """Return rule name."""
170
+ return "File Placement"
274
171
 
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 []
172
+ @property
173
+ def description(self) -> str:
174
+ """Return rule description."""
175
+ return "Validate file organization against project structure rules"
278
176
 
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.
177
+ def check(self, context: BaseLintContext) -> list[Violation]:
178
+ """Check file placement.
283
179
 
284
180
  Args:
285
- path_str: File path string
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
- dir_rule, matched_path = self._find_matching_directory_rule(path_str, directories)
293
- if not dir_rule or not matched_path:
186
+ if not context.file_path:
294
187
  return []
295
188
 
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
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 _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.
193
+ def _get_project_root(self, context: BaseLintContext) -> Path:
194
+ """Get project root from context or detect it.
321
195
 
322
196
  Args:
323
- path_str: File path string
324
- directories: Directory rules
197
+ context: Lint context
325
198
 
326
199
  Returns:
327
- Tuple of (rule_dict, matched_path)
200
+ Project root directory path
328
201
  """
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
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
- return best_match, best_path
207
+ # Otherwise detect it from file path
208
+ return self._detect_project_root(context)
341
209
 
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.
210
+ def _get_root_from_metadata(self, context: BaseLintContext) -> Path | None:
211
+ """Extract project root from context metadata.
346
212
 
347
213
  Args:
348
- path_str: File path string
349
- rel_path: Relative path
350
- global_deny: Global deny patterns
214
+ context: Lint context
351
215
 
352
216
  Returns:
353
- List of violations
217
+ Project root from metadata, or None if not available
354
218
  """
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
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 _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.
227
+ def _detect_project_root(self, context: BaseLintContext) -> Path:
228
+ """Detect project root from file path.
420
229
 
421
230
  Args:
422
- path_str: File path string
423
- rel_path: Relative path
424
- global_patterns: Global patterns config
231
+ context: Lint context
425
232
 
426
233
  Returns:
427
- List of violations
234
+ Detected project root directory path
428
235
  """
429
- deny_violations = self._check_global_deny_patterns(path_str, rel_path, global_patterns)
430
- if deny_violations:
431
- return deny_violations
236
+ from src.utils.project_root import get_project_root
432
237
 
433
- return self._check_global_allow_patterns(path_str, rel_path, global_patterns)
238
+ if context.file_path is None:
239
+ return Path.cwd()
434
240
 
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
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 _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
244
+ def _extract_inline_config(self, context: BaseLintContext | None) -> dict[str, Any] | None:
245
+ """Extract file-placement config from context metadata.
448
246
 
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.
247
+ Handles both wrapped format: {"file-placement": {...}}
248
+ and unwrapped format: {"global_deny": [...], "directories": {...}, ...}
461
249
 
462
250
  Args:
463
- filename: File name
464
- current_location: Current directory location
251
+ context: Lint context
465
252
 
466
253
  Returns:
467
- Suggestion string
254
+ File placement config dict, or None if no config in metadata
468
255
  """
469
- suggestion = self._suggest_for_test_file(filename)
470
- if suggestion:
471
- return suggestion
256
+ if not self._has_valid_metadata(context):
257
+ return None
472
258
 
473
- suggestion = self._suggest_for_typescript_file(filename)
474
- if suggestion:
475
- return suggestion
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
- return self._suggest_for_other_files(filename)
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
- def check_file_allowed(self, file_path: Path) -> bool:
480
- """Check if file is allowed (no violations).
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
- file_path: File to check
276
+ context: Lint context
484
277
 
485
278
  Returns:
486
- True if file is allowed (no violations)
279
+ True if context has metadata dict
487
280
  """
488
- violations = self.lint_path(file_path)
489
- return len(violations) == 0
281
+ return bool(context and hasattr(context, "metadata") and context.metadata)
490
282
 
491
- def lint_directory(self, dir_path: Path, recursive: bool = True) -> list[Violation]:
492
- """Lint all files in directory.
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
- dir_path: Directory to scan
496
- recursive: Scan recursively
290
+ context: Lint context with metadata
497
291
 
498
292
  Returns:
499
- List of all violations found
293
+ Config dict or None if not in wrapped format
500
294
  """
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)
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
- return violations
305
+ @staticmethod
306
+ def _get_unwrapped_config(context: BaseLintContext) -> dict[str, Any] | None:
307
+ """Get config from unwrapped format: {"directories": {...}, ...}.
514
308
 
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)
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
- class FilePlacementRule(BaseLintRule):
523
- """File placement linting rule (integrates with framework)."""
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 __init__(self, config: dict[str, Any] | None = None):
526
- """Initialize rule with config.
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
- config: Rule configuration
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
- self.config = config or {}
532
- self._linter_cache: dict[Path, FilePlacementLinter] = {}
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
- @property
535
- def rule_id(self) -> str:
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
- @property
540
- def rule_name(self) -> str:
541
- """Return rule name."""
542
- return "File Placement"
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
- @property
545
- def description(self) -> str:
546
- """Return rule description."""
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
- 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 {}
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
- 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)
371
+ for path in [thailint_yaml, thailint_json]:
372
+ if path.exists():
373
+ return path
576
374
 
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]
375
+ # Return default path if no config exists
376
+ return thailint_yaml
586
377
 
587
- def check(self, context: BaseLintContext) -> list[Violation]:
588
- """Check file placement.
378
+ def _load_layout_config(self, layout_path: Path) -> dict[str, Any]:
379
+ """Load layout configuration from file.
589
380
 
590
381
  Args:
591
- context: Lint context
382
+ layout_path: Path to layout file
592
383
 
593
384
  Returns:
594
- List of violations
385
+ Layout configuration dict (unwrapped from file-placement key), or empty dict on error
595
386
  """
596
- if not context.file_path:
597
- return []
387
+ try:
388
+ config = self._parse_layout_file(layout_path)
598
389
 
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)
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 _find_project_root(self, file_path: Path) -> Path:
604
- """Find project root by looking for .ai directory.
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
- file_path: File being linted
404
+ layout_path: Path to layout file
608
405
 
609
406
  Returns:
610
- Project root directory
407
+ Parsed configuration dict
611
408
  """
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()
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)