thailint 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- src/.ai/layout.yaml +48 -0
- src/__init__.py +49 -0
- src/api.py +118 -0
- src/cli.py +698 -0
- src/config.py +386 -0
- src/core/__init__.py +17 -0
- src/core/base.py +122 -0
- src/core/registry.py +170 -0
- src/core/types.py +83 -0
- src/linter_config/__init__.py +13 -0
- src/linter_config/ignore.py +403 -0
- src/linter_config/loader.py +77 -0
- src/linters/__init__.py +4 -0
- src/linters/file_placement/__init__.py +31 -0
- src/linters/file_placement/linter.py +621 -0
- src/linters/nesting/__init__.py +87 -0
- src/linters/nesting/config.py +50 -0
- src/linters/nesting/linter.py +257 -0
- src/linters/nesting/python_analyzer.py +89 -0
- src/linters/nesting/typescript_analyzer.py +180 -0
- src/orchestrator/__init__.py +9 -0
- src/orchestrator/core.py +188 -0
- src/orchestrator/language_detector.py +81 -0
- thailint-0.1.0.dist-info/LICENSE +21 -0
- thailint-0.1.0.dist-info/METADATA +601 -0
- thailint-0.1.0.dist-info/RECORD +28 -0
- thailint-0.1.0.dist-info/WHEEL +4 -0
- thailint-0.1.0.dist-info/entry_points.txt +4 -0
src/core/types.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Core type definitions for the linter framework
|
|
3
|
+
|
|
4
|
+
Scope: Fundamental data types used across all linter components and rules
|
|
5
|
+
|
|
6
|
+
Overview: Defines the essential data structures that form the foundation of the linter
|
|
7
|
+
framework, including violation reporting and severity classification. Provides the
|
|
8
|
+
Violation dataclass for representing linting issues with complete location and context
|
|
9
|
+
information, and the Severity enum implementing a binary error model (violations are
|
|
10
|
+
either errors or not violations). These types are used throughout the framework by
|
|
11
|
+
rules, orchestrators, and output formatters to maintain consistent violation reporting
|
|
12
|
+
and severity handling across all linting operations.
|
|
13
|
+
|
|
14
|
+
Dependencies: dataclasses for Violation structure, enum for Severity classification
|
|
15
|
+
|
|
16
|
+
Exports: Severity enum (ERROR level), Violation dataclass with serialization support
|
|
17
|
+
|
|
18
|
+
Interfaces: Violation.to_dict() -> dict for JSON serialization, Severity.ERROR constant
|
|
19
|
+
|
|
20
|
+
Implementation: Binary severity model (errors only), dataclass-based violation structure
|
|
21
|
+
with comprehensive field set (rule_id, file_path, line, column, message, severity, suggestion)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from enum import Enum
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Severity(Enum):
|
|
29
|
+
"""Binary severity model - errors only.
|
|
30
|
+
|
|
31
|
+
Following the design principle that linting violations are either
|
|
32
|
+
errors that must be fixed or they're not violations at all.
|
|
33
|
+
No warnings, info, or other severity levels.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
ERROR = "error"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Violation:
|
|
41
|
+
"""Represents a linting violation.
|
|
42
|
+
|
|
43
|
+
A violation contains all the information needed to report a linting
|
|
44
|
+
issue to the user, including location, message, and optional suggestion
|
|
45
|
+
for how to fix it.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
rule_id: str
|
|
49
|
+
"""Unique identifier of the rule that detected this violation."""
|
|
50
|
+
|
|
51
|
+
file_path: str
|
|
52
|
+
"""Path to the file containing the violation."""
|
|
53
|
+
|
|
54
|
+
line: int
|
|
55
|
+
"""Line number where the violation occurs (1-indexed)."""
|
|
56
|
+
|
|
57
|
+
column: int
|
|
58
|
+
"""Column number where the violation occurs (0-indexed)."""
|
|
59
|
+
|
|
60
|
+
message: str
|
|
61
|
+
"""Human-readable description of the violation."""
|
|
62
|
+
|
|
63
|
+
severity: Severity = Severity.ERROR
|
|
64
|
+
"""Severity level of the violation (always ERROR in binary model)."""
|
|
65
|
+
|
|
66
|
+
suggestion: str | None = None
|
|
67
|
+
"""Optional suggestion for how to fix the violation."""
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, str | int | None]:
|
|
70
|
+
"""Convert violation to dictionary for JSON serialization.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Dictionary representation of the violation with all fields.
|
|
74
|
+
"""
|
|
75
|
+
return {
|
|
76
|
+
"rule_id": self.rule_id,
|
|
77
|
+
"file_path": self.file_path,
|
|
78
|
+
"line": self.line,
|
|
79
|
+
"column": self.column,
|
|
80
|
+
"message": self.message,
|
|
81
|
+
"severity": self.severity.value,
|
|
82
|
+
"suggestion": self.suggestion,
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Linter configuration system.
|
|
2
|
+
|
|
3
|
+
This package provides configuration loading and ignore directive parsing
|
|
4
|
+
for the thai-lint linter framework.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .ignore import IgnoreDirectiveParser
|
|
8
|
+
from .loader import LinterConfigLoader
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"IgnoreDirectiveParser",
|
|
12
|
+
"LinterConfigLoader",
|
|
13
|
+
]
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Comprehensive 5-level ignore directive parser for suppressing linting violations
|
|
3
|
+
|
|
4
|
+
Scope: Multi-level ignore system across repository, directory, file, method, and line scopes
|
|
5
|
+
|
|
6
|
+
Overview: Implements a sophisticated ignore directive system that allows developers to suppress
|
|
7
|
+
linting violations at five different granularity levels, from entire repository patterns down
|
|
8
|
+
to individual lines of code. Repository level uses .thailintignore file with gitignore-style
|
|
9
|
+
glob patterns for excluding files like build artifacts and dependencies. File level scans the
|
|
10
|
+
first 10 lines for ignore-file directives (performance optimization). Method level supports
|
|
11
|
+
ignore-next-line directives placed before functions. Line level enables inline ignore comments
|
|
12
|
+
at the end of code lines. All levels support rule-specific ignores using bracket syntax
|
|
13
|
+
[rule-id] and wildcard rule matching (literals.* matches literals.magic-number). The
|
|
14
|
+
should_ignore_violation() method provides unified checking across all levels, integrating
|
|
15
|
+
with the violation reporting system to filter out suppressed violations before displaying
|
|
16
|
+
results to users.
|
|
17
|
+
|
|
18
|
+
Dependencies: fnmatch for gitignore-style pattern matching, re for regex-based directive parsing,
|
|
19
|
+
pathlib for file operations, Violation type for violation checking
|
|
20
|
+
|
|
21
|
+
Exports: IgnoreDirectiveParser class
|
|
22
|
+
|
|
23
|
+
Interfaces: is_ignored(file_path: Path) -> bool for repo-level checking,
|
|
24
|
+
has_file_ignore(file_path: Path, rule_id: str | None) -> bool for file-level,
|
|
25
|
+
has_line_ignore(code: str, line_num: int, rule_id: str | None) -> bool for line-level,
|
|
26
|
+
should_ignore_violation(violation: Violation, file_content: str) -> bool for unified checking
|
|
27
|
+
|
|
28
|
+
Implementation: Gitignore-style pattern matching with fnmatch, first-10-lines scanning for
|
|
29
|
+
performance, regex-based directive parsing with rule ID extraction, wildcard rule matching
|
|
30
|
+
with prefix comparison, graceful error handling for malformed directives
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import fnmatch
|
|
34
|
+
import re
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import TYPE_CHECKING
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from src.core.types import Violation
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class IgnoreDirectiveParser:
|
|
43
|
+
"""Parse and check ignore directives at all 5 levels.
|
|
44
|
+
|
|
45
|
+
Provides comprehensive ignore checking for repository-level patterns,
|
|
46
|
+
file-level directives, and inline code comments.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, project_root: Path | None = None):
|
|
50
|
+
"""Initialize parser.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
project_root: Root directory of the project. Defaults to current directory.
|
|
54
|
+
"""
|
|
55
|
+
self.project_root = project_root or Path.cwd()
|
|
56
|
+
self.repo_patterns = self._load_repo_ignores()
|
|
57
|
+
|
|
58
|
+
def _load_repo_ignores(self) -> list[str]:
|
|
59
|
+
"""Load .thailintignore file patterns.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of gitignore-style patterns.
|
|
63
|
+
"""
|
|
64
|
+
ignore_file = self.project_root / ".thailintignore"
|
|
65
|
+
if not ignore_file.exists():
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
patterns = []
|
|
69
|
+
for line in ignore_file.read_text(encoding="utf-8").splitlines():
|
|
70
|
+
line = line.strip()
|
|
71
|
+
# Skip comments and blank lines
|
|
72
|
+
if line and not line.startswith("#"):
|
|
73
|
+
patterns.append(line)
|
|
74
|
+
return patterns
|
|
75
|
+
|
|
76
|
+
def is_ignored(self, file_path: Path) -> bool:
|
|
77
|
+
"""Check if file matches repository-level ignore patterns.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
file_path: Path to check against ignore patterns.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if file should be ignored.
|
|
84
|
+
"""
|
|
85
|
+
# Convert to string relative to project root if possible
|
|
86
|
+
try:
|
|
87
|
+
relative_path = file_path.relative_to(self.project_root)
|
|
88
|
+
path_str = str(relative_path)
|
|
89
|
+
except ValueError:
|
|
90
|
+
# Path is not relative to project root
|
|
91
|
+
path_str = str(file_path)
|
|
92
|
+
|
|
93
|
+
for pattern in self.repo_patterns:
|
|
94
|
+
if self._matches_pattern(path_str, pattern):
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
def _matches_pattern(self, path: str, pattern: str) -> bool:
|
|
99
|
+
"""Check if path matches gitignore-style pattern.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
path: File path to check.
|
|
103
|
+
pattern: Gitignore-style pattern.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
True if path matches pattern.
|
|
107
|
+
"""
|
|
108
|
+
# Handle directory patterns (trailing /)
|
|
109
|
+
if pattern.endswith("/"):
|
|
110
|
+
# Match directory and all its contents
|
|
111
|
+
dir_pattern = pattern.rstrip("/")
|
|
112
|
+
# Check if path starts with the directory
|
|
113
|
+
path_parts = Path(path).parts
|
|
114
|
+
if dir_pattern in path_parts:
|
|
115
|
+
return True
|
|
116
|
+
# Also check direct match
|
|
117
|
+
if fnmatch.fnmatch(path, dir_pattern + "*"):
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
# Standard fnmatch for file patterns
|
|
121
|
+
return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(str(Path(path)), pattern)
|
|
122
|
+
|
|
123
|
+
def _has_ignore_directive_marker(self, line: str) -> bool:
|
|
124
|
+
"""Check if line contains an ignore directive marker."""
|
|
125
|
+
return "# thailint: ignore-file" in line or "# design-lint: ignore-file" in line
|
|
126
|
+
|
|
127
|
+
def _check_specific_rule_ignore(self, line: str, rule_id: str) -> bool:
|
|
128
|
+
"""Check if line ignores a specific rule."""
|
|
129
|
+
match = re.search(r"ignore-file\[([^\]]+)\]", line)
|
|
130
|
+
if match:
|
|
131
|
+
ignored_rules = [r.strip() for r in match.group(1).split(",")]
|
|
132
|
+
return any(self._rule_matches(rule_id, r) for r in ignored_rules)
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def _check_general_ignore(self, line: str) -> bool:
|
|
136
|
+
"""Check if line has general ignore directive (no specific rules)."""
|
|
137
|
+
return "ignore-file[" not in line
|
|
138
|
+
|
|
139
|
+
def _read_file_first_lines(self, file_path: Path) -> list[str]:
|
|
140
|
+
"""Read first 10 lines of file, return empty list on error."""
|
|
141
|
+
if not file_path.exists():
|
|
142
|
+
return []
|
|
143
|
+
try:
|
|
144
|
+
content = file_path.read_text(encoding="utf-8")
|
|
145
|
+
return content.splitlines()[:10]
|
|
146
|
+
except (UnicodeDecodeError, OSError):
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
def _check_line_for_ignore(self, line: str, rule_id: str | None) -> bool:
|
|
150
|
+
"""Check if line has matching ignore directive."""
|
|
151
|
+
if not self._has_ignore_directive_marker(line):
|
|
152
|
+
return False
|
|
153
|
+
if rule_id:
|
|
154
|
+
return self._check_specific_rule_ignore(line, rule_id)
|
|
155
|
+
return self._check_general_ignore(line)
|
|
156
|
+
|
|
157
|
+
def has_file_ignore(self, file_path: Path, rule_id: str | None = None) -> bool:
|
|
158
|
+
"""Check for file-level ignore directive.
|
|
159
|
+
|
|
160
|
+
Scans the first 10 lines of the file for ignore directives.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
file_path: Path to file to check.
|
|
164
|
+
rule_id: Optional specific rule ID to check for.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
True if file has ignore directive (general or for specific rule).
|
|
168
|
+
"""
|
|
169
|
+
first_lines = self._read_file_first_lines(file_path)
|
|
170
|
+
return any(self._check_line_for_ignore(line, rule_id) for line in first_lines)
|
|
171
|
+
|
|
172
|
+
def _has_line_ignore_marker(self, code: str) -> bool:
|
|
173
|
+
"""Check if code line has ignore marker."""
|
|
174
|
+
return (
|
|
175
|
+
"# thailint: ignore" in code
|
|
176
|
+
or "# design-lint: ignore" in code
|
|
177
|
+
or "// thailint: ignore" in code
|
|
178
|
+
or "// design-lint: ignore" in code
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _check_specific_rule_in_line(self, code: str, rule_id: str) -> bool:
|
|
182
|
+
"""Check if line's ignore directive matches specific rule."""
|
|
183
|
+
# Check for bracket syntax: # thailint: ignore[rule1, rule2]
|
|
184
|
+
bracket_match = re.search(r"ignore\[([^\]]+)\]", code)
|
|
185
|
+
if bracket_match:
|
|
186
|
+
return self._check_bracket_rules(bracket_match.group(1), rule_id)
|
|
187
|
+
|
|
188
|
+
# Check for space-separated syntax: # thailint: ignore rule1 rule2
|
|
189
|
+
space_match = re.search(r"ignore\s+([^\s#]+(?:\s+[^\s#]+)*)", code)
|
|
190
|
+
if space_match:
|
|
191
|
+
return self._check_space_separated_rules(space_match.group(1), rule_id)
|
|
192
|
+
|
|
193
|
+
# No specific rules - check for "ignore-all"
|
|
194
|
+
return "ignore-all" in code
|
|
195
|
+
|
|
196
|
+
def _check_bracket_rules(self, rules_text: str, rule_id: str) -> bool:
|
|
197
|
+
"""Check if bracketed rules match the rule ID."""
|
|
198
|
+
ignored_rules = [r.strip() for r in rules_text.split(",")]
|
|
199
|
+
return any(self._rule_matches(rule_id, r) for r in ignored_rules)
|
|
200
|
+
|
|
201
|
+
def _check_space_separated_rules(self, rules_text: str, rule_id: str) -> bool:
|
|
202
|
+
"""Check if space-separated rules match the rule ID."""
|
|
203
|
+
ignored_rules = [r.strip() for r in re.split(r"[,\s]+", rules_text) if r.strip()]
|
|
204
|
+
return any(self._rule_matches(rule_id, r) for r in ignored_rules)
|
|
205
|
+
|
|
206
|
+
def has_line_ignore(self, code: str, line_num: int, rule_id: str | None = None) -> bool:
|
|
207
|
+
"""Check for line-level ignore directive.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
code: Line of code to check.
|
|
211
|
+
line_num: Line number (currently unused, for API compatibility).
|
|
212
|
+
rule_id: Optional specific rule ID to check for.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if line has ignore directive.
|
|
216
|
+
"""
|
|
217
|
+
if not self._has_line_ignore_marker(code):
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
if rule_id:
|
|
221
|
+
return self._check_specific_rule_in_line(code, rule_id)
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
def _rule_matches(self, rule_id: str, pattern: str) -> bool:
|
|
225
|
+
"""Check if rule ID matches pattern (supports wildcards and prefixes).
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
rule_id: Rule ID to check (e.g., "nesting.excessive-depth").
|
|
229
|
+
pattern: Pattern with optional wildcard (e.g., "nesting.*" or "nesting").
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
True if rule matches pattern.
|
|
233
|
+
"""
|
|
234
|
+
if pattern.endswith("*"):
|
|
235
|
+
# Wildcard match: literals.* matches literals.magic-number
|
|
236
|
+
prefix = pattern[:-1]
|
|
237
|
+
return rule_id.startswith(prefix)
|
|
238
|
+
|
|
239
|
+
# Exact match
|
|
240
|
+
if rule_id == pattern:
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
# Prefix match: "nesting" matches "nesting.excessive-depth"
|
|
244
|
+
if rule_id.startswith(pattern + "."):
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
def _has_ignore_next_line_marker(self, prev_line: str) -> bool:
|
|
250
|
+
"""Check if line has ignore-next-line marker."""
|
|
251
|
+
return (
|
|
252
|
+
"# thailint: ignore-next-line" in prev_line
|
|
253
|
+
or "# design-lint: ignore-next-line" in prev_line
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _matches_ignore_next_line_rules(self, prev_line: str, rule_id: str) -> bool:
|
|
257
|
+
"""Check if ignore-next-line directive matches the rule."""
|
|
258
|
+
match = re.search(r"ignore-next-line\[([^\]]+)\]", prev_line)
|
|
259
|
+
if match:
|
|
260
|
+
ignored_rules = [r.strip() for r in match.group(1).split(",")]
|
|
261
|
+
return any(self._rule_matches(rule_id, r) for r in ignored_rules)
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
def _is_valid_prev_line_index(self, lines: list[str], violation: "Violation") -> bool:
|
|
265
|
+
"""Check if previous line index is valid."""
|
|
266
|
+
if violation.line <= 1 or violation.line > len(lines) + 1:
|
|
267
|
+
return False
|
|
268
|
+
prev_line_idx = violation.line - 2
|
|
269
|
+
return 0 <= prev_line_idx < len(lines)
|
|
270
|
+
|
|
271
|
+
def _check_prev_line_ignore(self, lines: list[str], violation: "Violation") -> bool:
|
|
272
|
+
"""Check if previous line has ignore-next-line directive."""
|
|
273
|
+
if not self._is_valid_prev_line_index(lines, violation):
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
prev_line_idx = violation.line - 2
|
|
277
|
+
prev_line = lines[prev_line_idx]
|
|
278
|
+
if not self._has_ignore_next_line_marker(prev_line):
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
return self._matches_ignore_next_line_rules(prev_line, violation.rule_id)
|
|
282
|
+
|
|
283
|
+
def _check_current_line_ignore(self, lines: list[str], violation: "Violation") -> bool:
|
|
284
|
+
"""Check if current line has inline ignore directive."""
|
|
285
|
+
if violation.line <= 0 or violation.line > len(lines):
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
current_line = lines[violation.line - 1] # Convert to 0-indexed
|
|
289
|
+
return self.has_line_ignore(current_line, violation.line, violation.rule_id)
|
|
290
|
+
|
|
291
|
+
def should_ignore_violation(self, violation: "Violation", file_content: str) -> bool:
|
|
292
|
+
"""Check if a violation should be ignored based on all levels."""
|
|
293
|
+
file_path = Path(violation.file_path)
|
|
294
|
+
|
|
295
|
+
# Repository and file level checks
|
|
296
|
+
if self._is_ignored_at_file_level(file_path, violation.rule_id):
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
# Line-based checks
|
|
300
|
+
return self._is_ignored_in_content(file_content, violation)
|
|
301
|
+
|
|
302
|
+
def _is_ignored_at_file_level(self, file_path: Path, rule_id: str) -> bool:
|
|
303
|
+
"""Check repository and file level ignores."""
|
|
304
|
+
if self.is_ignored(file_path):
|
|
305
|
+
return True
|
|
306
|
+
return self.has_file_ignore(file_path, rule_id)
|
|
307
|
+
|
|
308
|
+
def _is_ignored_in_content(self, file_content: str, violation: "Violation") -> bool:
|
|
309
|
+
"""Check content-based ignores (block, line, method level)."""
|
|
310
|
+
lines = file_content.splitlines()
|
|
311
|
+
|
|
312
|
+
if self._check_block_ignore(lines, violation):
|
|
313
|
+
return True
|
|
314
|
+
if self._check_prev_line_ignore(lines, violation):
|
|
315
|
+
return True
|
|
316
|
+
if self._check_current_line_ignore(lines, violation):
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
def _check_block_ignore(self, lines: list[str], violation: "Violation") -> bool:
|
|
322
|
+
"""Check if violation is within an ignore-start/ignore-end block."""
|
|
323
|
+
if violation.line <= 0 or violation.line > len(lines):
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
block_state = {"in_block": False, "rules": set()}
|
|
327
|
+
|
|
328
|
+
for i, line in enumerate(lines):
|
|
329
|
+
if self._process_block_line(line, i + 1, violation, block_state):
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
def _process_block_line(
|
|
335
|
+
self, line: str, line_num: int, violation: "Violation", block_state: dict
|
|
336
|
+
) -> bool:
|
|
337
|
+
"""Process a single line for block ignore checking."""
|
|
338
|
+
if "ignore-start" in line:
|
|
339
|
+
block_state["rules"] = self._parse_ignore_start_rules(line)
|
|
340
|
+
block_state["in_block"] = True
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
if self._is_block_end_matching(
|
|
344
|
+
line, block_state["in_block"], line_num, violation, block_state["rules"]
|
|
345
|
+
):
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
if self._is_violation_line_ignored(
|
|
349
|
+
line_num, violation, block_state["in_block"], block_state["rules"]
|
|
350
|
+
):
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
if "ignore-end" in line:
|
|
354
|
+
block_state["in_block"] = False
|
|
355
|
+
block_state["rules"] = set()
|
|
356
|
+
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
def _is_block_end_matching( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
360
|
+
self,
|
|
361
|
+
line: str,
|
|
362
|
+
in_ignore_block: bool,
|
|
363
|
+
line_num: int,
|
|
364
|
+
violation: "Violation",
|
|
365
|
+
current_ignored_rules: set[str],
|
|
366
|
+
) -> bool:
|
|
367
|
+
"""Check if ignore-end matches and violation was in the block."""
|
|
368
|
+
if "ignore-end" not in line:
|
|
369
|
+
return False
|
|
370
|
+
if not in_ignore_block or line_num <= violation.line:
|
|
371
|
+
return False
|
|
372
|
+
return self._rules_match_violation(current_ignored_rules, violation.rule_id)
|
|
373
|
+
|
|
374
|
+
def _is_violation_line_ignored(
|
|
375
|
+
self,
|
|
376
|
+
line_num: int,
|
|
377
|
+
violation: "Violation",
|
|
378
|
+
in_ignore_block: bool,
|
|
379
|
+
current_ignored_rules: set[str],
|
|
380
|
+
) -> bool:
|
|
381
|
+
"""Check if current line is the violation line in an ignore block."""
|
|
382
|
+
if line_num != violation.line or not in_ignore_block:
|
|
383
|
+
return False
|
|
384
|
+
return self._rules_match_violation(current_ignored_rules, violation.rule_id)
|
|
385
|
+
|
|
386
|
+
def _parse_ignore_start_rules(self, line: str) -> set[str]:
|
|
387
|
+
"""Extract rule names from ignore-start directive."""
|
|
388
|
+
match = re.search(r"ignore-start\s+([^\s#]+(?:\s+[^\s#]+)*)", line)
|
|
389
|
+
if match:
|
|
390
|
+
rules_text = match.group(1).strip()
|
|
391
|
+
rules = [r.strip() for r in re.split(r"[,\s]+", rules_text) if r.strip()]
|
|
392
|
+
return set(rules)
|
|
393
|
+
return {"*"}
|
|
394
|
+
|
|
395
|
+
def _rules_match_violation(self, ignored_rules: set[str], rule_id: str) -> bool:
|
|
396
|
+
"""Check if any of the ignored rules match the violation rule ID."""
|
|
397
|
+
if "*" in ignored_rules:
|
|
398
|
+
return True
|
|
399
|
+
return any(self._rule_matches(rule_id, pattern) for pattern in ignored_rules)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# Alias for backwards compatibility
|
|
403
|
+
IgnoreParser = IgnoreDirectiveParser
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Multi-format configuration loader for linter settings and rule configuration
|
|
3
|
+
|
|
4
|
+
Scope: Linter configuration management supporting YAML and JSON formats
|
|
5
|
+
|
|
6
|
+
Overview: Loads linter configuration from .thailint.yaml or .thailint.json files with graceful
|
|
7
|
+
fallback to sensible defaults when config files don't exist or cannot be read. Supports both
|
|
8
|
+
YAML and JSON formats to accommodate different user preferences and tooling requirements,
|
|
9
|
+
using format detection based on file extension. Provides unified configuration structure for
|
|
10
|
+
rule settings, ignore patterns, and linter behavior across all deployment modes (CLI, library,
|
|
11
|
+
Docker). Returns empty defaults (empty rules dict, empty ignore list) when config files are
|
|
12
|
+
missing, allowing the linter to function without configuration. Validates file formats and
|
|
13
|
+
raises clear errors with specific exception types for malformed YAML or JSON, helping users
|
|
14
|
+
quickly identify and fix configuration syntax issues.
|
|
15
|
+
|
|
16
|
+
Dependencies: PyYAML for YAML parsing with safe_load(), json (stdlib) for JSON parsing,
|
|
17
|
+
pathlib for file path handling
|
|
18
|
+
|
|
19
|
+
Exports: LinterConfigLoader class
|
|
20
|
+
|
|
21
|
+
Interfaces: load(config_path: Path) -> dict[str, Any] for loading config files,
|
|
22
|
+
get_defaults() -> dict[str, Any] for default configuration structure
|
|
23
|
+
|
|
24
|
+
Implementation: Extension-based format detection (.yaml/.yml vs .json), yaml.safe_load()
|
|
25
|
+
for security, empty dict handling for null YAML, ValueError for unsupported formats
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LinterConfigLoader:
|
|
36
|
+
"""Load linter configuration from YAML or JSON files.
|
|
37
|
+
|
|
38
|
+
Supports loading from .thailint.yaml, .thailint.json, or custom paths.
|
|
39
|
+
Provides sensible defaults when config files don't exist.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def load(self, config_path: Path) -> dict[str, Any]:
|
|
43
|
+
"""Load configuration from file.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
config_path: Path to YAML or JSON config file.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Configuration dictionary.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If file format is unsupported.
|
|
53
|
+
yaml.YAMLError: If YAML is malformed.
|
|
54
|
+
json.JSONDecodeError: If JSON is malformed.
|
|
55
|
+
"""
|
|
56
|
+
if not config_path.exists():
|
|
57
|
+
return self.get_defaults()
|
|
58
|
+
|
|
59
|
+
suffix = config_path.suffix.lower()
|
|
60
|
+
|
|
61
|
+
with config_path.open(encoding="utf-8") as f:
|
|
62
|
+
if suffix in [".yaml", ".yml"]:
|
|
63
|
+
return yaml.safe_load(f) or {}
|
|
64
|
+
if suffix == ".json":
|
|
65
|
+
return json.load(f)
|
|
66
|
+
raise ValueError(f"Unsupported config format: {suffix}")
|
|
67
|
+
|
|
68
|
+
def get_defaults(self) -> dict[str, Any]:
|
|
69
|
+
"""Get default configuration.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Default configuration with empty rules and ignore lists.
|
|
73
|
+
"""
|
|
74
|
+
return {
|
|
75
|
+
"rules": {},
|
|
76
|
+
"ignore": [],
|
|
77
|
+
}
|
src/linters/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: File placement linter module
|
|
3
|
+
Scope: File organization and placement validation
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .linter import FilePlacementLinter, FilePlacementRule
|
|
10
|
+
|
|
11
|
+
__all__ = ["FilePlacementLinter", "FilePlacementRule", "lint"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def lint(path: Path | str, config: dict[str, Any] | None = None) -> list:
|
|
15
|
+
"""Lint a file or directory using file placement rules.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
path: Path to file or directory to lint
|
|
19
|
+
config: Configuration dict (compatible with FilePlacementLinter)
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
List of violations
|
|
23
|
+
"""
|
|
24
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
25
|
+
linter = FilePlacementLinter(config_obj=config or {})
|
|
26
|
+
|
|
27
|
+
if path_obj.is_file():
|
|
28
|
+
return linter.lint_path(path_obj)
|
|
29
|
+
if path_obj.is_dir():
|
|
30
|
+
return linter.lint_directory(path_obj)
|
|
31
|
+
return []
|