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
@@ -0,0 +1,313 @@
1
+ """
2
+ File: src/linters/file_header/linter.py
3
+ Purpose: Main file header linter rule implementation
4
+ Exports: FileHeaderRule class
5
+ Depends: BaseLintRule, PythonHeaderParser, FieldValidator, AtemporalDetector, ViolationBuilder
6
+ Implements: Composition pattern with helper classes, AST-based Python parsing
7
+ Related: config.py for configuration, python_parser.py for extraction
8
+
9
+ Overview:
10
+ Orchestrates file header validation for Python files using focused helper classes.
11
+ Coordinates docstring extraction, field validation, atemporal language detection, and
12
+ violation building. Supports configuration from .thailint.yaml and ignore directives.
13
+ Validates headers against mandatory field requirements and atemporal language standards.
14
+
15
+ Usage:
16
+ rule = FileHeaderRule()
17
+ violations = rule.check(context)
18
+
19
+ Notes: Follows composition pattern from magic_numbers linter for maintainability
20
+ """
21
+
22
+ from pathlib import Path
23
+
24
+ from src.core.base import BaseLintContext, BaseLintRule
25
+ from src.core.linter_utils import load_linter_config
26
+ from src.core.types import Violation
27
+ from src.linter_config.ignore import IgnoreDirectiveParser
28
+
29
+ from .atemporal_detector import AtemporalDetector
30
+ from .config import FileHeaderConfig
31
+ from .field_validator import FieldValidator
32
+ from .python_parser import PythonHeaderParser
33
+ from .violation_builder import ViolationBuilder
34
+
35
+
36
+ class FileHeaderRule(BaseLintRule): # thailint: ignore[srp]
37
+ """Validates file headers for mandatory fields and atemporal language.
38
+
39
+ Method count (17) exceeds SRP guideline (8) because proper A-grade complexity
40
+ refactoring requires extracting helper methods. Class maintains single responsibility
41
+ of file header validation - all methods support this core purpose through composition
42
+ pattern with focused helper classes (parser, validator, detector, builder).
43
+ """
44
+
45
+ def __init__(self) -> None:
46
+ """Initialize the file header rule."""
47
+ self._violation_builder = ViolationBuilder(self.rule_id)
48
+ self._ignore_parser = IgnoreDirectiveParser()
49
+
50
+ @property
51
+ def rule_id(self) -> str:
52
+ """Unique identifier for this rule.
53
+
54
+ Returns:
55
+ Rule identifier string
56
+ """
57
+ return "file-header.validation"
58
+
59
+ @property
60
+ def rule_name(self) -> str:
61
+ """Human-readable name for this rule.
62
+
63
+ Returns:
64
+ Rule name string
65
+ """
66
+ return "File Header Validation"
67
+
68
+ @property
69
+ def description(self) -> str:
70
+ """Description of what this rule checks.
71
+
72
+ Returns:
73
+ Rule description string
74
+ """
75
+ return "Validates file headers for mandatory fields and atemporal language"
76
+
77
+ def check(self, context: BaseLintContext) -> list[Violation]:
78
+ """Check file header for violations.
79
+
80
+ Args:
81
+ context: Lint context with file information
82
+
83
+ Returns:
84
+ List of violations found in file header
85
+ """
86
+ # Only Python for now (PR3), multi-language in PR5
87
+ if context.language != "python":
88
+ return []
89
+
90
+ # Check for file-level ignore directives first
91
+ if self._has_file_ignore(context):
92
+ return []
93
+
94
+ # Load configuration
95
+ config = self._load_config(context)
96
+
97
+ # Check if file should be ignored by pattern
98
+ if self._should_ignore_file(context, config):
99
+ return []
100
+
101
+ # Extract and validate header
102
+ return self._check_python_header(context, config)
103
+
104
+ def _has_file_ignore(self, context: BaseLintContext) -> bool:
105
+ """Check if file has file-level ignore directive.
106
+
107
+ Args:
108
+ context: Lint context
109
+
110
+ Returns:
111
+ True if file has ignore-file directive
112
+ """
113
+ file_content = context.file_content or ""
114
+
115
+ if self._has_standard_ignore(file_content):
116
+ return True
117
+
118
+ return self._has_custom_ignore_syntax(file_content)
119
+
120
+ def _has_standard_ignore(self, file_content: str) -> bool: # thailint: ignore[nesting]
121
+ """Check standard ignore parser for file-level ignores."""
122
+ # Check first 10 lines for standard ignore directives
123
+ first_lines = file_content.splitlines()[:10]
124
+ for line in first_lines:
125
+ if self._ignore_parser._has_ignore_directive_marker(line): # pylint: disable=protected-access
126
+ if self._ignore_parser._check_specific_rule_ignore(line, self.rule_id): # pylint: disable=protected-access
127
+ return True
128
+ if self._ignore_parser._check_general_ignore(line): # pylint: disable=protected-access
129
+ return True
130
+ return False
131
+
132
+ def _has_custom_ignore_syntax(self, file_content: str) -> bool:
133
+ """Check custom file-level ignore syntax."""
134
+ first_lines = file_content.splitlines()[:10]
135
+ return any(self._is_ignore_line(line) for line in first_lines)
136
+
137
+ def _is_ignore_line(self, line: str) -> bool:
138
+ """Check if line contains ignore directive."""
139
+ line_lower = line.lower()
140
+ return "# thailint-ignore-file:" in line_lower or "# thailint-ignore" in line_lower
141
+
142
+ def _load_config(self, context: BaseLintContext) -> FileHeaderConfig:
143
+ """Load configuration from context.
144
+
145
+ Args:
146
+ context: Lint context
147
+
148
+ Returns:
149
+ FileHeaderConfig with loaded or default values
150
+ """
151
+ # Try production config first
152
+ if hasattr(context, "metadata") and isinstance(context.metadata, dict):
153
+ if "file_header" in context.metadata:
154
+ return load_linter_config(context, "file_header", FileHeaderConfig) # type: ignore[type-var]
155
+
156
+ # Use defaults
157
+ return FileHeaderConfig()
158
+
159
+ def _should_ignore_file(self, context: BaseLintContext, config: FileHeaderConfig) -> bool:
160
+ """Check if file matches ignore patterns.
161
+
162
+ Args:
163
+ context: Lint context
164
+ config: File header configuration
165
+
166
+ Returns:
167
+ True if file should be ignored
168
+ """
169
+ if not context.file_path:
170
+ return False
171
+
172
+ file_path = Path(context.file_path)
173
+ return any(self._matches_ignore_pattern(file_path, p) for p in config.ignore)
174
+
175
+ def _matches_ignore_pattern(self, file_path: Path, pattern: str) -> bool:
176
+ """Check if file path matches a single ignore pattern."""
177
+ if file_path.match(pattern):
178
+ return True
179
+
180
+ if self._matches_directory_pattern(file_path, pattern):
181
+ return True
182
+
183
+ if self._matches_file_pattern(file_path, pattern):
184
+ return True
185
+
186
+ return pattern in str(file_path)
187
+
188
+ def _matches_directory_pattern(self, file_path: Path, pattern: str) -> bool:
189
+ """Match directory patterns like **/migrations/**."""
190
+ if pattern.startswith("**/") and pattern.endswith("/**"):
191
+ dir_name = pattern[3:-3]
192
+ return dir_name in file_path.parts
193
+ return False
194
+
195
+ def _matches_file_pattern(self, file_path: Path, pattern: str) -> bool:
196
+ """Match file patterns like **/__init__.py."""
197
+ if pattern.startswith("**/"):
198
+ filename_pattern = pattern[3:]
199
+ path_str = str(file_path)
200
+ return file_path.name == filename_pattern or path_str.endswith(filename_pattern)
201
+ return False
202
+
203
+ def _check_python_header(
204
+ self, context: BaseLintContext, config: FileHeaderConfig
205
+ ) -> list[Violation]:
206
+ """Check Python file header.
207
+
208
+ Args:
209
+ context: Lint context
210
+ config: Configuration
211
+
212
+ Returns:
213
+ List of violations filtered through ignore directives
214
+ """
215
+ parser = PythonHeaderParser()
216
+ header = parser.extract_header(context.file_content or "")
217
+
218
+ if not header:
219
+ return self._build_missing_header_violations(context)
220
+
221
+ fields = parser.parse_fields(header)
222
+ violations = self._validate_header_fields(fields, context, config)
223
+ violations.extend(self._check_atemporal_violations(header, context, config))
224
+
225
+ return self._filter_ignored_violations(violations, context)
226
+
227
+ def _build_missing_header_violations(self, context: BaseLintContext) -> list[Violation]:
228
+ """Build violations for missing header."""
229
+ return [
230
+ self._violation_builder.build_missing_field(
231
+ "docstring", str(context.file_path or ""), 1
232
+ )
233
+ ]
234
+
235
+ def _validate_header_fields(
236
+ self, fields: dict[str, str], context: BaseLintContext, config: FileHeaderConfig
237
+ ) -> list[Violation]:
238
+ """Validate mandatory header fields."""
239
+ violations = []
240
+ field_validator = FieldValidator(config)
241
+ field_violations = field_validator.validate_fields(fields, context.language)
242
+
243
+ for field_name, _error_message in field_violations:
244
+ violations.append(
245
+ self._violation_builder.build_missing_field(
246
+ field_name, str(context.file_path or ""), 1
247
+ )
248
+ )
249
+ return violations
250
+
251
+ def _check_atemporal_violations(
252
+ self, header: str, context: BaseLintContext, config: FileHeaderConfig
253
+ ) -> list[Violation]:
254
+ """Check for atemporal language violations."""
255
+ if not config.enforce_atemporal:
256
+ return []
257
+
258
+ violations = []
259
+ atemporal_detector = AtemporalDetector()
260
+ atemporal_violations = atemporal_detector.detect_violations(header)
261
+
262
+ for pattern, description, line_num in atemporal_violations:
263
+ violations.append(
264
+ self._violation_builder.build_atemporal_violation(
265
+ pattern, description, str(context.file_path or ""), line_num
266
+ )
267
+ )
268
+ return violations
269
+
270
+ def _filter_ignored_violations(
271
+ self, violations: list[Violation], context: BaseLintContext
272
+ ) -> list[Violation]:
273
+ """Filter out violations that should be ignored.
274
+
275
+ Args:
276
+ violations: List of violations to filter
277
+ context: Lint context with file content
278
+
279
+ Returns:
280
+ Filtered list of violations
281
+ """
282
+ file_content = context.file_content or ""
283
+ lines = file_content.splitlines()
284
+
285
+ filtered = []
286
+ for v in violations:
287
+ # Check standard ignore directives
288
+ if self._ignore_parser.should_ignore_violation(v, file_content):
289
+ continue
290
+
291
+ # Check custom line-level ignore syntax: # thailint-ignore-line:
292
+ if self._has_line_level_ignore(lines, v):
293
+ continue
294
+
295
+ filtered.append(v)
296
+
297
+ return filtered
298
+
299
+ def _has_line_level_ignore(self, lines: list[str], violation: Violation) -> bool:
300
+ """Check for thailint-ignore-line directive.
301
+
302
+ Args:
303
+ lines: File content split into lines
304
+ violation: Violation to check
305
+
306
+ Returns:
307
+ True if line has ignore directive
308
+ """
309
+ if violation.line <= 0 or violation.line > len(lines):
310
+ return False
311
+
312
+ line_content = lines[violation.line - 1] # Convert to 0-indexed
313
+ return "# thailint-ignore-line:" in line_content.lower()
@@ -0,0 +1,86 @@
1
+ """
2
+ File: src/linters/file_header/python_parser.py
3
+ Purpose: Python docstring extraction and parsing for file headers
4
+ Exports: PythonHeaderParser class
5
+ Depends: Python ast module
6
+ Implements: AST-based docstring extraction with field parsing
7
+ Related: linter.py for parser usage, field_validator.py for field validation
8
+
9
+ Overview:
10
+ Extracts module-level docstrings from Python files using AST parsing.
11
+ Parses structured header fields from docstring content and handles both
12
+ well-formed and malformed headers. Provides field extraction and validation
13
+ support for FileHeaderRule.
14
+
15
+ Usage:
16
+ parser = PythonHeaderParser()
17
+ header = parser.extract_header(code)
18
+ fields = parser.parse_fields(header)
19
+
20
+ Notes: Uses ast.get_docstring() for reliable module-level docstring extraction
21
+ """
22
+
23
+ import ast
24
+
25
+
26
+ class PythonHeaderParser:
27
+ """Extracts and parses Python file headers from docstrings."""
28
+
29
+ def extract_header(self, code: str) -> str | None:
30
+ """Extract module-level docstring from Python code.
31
+
32
+ Args:
33
+ code: Python source code
34
+
35
+ Returns:
36
+ Module docstring or None if not found or parse error
37
+ """
38
+ try:
39
+ tree = ast.parse(code)
40
+ return ast.get_docstring(tree)
41
+ except SyntaxError:
42
+ return None
43
+
44
+ def parse_fields(self, header: str) -> dict[str, str]: # thailint: ignore[nesting]
45
+ """Parse structured fields from header text.
46
+
47
+ Args:
48
+ header: Header docstring text
49
+
50
+ Returns:
51
+ Dictionary mapping field_name -> field_value
52
+ """
53
+ fields: dict[str, str] = {}
54
+ current_field: str | None = None
55
+ current_value: list[str] = []
56
+
57
+ for line in header.split("\n"):
58
+ if self._is_new_field_line(line):
59
+ current_field = self._save_and_start_new_field(
60
+ fields, current_field, current_value, line
61
+ )
62
+ current_value = [line.split(":", 1)[1].strip()]
63
+ elif current_field:
64
+ current_value.append(line.strip())
65
+
66
+ self._save_current_field(fields, current_field, current_value)
67
+ return fields
68
+
69
+ def _is_new_field_line(self, line: str) -> bool:
70
+ """Check if line starts a new field."""
71
+ return ":" in line and not line.startswith(" ")
72
+
73
+ def _save_and_start_new_field(
74
+ self, fields: dict[str, str], current_field: str | None, current_value: list[str], line: str
75
+ ) -> str:
76
+ """Save current field and start new one."""
77
+ if current_field:
78
+ fields[current_field] = "\n".join(current_value).strip()
79
+ return line.split(":", 1)[0].strip()
80
+
81
+ def _save_current_field(
82
+ self, fields: dict[str, str], current_field: str | None, current_value: list[str]
83
+ ) -> None:
84
+ """Save the last field."""
85
+ if current_field:
86
+ fields[current_field] = "\n".join(current_value).strip()
@@ -0,0 +1,78 @@
1
+ """
2
+ File: src/linters/file_header/violation_builder.py
3
+ Purpose: Builds violation messages for file header linter
4
+ Exports: ViolationBuilder class
5
+ Depends: Violation type from core
6
+ Implements: Message templates with context-specific details
7
+ Related: linter.py for builder usage, atemporal_detector.py for temporal violations
8
+
9
+ Overview:
10
+ Creates formatted violation messages for file header validation failures.
11
+ Handles missing fields, atemporal language, and other header issues with clear,
12
+ actionable messages. Provides consistent violation format across all validation types.
13
+
14
+ Usage:
15
+ builder = ViolationBuilder("file-header.validation")
16
+ violation = builder.build_missing_field("Purpose", "test.py", 1)
17
+
18
+ Notes: Follows standard violation format with rule_id, message, location, severity, suggestion
19
+ """
20
+
21
+ from src.core.types import Severity, Violation
22
+
23
+
24
+ class ViolationBuilder:
25
+ """Builds violation messages for file header issues."""
26
+
27
+ def __init__(self, rule_id: str):
28
+ """Initialize with rule ID.
29
+
30
+ Args:
31
+ rule_id: Rule identifier for violations
32
+ """
33
+ self.rule_id = rule_id
34
+
35
+ def build_missing_field(self, field_name: str, file_path: str, line: int = 1) -> Violation:
36
+ """Build violation for missing mandatory field.
37
+
38
+ Args:
39
+ field_name: Name of missing field
40
+ file_path: Path to file
41
+ line: Line number (default 1 for header)
42
+
43
+ Returns:
44
+ Violation object describing missing field
45
+ """
46
+ return Violation(
47
+ rule_id=self.rule_id,
48
+ message=f"Missing mandatory field: {field_name}",
49
+ file_path=file_path,
50
+ line=line,
51
+ column=1,
52
+ severity=Severity.ERROR,
53
+ suggestion=f"Add '{field_name}:' field to file header",
54
+ )
55
+
56
+ def build_atemporal_violation(
57
+ self, pattern: str, description: str, file_path: str, line: int
58
+ ) -> Violation:
59
+ """Build violation for temporal language.
60
+
61
+ Args:
62
+ pattern: Matched regex pattern
63
+ description: Description of temporal language
64
+ file_path: Path to file
65
+ line: Line number of violation
66
+
67
+ Returns:
68
+ Violation object describing temporal language issue
69
+ """
70
+ return Violation(
71
+ rule_id=self.rule_id,
72
+ message=f"Temporal language detected: {description}",
73
+ file_path=file_path,
74
+ line=line,
75
+ column=1,
76
+ severity=Severity.ERROR,
77
+ suggestion="Use present-tense factual descriptions without temporal references",
78
+ )
@@ -0,0 +1,86 @@
1
+ """
2
+ Purpose: Configuration file loading for file placement linter
3
+
4
+ Scope: Handles loading and parsing of JSON/YAML configuration files
5
+
6
+ Overview: Provides configuration file loading functionality for the file placement linter.
7
+ Supports both JSON and YAML config formats, handles path resolution relative to project
8
+ root, and provides safe defaults when config files are missing or invalid. Isolates
9
+ file I/O concerns from business logic to maintain single responsibility.
10
+
11
+ Dependencies: pathlib, json, yaml
12
+
13
+ Exports: ConfigLoader
14
+
15
+ Interfaces: load_config_file(config_file, project_root) -> dict
16
+
17
+ Implementation: Uses standard library JSON and PyYAML for parsing, returns empty dict on errors
18
+ """
19
+
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import yaml
25
+
26
+
27
+ class ConfigLoader:
28
+ """Loads configuration files for file placement linter."""
29
+
30
+ def __init__(self, project_root: Path):
31
+ """Initialize config loader.
32
+
33
+ Args:
34
+ project_root: Project root directory
35
+ """
36
+ self.project_root = project_root
37
+
38
+ def load_config_file(self, config_file: str) -> dict[str, Any]:
39
+ """Load configuration from file.
40
+
41
+ Args:
42
+ config_file: Path to config file
43
+
44
+ Returns:
45
+ Loaded configuration dict, or empty dict if file doesn't exist
46
+
47
+ Raises:
48
+ ValueError: If config file format is unsupported
49
+ """
50
+ config_path = self._resolve_path(config_file)
51
+ if not config_path.exists():
52
+ return {}
53
+ return self._parse_file(config_path)
54
+
55
+ def _resolve_path(self, config_file: str) -> Path:
56
+ """Resolve config file path relative to project root.
57
+
58
+ Args:
59
+ config_file: Config file path (relative or absolute)
60
+
61
+ Returns:
62
+ Resolved absolute path
63
+ """
64
+ config_path = Path(config_file)
65
+ if not config_path.is_absolute():
66
+ config_path = self.project_root / config_path
67
+ return config_path
68
+
69
+ def _parse_file(self, config_path: Path) -> dict[str, Any]:
70
+ """Parse config file based on extension.
71
+
72
+ Args:
73
+ config_path: Path to config file
74
+
75
+ Returns:
76
+ Parsed configuration dict
77
+
78
+ Raises:
79
+ ValueError: If file format is unsupported
80
+ """
81
+ with config_path.open(encoding="utf-8") as f:
82
+ if config_path.suffix in [".yaml", ".yml"]:
83
+ return yaml.safe_load(f) or {}
84
+ if config_path.suffix == ".json":
85
+ return json.load(f)
86
+ raise ValueError(f"Unsupported config format: {config_path.suffix}")
@@ -0,0 +1,80 @@
1
+ """
2
+ Purpose: Directory rule matching for file placement linter
3
+
4
+ Scope: Finds most specific directory rule matching a file path
5
+
6
+ Overview: Provides directory matching functionality for the file placement linter. Implements
7
+ most-specific-directory matching logic by comparing path prefixes and calculating directory
8
+ depth. Handles special case of root directory matching. Returns matched rule and path for
9
+ further processing. Isolates directory matching logic from rule checking and pattern matching.
10
+
11
+ Dependencies: typing
12
+
13
+ Exports: DirectoryMatcher
14
+
15
+ Interfaces: find_matching_rule(path_str, directories) -> (rule_dict, matched_path)
16
+
17
+ Implementation: Prefix matching with depth-based precedence, root directory special case
18
+ """
19
+
20
+ from typing import Any
21
+
22
+
23
+ class DirectoryMatcher:
24
+ """Finds matching directory rules based on path prefixes."""
25
+
26
+ def find_matching_rule(
27
+ self, path_str: str, directories: dict[str, Any]
28
+ ) -> tuple[dict[str, Any] | None, str | None]:
29
+ """Find most specific directory rule matching the path.
30
+
31
+ Args:
32
+ path_str: File path string
33
+ directories: Directory rules
34
+
35
+ Returns:
36
+ Tuple of (rule_dict, matched_path)
37
+ """
38
+ best_match = None
39
+ best_path = None
40
+ best_depth = -1
41
+
42
+ for dir_path, rules in directories.items():
43
+ matches, depth = self._check_path_match(dir_path, path_str)
44
+ if matches and depth > best_depth:
45
+ best_match = rules
46
+ best_path = dir_path
47
+ best_depth = depth
48
+
49
+ return best_match, best_path
50
+
51
+ def _check_path_match(self, dir_path: str, path_str: str) -> tuple[bool, int]:
52
+ """Check if path matches directory rule.
53
+
54
+ Args:
55
+ dir_path: Directory path pattern
56
+ path_str: File path string
57
+
58
+ Returns:
59
+ Tuple of (matches, depth) where depth is directory nesting level
60
+ """
61
+ if dir_path == "/":
62
+ return self._check_root_match(dir_path, path_str)
63
+ if path_str.startswith(dir_path):
64
+ depth = len(dir_path.split("/"))
65
+ return True, depth
66
+ return False, -1
67
+
68
+ def _check_root_match(self, dir_path: str, path_str: str) -> tuple[bool, int]:
69
+ """Check if path matches root directory rule.
70
+
71
+ Args:
72
+ dir_path: Directory path (should be "/")
73
+ path_str: File path string
74
+
75
+ Returns:
76
+ Tuple of (matches, depth)
77
+ """
78
+ if dir_path == "/" and "/" not in path_str:
79
+ return True, 0
80
+ return False, -1