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.
Files changed (121) hide show
  1. src/analyzers/__init__.py +4 -3
  2. src/analyzers/ast_utils.py +54 -0
  3. src/analyzers/typescript_base.py +4 -0
  4. src/cli/__init__.py +3 -0
  5. src/cli/config.py +12 -12
  6. src/cli/config_merge.py +241 -0
  7. src/cli/linters/__init__.py +3 -0
  8. src/cli/linters/code_patterns.py +113 -5
  9. src/cli/linters/code_smells.py +4 -0
  10. src/cli/linters/documentation.py +3 -0
  11. src/cli/linters/structure.py +3 -0
  12. src/cli/linters/structure_quality.py +3 -0
  13. src/cli_main.py +3 -0
  14. src/config.py +2 -1
  15. src/core/base.py +3 -2
  16. src/core/cli_utils.py +3 -1
  17. src/core/config_parser.py +5 -2
  18. src/core/constants.py +54 -0
  19. src/core/linter_utils.py +4 -0
  20. src/core/rule_discovery.py +5 -1
  21. src/core/violation_builder.py +3 -0
  22. src/linter_config/directive_markers.py +109 -0
  23. src/linter_config/ignore.py +225 -383
  24. src/linter_config/pattern_utils.py +65 -0
  25. src/linter_config/rule_matcher.py +89 -0
  26. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  27. src/linters/collection_pipeline/ast_utils.py +40 -0
  28. src/linters/collection_pipeline/config.py +12 -0
  29. src/linters/collection_pipeline/continue_analyzer.py +2 -8
  30. src/linters/collection_pipeline/detector.py +262 -32
  31. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  32. src/linters/collection_pipeline/linter.py +18 -35
  33. src/linters/collection_pipeline/suggestion_builder.py +68 -1
  34. src/linters/dry/base_token_analyzer.py +16 -9
  35. src/linters/dry/block_filter.py +7 -4
  36. src/linters/dry/cache.py +7 -2
  37. src/linters/dry/config.py +7 -1
  38. src/linters/dry/constant_matcher.py +34 -25
  39. src/linters/dry/file_analyzer.py +4 -2
  40. src/linters/dry/inline_ignore.py +7 -16
  41. src/linters/dry/linter.py +48 -25
  42. src/linters/dry/python_analyzer.py +18 -10
  43. src/linters/dry/python_constant_extractor.py +51 -52
  44. src/linters/dry/single_statement_detector.py +14 -12
  45. src/linters/dry/token_hasher.py +115 -115
  46. src/linters/dry/typescript_analyzer.py +11 -6
  47. src/linters/dry/typescript_constant_extractor.py +4 -0
  48. src/linters/dry/typescript_statement_detector.py +208 -208
  49. src/linters/dry/typescript_value_extractor.py +3 -0
  50. src/linters/dry/violation_filter.py +1 -4
  51. src/linters/dry/violation_generator.py +1 -4
  52. src/linters/file_header/atemporal_detector.py +4 -0
  53. src/linters/file_header/base_parser.py +4 -0
  54. src/linters/file_header/bash_parser.py +4 -0
  55. src/linters/file_header/field_validator.py +5 -8
  56. src/linters/file_header/linter.py +19 -12
  57. src/linters/file_header/markdown_parser.py +6 -0
  58. src/linters/file_placement/config_loader.py +3 -1
  59. src/linters/file_placement/linter.py +22 -8
  60. src/linters/file_placement/pattern_matcher.py +21 -4
  61. src/linters/file_placement/pattern_validator.py +21 -7
  62. src/linters/file_placement/rule_checker.py +2 -2
  63. src/linters/lazy_ignores/__init__.py +43 -0
  64. src/linters/lazy_ignores/config.py +66 -0
  65. src/linters/lazy_ignores/directive_utils.py +121 -0
  66. src/linters/lazy_ignores/header_parser.py +177 -0
  67. src/linters/lazy_ignores/linter.py +158 -0
  68. src/linters/lazy_ignores/matcher.py +135 -0
  69. src/linters/lazy_ignores/python_analyzer.py +201 -0
  70. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  71. src/linters/lazy_ignores/skip_detector.py +298 -0
  72. src/linters/lazy_ignores/types.py +67 -0
  73. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  74. src/linters/lazy_ignores/violation_builder.py +131 -0
  75. src/linters/lbyl/__init__.py +29 -0
  76. src/linters/lbyl/config.py +63 -0
  77. src/linters/lbyl/pattern_detectors/__init__.py +25 -0
  78. src/linters/lbyl/pattern_detectors/base.py +46 -0
  79. src/linters/magic_numbers/context_analyzer.py +227 -229
  80. src/linters/magic_numbers/linter.py +20 -15
  81. src/linters/magic_numbers/python_analyzer.py +4 -16
  82. src/linters/magic_numbers/typescript_analyzer.py +9 -16
  83. src/linters/method_property/config.py +4 -0
  84. src/linters/method_property/linter.py +5 -4
  85. src/linters/method_property/python_analyzer.py +5 -4
  86. src/linters/method_property/violation_builder.py +3 -0
  87. src/linters/nesting/typescript_analyzer.py +6 -12
  88. src/linters/nesting/typescript_function_extractor.py +0 -4
  89. src/linters/print_statements/linter.py +6 -4
  90. src/linters/print_statements/python_analyzer.py +85 -81
  91. src/linters/print_statements/typescript_analyzer.py +6 -15
  92. src/linters/srp/heuristics.py +4 -4
  93. src/linters/srp/linter.py +12 -12
  94. src/linters/srp/violation_builder.py +0 -4
  95. src/linters/stateless_class/linter.py +30 -36
  96. src/linters/stateless_class/python_analyzer.py +11 -20
  97. src/linters/stringly_typed/config.py +4 -5
  98. src/linters/stringly_typed/context_filter.py +410 -410
  99. src/linters/stringly_typed/function_call_violation_builder.py +93 -95
  100. src/linters/stringly_typed/linter.py +48 -16
  101. src/linters/stringly_typed/python/analyzer.py +5 -1
  102. src/linters/stringly_typed/python/call_tracker.py +8 -5
  103. src/linters/stringly_typed/python/comparison_tracker.py +10 -5
  104. src/linters/stringly_typed/python/condition_extractor.py +3 -0
  105. src/linters/stringly_typed/python/conditional_detector.py +4 -1
  106. src/linters/stringly_typed/python/match_analyzer.py +8 -2
  107. src/linters/stringly_typed/python/validation_detector.py +3 -0
  108. src/linters/stringly_typed/storage.py +14 -14
  109. src/linters/stringly_typed/typescript/call_tracker.py +9 -3
  110. src/linters/stringly_typed/typescript/comparison_tracker.py +9 -3
  111. src/linters/stringly_typed/violation_generator.py +288 -259
  112. src/orchestrator/core.py +13 -4
  113. src/templates/thailint_config_template.yaml +166 -0
  114. src/utils/project_root.py +3 -0
  115. thailint-0.13.0.dist-info/METADATA +184 -0
  116. thailint-0.13.0.dist-info/RECORD +189 -0
  117. thailint-0.12.0.dist-info/METADATA +0 -1667
  118. thailint-0.12.0.dist-info/RECORD +0 -164
  119. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/WHEEL +0 -0
  120. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/entry_points.txt +0 -0
  121. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -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: fnmatch for gitignore-style pattern matching, re for regex-based directive parsing,
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: 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
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: Gitignore-style pattern matching with fnmatch, YAML config loading for global patterns,
29
- first-10-lines scanning for performance, regex-based directive parsing with rule ID extraction,
30
- wildcard rule matching with prefix comparison, graceful error handling for malformed directives
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 fnmatch
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
- Provides comprehensive ignore checking for repository-level patterns,
48
- file-level directives, and inline code comments.
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._load_repo_ignores()
59
- self._ignore_cache: dict[str, bool] = {} # Cache for is_ignored results
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
- Scans the first 10 lines of the file for ignore directives.
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 self._check_specific_rule_in_line(code, rule_id)
89
+ return _check_specific_rule_in_line(code, rule_id)
276
90
  return True
