thailint 0.2.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 (214) 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 +44 -27
  23. src/core/base.py +95 -5
  24. src/core/cli_utils.py +19 -2
  25. src/core/config_parser.py +36 -6
  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 +125 -22
  65. src/linters/dry/block_grouper.py +4 -0
  66. src/linters/dry/cache.py +142 -94
  67. src/linters/dry/cache_query.py +4 -0
  68. src/linters/dry/config.py +68 -21
  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 +20 -82
  73. src/linters/dry/file_analyzer.py +15 -50
  74. src/linters/dry/inline_ignore.py +7 -16
  75. src/linters/dry/linter.py +182 -54
  76. src/linters/dry/python_analyzer.py +108 -336
  77. src/linters/dry/python_constant_extractor.py +100 -0
  78. src/linters/dry/single_statement_detector.py +417 -0
  79. src/linters/dry/storage_initializer.py +9 -18
  80. src/linters/dry/token_hasher.py +129 -71
  81. src/linters/dry/typescript_analyzer.py +68 -380
  82. src/linters/dry/typescript_constant_extractor.py +138 -0
  83. src/linters/dry/typescript_statement_detector.py +255 -0
  84. src/linters/dry/typescript_value_extractor.py +70 -0
  85. src/linters/dry/violation_builder.py +4 -0
  86. src/linters/dry/violation_filter.py +9 -5
  87. src/linters/dry/violation_generator.py +71 -14
  88. src/linters/file_header/__init__.py +24 -0
  89. src/linters/file_header/atemporal_detector.py +105 -0
  90. src/linters/file_header/base_parser.py +93 -0
  91. src/linters/file_header/bash_parser.py +66 -0
  92. src/linters/file_header/config.py +140 -0
  93. src/linters/file_header/css_parser.py +70 -0
  94. src/linters/file_header/field_validator.py +72 -0
  95. src/linters/file_header/linter.py +309 -0
  96. src/linters/file_header/markdown_parser.py +130 -0
  97. src/linters/file_header/python_parser.py +42 -0
  98. src/linters/file_header/typescript_parser.py +73 -0
  99. src/linters/file_header/violation_builder.py +79 -0
  100. src/linters/file_placement/config_loader.py +3 -1
  101. src/linters/file_placement/directory_matcher.py +4 -0
  102. src/linters/file_placement/linter.py +74 -31
  103. src/linters/file_placement/pattern_matcher.py +41 -6
  104. src/linters/file_placement/pattern_validator.py +31 -12
  105. src/linters/file_placement/rule_checker.py +12 -7
  106. src/linters/lazy_ignores/__init__.py +43 -0
  107. src/linters/lazy_ignores/config.py +74 -0
  108. src/linters/lazy_ignores/directive_utils.py +164 -0
  109. src/linters/lazy_ignores/header_parser.py +177 -0
  110. src/linters/lazy_ignores/linter.py +158 -0
  111. src/linters/lazy_ignores/matcher.py +168 -0
  112. src/linters/lazy_ignores/python_analyzer.py +209 -0
  113. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  114. src/linters/lazy_ignores/skip_detector.py +298 -0
  115. src/linters/lazy_ignores/types.py +71 -0
  116. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  117. src/linters/lazy_ignores/violation_builder.py +135 -0
  118. src/linters/lbyl/__init__.py +31 -0
  119. src/linters/lbyl/config.py +63 -0
  120. src/linters/lbyl/linter.py +67 -0
  121. src/linters/lbyl/pattern_detectors/__init__.py +53 -0
  122. src/linters/lbyl/pattern_detectors/base.py +63 -0
  123. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  124. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  125. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  126. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  127. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  128. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  129. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  130. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  131. src/linters/lbyl/python_analyzer.py +215 -0
  132. src/linters/lbyl/violation_builder.py +354 -0
  133. src/linters/magic_numbers/__init__.py +48 -0
  134. src/linters/magic_numbers/config.py +82 -0
  135. src/linters/magic_numbers/context_analyzer.py +249 -0
  136. src/linters/magic_numbers/linter.py +462 -0
  137. src/linters/magic_numbers/python_analyzer.py +64 -0
  138. src/linters/magic_numbers/typescript_analyzer.py +215 -0
  139. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  140. src/linters/magic_numbers/violation_builder.py +98 -0
  141. src/linters/method_property/__init__.py +49 -0
  142. src/linters/method_property/config.py +138 -0
  143. src/linters/method_property/linter.py +414 -0
  144. src/linters/method_property/python_analyzer.py +473 -0
  145. src/linters/method_property/violation_builder.py +119 -0
  146. src/linters/nesting/__init__.py +6 -2
  147. src/linters/nesting/config.py +6 -3
  148. src/linters/nesting/linter.py +31 -34
  149. src/linters/nesting/python_analyzer.py +4 -0
  150. src/linters/nesting/typescript_analyzer.py +6 -11
  151. src/linters/nesting/violation_builder.py +1 -0
  152. src/linters/performance/__init__.py +91 -0
  153. src/linters/performance/config.py +43 -0
  154. src/linters/performance/constants.py +49 -0
  155. src/linters/performance/linter.py +149 -0
  156. src/linters/performance/python_analyzer.py +365 -0
  157. src/linters/performance/regex_analyzer.py +312 -0
  158. src/linters/performance/regex_linter.py +139 -0
  159. src/linters/performance/typescript_analyzer.py +236 -0
  160. src/linters/performance/violation_builder.py +160 -0
  161. src/linters/print_statements/__init__.py +53 -0
  162. src/linters/print_statements/config.py +78 -0
  163. src/linters/print_statements/linter.py +413 -0
  164. src/linters/print_statements/python_analyzer.py +153 -0
  165. src/linters/print_statements/typescript_analyzer.py +125 -0
  166. src/linters/print_statements/violation_builder.py +96 -0
  167. src/linters/srp/__init__.py +3 -3
  168. src/linters/srp/class_analyzer.py +11 -7
  169. src/linters/srp/config.py +12 -6
  170. src/linters/srp/heuristics.py +56 -22
  171. src/linters/srp/linter.py +47 -39
  172. src/linters/srp/python_analyzer.py +55 -20
  173. src/linters/srp/typescript_metrics_calculator.py +110 -50
  174. src/linters/stateless_class/__init__.py +25 -0
  175. src/linters/stateless_class/config.py +58 -0
  176. src/linters/stateless_class/linter.py +349 -0
  177. src/linters/stateless_class/python_analyzer.py +290 -0
  178. src/linters/stringly_typed/__init__.py +36 -0
  179. src/linters/stringly_typed/config.py +189 -0
  180. src/linters/stringly_typed/context_filter.py +451 -0
  181. src/linters/stringly_typed/function_call_violation_builder.py +135 -0
  182. src/linters/stringly_typed/ignore_checker.py +100 -0
  183. src/linters/stringly_typed/ignore_utils.py +51 -0
  184. src/linters/stringly_typed/linter.py +376 -0
  185. src/linters/stringly_typed/python/__init__.py +33 -0
  186. src/linters/stringly_typed/python/analyzer.py +348 -0
  187. src/linters/stringly_typed/python/call_tracker.py +175 -0
  188. src/linters/stringly_typed/python/comparison_tracker.py +257 -0
  189. src/linters/stringly_typed/python/condition_extractor.py +134 -0
  190. src/linters/stringly_typed/python/conditional_detector.py +179 -0
  191. src/linters/stringly_typed/python/constants.py +21 -0
  192. src/linters/stringly_typed/python/match_analyzer.py +94 -0
  193. src/linters/stringly_typed/python/validation_detector.py +189 -0
  194. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  195. src/linters/stringly_typed/storage.py +620 -0
  196. src/linters/stringly_typed/storage_initializer.py +45 -0
  197. src/linters/stringly_typed/typescript/__init__.py +28 -0
  198. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  199. src/linters/stringly_typed/typescript/call_tracker.py +335 -0
  200. src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
  201. src/linters/stringly_typed/violation_generator.py +419 -0
  202. src/orchestrator/core.py +264 -16
  203. src/orchestrator/language_detector.py +5 -3
  204. src/templates/thailint_config_template.yaml +354 -0
  205. src/utils/project_root.py +138 -16
  206. thailint-0.15.3.dist-info/METADATA +187 -0
  207. thailint-0.15.3.dist-info/RECORD +226 -0
  208. {thailint-0.2.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +1 -1
  209. thailint-0.15.3.dist-info/entry_points.txt +4 -0
  210. src/cli.py +0 -1055
  211. thailint-0.2.0.dist-info/METADATA +0 -980
  212. thailint-0.2.0.dist-info/RECORD +0 -75
  213. thailint-0.2.0.dist-info/entry_points.txt +0 -4
  214. {thailint-0.2.0.dist-info → thailint-0.15.3.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,309 @@
1
+ """
2
+ Purpose: Main file header linter rule implementation
3
+
4
+ Scope: File header validation for Python, TypeScript, JavaScript, Bash, Markdown, and CSS files
5
+
6
+ Overview: Orchestrates file header validation for multiple languages using focused helper classes.
7
+ Coordinates header extraction, field validation, atemporal language detection, and
8
+ violation building. Supports configuration from .thailint.yaml and ignore directives
9
+ including file-level and line-level ignore markers. Validates headers against mandatory
10
+ field requirements and atemporal language standards. Handles language-specific parsing
11
+ and special Markdown prose field extraction for atemporal checking.
12
+
13
+ Dependencies: BaseLintRule and BaseLintContext from core, language-specific parsers,
14
+ FieldValidator, AtemporalDetector, ViolationBuilder
15
+
16
+ Exports: FileHeaderRule class implementing BaseLintRule interface
17
+
18
+ Interfaces: check(context) -> list[Violation] for rule validation, standard rule properties
19
+ (rule_id, rule_name, description)
20
+
21
+ Implementation: Composition pattern with helper classes for parsing, validation,
22
+ and violation building
23
+
24
+ Suppressions:
25
+ - type:ignore[type-var]: Protocol pattern with generic type matching
26
+ - srp: Rule class coordinates parsing, validation, and violation building for multiple
27
+ languages. Methods support single responsibility of file header validation.
28
+ """
29
+
30
+ from contextlib import suppress
31
+ from pathlib import Path
32
+ from typing import Protocol
33
+
34
+ from src.core.base import BaseLintContext, BaseLintRule
35
+ from src.core.constants import HEADER_SCAN_LINES, Language
36
+ from src.core.linter_utils import load_linter_config
37
+ from src.core.types import Violation
38
+ from src.linter_config.directive_markers import check_general_ignore, has_ignore_directive_marker
39
+ from src.linter_config.ignore import _check_specific_rule_ignore, get_ignore_parser
40
+
41
+ from .atemporal_detector import AtemporalDetector
42
+ from .bash_parser import BashHeaderParser
43
+ from .config import FileHeaderConfig
44
+ from .css_parser import CssHeaderParser
45
+ from .field_validator import FieldValidator
46
+ from .markdown_parser import MarkdownHeaderParser
47
+ from .python_parser import PythonHeaderParser
48
+ from .typescript_parser import TypeScriptHeaderParser
49
+ from .violation_builder import ViolationBuilder
50
+
51
+
52
+ class HeaderParser(Protocol):
53
+ """Protocol for header parsers."""
54
+
55
+ def extract_header(self, code: str) -> str | None:
56
+ """Extract header from source code."""
57
+
58
+ def parse_fields(self, header: str) -> dict[str, str]:
59
+ """Parse fields from header."""
60
+
61
+
62
+ class FileHeaderRule(BaseLintRule): # thailint: ignore[srp]
63
+ """Validates file headers for mandatory fields and atemporal language.
64
+
65
+ Method count (17) exceeds SRP guideline (8) because proper A-grade complexity
66
+ refactoring requires extracting helper methods. Class maintains single responsibility
67
+ of file header validation - all methods support this core purpose through composition
68
+ pattern with focused helper classes (parser, validator, detector, builder).
69
+ """
70
+
71
+ # Parser instances for each language
72
+ _parsers: dict[str, HeaderParser] = {
73
+ "python": PythonHeaderParser(),
74
+ "typescript": TypeScriptHeaderParser(),
75
+ "javascript": TypeScriptHeaderParser(),
76
+ "bash": BashHeaderParser(),
77
+ "markdown": MarkdownHeaderParser(),
78
+ "css": CssHeaderParser(),
79
+ }
80
+
81
+ def __init__(self) -> None:
82
+ """Initialize the file header rule."""
83
+ self._violation_builder = ViolationBuilder(self.rule_id)
84
+ self._ignore_parser = get_ignore_parser()
85
+
86
+ @property
87
+ def rule_id(self) -> str:
88
+ """Unique identifier for this rule."""
89
+ return "file-header.validation"
90
+
91
+ @property
92
+ def rule_name(self) -> str:
93
+ """Human-readable name for this rule."""
94
+ return "File Header Validation"
95
+
96
+ @property
97
+ def description(self) -> str:
98
+ """Description of what this rule checks."""
99
+ return "Validates file headers for mandatory fields and atemporal language"
100
+
101
+ def check(self, context: BaseLintContext) -> list[Violation]:
102
+ """Check file header for violations."""
103
+ if self._has_file_ignore(context):
104
+ return []
105
+
106
+ config = self._load_config(context)
107
+
108
+ if self._should_ignore_file(context, config):
109
+ return []
110
+
111
+ return self._check_language_header(context, config)
112
+
113
+ def _check_language_header(
114
+ self, context: BaseLintContext, config: FileHeaderConfig
115
+ ) -> list[Violation]:
116
+ """Dispatch to language-specific header checking."""
117
+ parser = self._parsers.get(context.language)
118
+ if not parser:
119
+ return []
120
+
121
+ # Markdown has special atemporal handling
122
+ if context.language == Language.MARKDOWN:
123
+ return self._check_markdown_header(parser, context, config)
124
+
125
+ return self._check_header_with_parser(parser, context, config)
126
+
127
+ def _check_header_with_parser(
128
+ self, parser: HeaderParser, context: BaseLintContext, config: FileHeaderConfig
129
+ ) -> list[Violation]:
130
+ """Check header using the given parser."""
131
+ header = parser.extract_header(context.file_content or "")
132
+
133
+ if not header:
134
+ return self._build_missing_header_violations(context)
135
+
136
+ fields = parser.parse_fields(header)
137
+ violations = self._validate_header_fields(fields, context, config)
138
+ violations.extend(self._check_atemporal_violations(header, context, config))
139
+
140
+ return self._filter_ignored_violations(violations, context)
141
+
142
+ def _check_markdown_header(
143
+ self, parser: HeaderParser, context: BaseLintContext, config: FileHeaderConfig
144
+ ) -> list[Violation]:
145
+ """Check Markdown file header with special prose-only atemporal checking."""
146
+ header = parser.extract_header(context.file_content or "")
147
+
148
+ if not header:
149
+ return self._build_missing_header_violations(context)
150
+
151
+ fields = parser.parse_fields(header)
152
+ violations = self._validate_header_fields(fields, context, config)
153
+
154
+ # For Markdown, only check atemporal language in prose fields
155
+ prose_content = self._extract_markdown_prose_fields(fields)
156
+ violations.extend(self._check_atemporal_violations(prose_content, context, config))
157
+
158
+ return self._filter_ignored_violations(violations, context)
159
+
160
+ def _has_file_ignore(self, context: BaseLintContext) -> bool:
161
+ """Check if file has file-level ignore directive."""
162
+ file_content = context.file_content or ""
163
+
164
+ if self._has_standard_ignore(file_content):
165
+ return True
166
+
167
+ return self._has_custom_ignore_syntax(file_content)
168
+
169
+ def _has_standard_ignore(self, file_content: str) -> bool:
170
+ """Check standard ignore parser for file-level ignores."""
171
+ first_lines = file_content.splitlines()[:HEADER_SCAN_LINES]
172
+ return any(self._line_has_matching_ignore(line) for line in first_lines)
173
+
174
+ def _line_has_matching_ignore(self, line: str) -> bool:
175
+ """Check if line has matching ignore directive for this rule."""
176
+ if not has_ignore_directive_marker(line):
177
+ return False
178
+ return _check_specific_rule_ignore(line, self.rule_id) or check_general_ignore(line)
179
+
180
+ def _has_custom_ignore_syntax(self, file_content: str) -> bool:
181
+ """Check custom file-level ignore syntax."""
182
+ first_lines = file_content.splitlines()[:HEADER_SCAN_LINES]
183
+ return any(self._is_ignore_line(line) for line in first_lines)
184
+
185
+ def _is_ignore_line(self, line: str) -> bool:
186
+ """Check if line contains ignore directive."""
187
+ line_lower = line.lower()
188
+ return "# thailint-ignore-file:" in line_lower or "# thailint-ignore" in line_lower
189
+
190
+ def _load_config(self, context: BaseLintContext) -> FileHeaderConfig:
191
+ """Load configuration from context."""
192
+ if hasattr(context, "metadata") and isinstance(context.metadata, dict):
193
+ if "file_header" in context.metadata:
194
+ return load_linter_config(context, "file_header", FileHeaderConfig) # type: ignore[type-var]
195
+
196
+ return FileHeaderConfig()
197
+
198
+ def _should_ignore_file(self, context: BaseLintContext, config: FileHeaderConfig) -> bool:
199
+ """Check if file matches ignore patterns."""
200
+ if not context.file_path:
201
+ return False
202
+
203
+ file_path = Path(context.file_path)
204
+ return any(self._matches_ignore_pattern(file_path, p) for p in config.ignore)
205
+
206
+ def _matches_ignore_pattern(self, file_path: Path, pattern: str) -> bool:
207
+ """Check if file path matches a single ignore pattern."""
208
+ if file_path.match(pattern):
209
+ return True
210
+
211
+ if self._matches_directory_pattern(file_path, pattern):
212
+ return True
213
+
214
+ if self._matches_file_pattern(file_path, pattern):
215
+ return True
216
+
217
+ return pattern in str(file_path)
218
+
219
+ def _matches_directory_pattern(self, file_path: Path, pattern: str) -> bool:
220
+ """Match directory patterns like **/migrations/**."""
221
+ if pattern.startswith("**/") and pattern.endswith("/**"):
222
+ dir_name = pattern[3:-3]
223
+ return dir_name in file_path.parts
224
+ return False
225
+
226
+ def _matches_file_pattern(self, file_path: Path, pattern: str) -> bool:
227
+ """Match file patterns like **/__init__.py."""
228
+ if pattern.startswith("**/"):
229
+ filename_pattern = pattern[3:]
230
+ path_str = str(file_path)
231
+ return file_path.name == filename_pattern or path_str.endswith(filename_pattern)
232
+ return False
233
+
234
+ def _build_missing_header_violations(self, context: BaseLintContext) -> list[Violation]:
235
+ """Build violations for missing header."""
236
+ return [
237
+ self._violation_builder.build_missing_field(
238
+ "docstring", str(context.file_path or ""), 1
239
+ )
240
+ ]
241
+
242
+ def _validate_header_fields(
243
+ self, fields: dict[str, str], context: BaseLintContext, config: FileHeaderConfig
244
+ ) -> list[Violation]:
245
+ """Validate mandatory header fields."""
246
+ violations = []
247
+ field_validator = FieldValidator(config)
248
+ field_violations = field_validator.validate_fields(fields, context.language)
249
+
250
+ for field_name, _error_message in field_violations:
251
+ violations.append(
252
+ self._violation_builder.build_missing_field(
253
+ field_name, str(context.file_path or ""), 1
254
+ )
255
+ )
256
+ return violations
257
+
258
+ def _check_atemporal_violations(
259
+ self, header: str, context: BaseLintContext, config: FileHeaderConfig
260
+ ) -> list[Violation]:
261
+ """Check for atemporal language violations."""
262
+ if not config.enforce_atemporal:
263
+ return []
264
+
265
+ violations = []
266
+ atemporal_detector = AtemporalDetector()
267
+ atemporal_violations = atemporal_detector.detect_violations(header)
268
+
269
+ for pattern, description, line_num in atemporal_violations:
270
+ violations.append(
271
+ self._violation_builder.build_atemporal_violation(
272
+ pattern, description, str(context.file_path or ""), line_num
273
+ )
274
+ )
275
+ return violations
276
+
277
+ def _filter_ignored_violations(
278
+ self, violations: list[Violation], context: BaseLintContext
279
+ ) -> list[Violation]:
280
+ """Filter out violations that should be ignored."""
281
+ file_content = context.file_content or ""
282
+ lines = file_content.splitlines()
283
+
284
+ non_ignored = (
285
+ v
286
+ for v in violations
287
+ if not self._ignore_parser.should_ignore_violation(v, file_content)
288
+ and not self._has_line_level_ignore(lines, v)
289
+ )
290
+ return list(non_ignored)
291
+
292
+ def _has_line_level_ignore(self, lines: list[str], violation: Violation) -> bool:
293
+ """Check for thailint-ignore-line directive."""
294
+ if violation.line <= 0 or violation.line > len(lines):
295
+ return False
296
+
297
+ line_content = lines[violation.line - 1]
298
+ return "# thailint-ignore-line:" in line_content.lower()
299
+
300
+ def _extract_markdown_prose_fields(self, fields: dict[str, str]) -> str:
301
+ """Extract prose fields from Markdown frontmatter for atemporal checking."""
302
+ prose_fields = ["purpose", "scope", "overview"]
303
+ prose_parts = []
304
+
305
+ for field_name in prose_fields:
306
+ with suppress(KeyError):
307
+ prose_parts.append(f"{field_name}: {fields[field_name]}")
308
+
309
+ return "\n".join(prose_parts)
@@ -0,0 +1,130 @@
1
+ """
2
+ Purpose: Markdown YAML frontmatter extraction and parsing
3
+
4
+ Scope: Markdown file header parsing from YAML frontmatter
5
+
6
+ Overview: Extracts YAML frontmatter from Markdown files. Frontmatter must be at the
7
+ start of the file, enclosed in --- markers. Parses YAML content to extract
8
+ field values using PyYAML when available, falling back to regex parsing if not.
9
+ Handles both simple key-value pairs and complex YAML structures including lists.
10
+ Flattens nested structures into string representations for field validation.
11
+
12
+ Dependencies: re module for frontmatter pattern matching, yaml module (optional) for parsing, logging module
13
+
14
+ Exports: MarkdownHeaderParser class
15
+
16
+ Interfaces: extract_header(code) -> str | None for frontmatter extraction,
17
+ parse_fields(header) -> dict[str, str] for field parsing
18
+
19
+ Implementation: YAML frontmatter extraction with PyYAML parsing and regex fallback for robustness
20
+
21
+ Suppressions:
22
+ - BLE001: Broad exception catch for YAML parsing fallback (any exception triggers regex fallback)
23
+ - srp: Class coordinates YAML extraction, parsing, and field validation for Markdown.
24
+ Method count exceeds limit due to complexity refactoring.
25
+ - nesting,dry: _parse_simple_yaml uses nested loops for YAML structure traversal.
26
+ """
27
+
28
+ import logging
29
+ import re
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class MarkdownHeaderParser: # thailint: ignore[srp]
35
+ """Extracts and parses Markdown file headers from YAML frontmatter.
36
+
37
+ Method count (10) exceeds SRP guideline (8) because proper A-grade complexity
38
+ refactoring requires extracting small focused helper methods. Class maintains
39
+ single responsibility of YAML frontmatter parsing - all methods support this
40
+ core purpose through either PyYAML or simple regex parsing fallback.
41
+ """
42
+
43
+ # Pattern to match YAML frontmatter at start of file
44
+ FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
45
+
46
+ def extract_header(self, code: str) -> str | None:
47
+ """Extract YAML frontmatter from Markdown file."""
48
+ if not code or not code.strip():
49
+ return None
50
+
51
+ match = self.FRONTMATTER_PATTERN.match(code)
52
+ return match.group(1).strip() if match else None
53
+
54
+ def parse_fields(self, header: str) -> dict[str, str]:
55
+ """Parse YAML frontmatter into field dictionary."""
56
+ yaml_result = self._try_yaml_parse(header)
57
+ if yaml_result is not None:
58
+ return yaml_result
59
+
60
+ return self._parse_simple_yaml(header)
61
+
62
+ def _try_yaml_parse(self, header: str) -> dict[str, str] | None:
63
+ """Try to parse with PyYAML, returning None if unavailable or failed."""
64
+ try:
65
+ import yaml
66
+
67
+ data = yaml.safe_load(header)
68
+ if isinstance(data, dict):
69
+ return self._flatten_yaml_dict(data)
70
+ except ImportError:
71
+ logger.debug("PyYAML not available, using simple parser")
72
+ except Exception: # noqa: BLE001
73
+ logger.debug("YAML parsing failed, falling back to simple parser")
74
+ return None
75
+
76
+ def _flatten_yaml_dict(self, data: dict) -> dict[str, str]:
77
+ """Convert YAML dict to string values."""
78
+ result: dict[str, str] = {}
79
+ for key, value in data.items():
80
+ result[str(key)] = self._convert_value(value)
81
+ return result
82
+
83
+ def _convert_value(self, value: object) -> str:
84
+ """Convert a single YAML value to string."""
85
+ if isinstance(value, list):
86
+ return ", ".join(str(v) for v in value)
87
+ if value is not None:
88
+ return str(value)
89
+ return ""
90
+
91
+ def _parse_simple_yaml( # thailint: ignore[nesting,dry]
92
+ self, header: str
93
+ ) -> dict[str, str]:
94
+ """Simple regex-based YAML parsing fallback."""
95
+ fields: dict[str, str] = {}
96
+ current_field: str | None = None
97
+ current_value: list[str] = []
98
+
99
+ for line in header.split("\n"):
100
+ if self._is_field_start(line):
101
+ self._save_field(fields, current_field, current_value)
102
+ current_field, current_value = self._start_field(line)
103
+ elif current_field and line.strip():
104
+ current_value.append(self._process_continuation(line))
105
+
106
+ self._save_field(fields, current_field, current_value)
107
+ return fields
108
+
109
+ def _is_field_start(self, line: str) -> bool:
110
+ """Check if line starts a new field (not indented, has colon)."""
111
+ return not line.startswith(" ") and ":" in line
112
+
113
+ def _start_field(self, line: str) -> tuple[str, list[str]]:
114
+ """Parse field start and return field name and initial value."""
115
+ parts = line.split(":", 1)
116
+ field_name = parts[0].strip()
117
+ value = parts[1].strip() if len(parts) > 1 else ""
118
+ return field_name, [value] if value else []
119
+
120
+ def _process_continuation(self, line: str) -> str:
121
+ """Process a continuation line (list item or multiline value)."""
122
+ stripped = line.strip()
123
+ return stripped[2:] if stripped.startswith("- ") else stripped
124
+
125
+ def _save_field(
126
+ self, fields: dict[str, str], field_name: str | None, values: list[str]
127
+ ) -> None:
128
+ """Save field to dictionary if field name exists."""
129
+ if field_name:
130
+ fields[field_name] = "\n".join(values).strip()
@@ -0,0 +1,42 @@
1
+ """
2
+ Purpose: Python docstring extraction and parsing for file headers
3
+
4
+ Scope: Python file header parsing from module-level docstrings
5
+
6
+ Overview: Extracts module-level docstrings from Python files using AST parsing.
7
+ Parses structured header fields from docstring content and handles both
8
+ well-formed and malformed headers. Provides field extraction and validation
9
+ support for FileHeaderRule. Uses ast.get_docstring() for reliable extraction
10
+ and gracefully handles syntax errors in source code.
11
+
12
+ Dependencies: Python ast module for AST parsing, base_parser.BaseHeaderParser for field parsing
13
+
14
+ Exports: PythonHeaderParser class
15
+
16
+ Interfaces: extract_header(code) -> str | None for docstring extraction, parse_fields(header) inherited from base
17
+
18
+ Implementation: AST-based docstring extraction with syntax error handling
19
+ """
20
+
21
+ import ast
22
+
23
+ from src.linters.file_header.base_parser import BaseHeaderParser
24
+
25
+
26
+ class PythonHeaderParser(BaseHeaderParser):
27
+ """Extracts and parses Python file headers from docstrings."""
28
+
29
+ def extract_header(self, code: str) -> str | None:
30
+ """Extract module-level docstring from Python code.
31
+
32
+ Args:
33
+ code: Python source code
34
+
35
+ Returns:
36
+ Module docstring or None if not found or parse error
37
+ """
38
+ try:
39
+ tree = ast.parse(code)
40
+ return ast.get_docstring(tree)
41
+ except SyntaxError:
42
+ return None
@@ -0,0 +1,73 @@
1
+ """
2
+ Purpose: TypeScript/JavaScript JSDoc comment extraction and parsing
3
+
4
+ Scope: TypeScript and JavaScript file header parsing from JSDoc comments
5
+
6
+ Overview: Extracts JSDoc-style comments (/** ... */) from TypeScript and JavaScript files.
7
+ Parses structured header fields from JSDoc content and handles both single-line
8
+ and multi-line field values. Distinguishes JSDoc comments from regular block
9
+ comments (/* ... */) by requiring the double asterisk syntax. Cleans formatting
10
+ characters including leading asterisks from content lines.
11
+
12
+ Dependencies: re module for regex-based JSDoc pattern matching, base_parser.BaseHeaderParser for field parsing
13
+
14
+ Exports: TypeScriptHeaderParser class
15
+
16
+ Interfaces: extract_header(code) -> str | None for JSDoc extraction, parse_fields(header) inherited from base
17
+
18
+ Implementation: Regex-based JSDoc extraction with content cleaning and formatting removal
19
+ """
20
+
21
+ import re
22
+
23
+ from src.linters.file_header.base_parser import BaseHeaderParser
24
+
25
+
26
+ class TypeScriptHeaderParser(BaseHeaderParser):
27
+ """Extracts and parses TypeScript/JavaScript file headers from JSDoc comments."""
28
+
29
+ # Pattern to match JSDoc comment at start of file (allowing whitespace before)
30
+ JSDOC_PATTERN = re.compile(r"^\s*/\*\*\s*(.*?)\s*\*/", re.DOTALL)
31
+
32
+ def extract_header(self, code: str) -> str | None:
33
+ """Extract JSDoc comment from TypeScript/JavaScript code.
34
+
35
+ Args:
36
+ code: TypeScript/JavaScript source code
37
+
38
+ Returns:
39
+ JSDoc content or None if not found
40
+ """
41
+ if not code or not code.strip():
42
+ return None
43
+
44
+ match = self.JSDOC_PATTERN.match(code)
45
+ if not match:
46
+ return None
47
+
48
+ # Extract the content inside the JSDoc
49
+ jsdoc_content = match.group(1)
50
+
51
+ # Clean up the JSDoc content - remove leading * from each line
52
+ return self._clean_jsdoc_content(jsdoc_content)
53
+
54
+ def _clean_jsdoc_content(self, content: str) -> str:
55
+ """Remove JSDoc formatting (leading asterisks) from content.
56
+
57
+ Args:
58
+ content: Raw JSDoc content
59
+
60
+ Returns:
61
+ Cleaned content without leading asterisks
62
+ """
63
+ lines = content.split("\n")
64
+ cleaned_lines = []
65
+
66
+ for line in lines:
67
+ # Remove leading whitespace and asterisk
68
+ stripped = line.strip()
69
+ if stripped.startswith("*"):
70
+ stripped = stripped[1:].strip()
71
+ cleaned_lines.append(stripped)
72
+
73
+ return "\n".join(cleaned_lines)
@@ -0,0 +1,79 @@
1
+ """
2
+ Purpose: Builds violation messages for file header linter
3
+
4
+ Scope: Violation message creation for file header validation failures
5
+
6
+ Overview: Creates formatted violation messages for file header validation failures.
7
+ Handles missing fields, atemporal language, and other header issues with clear,
8
+ actionable messages. Provides consistent violation format across all validation types
9
+ including rule_id, message, location, severity, and helpful suggestions. Supports
10
+ multiple violation types with appropriate error messages and remediation guidance.
11
+
12
+ Dependencies: Violation and Severity types from core.types module
13
+
14
+ Exports: ViolationBuilder class
15
+
16
+ Interfaces: build_missing_field(field_name, file_path, line) -> Violation,
17
+ build_atemporal_violation(pattern, description, file_path, line) -> Violation
18
+
19
+ Implementation: Builder pattern with message templates for different violation types
20
+ """
21
+
22
+ from src.core.types import Severity, Violation
23
+
24
+
25
+ class ViolationBuilder:
26
+ """Builds violation messages for file header issues."""
27
+
28
+ def __init__(self, rule_id: str):
29
+ """Initialize with rule ID.
30
+
31
+ Args:
32
+ rule_id: Rule identifier for violations
33
+ """
34
+ self.rule_id = rule_id
35
+
36
+ def build_missing_field(self, field_name: str, file_path: str, line: int = 1) -> Violation:
37
+ """Build violation for missing mandatory field.
38
+
39
+ Args:
40
+ field_name: Name of missing field
41
+ file_path: Path to file
42
+ line: Line number (default 1 for header)
43
+
44
+ Returns:
45
+ Violation object describing missing field
46
+ """
47
+ return Violation(
48
+ rule_id=self.rule_id,
49
+ message=f"Missing mandatory field: {field_name}",
50
+ file_path=file_path,
51
+ line=line,
52
+ column=1,
53
+ severity=Severity.ERROR,
54
+ suggestion=f"Add '{field_name}:' field to file header",
55
+ )
56
+
57
+ def build_atemporal_violation(
58
+ self, pattern: str, description: str, file_path: str, line: int
59
+ ) -> Violation:
60
+ """Build violation for temporal language.
61
+
62
+ Args:
63
+ pattern: Matched regex pattern
64
+ description: Description of temporal language
65
+ file_path: Path to file
66
+ line: Line number of violation
67
+
68
+ Returns:
69
+ Violation object describing temporal language issue
70
+ """
71
+ return Violation(
72
+ rule_id=self.rule_id,
73
+ message=f"Temporal language detected: {description}",
74
+ file_path=file_path,
75
+ line=line,
76
+ column=1,
77
+ severity=Severity.ERROR,
78
+ suggestion="Use present-tense factual descriptions without temporal references",
79
+ )
@@ -23,6 +23,8 @@ from typing import Any
23
23
 
24
24
  import yaml
25
25
 
26
+ from src.core.constants import CONFIG_EXTENSIONS
27
+
26
28
 
27
29
  class ConfigLoader:
28
30
  """Loads configuration files for file placement linter."""
@@ -79,7 +81,7 @@ class ConfigLoader:
79
81
  ValueError: If file format is unsupported
80
82
  """
81
83
  with config_path.open(encoding="utf-8") as f:
82
- if config_path.suffix in [".yaml", ".yml"]:
84
+ if config_path.suffix in CONFIG_EXTENSIONS:
83
85
  return yaml.safe_load(f) or {}
84
86
  if config_path.suffix == ".json":
85
87
  return json.load(f)
@@ -23,6 +23,10 @@ from typing import Any
23
23
  class DirectoryMatcher:
24
24
  """Finds matching directory rules based on path prefixes."""
25
25
 
26
+ def __init__(self) -> None:
27
+ """Initialize the directory matcher."""
28
+ pass # Stateless matcher for directory rules
29
+
26
30
  def find_matching_rule(
27
31
  self, path_str: str, directories: dict[str, Any]
28
32
  ) -> tuple[dict[str, Any] | None, str | None]: