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
|
@@ -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
|