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.
- src/__init__.py +1 -0
- src/analyzers/__init__.py +4 -3
- src/analyzers/ast_utils.py +54 -0
- src/analyzers/rust_base.py +155 -0
- src/analyzers/rust_context.py +141 -0
- src/analyzers/typescript_base.py +4 -0
- src/cli/__init__.py +30 -0
- src/cli/__main__.py +22 -0
- src/cli/config.py +480 -0
- src/cli/config_merge.py +241 -0
- src/cli/linters/__init__.py +67 -0
- src/cli/linters/code_patterns.py +270 -0
- src/cli/linters/code_smells.py +342 -0
- src/cli/linters/documentation.py +83 -0
- src/cli/linters/performance.py +287 -0
- src/cli/linters/shared.py +331 -0
- src/cli/linters/structure.py +327 -0
- src/cli/linters/structure_quality.py +328 -0
- src/cli/main.py +120 -0
- src/cli/utils.py +395 -0
- src/cli_main.py +37 -0
- src/config.py +38 -25
- src/core/base.py +7 -2
- src/core/cli_utils.py +19 -2
- src/core/config_parser.py +5 -2
- src/core/constants.py +54 -0
- src/core/linter_utils.py +95 -6
- src/core/python_lint_rule.py +101 -0
- src/core/registry.py +1 -1
- src/core/rule_discovery.py +147 -84
- src/core/types.py +13 -0
- src/core/violation_builder.py +78 -15
- src/core/violation_utils.py +69 -0
- src/formatters/__init__.py +22 -0
- src/formatters/sarif.py +202 -0
- src/linter_config/directive_markers.py +109 -0
- src/linter_config/ignore.py +254 -395
- src/linter_config/loader.py +45 -12
- src/linter_config/pattern_utils.py +65 -0
- src/linter_config/rule_matcher.py +89 -0
- src/linters/collection_pipeline/__init__.py +90 -0
- src/linters/collection_pipeline/any_all_analyzer.py +281 -0
- src/linters/collection_pipeline/ast_utils.py +40 -0
- src/linters/collection_pipeline/config.py +75 -0
- src/linters/collection_pipeline/continue_analyzer.py +94 -0
- src/linters/collection_pipeline/detector.py +360 -0
- src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
- src/linters/collection_pipeline/linter.py +420 -0
- src/linters/collection_pipeline/suggestion_builder.py +130 -0
- src/linters/cqs/__init__.py +54 -0
- src/linters/cqs/config.py +55 -0
- src/linters/cqs/function_analyzer.py +201 -0
- src/linters/cqs/input_detector.py +139 -0
- src/linters/cqs/linter.py +159 -0
- src/linters/cqs/output_detector.py +84 -0
- src/linters/cqs/python_analyzer.py +54 -0
- src/linters/cqs/types.py +82 -0
- src/linters/cqs/typescript_cqs_analyzer.py +61 -0
- src/linters/cqs/typescript_function_analyzer.py +192 -0
- src/linters/cqs/typescript_input_detector.py +203 -0
- src/linters/cqs/typescript_output_detector.py +117 -0
- src/linters/cqs/violation_builder.py +94 -0
- src/linters/dry/base_token_analyzer.py +16 -9
- src/linters/dry/block_filter.py +120 -20
- src/linters/dry/block_grouper.py +4 -0
- src/linters/dry/cache.py +104 -10
- src/linters/dry/cache_query.py +4 -0
- src/linters/dry/config.py +54 -11
- src/linters/dry/constant.py +92 -0
- src/linters/dry/constant_matcher.py +223 -0
- src/linters/dry/constant_violation_builder.py +98 -0
- src/linters/dry/duplicate_storage.py +5 -4
- src/linters/dry/file_analyzer.py +4 -2
- src/linters/dry/inline_ignore.py +7 -16
- src/linters/dry/linter.py +183 -48
- src/linters/dry/python_analyzer.py +60 -439
- src/linters/dry/python_constant_extractor.py +100 -0
- src/linters/dry/single_statement_detector.py +417 -0
- src/linters/dry/token_hasher.py +116 -112
- src/linters/dry/typescript_analyzer.py +68 -382
- src/linters/dry/typescript_constant_extractor.py +138 -0
- src/linters/dry/typescript_statement_detector.py +255 -0
- src/linters/dry/typescript_value_extractor.py +70 -0
- src/linters/dry/violation_builder.py +4 -0
- src/linters/dry/violation_filter.py +5 -4
- src/linters/dry/violation_generator.py +71 -14
- src/linters/file_header/atemporal_detector.py +68 -50
- src/linters/file_header/base_parser.py +93 -0
- src/linters/file_header/bash_parser.py +66 -0
- src/linters/file_header/config.py +90 -16
- src/linters/file_header/css_parser.py +70 -0
- src/linters/file_header/field_validator.py +36 -33
- src/linters/file_header/linter.py +140 -144
- src/linters/file_header/markdown_parser.py +130 -0
- src/linters/file_header/python_parser.py +14 -58
- src/linters/file_header/typescript_parser.py +73 -0
- src/linters/file_header/violation_builder.py +13 -12
- src/linters/file_placement/config_loader.py +3 -1
- src/linters/file_placement/directory_matcher.py +4 -0
- src/linters/file_placement/linter.py +66 -34
- src/linters/file_placement/pattern_matcher.py +41 -6
- src/linters/file_placement/pattern_validator.py +31 -12
- src/linters/file_placement/rule_checker.py +12 -7
- src/linters/lazy_ignores/__init__.py +43 -0
- src/linters/lazy_ignores/config.py +74 -0
- src/linters/lazy_ignores/directive_utils.py +164 -0
- src/linters/lazy_ignores/header_parser.py +177 -0
- src/linters/lazy_ignores/linter.py +158 -0
- src/linters/lazy_ignores/matcher.py +168 -0
- src/linters/lazy_ignores/python_analyzer.py +209 -0
- src/linters/lazy_ignores/rule_id_utils.py +180 -0
- src/linters/lazy_ignores/skip_detector.py +298 -0
- src/linters/lazy_ignores/types.py +71 -0
- src/linters/lazy_ignores/typescript_analyzer.py +146 -0
- src/linters/lazy_ignores/violation_builder.py +135 -0
- src/linters/lbyl/__init__.py +31 -0
- src/linters/lbyl/config.py +63 -0
- src/linters/lbyl/linter.py +67 -0
- src/linters/lbyl/pattern_detectors/__init__.py +53 -0
- src/linters/lbyl/pattern_detectors/base.py +63 -0
- src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
- src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
- src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
- src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
- src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
- src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
- src/linters/lbyl/python_analyzer.py +215 -0
- src/linters/lbyl/violation_builder.py +354 -0
- src/linters/magic_numbers/context_analyzer.py +227 -225
- src/linters/magic_numbers/linter.py +28 -82
- src/linters/magic_numbers/python_analyzer.py +4 -16
- src/linters/magic_numbers/typescript_analyzer.py +9 -12
- src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
- src/linters/method_property/__init__.py +49 -0
- src/linters/method_property/config.py +138 -0
- src/linters/method_property/linter.py +414 -0
- src/linters/method_property/python_analyzer.py +473 -0
- src/linters/method_property/violation_builder.py +119 -0
- src/linters/nesting/linter.py +24 -16
- src/linters/nesting/python_analyzer.py +4 -0
- src/linters/nesting/typescript_analyzer.py +6 -12
- src/linters/nesting/violation_builder.py +1 -0
- src/linters/performance/__init__.py +91 -0
- src/linters/performance/config.py +43 -0
- src/linters/performance/constants.py +49 -0
- src/linters/performance/linter.py +149 -0
- src/linters/performance/python_analyzer.py +365 -0
- src/linters/performance/regex_analyzer.py +312 -0
- src/linters/performance/regex_linter.py +139 -0
- src/linters/performance/typescript_analyzer.py +236 -0
- src/linters/performance/violation_builder.py +160 -0
- src/linters/print_statements/config.py +7 -12
- src/linters/print_statements/linter.py +26 -43
- src/linters/print_statements/python_analyzer.py +91 -93
- src/linters/print_statements/typescript_analyzer.py +15 -25
- src/linters/print_statements/violation_builder.py +12 -14
- src/linters/srp/class_analyzer.py +11 -7
- src/linters/srp/heuristics.py +56 -22
- src/linters/srp/linter.py +15 -16
- src/linters/srp/python_analyzer.py +55 -20
- src/linters/srp/typescript_metrics_calculator.py +110 -50
- src/linters/stateless_class/__init__.py +25 -0
- src/linters/stateless_class/config.py +58 -0
- src/linters/stateless_class/linter.py +349 -0
- src/linters/stateless_class/python_analyzer.py +290 -0
- src/linters/stringly_typed/__init__.py +36 -0
- src/linters/stringly_typed/config.py +189 -0
- src/linters/stringly_typed/context_filter.py +451 -0
- src/linters/stringly_typed/function_call_violation_builder.py +135 -0
- src/linters/stringly_typed/ignore_checker.py +100 -0
- src/linters/stringly_typed/ignore_utils.py +51 -0
- src/linters/stringly_typed/linter.py +376 -0
- src/linters/stringly_typed/python/__init__.py +33 -0
- src/linters/stringly_typed/python/analyzer.py +348 -0
- src/linters/stringly_typed/python/call_tracker.py +175 -0
- src/linters/stringly_typed/python/comparison_tracker.py +257 -0
- src/linters/stringly_typed/python/condition_extractor.py +134 -0
- src/linters/stringly_typed/python/conditional_detector.py +179 -0
- src/linters/stringly_typed/python/constants.py +21 -0
- src/linters/stringly_typed/python/match_analyzer.py +94 -0
- src/linters/stringly_typed/python/validation_detector.py +189 -0
- src/linters/stringly_typed/python/variable_extractor.py +96 -0
- src/linters/stringly_typed/storage.py +620 -0
- src/linters/stringly_typed/storage_initializer.py +45 -0
- src/linters/stringly_typed/typescript/__init__.py +28 -0
- src/linters/stringly_typed/typescript/analyzer.py +157 -0
- src/linters/stringly_typed/typescript/call_tracker.py +335 -0
- src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
- src/linters/stringly_typed/violation_generator.py +419 -0
- src/orchestrator/core.py +252 -14
- src/orchestrator/language_detector.py +5 -3
- src/templates/thailint_config_template.yaml +196 -0
- src/utils/project_root.py +3 -0
- thailint-0.15.3.dist-info/METADATA +187 -0
- thailint-0.15.3.dist-info/RECORD +226 -0
- thailint-0.15.3.dist-info/entry_points.txt +4 -0
- src/cli.py +0 -1665
- thailint-0.5.0.dist-info/METADATA +0 -1286
- thailint-0.5.0.dist-info/RECORD +0 -96
- thailint-0.5.0.dist-info/entry_points.txt +0 -4
- {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +0 -0
- {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
|