thailint 0.12.0__py3-none-any.whl → 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. src/analyzers/__init__.py +4 -3
  2. src/analyzers/ast_utils.py +54 -0
  3. src/analyzers/typescript_base.py +4 -0
  4. src/cli/__init__.py +3 -0
  5. src/cli/config.py +12 -12
  6. src/cli/config_merge.py +241 -0
  7. src/cli/linters/__init__.py +3 -0
  8. src/cli/linters/code_patterns.py +113 -5
  9. src/cli/linters/code_smells.py +4 -0
  10. src/cli/linters/documentation.py +3 -0
  11. src/cli/linters/structure.py +3 -0
  12. src/cli/linters/structure_quality.py +3 -0
  13. src/cli_main.py +3 -0
  14. src/config.py +2 -1
  15. src/core/base.py +3 -2
  16. src/core/cli_utils.py +3 -1
  17. src/core/config_parser.py +5 -2
  18. src/core/constants.py +54 -0
  19. src/core/linter_utils.py +4 -0
  20. src/core/rule_discovery.py +5 -1
  21. src/core/violation_builder.py +3 -0
  22. src/linter_config/directive_markers.py +109 -0
  23. src/linter_config/ignore.py +225 -383
  24. src/linter_config/pattern_utils.py +65 -0
  25. src/linter_config/rule_matcher.py +89 -0
  26. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  27. src/linters/collection_pipeline/ast_utils.py +40 -0
  28. src/linters/collection_pipeline/config.py +12 -0
  29. src/linters/collection_pipeline/continue_analyzer.py +2 -8
  30. src/linters/collection_pipeline/detector.py +262 -32
  31. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  32. src/linters/collection_pipeline/linter.py +18 -35
  33. src/linters/collection_pipeline/suggestion_builder.py +68 -1
  34. src/linters/dry/base_token_analyzer.py +16 -9
  35. src/linters/dry/block_filter.py +7 -4
  36. src/linters/dry/cache.py +7 -2
  37. src/linters/dry/config.py +7 -1
  38. src/linters/dry/constant_matcher.py +34 -25
  39. src/linters/dry/file_analyzer.py +4 -2
  40. src/linters/dry/inline_ignore.py +7 -16
  41. src/linters/dry/linter.py +48 -25
  42. src/linters/dry/python_analyzer.py +18 -10
  43. src/linters/dry/python_constant_extractor.py +51 -52
  44. src/linters/dry/single_statement_detector.py +14 -12
  45. src/linters/dry/token_hasher.py +115 -115
  46. src/linters/dry/typescript_analyzer.py +11 -6
  47. src/linters/dry/typescript_constant_extractor.py +4 -0
  48. src/linters/dry/typescript_statement_detector.py +208 -208
  49. src/linters/dry/typescript_value_extractor.py +3 -0
  50. src/linters/dry/violation_filter.py +1 -4
  51. src/linters/dry/violation_generator.py +1 -4
  52. src/linters/file_header/atemporal_detector.py +4 -0
  53. src/linters/file_header/base_parser.py +4 -0
  54. src/linters/file_header/bash_parser.py +4 -0
  55. src/linters/file_header/field_validator.py +5 -8
  56. src/linters/file_header/linter.py +19 -12
  57. src/linters/file_header/markdown_parser.py +6 -0
  58. src/linters/file_placement/config_loader.py +3 -1
  59. src/linters/file_placement/linter.py +22 -8
  60. src/linters/file_placement/pattern_matcher.py +21 -4
  61. src/linters/file_placement/pattern_validator.py +21 -7
  62. src/linters/file_placement/rule_checker.py +2 -2
  63. src/linters/lazy_ignores/__init__.py +43 -0
  64. src/linters/lazy_ignores/config.py +66 -0
  65. src/linters/lazy_ignores/directive_utils.py +121 -0
  66. src/linters/lazy_ignores/header_parser.py +177 -0
  67. src/linters/lazy_ignores/linter.py +158 -0
  68. src/linters/lazy_ignores/matcher.py +135 -0
  69. src/linters/lazy_ignores/python_analyzer.py +201 -0
  70. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  71. src/linters/lazy_ignores/skip_detector.py +298 -0
  72. src/linters/lazy_ignores/types.py +67 -0
  73. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  74. src/linters/lazy_ignores/violation_builder.py +131 -0
  75. src/linters/lbyl/__init__.py +29 -0
  76. src/linters/lbyl/config.py +63 -0
  77. src/linters/lbyl/pattern_detectors/__init__.py +25 -0
  78. src/linters/lbyl/pattern_detectors/base.py +46 -0
  79. src/linters/magic_numbers/context_analyzer.py +227 -229
  80. src/linters/magic_numbers/linter.py +20 -15
  81. src/linters/magic_numbers/python_analyzer.py +4 -16
  82. src/linters/magic_numbers/typescript_analyzer.py +9 -16
  83. src/linters/method_property/config.py +4 -0
  84. src/linters/method_property/linter.py +5 -4
  85. src/linters/method_property/python_analyzer.py +5 -4
  86. src/linters/method_property/violation_builder.py +3 -0
  87. src/linters/nesting/typescript_analyzer.py +6 -12
  88. src/linters/nesting/typescript_function_extractor.py +0 -4
  89. src/linters/print_statements/linter.py +6 -4
  90. src/linters/print_statements/python_analyzer.py +85 -81
  91. src/linters/print_statements/typescript_analyzer.py +6 -15
  92. src/linters/srp/heuristics.py +4 -4
  93. src/linters/srp/linter.py +12 -12
  94. src/linters/srp/violation_builder.py +0 -4
  95. src/linters/stateless_class/linter.py +30 -36
  96. src/linters/stateless_class/python_analyzer.py +11 -20
  97. src/linters/stringly_typed/config.py +4 -5
  98. src/linters/stringly_typed/context_filter.py +410 -410
  99. src/linters/stringly_typed/function_call_violation_builder.py +93 -95
  100. src/linters/stringly_typed/linter.py +48 -16
  101. src/linters/stringly_typed/python/analyzer.py +5 -1
  102. src/linters/stringly_typed/python/call_tracker.py +8 -5
  103. src/linters/stringly_typed/python/comparison_tracker.py +10 -5
  104. src/linters/stringly_typed/python/condition_extractor.py +3 -0
  105. src/linters/stringly_typed/python/conditional_detector.py +4 -1
  106. src/linters/stringly_typed/python/match_analyzer.py +8 -2
  107. src/linters/stringly_typed/python/validation_detector.py +3 -0
  108. src/linters/stringly_typed/storage.py +14 -14
  109. src/linters/stringly_typed/typescript/call_tracker.py +9 -3
  110. src/linters/stringly_typed/typescript/comparison_tracker.py +9 -3
  111. src/linters/stringly_typed/violation_generator.py +288 -259
  112. src/orchestrator/core.py +13 -4
  113. src/templates/thailint_config_template.yaml +166 -0
  114. src/utils/project_root.py +3 -0
  115. thailint-0.13.0.dist-info/METADATA +184 -0
  116. thailint-0.13.0.dist-info/RECORD +189 -0
  117. thailint-0.12.0.dist-info/METADATA +0 -1667
  118. thailint-0.12.0.dist-info/RECORD +0 -164
  119. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/WHEEL +0 -0
  120. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/entry_points.txt +0 -0
  121. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,158 @@
