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