thailint 0.12.0__py3-none-any.whl → 0.13.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/analyzers/__init__.py +4 -3
- src/analyzers/ast_utils.py +54 -0
- src/analyzers/typescript_base.py +4 -0
- src/cli/__init__.py +3 -0
- src/cli/config.py +12 -12
- src/cli/config_merge.py +241 -0
- src/cli/linters/__init__.py +3 -0
- src/cli/linters/code_patterns.py +113 -5
- src/cli/linters/code_smells.py +4 -0
- src/cli/linters/documentation.py +3 -0
- src/cli/linters/structure.py +3 -0
- src/cli/linters/structure_quality.py +3 -0
- src/cli_main.py +3 -0
- src/config.py +2 -1
- src/core/base.py +3 -2
- src/core/cli_utils.py +3 -1
- src/core/config_parser.py +5 -2
- src/core/constants.py +54 -0
- src/core/linter_utils.py +4 -0
- src/core/rule_discovery.py +5 -1
- src/core/violation_builder.py +3 -0
- src/linter_config/directive_markers.py +109 -0
- src/linter_config/ignore.py +225 -383
- src/linter_config/pattern_utils.py +65 -0
- src/linter_config/rule_matcher.py +89 -0
- src/linters/collection_pipeline/any_all_analyzer.py +281 -0
- src/linters/collection_pipeline/ast_utils.py +40 -0
- src/linters/collection_pipeline/config.py +12 -0
- src/linters/collection_pipeline/continue_analyzer.py +2 -8
- src/linters/collection_pipeline/detector.py +262 -32
- src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
- src/linters/collection_pipeline/linter.py +18 -35
- src/linters/collection_pipeline/suggestion_builder.py +68 -1
- src/linters/dry/base_token_analyzer.py +16 -9
- src/linters/dry/block_filter.py +7 -4
- src/linters/dry/cache.py +7 -2
- src/linters/dry/config.py +7 -1
- src/linters/dry/constant_matcher.py +34 -25
- src/linters/dry/file_analyzer.py +4 -2
- src/linters/dry/inline_ignore.py +7 -16
- src/linters/dry/linter.py +48 -25
- src/linters/dry/python_analyzer.py +18 -10
- src/linters/dry/python_constant_extractor.py +51 -52
- src/linters/dry/single_statement_detector.py +14 -12
- src/linters/dry/token_hasher.py +115 -115
- src/linters/dry/typescript_analyzer.py +11 -6
- src/linters/dry/typescript_constant_extractor.py +4 -0
- src/linters/dry/typescript_statement_detector.py +208 -208
- src/linters/dry/typescript_value_extractor.py +3 -0
- src/linters/dry/violation_filter.py +1 -4
- src/linters/dry/violation_generator.py +1 -4
- src/linters/file_header/atemporal_detector.py +4 -0
- src/linters/file_header/base_parser.py +4 -0
- src/linters/file_header/bash_parser.py +4 -0
- src/linters/file_header/field_validator.py +5 -8
- src/linters/file_header/linter.py +19 -12
- src/linters/file_header/markdown_parser.py +6 -0
- src/linters/file_placement/config_loader.py +3 -1
- src/linters/file_placement/linter.py +22 -8
- src/linters/file_placement/pattern_matcher.py +21 -4
- src/linters/file_placement/pattern_validator.py +21 -7
- src/linters/file_placement/rule_checker.py +2 -2
- src/linters/lazy_ignores/__init__.py +43 -0
- src/linters/lazy_ignores/config.py +66 -0
- src/linters/lazy_ignores/directive_utils.py +121 -0
- src/linters/lazy_ignores/header_parser.py +177 -0
- src/linters/lazy_ignores/linter.py +158 -0
- src/linters/lazy_ignores/matcher.py +135 -0
- src/linters/lazy_ignores/python_analyzer.py +201 -0
- src/linters/lazy_ignores/rule_id_utils.py +180 -0
- src/linters/lazy_ignores/skip_detector.py +298 -0
- src/linters/lazy_ignores/types.py +67 -0
- src/linters/lazy_ignores/typescript_analyzer.py +146 -0
- src/linters/lazy_ignores/violation_builder.py +131 -0
- src/linters/lbyl/__init__.py +29 -0
- src/linters/lbyl/config.py +63 -0
- src/linters/lbyl/pattern_detectors/__init__.py +25 -0
- src/linters/lbyl/pattern_detectors/base.py +46 -0
- src/linters/magic_numbers/context_analyzer.py +227 -229
- src/linters/magic_numbers/linter.py +20 -15
- src/linters/magic_numbers/python_analyzer.py +4 -16
- src/linters/magic_numbers/typescript_analyzer.py +9 -16
- src/linters/method_property/config.py +4 -0
- src/linters/method_property/linter.py +5 -4
- src/linters/method_property/python_analyzer.py +5 -4
- src/linters/method_property/violation_builder.py +3 -0
- src/linters/nesting/typescript_analyzer.py +6 -12
- src/linters/nesting/typescript_function_extractor.py +0 -4
- src/linters/print_statements/linter.py +6 -4
- src/linters/print_statements/python_analyzer.py +85 -81
- src/linters/print_statements/typescript_analyzer.py +6 -15
- src/linters/srp/heuristics.py +4 -4
- src/linters/srp/linter.py +12 -12
- src/linters/srp/violation_builder.py +0 -4
- src/linters/stateless_class/linter.py +30 -36
- src/linters/stateless_class/python_analyzer.py +11 -20
- src/linters/stringly_typed/config.py +4 -5
- src/linters/stringly_typed/context_filter.py +410 -410
- src/linters/stringly_typed/function_call_violation_builder.py +93 -95
- src/linters/stringly_typed/linter.py +48 -16
- src/linters/stringly_typed/python/analyzer.py +5 -1
- src/linters/stringly_typed/python/call_tracker.py +8 -5
- src/linters/stringly_typed/python/comparison_tracker.py +10 -5
- src/linters/stringly_typed/python/condition_extractor.py +3 -0
- src/linters/stringly_typed/python/conditional_detector.py +4 -1
- src/linters/stringly_typed/python/match_analyzer.py +8 -2
- src/linters/stringly_typed/python/validation_detector.py +3 -0
- src/linters/stringly_typed/storage.py +14 -14
- src/linters/stringly_typed/typescript/call_tracker.py +9 -3
- src/linters/stringly_typed/typescript/comparison_tracker.py +9 -3
- src/linters/stringly_typed/violation_generator.py +288 -259
- src/orchestrator/core.py +13 -4
- src/templates/thailint_config_template.yaml +166 -0
- src/utils/project_root.py +3 -0
- thailint-0.13.0.dist-info/METADATA +184 -0
- thailint-0.13.0.dist-info/RECORD +189 -0
- thailint-0.12.0.dist-info/METADATA +0 -1667
- thailint-0.12.0.dist-info/RECORD +0 -164
- {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/WHEEL +0 -0
- {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/entry_points.txt +0 -0
- {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/licenses/LICENSE +0 -0
src/linter_config/ignore.py
CHANGED
|
@@ -11,465 +11,307 @@ Overview: Implements a sophisticated ignore directive system that allows develop
|
|
|
11
11
|
Method level supports ignore-next-line directives placed before functions. Line level enables
|
|
12
12
|
inline ignore comments at the end of code lines. All levels support rule-specific ignores
|
|
13
13
|
using bracket syntax [rule-id] and wildcard rule matching (literals.* matches literals.magic-number).
|
|
14
|
-
The 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
14
|
|
|
18
|
-
Dependencies:
|
|
19
|
-
pathlib for file operations, Violation type for violation checking, yaml for config loading
|
|
15
|
+
Dependencies: pathlib, yaml, rule_matcher module, directive_markers module, pattern_utils module
|
|
20
16
|
|
|
21
|
-
Exports: IgnoreDirectiveParser class
|
|
17
|
+
Exports: IgnoreDirectiveParser class, get_ignore_parser, clear_ignore_parser_cache
|
|
22
18
|
|
|
23
|
-
Interfaces: is_ignored(file_path
|
|
24
|
-
|
|
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
|
|
19
|
+
Interfaces: is_ignored(file_path) -> bool, has_file_ignore(file_path, rule_id) -> bool,
|
|
20
|
+
has_line_ignore(code, line_num, rule_id) -> bool, should_ignore_violation(violation, content) -> bool
|
|
27
21
|
|
|
28
|
-
Implementation:
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
Implementation: Modular design with extracted pure functions for pattern matching and marker detection
|
|
23
|
+
|
|
24
|
+
Suppressions:
|
|
25
|
+
- global-statement: Module-level singleton pattern for parser caching (performance optimization)
|
|
31
26
|
"""
|
|
32
27
|
|
|
33
|
-
import
|
|
28
|
+
import logging
|
|
34
29
|
import re
|
|
35
30
|
from pathlib import Path
|
|
36
31
|
from typing import TYPE_CHECKING
|
|
37
32
|
|
|
38
33
|
import yaml
|
|
39
34
|
|
|
35
|
+
from src.core.constants import HEADER_SCAN_LINES
|
|
36
|
+
from src.linter_config.directive_markers import (
|
|
37
|
+
check_general_ignore,
|
|
38
|
+
has_ignore_directive_marker,
|
|
39
|
+
has_ignore_end_marker,
|
|
40
|
+
has_ignore_next_line_marker,
|
|
41
|
+
has_ignore_start_marker,
|
|
42
|
+
has_line_ignore_marker,
|
|
43
|
+
)
|
|
44
|
+
from src.linter_config.pattern_utils import extract_patterns_from_content, matches_pattern
|
|
45
|
+
from src.linter_config.rule_matcher import (
|
|
46
|
+
check_bracket_rules,
|
|
47
|
+
check_space_separated_rules,
|
|
48
|
+
rules_match_violation,
|
|
49
|
+
)
|
|
50
|
+
|
|
40
51
|
if TYPE_CHECKING:
|
|
41
52
|
from src.core.types import Violation
|
|
42
53
|
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
43
55
|
|
|
44
|
-
class IgnoreDirectiveParser:
|
|
45
|
-
"""Parse and check ignore directives at all 5 levels.
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"""
|
|
57
|
+
class IgnoreDirectiveParser:
|
|
58
|
+
"""Parse and check ignore directives at all 5 levels."""
|
|
50
59
|
|
|
51
60
|
def __init__(self, project_root: Path | None = None):
|
|
52
|
-
"""Initialize parser.
|
|
53
|
-
|
|
54
|
-
Args:
|
|
55
|
-
project_root: Root directory of the project. Defaults to current directory.
|
|
56
|
-
"""
|
|
61
|
+
"""Initialize parser with project root directory."""
|
|
57
62
|
self.project_root = project_root or Path.cwd()
|
|
58
|
-
self.repo_patterns = self.
|
|
59
|
-
self._ignore_cache: dict[str, bool] = {}
|
|
60
|
-
|
|
61
|
-
def _load_repo_ignores(self) -> list[str]:
|
|
62
|
-
"""Load global ignore patterns from .thailintignore or .thailint.yaml."""
|
|
63
|
-
# First, try to load from .thailintignore (gitignore-style)
|
|
64
|
-
thailintignore = self.project_root / ".thailintignore"
|
|
65
|
-
if thailintignore.exists():
|
|
66
|
-
return self._parse_thailintignore_file(thailintignore)
|
|
67
|
-
|
|
68
|
-
# Fall back to .thailint.yaml
|
|
69
|
-
config_file = self.project_root / ".thailint.yaml"
|
|
70
|
-
if config_file.exists():
|
|
71
|
-
return self._parse_config_file(config_file)
|
|
72
|
-
|
|
73
|
-
return []
|
|
74
|
-
|
|
75
|
-
def _parse_thailintignore_file(self, ignore_file: Path) -> list[str]:
|
|
76
|
-
"""Parse .thailintignore file (gitignore-style).
|
|
77
|
-
|
|
78
|
-
Args:
|
|
79
|
-
ignore_file: Path to .thailintignore file
|
|
80
|
-
|
|
81
|
-
Returns:
|
|
82
|
-
List of ignore patterns
|
|
83
|
-
"""
|
|
84
|
-
try:
|
|
85
|
-
content = ignore_file.read_text(encoding="utf-8")
|
|
86
|
-
patterns = []
|
|
87
|
-
for line in content.splitlines():
|
|
88
|
-
line = line.strip()
|
|
89
|
-
# Skip empty lines and comments
|
|
90
|
-
if line and not line.startswith("#"):
|
|
91
|
-
patterns.append(line)
|
|
92
|
-
return patterns
|
|
93
|
-
except (OSError, UnicodeDecodeError):
|
|
94
|
-
return []
|
|
95
|
-
|
|
96
|
-
def _parse_config_file(self, config_file: Path) -> list[str]:
|
|
97
|
-
"""Parse YAML config file and extract ignore patterns."""
|
|
98
|
-
try:
|
|
99
|
-
config = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
|
100
|
-
return self._extract_ignore_patterns(config)
|
|
101
|
-
except (yaml.YAMLError, OSError, UnicodeDecodeError):
|
|
102
|
-
return []
|
|
103
|
-
|
|
104
|
-
@staticmethod
|
|
105
|
-
def _extract_ignore_patterns(config: dict | None) -> list[str]:
|
|
106
|
-
"""Extract ignore patterns from config dict."""
|
|
107
|
-
if not config or not isinstance(config, dict):
|
|
108
|
-
return []
|
|
109
|
-
|
|
110
|
-
ignore_patterns = config.get("ignore", [])
|
|
111
|
-
if isinstance(ignore_patterns, list):
|
|
112
|
-
return [str(pattern) for pattern in ignore_patterns]
|
|
113
|
-
return []
|
|
63
|
+
self.repo_patterns = _load_repo_ignores(self.project_root)
|
|
64
|
+
self._ignore_cache: dict[str, bool] = {}
|
|
114
65
|
|
|
115
66
|
def is_ignored(self, file_path: Path) -> bool:
|
|
116
67
|
"""Check if file matches repository-level ignore patterns (cached)."""
|
|
117
68
|
path_str = str(file_path)
|
|
118
69
|
if path_str in self._ignore_cache:
|
|
119
70
|
return self._ignore_cache[path_str]
|
|
120
|
-
|
|
121
|
-
# Convert to relative path for pattern matching
|
|
122
71
|
try:
|
|
123
72
|
check_path = str(file_path.relative_to(self.project_root))
|
|
124
73
|
except ValueError:
|
|
125
74
|
check_path = path_str
|
|
126
|
-
|
|
127
|
-
result = any(self._matches_pattern(check_path, p) for p in self.repo_patterns)
|
|
75
|
+
result = any(matches_pattern(check_path, p) for p in self.repo_patterns)
|
|
128
76
|
self._ignore_cache[path_str] = result
|
|
129
77
|
return result
|
|
130
78
|
|
|
131
|
-
def _matches_pattern(self, path: str, pattern: str) -> bool:
|
|
132
|
-
"""Check if path matches gitignore-style pattern.
|
|
133
|
-
|
|
134
|
-
Args:
|
|
135
|
-
path: File path to check.
|
|
136
|
-
pattern: Gitignore-style pattern.
|
|
137
|
-
|
|
138
|
-
Returns:
|
|
139
|
-
True if path matches pattern.
|
|
140
|
-
"""
|
|
141
|
-
# Handle directory patterns (trailing /)
|
|
142
|
-
if pattern.endswith("/"):
|
|
143
|
-
# Match directory and all its contents
|
|
144
|
-
dir_pattern = pattern.rstrip("/")
|
|
145
|
-
# Check if path starts with the directory
|
|
146
|
-
path_parts = Path(path).parts
|
|
147
|
-
if dir_pattern in path_parts:
|
|
148
|
-
return True
|
|
149
|
-
# Also check direct match
|
|
150
|
-
if fnmatch.fnmatch(path, dir_pattern + "*"):
|
|
151
|
-
return True
|
|
152
|
-
|
|
153
|
-
# Standard fnmatch for file patterns
|
|
154
|
-
return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(str(Path(path)), pattern)
|
|
155
|
-
|
|
156
|
-
def _has_ignore_directive_marker(self, line: str) -> bool:
|
|
157
|
-
"""Check if line contains an ignore directive marker."""
|
|
158
|
-
line_lower = line.lower()
|
|
159
|
-
return "# thailint: ignore-file" in line_lower or "# design-lint: ignore-file" in line_lower
|
|
160
|
-
|
|
161
|
-
def _check_specific_rule_ignore(self, line: str, rule_id: str) -> bool:
|
|
162
|
-
"""Check if line ignores a specific rule."""
|
|
163
|
-
# Check for bracket syntax: # thailint: ignore-file[rule1, rule2]
|
|
164
|
-
if self._check_bracket_syntax_file_ignore(line, rule_id):
|
|
165
|
-
return True
|
|
166
|
-
|
|
167
|
-
# Check for space-separated syntax: # thailint: ignore-file rule1 rule2
|
|
168
|
-
return self._check_space_syntax_file_ignore(line, rule_id)
|
|
169
|
-
|
|
170
|
-
def _check_bracket_syntax_file_ignore(self, line: str, rule_id: str) -> bool:
|
|
171
|
-
"""Check bracket syntax for file-level ignore."""
|
|
172
|
-
bracket_match = re.search(r"ignore-file\[([^\]]+)\]", line, re.IGNORECASE)
|
|
173
|
-
if bracket_match:
|
|
174
|
-
ignored_rules = [r.strip() for r in bracket_match.group(1).split(",")]
|
|
175
|
-
return any(self._rule_matches(rule_id, r) for r in ignored_rules)
|
|
176
|
-
return False
|
|
177
|
-
|
|
178
|
-
def _check_space_syntax_file_ignore(self, line: str, rule_id: str) -> bool:
|
|
179
|
-
"""Check space-separated syntax for file-level ignore."""
|
|
180
|
-
space_match = re.search(r"ignore-file\s+([^\s#]+(?:\s+[^\s#]+)*)", line, re.IGNORECASE)
|
|
181
|
-
if space_match:
|
|
182
|
-
ignored_rules = [
|
|
183
|
-
r.strip() for r in re.split(r"[,\s]+", space_match.group(1)) if r.strip()
|
|
184
|
-
]
|
|
185
|
-
return any(self._rule_matches(rule_id, r) for r in ignored_rules)
|
|
186
|
-
return False
|
|
187
|
-
|
|
188
|
-
def _check_general_ignore(self, line: str) -> bool:
|
|
189
|
-
"""Check if line has general ignore directive (no specific rules)."""
|
|
190
|
-
return "ignore-file[" not in line
|
|
191
|
-
|
|
192
|
-
def _read_file_first_lines(self, file_path: Path) -> list[str]:
|
|
193
|
-
"""Read first 10 lines of file, return empty list on error."""
|
|
194
|
-
if not file_path.exists():
|
|
195
|
-
return []
|
|
196
|
-
try:
|
|
197
|
-
content = file_path.read_text(encoding="utf-8")
|
|
198
|
-
return content.splitlines()[:10]
|
|
199
|
-
except (UnicodeDecodeError, OSError):
|
|
200
|
-
return []
|
|
201
|
-
|
|
202
|
-
def _check_line_for_ignore(self, line: str, rule_id: str | None) -> bool:
|
|
203
|
-
"""Check if line has matching ignore directive."""
|
|
204
|
-
if not self._has_ignore_directive_marker(line):
|
|
205
|
-
return False
|
|
206
|
-
if rule_id:
|
|
207
|
-
return self._check_specific_rule_ignore(line, rule_id)
|
|
208
|
-
return self._check_general_ignore(line)
|
|
209
|
-
|
|
210
79
|
def has_file_ignore(self, file_path: Path, rule_id: str | None = None) -> bool:
|
|
211
|
-
"""Check for file-level ignore directive.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
Args:
|
|
216
|
-
file_path: Path to file to check.
|
|
217
|
-
rule_id: Optional specific rule ID to check for.
|
|
218
|
-
|
|
219
|
-
Returns:
|
|
220
|
-
True if file has ignore directive (general or for specific rule).
|
|
221
|
-
"""
|
|
222
|
-
first_lines = self._read_file_first_lines(file_path)
|
|
223
|
-
return any(self._check_line_for_ignore(line, rule_id) for line in first_lines)
|
|
224
|
-
|
|
225
|
-
def _has_line_ignore_marker(self, code: str) -> bool:
|
|
226
|
-
"""Check if code line has ignore marker."""
|
|
227
|
-
code_lower = code.lower()
|
|
228
|
-
return (
|
|
229
|
-
"# thailint: ignore" in code_lower
|
|
230
|
-
or "# design-lint: ignore" in code_lower
|
|
231
|
-
or "// thailint: ignore" in code_lower
|
|
232
|
-
or "// design-lint: ignore" in code_lower
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
def _check_specific_rule_in_line(self, code: str, rule_id: str) -> bool:
|
|
236
|
-
"""Check if line's ignore directive matches specific rule."""
|
|
237
|
-
# Check for bracket syntax: # thailint: ignore[rule1, rule2]
|
|
238
|
-
bracket_match = re.search(r"ignore\[([^\]]+)\]", code, re.IGNORECASE)
|
|
239
|
-
if bracket_match:
|
|
240
|
-
return self._check_bracket_rules(bracket_match.group(1), rule_id)
|
|
241
|
-
|
|
242
|
-
# Check for space-separated syntax: # thailint: ignore rule1 rule2
|
|
243
|
-
space_match = re.search(r"ignore\s+([^\s#]+(?:\s+[^\s#]+)*)", code, re.IGNORECASE)
|
|
244
|
-
if space_match:
|
|
245
|
-
return self._check_space_separated_rules(space_match.group(1), rule_id)
|
|
246
|
-
|
|
247
|
-
# No specific rules - check for "ignore-all"
|
|
248
|
-
return "ignore-all" in code.lower()
|
|
249
|
-
|
|
250
|
-
def _check_bracket_rules(self, rules_text: str, rule_id: str) -> bool:
|
|
251
|
-
"""Check if bracketed rules match the rule ID."""
|
|
252
|
-
ignored_rules = [r.strip() for r in rules_text.split(",")]
|
|
253
|
-
return any(self._rule_matches(rule_id, r) for r in ignored_rules)
|
|
254
|
-
|
|
255
|
-
def _check_space_separated_rules(self, rules_text: str, rule_id: str) -> bool:
|
|
256
|
-
"""Check if space-separated rules match the rule ID."""
|
|
257
|
-
ignored_rules = [r.strip() for r in re.split(r"[,\s]+", rules_text) if r.strip()]
|
|
258
|
-
return any(self._rule_matches(rule_id, r) for r in ignored_rules)
|
|
80
|
+
"""Check for file-level ignore directive in first 10 lines."""
|
|
81
|
+
first_lines = _read_file_first_lines(file_path)
|
|
82
|
+
return any(_check_line_for_ignore(line, rule_id) for line in first_lines)
|
|
259
83
|
|
|
260
84
|
def has_line_ignore(self, code: str, line_num: int, rule_id: str | None = None) -> bool:
|
|
261
|
-
"""Check for line-level ignore directive.
|
|
262
|
-
|
|
263
|
-
Args:
|
|
264
|
-
code: Line of code to check.
|
|
265
|
-
line_num: Line number (currently unused, for API compatibility).
|
|
266
|
-
rule_id: Optional specific rule ID to check for.
|
|
267
|
-
|
|
268
|
-
Returns:
|
|
269
|
-
True if line has ignore directive.
|
|
270
|
-
"""
|
|
271
|
-
if not self._has_line_ignore_marker(code):
|
|
85
|
+
"""Check for line-level ignore directive."""
|
|
86
|
+
if not has_line_ignore_marker(code):
|
|
272
87
|
return False
|
|
273
|
-
|
|
274
88
|
if rule_id:
|
|
275
|
-
return
|
|
89
|
+
return _check_specific_rule_in_line(code, rule_id)
|
|
276
90
|
return True
|
|
277
91
|
|
|
278
|
-
def
|
|
279
|
-
"""Check if
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
rule_id: Rule ID to check (e.g., "nesting.excessive-depth").
|
|
283
|
-
pattern: Pattern with optional wildcard (e.g., "nesting.*" or "nesting").
|
|
284
|
-
|
|
285
|
-
Returns:
|
|
286
|
-
True if rule matches pattern.
|
|
287
|
-
"""
|
|
288
|
-
# Case-insensitive comparison
|
|
289
|
-
rule_id_lower = rule_id.lower()
|
|
290
|
-
pattern_lower = pattern.lower()
|
|
291
|
-
|
|
292
|
-
if pattern_lower.endswith("*"):
|
|
293
|
-
# Wildcard match: literals.* matches literals.magic-number
|
|
294
|
-
prefix = pattern_lower[:-1]
|
|
295
|
-
return rule_id_lower.startswith(prefix)
|
|
296
|
-
|
|
297
|
-
# Exact match
|
|
298
|
-
if rule_id_lower == pattern_lower:
|
|
92
|
+
def should_ignore_violation(self, violation: "Violation", file_content: str) -> bool:
|
|
93
|
+
"""Check if a violation should be ignored based on all levels."""
|
|
94
|
+
file_path = Path(violation.file_path)
|
|
95
|
+
if self._is_ignored_at_file_level(file_path, violation.rule_id, file_content):
|
|
299
96
|
return True
|
|
97
|
+
return _is_ignored_in_content(file_content, violation)
|
|
300
98
|
|
|
301
|
-
|
|
302
|
-
|
|
99
|
+
def _is_ignored_at_file_level(self, file_path: Path, rule_id: str, file_content: str) -> bool:
|
|
100
|
+
"""Check repository and file level ignores."""
|
|
101
|
+
if self.is_ignored(file_path):
|
|
303
102
|
return True
|
|
103
|
+
if _has_file_ignore_in_content(file_content, rule_id):
|
|
104
|
+
return True
|
|
105
|
+
return self.has_file_ignore(file_path, rule_id)
|
|
304
106
|
|
|
305
|
-
return False
|
|
306
107
|
|
|
307
|
-
|
|
308
|
-
"""Check if line has ignore-next-line marker."""
|
|
309
|
-
return (
|
|
310
|
-
"# thailint: ignore-next-line" in prev_line
|
|
311
|
-
or "# design-lint: ignore-next-line" in prev_line
|
|
312
|
-
)
|
|
313
|
-
|
|
314
|
-
def _matches_ignore_next_line_rules(self, prev_line: str, rule_id: str) -> bool:
|
|
315
|
-
"""Check if ignore-next-line directive matches the rule."""
|
|
316
|
-
match = re.search(r"ignore-next-line\[([^\]]+)\]", prev_line)
|
|
317
|
-
if match:
|
|
318
|
-
ignored_rules = [r.strip() for r in match.group(1).split(",")]
|
|
319
|
-
return any(self._rule_matches(rule_id, r) for r in ignored_rules)
|
|
320
|
-
return True
|
|
108
|
+
# Module-level helper functions (don't need instance state)
|
|
321
109
|
|
|
322
|
-
def _is_valid_prev_line_index(self, lines: list[str], violation: "Violation") -> bool:
|
|
323
|
-
"""Check if previous line index is valid."""
|
|
324
|
-
if violation.line <= 1 or violation.line > len(lines) + 1:
|
|
325
|
-
return False
|
|
326
|
-
prev_line_idx = violation.line - 2
|
|
327
|
-
return 0 <= prev_line_idx < len(lines)
|
|
328
110
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
111
|
+
def _load_repo_ignores(project_root: Path) -> list[str]:
|
|
112
|
+
"""Load global ignore patterns from .thailintignore or .thailint.yaml."""
|
|
113
|
+
thailintignore = project_root / ".thailintignore"
|
|
114
|
+
if thailintignore.exists():
|
|
115
|
+
return _parse_thailintignore_file(thailintignore)
|
|
116
|
+
config_file = project_root / ".thailint.yaml"
|
|
117
|
+
if config_file.exists():
|
|
118
|
+
return _parse_config_file(config_file)
|
|
119
|
+
return []
|
|
333
120
|
|
|
334
|
-
prev_line_idx = violation.line - 2
|
|
335
|
-
prev_line = lines[prev_line_idx]
|
|
336
|
-
if not self._has_ignore_next_line_marker(prev_line):
|
|
337
|
-
return False
|
|
338
121
|
|
|
339
|
-
|
|
122
|
+
def _parse_thailintignore_file(ignore_file: Path) -> list[str]:
|
|
123
|
+
"""Parse .thailintignore file (gitignore-style)."""
|
|
124
|
+
try:
|
|
125
|
+
content = ignore_file.read_text(encoding="utf-8")
|
|
126
|
+
return extract_patterns_from_content(content)
|
|
127
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
128
|
+
logger.warning("Failed to read .thailintignore file %s: %s", ignore_file, e)
|
|
129
|
+
return []
|
|
340
130
|
|
|
341
|
-
def _check_current_line_ignore(self, lines: list[str], violation: "Violation") -> bool:
|
|
342
|
-
"""Check if current line has inline ignore directive."""
|
|
343
|
-
if violation.line <= 0 or violation.line > len(lines):
|
|
344
|
-
return False
|
|
345
131
|
|
|
346
|
-
|
|
347
|
-
|
|
132
|
+
def _parse_config_file(config_file: Path) -> list[str]:
|
|
133
|
+
"""Parse YAML config file and extract ignore patterns."""
|
|
134
|
+
try:
|
|
135
|
+
config = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
|
136
|
+
return _extract_ignore_patterns(config)
|
|
137
|
+
except (yaml.YAMLError, OSError, UnicodeDecodeError) as e:
|
|
138
|
+
logger.warning("Failed to parse config file %s: %s", config_file, e)
|
|
139
|
+
return []
|
|
348
140
|
|
|
349
|
-
def should_ignore_violation(self, violation: "Violation", file_content: str) -> bool:
|
|
350
|
-
"""Check if a violation should be ignored based on all levels."""
|
|
351
|
-
file_path = Path(violation.file_path)
|
|
352
141
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
142
|
+
def _extract_ignore_patterns(config: dict | None) -> list[str]:
|
|
143
|
+
"""Extract ignore patterns from config dict."""
|
|
144
|
+
if not config or not isinstance(config, dict):
|
|
145
|
+
return []
|
|
146
|
+
ignore_patterns = config.get("ignore", [])
|
|
147
|
+
if isinstance(ignore_patterns, list):
|
|
148
|
+
return [str(pattern) for pattern in ignore_patterns]
|
|
149
|
+
return []
|
|
356
150
|
|
|
357
|
-
# Line-based checks
|
|
358
|
-
return self._is_ignored_in_content(file_content, violation)
|
|
359
151
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
152
|
+
def _read_file_first_lines(file_path: Path) -> list[str]:
|
|
153
|
+
"""Read first lines of file for header scanning, return empty list on error."""
|
|
154
|
+
if not file_path.exists():
|
|
155
|
+
return []
|
|
156
|
+
try:
|
|
157
|
+
content = file_path.read_text(encoding="utf-8")
|
|
158
|
+
return content.splitlines()[:HEADER_SCAN_LINES]
|
|
159
|
+
except (UnicodeDecodeError, OSError) as e:
|
|
160
|
+
logger.debug("Failed to read file %s: %s", file_path, e)
|
|
161
|
+
return []
|
|
369
162
|
|
|
370
|
-
def _has_file_ignore_in_content(self, file_content: str, rule_id: str | None) -> bool:
|
|
371
|
-
"""Check if file content has ignore-file directive."""
|
|
372
|
-
lines = file_content.splitlines()[:10] # Check first 10 lines
|
|
373
|
-
return any(self._check_line_for_ignore(line, rule_id) for line in lines)
|
|
374
163
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
164
|
+
def _check_line_for_ignore(line: str, rule_id: str | None) -> bool:
|
|
165
|
+
"""Check if line has matching ignore directive."""
|
|
166
|
+
if not has_ignore_directive_marker(line):
|
|
167
|
+
return False
|
|
168
|
+
if rule_id:
|
|
169
|
+
return _check_specific_rule_ignore(line, rule_id)
|
|
170
|
+
return check_general_ignore(line)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check_specific_rule_ignore(line: str, rule_id: str) -> bool:
|
|
174
|
+
"""Check if line ignores a specific rule."""
|
|
175
|
+
bracket_match = re.search(r"ignore-file\[([^\]]+)\]", line, re.IGNORECASE)
|
|
176
|
+
if bracket_match:
|
|
177
|
+
return check_bracket_rules(bracket_match.group(1), rule_id)
|
|
178
|
+
space_match = re.search(r"ignore-file\s+([^\s#]+(?:\s+[^\s#]+)*)", line, re.IGNORECASE)
|
|
179
|
+
if space_match:
|
|
180
|
+
return check_space_separated_rules(space_match.group(1), rule_id)
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_specific_rule_in_line(code: str, rule_id: str) -> bool:
|
|
185
|
+
"""Check if line's ignore directive matches specific rule."""
|
|
186
|
+
bracket_match = re.search(r"ignore\[([^\]]+)\]", code, re.IGNORECASE)
|
|
187
|
+
if bracket_match:
|
|
188
|
+
return check_bracket_rules(bracket_match.group(1), rule_id)
|
|
189
|
+
space_match = re.search(r"ignore\s+([^\s#]+(?:\s+[^\s#]+)*)", code, re.IGNORECASE)
|
|
190
|
+
if space_match:
|
|
191
|
+
return check_space_separated_rules(space_match.group(1), rule_id)
|
|
192
|
+
return "ignore-all" in code.lower()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _has_file_ignore_in_content(file_content: str, rule_id: str | None) -> bool:
|
|
196
|
+
"""Check if file content has ignore-file directive."""
|
|
197
|
+
lines = file_content.splitlines()[:HEADER_SCAN_LINES]
|
|
198
|
+
return any(_check_line_for_ignore(line, rule_id) for line in lines)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _is_ignored_in_content(file_content: str, violation: "Violation") -> bool:
|
|
202
|
+
"""Check content-based ignores (block, line, method level)."""
|
|
203
|
+
lines = file_content.splitlines()
|
|
204
|
+
if _check_block_ignore(lines, violation):
|
|
205
|
+
return True
|
|
206
|
+
if _check_prev_line_ignore(lines, violation):
|
|
207
|
+
return True
|
|
208
|
+
return _check_current_line_ignore(lines, violation)
|
|
378
209
|
|
|
379
|
-
if self._check_block_ignore(lines, violation):
|
|
380
|
-
return True
|
|
381
|
-
if self._check_prev_line_ignore(lines, violation):
|
|
382
|
-
return True
|
|
383
|
-
if self._check_current_line_ignore(lines, violation):
|
|
384
|
-
return True
|
|
385
210
|
|
|
211
|
+
def _check_block_ignore(lines: list[str], violation: "Violation") -> bool:
|
|
212
|
+
"""Check if violation is within an ignore-start/ignore-end block."""
|
|
213
|
+
if not _is_valid_line_range(violation.line, len(lines)):
|
|
386
214
|
return False
|
|
215
|
+
state = _BlockState()
|
|
216
|
+
for i, line in enumerate(lines, 1):
|
|
217
|
+
result = _process_block_line(line, i, violation, state)
|
|
218
|
+
if result is not None:
|
|
219
|
+
return result
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _BlockState:
|
|
224
|
+
"""Mutable state for block ignore scanning."""
|
|
225
|
+
|
|
226
|
+
def __init__(self) -> None:
|
|
227
|
+
self.in_block = False
|
|
228
|
+
self.rules: set[str] = set()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _is_valid_line_range(line: int, max_lines: int) -> bool:
|
|
232
|
+
"""Check if line number is within valid range."""
|
|
233
|
+
return 0 < line <= max_lines
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _process_block_line(
|
|
237
|
+
line: str, line_num: int, violation: "Violation", state: _BlockState
|
|
238
|
+
) -> bool | None:
|
|
239
|
+
"""Process a line for block ignore, returning True/False if decided, None to continue."""
|
|
240
|
+
if has_ignore_start_marker(line):
|
|
241
|
+
state.rules = _parse_ignore_start_rules(line)
|
|
242
|
+
state.in_block = True
|
|
243
|
+
return None
|
|
244
|
+
if has_ignore_end_marker(line):
|
|
245
|
+
return _handle_block_end(line_num, violation, state)
|
|
246
|
+
if line_num == violation.line and state.in_block:
|
|
247
|
+
return rules_match_violation(state.rules, violation.rule_id)
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _handle_block_end(line_num: int, violation: "Violation", state: _BlockState) -> bool | None:
|
|
252
|
+
"""Handle block end marker."""
|
|
253
|
+
if state.in_block and line_num > violation.line:
|
|
254
|
+
if rules_match_violation(state.rules, violation.rule_id):
|
|
255
|
+
return True
|
|
256
|
+
state.in_block = False
|
|
257
|
+
state.rules = set()
|
|
258
|
+
return None
|
|
387
259
|
|
|
388
|
-
def _check_block_ignore(self, lines: list[str], violation: "Violation") -> bool:
|
|
389
|
-
"""Check if violation is within an ignore-start/ignore-end block."""
|
|
390
|
-
if violation.line <= 0 or violation.line > len(lines):
|
|
391
|
-
return False
|
|
392
260
|
|
|
393
|
-
|
|
261
|
+
def _parse_ignore_start_rules(line: str) -> set[str]:
|
|
262
|
+
"""Extract rule names from ignore-start directive."""
|
|
263
|
+
match = re.search(r"ignore-start\s+([^\s#]+(?:\s+[^\s#]+)*)", line)
|
|
264
|
+
if match:
|
|
265
|
+
rules_text = match.group(1).strip()
|
|
266
|
+
rules = [r.strip() for r in re.split(r"[,\s]+", rules_text) if r.strip()]
|
|
267
|
+
return set(rules)
|
|
268
|
+
return {"*"}
|
|
394
269
|
|
|
395
|
-
for i, line in enumerate(lines):
|
|
396
|
-
if self._process_block_line(line, i + 1, violation, block_state):
|
|
397
|
-
return True
|
|
398
270
|
|
|
271
|
+
def _check_prev_line_ignore(lines: list[str], violation: "Violation") -> bool:
|
|
272
|
+
"""Check if previous line has ignore-next-line directive."""
|
|
273
|
+
prev_line = _get_prev_line(lines, violation.line)
|
|
274
|
+
if prev_line is None:
|
|
275
|
+
return False
|
|
276
|
+
if not has_ignore_next_line_marker(prev_line):
|
|
399
277
|
return False
|
|
278
|
+
return _matches_ignore_next_line_rules(prev_line, violation.rule_id)
|
|
400
279
|
|
|
401
|
-
def _process_block_line(
|
|
402
|
-
self, line: str, line_num: int, violation: "Violation", block_state: dict
|
|
403
|
-
) -> bool:
|
|
404
|
-
"""Process a single line for block ignore checking."""
|
|
405
|
-
if "ignore-start" in line:
|
|
406
|
-
block_state["rules"] = self._parse_ignore_start_rules(line)
|
|
407
|
-
block_state["in_block"] = True
|
|
408
|
-
return False
|
|
409
280
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
281
|
+
def _get_prev_line(lines: list[str], violation_line: int) -> str | None:
|
|
282
|
+
"""Get previous line if it exists and is valid."""
|
|
283
|
+
if violation_line <= 1:
|
|
284
|
+
return None
|
|
285
|
+
prev_idx = violation_line - 2
|
|
286
|
+
if prev_idx < 0 or prev_idx >= len(lines):
|
|
287
|
+
return None
|
|
288
|
+
return lines[prev_idx]
|
|
414
289
|
|
|
415
|
-
if self._is_violation_line_ignored(
|
|
416
|
-
line_num, violation, block_state["in_block"], block_state["rules"]
|
|
417
|
-
):
|
|
418
|
-
return True
|
|
419
290
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
291
|
+
def _matches_ignore_next_line_rules(prev_line: str, rule_id: str) -> bool:
|
|
292
|
+
"""Check if ignore-next-line directive matches the rule."""
|
|
293
|
+
match = re.search(r"ignore-next-line\[([^\]]+)\]", prev_line)
|
|
294
|
+
if match:
|
|
295
|
+
return check_bracket_rules(match.group(1), rule_id)
|
|
296
|
+
return True
|
|
423
297
|
|
|
424
|
-
return False
|
|
425
298
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
return False
|
|
437
|
-
if not in_ignore_block or line_num <= violation.line:
|
|
438
|
-
return False
|
|
439
|
-
return self._rules_match_violation(current_ignored_rules, violation.rule_id)
|
|
440
|
-
|
|
441
|
-
def _is_violation_line_ignored(
|
|
442
|
-
self,
|
|
443
|
-
line_num: int,
|
|
444
|
-
violation: "Violation",
|
|
445
|
-
in_ignore_block: bool,
|
|
446
|
-
current_ignored_rules: set[str],
|
|
447
|
-
) -> bool:
|
|
448
|
-
"""Check if current line is the violation line in an ignore block."""
|
|
449
|
-
if line_num != violation.line or not in_ignore_block:
|
|
450
|
-
return False
|
|
451
|
-
return self._rules_match_violation(current_ignored_rules, violation.rule_id)
|
|
452
|
-
|
|
453
|
-
def _parse_ignore_start_rules(self, line: str) -> set[str]:
|
|
454
|
-
"""Extract rule names from ignore-start directive."""
|
|
455
|
-
match = re.search(r"ignore-start\s+([^\s#]+(?:\s+[^\s#]+)*)", line)
|
|
456
|
-
if match:
|
|
457
|
-
rules_text = match.group(1).strip()
|
|
458
|
-
rules = [r.strip() for r in re.split(r"[,\s]+", rules_text) if r.strip()]
|
|
459
|
-
return set(rules)
|
|
460
|
-
return {"*"}
|
|
461
|
-
|
|
462
|
-
def _rules_match_violation(self, ignored_rules: set[str], rule_id: str) -> bool:
|
|
463
|
-
"""Check if any of the ignored rules match the violation rule ID."""
|
|
464
|
-
if "*" in ignored_rules:
|
|
465
|
-
return True
|
|
466
|
-
return any(self._rule_matches(rule_id, pattern) for pattern in ignored_rules)
|
|
299
|
+
def _check_current_line_ignore(lines: list[str], violation: "Violation") -> bool:
|
|
300
|
+
"""Check if current line has inline ignore directive."""
|
|
301
|
+
if violation.line <= 0 or violation.line > len(lines):
|
|
302
|
+
return False
|
|
303
|
+
current_line = lines[violation.line - 1]
|
|
304
|
+
if not has_line_ignore_marker(current_line):
|
|
305
|
+
return False
|
|
306
|
+
return (
|
|
307
|
+
_check_specific_rule_in_line(current_line, violation.rule_id) if violation.rule_id else True
|
|
308
|
+
)
|
|
467
309
|
|
|
468
310
|
|
|
469
311
|
# Alias for backwards compatibility
|
|
470
312
|
IgnoreParser = IgnoreDirectiveParser
|
|
471
313
|
|
|
472
|
-
# Singleton pattern for performance
|
|
314
|
+
# Singleton pattern for performance
|
|
473
315
|
_CACHED_PARSER: IgnoreDirectiveParser | None = None
|
|
474
316
|
_CACHED_PROJECT_ROOT: Path | None = None
|
|
475
317
|
|