1
+ """
2
+ Purpose: Main LazyIgnoresRule class for detecting unjustified linting suppressions
3
+
4
+ Scope: Orchestration of ignore detection and header suppression validation
5
+
6
+ Overview: Provides LazyIgnoresRule that cross-references linting ignore directives found
7
+ in code (noqa, type:ignore, pylint:disable, nosec) and test skip patterns with
8
+ Suppressions entries declared in file headers. Detects two types of violations:
9
+ unjustified ignores/skips (directive without header declaration) and orphaned
10
+ suppressions (header declaration without matching ignore in code). Enforces the
11
+ header-based suppression model requiring human approval for all linting bypasses.
12
+
13
+ Dependencies: PythonIgnoreDetector, TestSkipDetector, SuppressionsParser, IgnoreSuppressionMatcher
14
+
15
+ Exports: LazyIgnoresRule
16
+
17
+ Interfaces: check(context: BaseLintContext) -> list[Violation]
18
+
19
+ Implementation: Delegation to matcher for cross-reference logic, violation builder for messages
20
+ """
21
+
22
+ from pathlib import Path
23
+
24
+ from src.core.base import BaseLintContext, BaseLintRule
25
+ from src.core.constants import Language
26
+ from src.core.types import Violation
27
+
28
+ from .header_parser import SuppressionsParser
29
+ from .matcher import IgnoreSuppressionMatcher
30
+ from .python_analyzer import PythonIgnoreDetector
31
+ from .skip_detector import TestSkipDetector
32
+ from .types import IgnoreDirective
33
+ from .violation_builder import build_orphaned_violation, build_unjustified_violation
34
+
35
+
36
+ class LazyIgnoresRule(BaseLintRule):
37
+ """Detects unjustified linting suppressions and orphaned header entries."""
38
+
39
+ def __init__(self, check_test_skips: bool = True) -> None:
40
+ """Initialize the lazy ignores rule with detection components.
41
+
42
+ Args:
43
+ check_test_skips: Whether to check for unjustified test skips.
44
+ """
45
+ self._python_detector = PythonIgnoreDetector()
46
+ self._test_skip_detector = TestSkipDetector()
47
+ self._suppression_parser = SuppressionsParser()
48
+ self._matcher = IgnoreSuppressionMatcher(self._suppression_parser)
49
+ self._check_test_skips = check_test_skips
50
+
51
+ @property
52
+ def rule_id(self) -> str:
53
+ """Unique identifier for this rule."""
54
+ return "lazy-ignores"
55
+
56
+ @property
57
+ def rule_name(self) -> str:
58
+ """Human-readable name for this rule."""
59
+ return "Lazy Ignores"
60
+
61
+ @property
62
+ def description(self) -> str:
63
+ """Description of what this rule checks."""
64
+ return (
65
+ "Detects linting suppressions (noqa, type:ignore, pylint:disable, nosec) "
66
+ "and test skips without corresponding entries in the file header's "
67
+ "Suppressions section."
68
+ )
69
+
70
+ def check(self, context: BaseLintContext) -> list[Violation]:
71
+ """Check for violations in the given context.
72
+
73
+ Args:
74
+ context: The lint context containing file information.
75
+
76
+ Returns:
77
+ List of violations for unjustified and orphaned suppressions.
78
+ """
79
+ if context.language != Language.PYTHON:
80
+ return []
81
+
82
+ if not context.file_content:
83
+ return []
84
+
85
+ file_path = str(context.file_path) if context.file_path else "unknown"
86
+ return self.check_content(context.file_content, file_path)
87
+
88
+ def check_content(self, code: str, file_path: str) -> list[Violation]:
89
+ """Check code for unjustified ignores and orphaned suppressions.
90
+
91
+ Args:
92
+ code: Source code content to analyze.
93
+ file_path: Path to the file being analyzed.
94
+
95
+ Returns:
96
+ List of violations for unjustified and orphaned suppressions.
97
+ """
98
+ # Extract and parse header suppressions
99
+ header = self._suppression_parser.extract_header(code, "python")
100
+ suppressions = self._suppression_parser.parse(header)
101
+
102
+ # Find all ignore directives in code
103
+ ignores = self._python_detector.find_ignores(code, Path(file_path))
104
+
105
+ # Find test skip directives if enabled
106
+ if self._check_test_skips:
107
+ test_skips = self._test_skip_detector.find_skips(code, Path(file_path), "python")
108
+ ignores = list(ignores) + list(test_skips)
109
+
110
+ # Build set of normalized rule IDs used in code
111
+ used_rule_ids = self._matcher.collect_used_rule_ids(ignores)
112
+
113
+ # Find violations
114
+ violations: list[Violation] = []
115
+ violations.extend(self._find_unjustified(ignores, suppressions, file_path))
116
+ violations.extend(self._find_orphaned(suppressions, used_rule_ids, file_path))
117
+
118
+ return violations
119
+
120
+ def _find_unjustified(
121
+ self, ignores: list[IgnoreDirective], suppressions: dict[str, str], file_path: str
122
+ ) -> list[Violation]:
123
+ """Find ignore directives without matching header suppressions."""
124
+ violations: list[Violation] = []
125
+
126
+ for ignore in ignores:
127
+ unjustified = self._matcher.find_unjustified_rule_ids(ignore, suppressions)
128
+ if unjustified:
129
+ violations.append(
130
+ build_unjustified_violation(
131
+ file_path=file_path,
132
+ line=ignore.line,
133
+ column=ignore.column,
134
+ rule_id=", ".join(unjustified),
135
+ raw_text=ignore.raw_text,
136
+ )
137
+ )
138
+
139
+ return violations
140
+
141
+ def _find_orphaned(
142
+ self, suppressions: dict[str, str], used_rule_ids: set[str], file_path: str
143
+ ) -> list[Violation]:
144
+ """Find header suppressions without matching code ignores."""
145
+ violations: list[Violation] = []
146
+ orphaned = self._matcher.find_orphaned_rule_ids(suppressions, used_rule_ids)
147
+
148
+ for rule_id, justification in orphaned:
149
+ violations.append(
150
+ build_orphaned_violation(
151
+ file_path=file_path,
152
+ header_line=1, # Header entries are at file start
153
+ rule_id=rule_id,
154
+ justification=justification,
155
+ )
156
+ )
157
+
158
+ return violations
@@ -0,0 +1,135 @@
1
+ """
2
+ Purpose: Cross-reference matcher for lazy-ignores linter
3
+
4
+ Scope: Matching ignore directives with header suppressions
5
+
6
+ Overview: Provides IgnoreSuppressionMatcher class that cross-references linting ignore
7
+ directives found in code with Suppressions entries declared in file headers. Handles
8
+ case-insensitive rule ID normalization and special patterns like type:ignore[code].
9
+ Identifies unjustified ignores (code ignores without header entries) and orphaned
10
+ suppressions (header entries without matching code ignores).
11
+
12
+ Dependencies: SuppressionsParser for normalization, types for IgnoreDirective and IgnoreType,
13
+ rule_id_utils for pure parsing functions
14
+
15
+ Exports: IgnoreSuppressionMatcher
16
+
17
+ Interfaces: find_unjustified(), find_orphaned()
18
+
19
+ Implementation: Set-based matching with rule ID normalization for case-insensitive comparison
20
+ """
21
+
22
+ from .header_parser import SuppressionsParser
23
+ from .rule_id_utils import (
24
+ comma_list_has_used_rule,
25
+ find_rule_in_suppressions,
26
+ is_type_ignore_format_in_suppressions,
27
+ type_ignore_bracket_has_used_rule,
28
+ )
29
+ from .types import IgnoreDirective, IgnoreType
30
+
31
+
32
+ class IgnoreSuppressionMatcher:
33
+ """Matches ignore directives with header suppressions."""
34
+
35
+ def __init__(self, parser: SuppressionsParser) -> None:
36
+ """Initialize the matcher.
37
+
38
+ Args:
39
+ parser: SuppressionsParser for rule ID normalization.
40
+ """
41
+ self._parser = parser
42
+
43
+ def collect_used_rule_ids(self, ignores: list[IgnoreDirective]) -> set[str]:
44
+ """Collect all normalized rule IDs used in ignore directives.
45
+
46
+ Args:
47
+ ignores: List of ignore directives from code.
48
+
49
+ Returns:
50
+ Set of normalized rule IDs that have ignore directives.
51
+ """
52
+ used: set[str] = set()
53
+ for ignore in ignores:
54
+ used.update(self._get_matchable_rule_ids(ignore))
55
+ return used
56
+
57
+ def _get_matchable_rule_ids(self, ignore: IgnoreDirective) -> list[str]:
58
+ """Get normalized rule IDs for matching, handling special formats."""
59
+ if not ignore.rule_ids:
60
+ return [self._normalize(ignore.ignore_type.value)]
61
+
62
+ ids: list[str] = []
63
+ for rule_id in ignore.rule_ids:
64
+ normalized = self._normalize(rule_id)
65
+ ids.append(normalized)
66
+ if ignore.ignore_type == IgnoreType.TYPE_IGNORE:
67
+ ids.append(f"type:ignore[{normalized}]")
68
+ return ids
69
+
70
+ def find_unjustified_rule_ids(
71
+ self, ignore: IgnoreDirective, suppressions: dict[str, str]
72
+ ) -> list[str]:
73
+ """Find which rule IDs in an ignore are not justified.
74
+
75
+ Args:
76
+ ignore: The ignore directive to check.
77
+ suppressions: Dict of normalized rule IDs to justifications.
78
+
79
+ Returns:
80
+ List of unjustified rule IDs (original case preserved).
81
+ """
82
+ if not ignore.rule_ids:
83
+ type_key = self._normalize(ignore.ignore_type.value)
84
+ if type_key not in suppressions:
85
+ return [ignore.ignore_type.value]
86
+ return []
87
+
88
+ unjustified: list[str] = []
89
+ for rule_id in ignore.rule_ids:
90
+ if not self._is_rule_justified(ignore, rule_id, suppressions):
91
+ unjustified.append(rule_id)
92
+ return unjustified
93
+
94
+ def _is_rule_justified(
95
+ self, ignore: IgnoreDirective, rule_id: str, suppressions: dict[str, str]
96
+ ) -> bool:
97
+ """Check if a specific rule ID is justified in suppressions."""
98
+ normalized = self._normalize(rule_id)
99
+ is_type_ignore = ignore.ignore_type == IgnoreType.TYPE_IGNORE
100
+
101
+ if normalized in suppressions:
102
+ return True
103
+ if is_type_ignore and is_type_ignore_format_in_suppressions(normalized, suppressions):
104
+ return True
105
+ return find_rule_in_suppressions(normalized, suppressions, is_type_ignore)
106
+
107
+ def find_orphaned_rule_ids(
108
+ self, suppressions: dict[str, str], used_rule_ids: set[str]
109
+ ) -> list[tuple[str, str]]:
110
+ """Find header suppressions without matching code ignores.
111
+
112
+ Args:
113
+ suppressions: Dict mapping normalized rule IDs to justifications.
114
+ used_rule_ids: Set of normalized rule IDs used in code.
115
+
116
+ Returns:
117
+ List of (rule_id, justification) tuples for orphaned suppressions.
118
+ """
119
+ orphaned: list[tuple[str, str]] = []
120
+ for rule_id, justification in suppressions.items():
121
+ if not self._suppression_is_used(rule_id, used_rule_ids):
122
+ orphaned.append((rule_id.upper(), justification))
123
+ return orphaned
124
+
125
+ def _suppression_is_used(self, suppression_key: str, used_rule_ids: set[str]) -> bool:
126
+ """Check if a suppression key is used by any code ignores."""
127
+ if suppression_key in used_rule_ids:
128
+ return True
129
+ if comma_list_has_used_rule(suppression_key, used_rule_ids):
130
+ return True
131
+ return type_ignore_bracket_has_used_rule(suppression_key, used_rule_ids)
132
+
133
+ def _normalize(self, rule_id: str) -> str:
134
+ """Normalize a rule ID for case-insensitive matching."""
135
+ return self._parser.normalize_rule_id(rule_id)
@@ -0,0 +1,201 @@
1
+ """
2
+ Purpose: Detect Python linting ignore directives in source code
3
+
4
+ Scope: noqa, type:ignore, pylint:disable, nosec 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.THAILINT_IGNORE: re.compile(
110
+ r"#\s*thailint:\s*ignore(?!-)(?:\[([^\]]+)\])?",
111
+ re.IGNORECASE,
112
+ ),
113
+ IgnoreType.THAILINT_IGNORE_FILE: re.compile(
114
+ r"#\s*thailint:\s*ignore-file(?:\[([^\]]+)\])?",
115
+ re.IGNORECASE,
116
+ ),
117
+ IgnoreType.THAILINT_IGNORE_NEXT: re.compile(
118
+ r"#\s*thailint:\s*ignore-next-line(?:\[([^\]]+)\])?",
119
+ re.IGNORECASE,
120
+ ),
121
+ IgnoreType.THAILINT_IGNORE_BLOCK: re.compile(
122
+ r"#\s*thailint:\s*ignore-start(?:\[([^\]]+)\])?",
123
+ re.IGNORECASE,
124
+ ),
125
+ }
126
+
127
+ def find_ignores(self, code: str, file_path: Path | None = None) -> list[IgnoreDirective]:
128
+ """Find all Python ignore directives in code.
129
+
130
+ Tracks docstring state across lines to avoid false positives from
131
+ patterns mentioned in documentation.
132
+
133
+ Args:
134
+ code: Python source code to scan
135
+ file_path: Optional path to the source file
136
+
137
+ Returns:
138
+ List of IgnoreDirective objects for each detected ignore pattern
139
+ """
140
+ effective_path = file_path or Path("unknown")
141
+ scannable_lines = self._get_scannable_lines(code)
142
+ directives: list[IgnoreDirective] = []
143
+ for line_num, line in scannable_lines:
144
+ directives.extend(self._scan_line(line, line_num, effective_path))
145
+ return directives
146
+
147
+ def _get_scannable_lines(self, code: str) -> list[tuple[int, str]]:
148
+ """Get lines that are not inside docstrings.
149
+
150
+ Args:
151
+ code: Source code to analyze
152
+
153
+ Returns:
154
+ List of (line_number, line_text) tuples for scannable lines
155
+ """
156
+ in_docstring = [False, False] # [triple_double, triple_single]
157
+ quotes = ['"""', "'''"]
158
+ scannable: list[tuple[int, str]] = []
159
+
160
+ for line_num, line in enumerate(code.splitlines(), start=1):
161
+ was_in_docstring = in_docstring[0] or in_docstring[1]
162
+ self._update_docstring_state(line, quotes, in_docstring)
163
+ if not was_in_docstring:
164
+ scannable.append((line_num, line))
165
+
166
+ return scannable
167
+
168
+ def _update_docstring_state(self, line: str, quotes: list[str], state: list[bool]) -> None:
169
+ """Update docstring tracking state based on quotes in line.
170
+
171
+ Args:
172
+ line: Line to analyze
173
+ quotes: List of quote patterns to check
174
+ state: Mutable list tracking in-docstring state for each quote type
175
+ """
176
+ for i, quote in enumerate(quotes):
177
+ if _count_unescaped_triple_quotes(line, quote) % 2 == 1:
178
+ state[i] = not state[i]
179
+
180
+ def _scan_line(self, line: str, line_num: int, file_path: Path) -> list[IgnoreDirective]:
181
+ """Scan a single line for ignore patterns.
182
+
183
+ Skips patterns that appear inside string literals.
184
+
185
+ Args:
186
+ line: Line of code to scan
187
+ line_num: 1-indexed line number
188
+ file_path: Path to the source file
189
+
190
+ Returns:
191
+ List of IgnoreDirective objects found on this line
192
+ """
193
+ found: list[IgnoreDirective] = []
194
+ for ignore_type, pattern in self.PATTERNS.items():
195
+ match = pattern.search(line)
196
+ if not match:
197
+ continue
198
+ if _is_pattern_in_string_literal(line, match.start()):
199
+ continue
200
+ found.append(create_directive(match, ignore_type, line_num, file_path))
201
+ 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
+ )