277
91
 
278
- def _rule_matches(self, rule_id: str, pattern: str) -> bool:
279
- """Check if rule ID matches pattern (supports wildcards and prefixes).
280
-
281
- Args:
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
- # Prefix match: "nesting" matches "nesting.excessive-depth"
302
- if rule_id_lower.startswith(pattern_lower + "."):
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
- def _has_ignore_next_line_marker(self, prev_line: str) -> bool:
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
- def _check_prev_line_ignore(self, lines: list[str], violation: "Violation") -> bool:
330
- """Check if previous line has ignore-next-line directive."""
331
- if not self._is_valid_prev_line_index(lines, violation):
332
- return False
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
- return self._matches_ignore_next_line_rules(prev_line, violation.rule_id)
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
- current_line = lines[violation.line - 1] # Convert to 0-indexed
347
- return self.has_line_ignore(current_line, violation.line, violation.rule_id)
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
- # Repository and file level checks
354
- if self._is_ignored_at_file_level(file_path, violation.rule_id, file_content):
355
- return True
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
- def _is_ignored_at_file_level(self, file_path: Path, rule_id: str, file_content: str) -> bool:
361
- """Check repository and file level ignores."""
362
- if self.is_ignored(file_path):
363
- return True
364
- # Check content first (for tests with in-memory content)
365
- if self._has_file_ignore_in_content(file_content, rule_id):
366
- return True
367
- # Fall back to reading from disk if file exists
368
- return self.has_file_ignore(file_path, rule_id)
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
- def _is_ignored_in_content(self, file_content: str, violation: "Violation") -> bool:
376
- """Check content-based ignores (block, line, method level)."""
377
- lines = file_content.splitlines()
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
- block_state = {"in_block": False, "rules": set()}
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
- if self._is_block_end_matching(
411
- line, block_state["in_block"], line_num, violation, block_state["rules"]
412
- ):
413
- return True
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
- if "ignore-end" in line:
421
- block_state["in_block"] = False
422
- block_state["rules"] = set()
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
- def _is_block_end_matching( # pylint: disable=too-many-arguments,too-many-positional-arguments
427
- self,
428
- line: str,
429
- in_ignore_block: bool,
430
- line_num: int,
431
- violation: "Violation",
432
- current_ignored_rules: set[str],
433
- ) -> bool:
434
- """Check if ignore-end matches and violation was in the block."""
435
- if "ignore-end" not in line:
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: YAML parsing repeated 9x consumed 44% overhead
314
+ # Singleton pattern for performance
473
315
  _CACHED_PARSER: IgnoreDirectiveParser | None = None
474
316
  _CACHED_PROJECT_ROOT: Path | None = None
475
317