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
@@ -0,0 +1,209 @@
1
+ """
2
+ Purpose: Detect Python linting ignore directives in source code
3
+
4
+ Scope: noqa, type:ignore, pylint:disable, nosec, pyright:ignore, dry:ignore-block pattern detection
5
+
6
+ Overview: Provides PythonIgnoreDetector class that scans Python source code for common
7
+ linting ignore patterns. Detects bare patterns (e.g., # noqa) and rule-specific
8
+ patterns (e.g., # noqa: PLR0912). Handles case-insensitive matching and extracts
9
+ rule IDs from comma-separated lists. Returns list of IgnoreDirective objects with
10
+ line/column positions for violation reporting. Skips patterns inside docstrings
11
+ and string literals to avoid false positives.
12
+
13
+ Dependencies: re for pattern matching, pathlib for file paths, types module for dataclasses
14
+
15
+ Exports: PythonIgnoreDetector
16
+
17
+ Interfaces: find_ignores(code: str, file_path: Path | None) -> list[IgnoreDirective]
18
+
19
+ Implementation: Regex-based line-by-line scanning with docstring-aware state tracking
20
+ """
21
+
22
+ import re
23
+ from pathlib import Path
24
+
25
+ from src.linters.lazy_ignores.directive_utils import create_directive
26
+ from src.linters.lazy_ignores.types import IgnoreDirective, IgnoreType
27
+
28
+
29
+ def _count_unescaped_triple_quotes(line: str, quote: str) -> int:
30
+ """Count unescaped triple-quote occurrences in a line.
31
+
32
+ Uses regex to find non-escaped triple quotes.
33
+
34
+ Args:
35
+ line: Line to scan
36
+ quote: Triple-quote pattern to count (single or double)
37
+
38
+ Returns:
39
+ Number of unescaped triple-quote occurrences
40
+ """
41
+ # Pattern matches triple quotes not preceded by odd number of backslashes
42
+ # Escape the quote for regex
43
+ escaped_quote = re.escape(quote)
44
+ pattern = re.compile(rf"(?<!\\){escaped_quote}")
45
+ return len(pattern.findall(line))
46
+
47
+
48
+ def _count_unescaped_single_quotes(text: str, quote_char: str) -> int:
49
+ """Count unescaped single quote characters in text.
50
+
51
+ Args:
52
+ text: Text to scan
53
+ quote_char: The quote character (' or ")
54
+
55
+ Returns:
56
+ Number of unescaped quote characters
57
+ """
58
+ count = 0
59
+ escaped = False
60
+ for char in text:
61
+ if escaped:
62
+ escaped = False
63
+ continue
64
+ if char == "\\":
65
+ escaped = True
66
+ continue
67
+ if char == quote_char:
68
+ count += 1
69
+ return count
70
+
71
+
72
+ def _is_pattern_in_string_literal(line: str, match_start: int) -> bool:
73
+ """Check if a match position is inside a string literal.
74
+
75
+ Args:
76
+ line: The line of code
77
+ match_start: The start position of the pattern match
78
+
79
+ Returns:
80
+ True if the match is inside a string literal
81
+ """
82
+ before_match = line[:match_start]
83
+ single_count = _count_unescaped_single_quotes(before_match, "'")
84
+ double_count = _count_unescaped_single_quotes(before_match, '"')
85
+ return (single_count % 2 == 1) or (double_count % 2 == 1)
86
+
87
+
88
+ class PythonIgnoreDetector:
89
+ """Detects Python linting ignore directives in source code."""
90
+
91
+ # Regex patterns for each ignore type
92
+ # Each pattern captures optional rule IDs in group 1
93
+ PATTERNS: dict[IgnoreType, re.Pattern[str]] = {
94
+ IgnoreType.NOQA: re.compile(
95
+ r"#\s*noqa(?::\s*([A-Z0-9,\s]+))?(?:\s|$)",
96
+ re.IGNORECASE,
97
+ ),
98
+ IgnoreType.TYPE_IGNORE: re.compile(
99
+ r"#\s*type:\s*ignore(?:\[([^\]]+)\])?",
100
+ ),
101
+ IgnoreType.PYLINT_DISABLE: re.compile(
102
+ r"#\s*pylint:\s*disable=([a-z0-9\-,\s]+)",
103
+ re.IGNORECASE,
104
+ ),
105
+ IgnoreType.NOSEC: re.compile(
106
+ r"#\s*nosec(?:\s+([A-Z0-9,\s]+))?(?:\s|$)",
107
+ re.IGNORECASE,
108
+ ),
109
+ IgnoreType.PYRIGHT_IGNORE: re.compile(
110
+ r"#\s*pyright:\s*ignore(?:\[([^\]]+)\])?",
111
+ re.IGNORECASE,
112
+ ),
113
+ IgnoreType.THAILINT_IGNORE: re.compile(
114
+ r"#\s*thailint:\s*ignore(?!-)(?:\[([^\]]+)\])?",
115
+ re.IGNORECASE,
116
+ ),
117
+ IgnoreType.THAILINT_IGNORE_FILE: re.compile(
118
+ r"#\s*thailint:\s*ignore-file(?:\[([^\]]+)\])?",
119
+ re.IGNORECASE,
120
+ ),
121
+ IgnoreType.THAILINT_IGNORE_NEXT: re.compile(
122
+ r"#\s*thailint:\s*ignore-next-line(?:\[([^\]]+)\])?",
123
+ re.IGNORECASE,
124
+ ),
125
+ IgnoreType.THAILINT_IGNORE_BLOCK: re.compile(
126
+ r"#\s*thailint:\s*ignore-start(?:\[([^\]]+)\])?",
127
+ re.IGNORECASE,
128
+ ),
129
+ IgnoreType.DRY_IGNORE_BLOCK: re.compile(
130
+ r"#\s*dry:\s*ignore-block\b",
131
+ re.IGNORECASE,
132
+ ),
133
+ }
134
+
135
+ def find_ignores(self, code: str, file_path: Path | None = None) -> list[IgnoreDirective]:
136
+ """Find all Python ignore directives in code.
137
+
138
+ Tracks docstring state across lines to avoid false positives from
139
+ patterns mentioned in documentation.
140
+
141
+ Args:
142
+ code: Python source code to scan
143
+ file_path: Optional path to the source file
144
+
145
+ Returns:
146
+ List of IgnoreDirective objects for each detected ignore pattern
147
+ """
148
+ effective_path = file_path or Path("unknown")
149
+ scannable_lines = self._get_scannable_lines(code)
150
+ directives: list[IgnoreDirective] = []
151
+ for line_num, line in scannable_lines:
152
+ directives.extend(self._scan_line(line, line_num, effective_path))
153
+ return directives
154
+
155
+ def _get_scannable_lines(self, code: str) -> list[tuple[int, str]]:
156
+ """Get lines that are not inside docstrings.
157
+
158
+ Args:
159
+ code: Source code to analyze
160
+
161
+ Returns:
162
+ List of (line_number, line_text) tuples for scannable lines
163
+ """
164
+ in_docstring = [False, False] # [triple_double, triple_single]
165
+ quotes = ['"""', "'''"]
166
+ scannable: list[tuple[int, str]] = []
167
+
168
+ for line_num, line in enumerate(code.splitlines(), start=1):
169
+ was_in_docstring = in_docstring[0] or in_docstring[1]
170
+ self._update_docstring_state(line, quotes, in_docstring)
171
+ if not was_in_docstring:
172
+ scannable.append((line_num, line))
173
+
174
+ return scannable
175
+
176
+ def _update_docstring_state(self, line: str, quotes: list[str], state: list[bool]) -> None:
177
+ """Update docstring tracking state based on quotes in line.
178
+
179
+ Args:
180
+ line: Line to analyze
181
+ quotes: List of quote patterns to check
182
+ state: Mutable list tracking in-docstring state for each quote type
183
+ """
184
+ for i, quote in enumerate(quotes):
185
+ if _count_unescaped_triple_quotes(line, quote) % 2 == 1:
186
+ state[i] = not state[i]
187
+
188
+ def _scan_line(self, line: str, line_num: int, file_path: Path) -> list[IgnoreDirective]:
189
+ """Scan a single line for ignore patterns.
190
+
191
+ Skips patterns that appear inside string literals.
192
+
193
+ Args:
194
+ line: Line of code to scan
195
+ line_num: 1-indexed line number
196
+ file_path: Path to the source file
197
+
198
+ Returns:
199
+ List of IgnoreDirective objects found on this line
200
+ """
201
+ found: list[IgnoreDirective] = []
202
+ for ignore_type, pattern in self.PATTERNS.items():
203
+ match = pattern.search(line)
204
+ if not match:
205
+ continue
206
+ if _is_pattern_in_string_literal(line, match.start()):
207
+ continue
208
+ found.append(create_directive(match, ignore_type, line_num, file_path, full_line=line))
209
+ return found
@@ -0,0 +1,180 @@
1
+ """
2
+ Purpose: Pure utility functions for rule ID parsing and matching
3
+
4
+ Scope: String parsing utilities for comma-separated rule lists and type:ignore formats
5
+
6
+ Overview: Provides pure functions for parsing and matching rule IDs in various formats
7
+ used by the lazy-ignores linter. Handles comma-separated rule lists (e.g.,
8
+ "too-many-arguments,too-many-positional-arguments") and type:ignore bracket
9
+ formats (e.g., "type:ignore[arg-type,return-value]"). Functions are stateless
10
+ and can be easily tested in isolation.
11
+
12
+ Dependencies: None (pure Python string operations)
13
+
14
+ Exports: extract_type_ignore_bracket, split_comma_list, rule_in_comma_list,
15
+ rule_in_type_ignore_bracket, any_part_in_set, comma_list_has_used_rule,
16
+ type_ignore_bracket_has_used_rule
17
+
18
+ Interfaces: All functions take strings/sets and return strings/bools
19
+
20
+ Implementation: String parsing with early returns for invalid formats
21
+ """
22
+
23
+ TYPE_IGNORE_PREFIX = "type:ignore["
24
+
25
+
26
+ def extract_type_ignore_bracket(suppression_key: str) -> str | None:
27
+ """Extract content from type:ignore[...] format.
28
+
29
+ Args:
30
+ suppression_key: String that may be in type:ignore[rules] format
31
+
32
+ Returns:
33
+ Content between brackets, or None if not valid format
34
+ """
35
+ if not suppression_key.startswith(TYPE_IGNORE_PREFIX):
36
+ return None
37
+ if not suppression_key.endswith("]"):
38
+ return None
39
+ return suppression_key[len(TYPE_IGNORE_PREFIX) : -1]
40
+
41
+
42
+ def split_comma_list(content: str) -> list[str]:
43
+ """Split comma-separated string into stripped parts.
44
+
45
+ Args:
46
+ content: Comma-separated string
47
+
48
+ Returns:
49
+ List of stripped parts, or empty list if no commas
50
+ """
51
+ if "," not in content:
52
+ return []
53
+ return [p.strip() for p in content.split(",")]
54
+
55
+
56
+ def rule_in_comma_list(rule_id: str, suppression_key: str) -> bool:
57
+ """Check if rule_id is in a plain comma-separated list.
58
+
59
+ Args:
60
+ rule_id: Normalized rule ID to find
61
+ suppression_key: String that may contain comma-separated rules
62
+
63
+ Returns:
64
+ True if rule_id is found in the comma-separated parts
65
+ """
66
+ parts = split_comma_list(suppression_key)
67
+ return rule_id in parts
68
+
69
+
70
+ def rule_in_type_ignore_bracket(rule_id: str, suppression_key: str) -> bool:
71
+ """Check if rule_id is in type:ignore[rule1,rule2] format.
72
+
73
+ Args:
74
+ rule_id: Normalized rule ID to find
75
+ suppression_key: String that may be in type:ignore[rules] format
76
+
77
+ Returns:
78
+ True if rule_id is found in the bracket content
79
+ """
80
+ bracket_content = extract_type_ignore_bracket(suppression_key)
81
+ if bracket_content is None:
82
+ return False
83
+ parts = split_comma_list(bracket_content)
84
+ return rule_id in parts
85
+
86
+
87
+ def any_part_in_set(content: str, rule_ids: set[str]) -> bool:
88
+ """Check if any comma-separated part of content is in rule_ids.
89
+
90
+ Args:
91
+ content: Comma-separated string
92
+ rule_ids: Set of rule IDs to check against
93
+
94
+ Returns:
95
+ True if any part is in the set
96
+ """
97
+ parts = split_comma_list(content)
98
+ return any(p in rule_ids for p in parts)
99
+
100
+
101
+ def comma_list_has_used_rule(suppression_key: str, used_rule_ids: set[str]) -> bool:
102
+ """Check if any rule in a comma-separated suppression is used.
103
+
104
+ Args:
105
+ suppression_key: Comma-separated suppression key
106
+ used_rule_ids: Set of rule IDs used in code
107
+
108
+ Returns:
109
+ True if any comma-separated rule is in used_rule_ids
110
+ """
111
+ parts = split_comma_list(suppression_key)
112
+ return any(p in used_rule_ids for p in parts)
113
+
114
+
115
+ def type_ignore_bracket_has_used_rule(suppression_key: str, used_rule_ids: set[str]) -> bool:
116
+ """Check if type:ignore[rules] suppression has any used rule.
117
+
118
+ Args:
119
+ suppression_key: String in type:ignore[rules] format
120
+ used_rule_ids: Set of rule IDs used in code
121
+
122
+ Returns:
123
+ True if bracket content or any comma part is in used_rule_ids
124
+ """
125
+ bracket_content = extract_type_ignore_bracket(suppression_key)
126
+ if bracket_content is None:
127
+ return False
128
+ if bracket_content in used_rule_ids:
129
+ return True
130
+ return any_part_in_set(bracket_content, used_rule_ids)
131
+
132
+
133
+ def is_type_ignore_format_in_suppressions(normalized: str, suppressions: dict[str, str]) -> bool:
134
+ """Check if type:ignore[rule] format is in suppressions.
135
+
136
+ Args:
137
+ normalized: Normalized rule ID
138
+ suppressions: Dict of suppression keys to justifications
139
+
140
+ Returns:
141
+ True if type:ignore[normalized] is in suppressions
142
+ """
143
+ return f"type:ignore[{normalized}]" in suppressions
144
+
145
+
146
+ def rule_matches_suppression(rule_id: str, suppression_key: str, is_type_ignore: bool) -> bool:
147
+ """Check if rule_id matches a suppression key (plain or type:ignore format).
148
+
149
+ Args:
150
+ rule_id: Normalized rule ID to find
151
+ suppression_key: Suppression key to check
152
+ is_type_ignore: True if this is a type:ignore directive
153
+
154
+ Returns:
155
+ True if rule_id is found in the suppression key
156
+ """
157
+ if rule_in_comma_list(rule_id, suppression_key):
158
+ return True
159
+ if is_type_ignore:
160
+ return rule_in_type_ignore_bracket(rule_id, suppression_key)
161
+ return False
162
+
163
+
164
+ def find_rule_in_suppressions(
165
+ normalized: str, suppressions: dict[str, str], is_type_ignore: bool
166
+ ) -> bool:
167
+ """Check if rule appears in any comma-separated suppression entry.
168
+
169
+ Args:
170
+ normalized: Normalized rule ID to find
171
+ suppressions: Dict of suppression keys to justifications
172
+ is_type_ignore: True if this is a type:ignore directive
173
+
174
+ Returns:
175
+ True if rule is found in any suppression's comma list
176
+ """
177
+ return any(
178
+ rule_matches_suppression(normalized, suppression_key, is_type_ignore)
179
+ for suppression_key in suppressions
180
+ )
@@ -0,0 +1,298 @@
1
+ """
2
+ Purpose: Detect test skip patterns without proper justification in source code
3
+
4
+ Scope: pytest.mark.skip, pytest.skip(), it.skip(), describe.skip(), test.skip() pattern detection
5
+
6
+ Overview: Provides TestSkipDetector class that scans Python and JavaScript/TypeScript source
7
+ code for test skip patterns that lack proper justification. For Python, detects bare
8
+ @pytest.mark.skip and pytest.skip() without reason arguments. For JavaScript/TypeScript,
9
+ detects it.skip(), describe.skip(), and test.skip() patterns. Returns list of
10
+ IgnoreDirective objects with line/column positions for violation reporting.
11
+
12
+ Dependencies: re for pattern matching, pathlib for file paths, types module for dataclasses
13
+
14
+ Exports: TestSkipDetector
15
+
16
+ Interfaces: find_skips(code: str, file_path: Path | str | None, language: str) -> list[IgnoreDirective]
17
+
18
+ Implementation: Regex-based line-by-line scanning with language-specific pattern detection
19
+ """
20
+
21
+ import re
22
+ from collections.abc import Callable
23
+ from pathlib import Path
24
+
25
+ from src.core.constants import Language
26
+ from src.linters.lazy_ignores.directive_utils import (
27
+ create_directive_no_rules,
28
+ normalize_path,
29
+ )
30
+ from src.linters.lazy_ignores.types import IgnoreDirective, IgnoreType
31
+
32
+
33
+ def _is_comment_line(line: str) -> bool:
34
+ """Check if a line is a Python comment.
35
+
36
+ Args:
37
+ line: Line of code to check
38
+
39
+ Returns:
40
+ True if the line is a comment (starts with # after whitespace)
41
+ """
42
+ return line.strip().startswith("#")
43
+
44
+
45
+ def _scan_empty(_line: str, _line_num: int, _file_path: Path) -> list[IgnoreDirective]:
46
+ """No-op scanner for unsupported languages.
47
+
48
+ Args:
49
+ _line: Unused - required for scanner interface
50
+ _line_num: Unused - required for scanner interface
51
+ _file_path: Unused - required for scanner interface
52
+
53
+ Returns:
54
+ Empty list
55
+ """
56
+ return []
57
+
58
+
59
+ def _count_unescaped_triple_quotes(line: str, quote: str) -> int:
60
+ """Count unescaped triple-quote occurrences in a line.
61
+
62
+ Uses regex to find non-escaped triple quotes.
63
+
64
+ Args:
65
+ line: Line to scan
66
+ quote: Triple-quote pattern to count (single or double)
67
+
68
+ Returns:
69
+ Number of unescaped triple-quote occurrences
70
+ """
71
+ escaped_quote = re.escape(quote)
72
+ pattern = re.compile(rf"(?<!\\){escaped_quote}")
73
+ return len(pattern.findall(line))
74
+
75
+
76
+ def _update_docstring_state(line: str, quotes: list[str], state: list[bool]) -> None:
77
+ """Update docstring tracking state based on quotes in line.
78
+
79
+ Args:
80
+ line: Line to analyze
81
+ quotes: List of quote patterns to check
82
+ state: Mutable list tracking in-docstring state for each quote type
83
+ """
84
+ for i, quote in enumerate(quotes):
85
+ if _count_unescaped_triple_quotes(line, quote) % 2 == 1:
86
+ state[i] = not state[i]
87
+
88
+
89
+ def _get_python_scannable_lines(code: str) -> list[tuple[int, str]]:
90
+ """Get Python lines that are not inside docstrings.
91
+
92
+ Args:
93
+ code: Source code to analyze
94
+
95
+ Returns:
96
+ List of (line_number, line_text) tuples for scannable lines
97
+ """
98
+ in_docstring = [False, False] # [triple_double, triple_single]
99
+ quotes = ['"""', "'''"]
100
+ scannable: list[tuple[int, str]] = []
101
+
102
+ for line_num, line in enumerate(code.splitlines(), start=1):
103
+ was_in_docstring = in_docstring[0] or in_docstring[1]
104
+ _update_docstring_state(line, quotes, in_docstring)
105
+ if not was_in_docstring:
106
+ scannable.append((line_num, line))
107
+
108
+ return scannable
109
+
110
+
111
+ class TestSkipDetector:
112
+ """Detects test skip patterns without proper justification."""
113
+
114
+ # Python patterns - violations are skips WITHOUT a reason argument
115
+ # These patterns match skips that should be flagged
116
+ # Must appear at start of line (after optional whitespace), not in comments
117
+ PYTHON_VIOLATION_PATTERNS: dict[IgnoreType, re.Pattern[str]] = {
118
+ # Matches @pytest.mark.skip or @pytest.mark.skip() without reason=
119
+ # Requires @ at start of line (after whitespace) to avoid matching in comments
120
+ IgnoreType.PYTEST_SKIP: re.compile(
121
+ r"^\s*@pytest\.mark\.skip(?:\s*\(\s*\))?(?!\s*\(.*reason\s*=)",
122
+ ),
123
+ }
124
+
125
+ # Python patterns that indicate a properly justified skip (no violation)
126
+ PYTHON_ALLOWED_PATTERNS: list[re.Pattern[str]] = [
127
+ # @pytest.mark.skip(reason="...")
128
+ re.compile(r"^\s*@pytest\.mark\.skip\s*\(\s*reason\s*="),
129
+ # @pytest.mark.skipif(..., reason="...")
130
+ re.compile(r"^\s*@pytest\.mark\.skipif\s*\(.*reason\s*="),
131
+ # pytest.skip("reason") - positional reason argument
132
+ re.compile(r"pytest\.skip\s*\(\s*['\"]"),
133
+ # pytest.skip(reason="...")
134
+ re.compile(r"pytest\.skip\s*\(\s*reason\s*="),
135
+ ]
136
+
137
+ # Pattern for bare pytest.skip() - needs special handling
138
+ PYTEST_SKIP_CALL_PATTERN = re.compile(
139
+ r"pytest\.skip\s*\(\s*\)",
140
+ )
141
+
142
+ # JavaScript/TypeScript patterns - these are always violations
143
+ # The proper way is to remove or fix the test, not skip it
144
+ JS_VIOLATION_PATTERNS: dict[IgnoreType, re.Pattern[str]] = {
145
+ IgnoreType.JEST_SKIP: re.compile(
146
+ r"(?:it|test)\.skip\s*\(",
147
+ ),
148
+ IgnoreType.MOCHA_SKIP: re.compile(
149
+ r"describe\.skip\s*\(",
150
+ ),
151
+ }
152
+
153
+ def find_skips(
154
+ self,
155
+ code: str,
156
+ file_path: Path | str | None = None,
157
+ language: str | Language = Language.PYTHON,
158
+ ) -> list[IgnoreDirective]:
159
+ """Find test skip patterns without justification.
160
+
161
+ Tracks docstring state across lines to avoid false positives from
162
+ patterns mentioned in documentation.
163
+
164
+ Args:
165
+ code: Source code to scan
166
+ file_path: Optional path to the source file (Path or string)
167
+ language: Language of source code (Language enum or string)
168
+
169
+ Returns:
170
+ List of IgnoreDirective objects for detected unjustified skips
171
+ """
172
+ effective_path = normalize_path(file_path)
173
+ lang = Language(language) if isinstance(language, str) else language
174
+ scanner = self._get_line_scanner(lang)
175
+
176
+ scannable_lines = self._get_scannable_lines(code, lang)
177
+ directives: list[IgnoreDirective] = []
178
+ for line_num, line in scannable_lines:
179
+ directives.extend(scanner(line, line_num, effective_path))
180
+ return directives
181
+
182
+ def _get_scannable_lines(self, code: str, lang: Language) -> list[tuple[int, str]]:
183
+ """Get lines that are not inside docstrings (Python) or all lines (other).
184
+
185
+ Args:
186
+ code: Source code to analyze
187
+ lang: Programming language
188
+
189
+ Returns:
190
+ List of (line_number, line_text) tuples for scannable lines
191
+ """
192
+ if lang != Language.PYTHON:
193
+ return list(enumerate(code.splitlines(), start=1))
194
+ return _get_python_scannable_lines(code)
195
+
196
+ def _get_line_scanner(
197
+ self, lang: Language
198
+ ) -> Callable[[str, int, Path], list[IgnoreDirective]]:
199
+ """Get the appropriate line scanner for a language.
200
+
201
+ Args:
202
+ lang: Programming language
203
+
204
+ Returns:
205
+ Line scanner function for the language
206
+ """
207
+ if lang == Language.PYTHON:
208
+ return self._scan_python_line
209
+ if lang in (Language.JAVASCRIPT, Language.TYPESCRIPT):
210
+ return self._scan_js_line
211
+ return _scan_empty
212
+
213
+ def _scan_python_line(self, line: str, line_num: int, file_path: Path) -> list[IgnoreDirective]:
214
+ """Scan a Python line for unjustified skip patterns.
215
+
216
+ Args:
217
+ line: Line of code to scan
218
+ line_num: 1-indexed line number
219
+ file_path: Path to the source file
220
+
221
+ Returns:
222
+ List of IgnoreDirective objects found on this line
223
+ """
224
+ if _is_comment_line(line) or self._is_justified_python_skip(line):
225
+ return []
226
+
227
+ found = self._find_decorator_violations(line, line_num, file_path)
228
+ found.extend(self._find_skip_call_violations(line, line_num, file_path))
229
+ return found
230
+
231
+ def _find_decorator_violations(
232
+ self, line: str, line_num: int, file_path: Path
233
+ ) -> list[IgnoreDirective]:
234
+ """Find @pytest.mark.skip decorator violations.
235
+
236
+ Args:
237
+ line: Line of code to scan
238
+ line_num: 1-indexed line number
239
+ file_path: Path to source file
240
+
241
+ Returns:
242
+ List of IgnoreDirective for decorator violations
243
+ """
244
+ found: list[IgnoreDirective] = []
245
+ for ignore_type, pattern in self.PYTHON_VIOLATION_PATTERNS.items():
246
+ match = pattern.search(line)
247
+ if match:
248
+ found.append(create_directive_no_rules(match, ignore_type, line_num, file_path))
249
+ return found
250
+
251
+ def _find_skip_call_violations(
252
+ self, line: str, line_num: int, file_path: Path
253
+ ) -> list[IgnoreDirective]:
254
+ """Find bare pytest.skip() call violations.
255
+
256
+ Args:
257
+ line: Line of code to scan
258
+ line_num: 1-indexed line number
259
+ file_path: Path to source file
260
+
261
+ Returns:
262
+ List of IgnoreDirective for skip call violations
263
+ """
264
+ match = self.PYTEST_SKIP_CALL_PATTERN.search(line)
265
+ if match:
266
+ return [create_directive_no_rules(match, IgnoreType.PYTEST_SKIP, line_num, file_path)]
267
+ return []
268
+
269
+ def _is_justified_python_skip(self, line: str) -> bool:
270
+ """Check if a Python line contains a justified skip.
271
+
272
+ Args:
273
+ line: Line of code to check
274
+
275
+ Returns:
276
+ True if the line has a skip with proper justification
277
+ """
278
+ return any(pattern.search(line) for pattern in self.PYTHON_ALLOWED_PATTERNS)
279
+
280
+ def _scan_js_line(self, line: str, line_num: int, file_path: Path) -> list[IgnoreDirective]:
281
+ """Scan a JavaScript/TypeScript line for skip patterns.
282
+
283
+ Args:
284
+ line: Line of code to scan
285
+ line_num: 1-indexed line number
286
+ file_path: Path to the source file
287
+
288
+ Returns:
289
+ List of IgnoreDirective objects found on this line
290
+ """
291
+ found: list[IgnoreDirective] = []
292
+
293
+ for ignore_type, pattern in self.JS_VIOLATION_PATTERNS.items():
294
+ match = pattern.search(line)
295
+ if match:
296
+ found.append(create_directive_no_rules(match, ignore_type, line_num, file_path))
297
+
298
+ return found