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
@@ -20,6 +20,10 @@ Interfaces: lint_path(file_path) -> list[Violation], check_file_allowed(file_pat
20
20
  Implementation: Composition pattern with helper classes for each responsibility
21
21
  (ConfigLoader, PathResolver, PatternMatcher, PatternValidator, RuleChecker,
22
22
  ViolationFactory)
23
+
24
+ Suppressions:
25
+ - srp.violation: Rule class coordinates multiple helper classes for comprehensive
26
+ file placement validation. Method count reflects composition orchestration.
23
27
  """
24
28
 
25
29
  import json
@@ -72,21 +76,31 @@ class FilePlacementLinter:
72
76
 
73
77
  # Load and validate config
74
78
  if config_obj:
75
- # Handle both wrapped and unwrapped config formats
76
- # Wrapped: {"file-placement": {...}} or {"file_placement": {...}}
77
- # Unwrapped: {"directories": {...}, "global_deny": [...], ...}
78
- # Try both hyphenated and underscored keys for backward compatibility
79
- self.config = config_obj.get(
80
- "file-placement", config_obj.get("file_placement", config_obj)
81
- )
79
+ self.config = self._unwrap_config(config_obj)
82
80
  elif config_file:
83
- self.config = self._components.config_loader.load_config_file(config_file)
81
+ raw_config = self._components.config_loader.load_config_file(config_file)
82
+ self.config = self._unwrap_config(raw_config)
84
83
  else:
85
84
  self.config = {}
86
85
 
87
86
  # Validate regex patterns in config
88
87
  self._components.pattern_validator.validate_config(self.config)
89
88
 
89
+ def _unwrap_config(self, config: dict[str, Any]) -> dict[str, Any]:
90
+ """Unwrap file-placement config from wrapper if present.
91
+
92
+ Args:
93
+ config: Raw config dict (may be wrapped or unwrapped)
94
+
95
+ Returns:
96
+ Unwrapped file-placement config dict
97
+ """
98
+ # Handle both wrapped and unwrapped config formats
99
+ # Wrapped: {"file-placement": {...}} or {"file_placement": {...}}
100
+ # Unwrapped: {"directories": {...}, "global_deny": [...], ...}
101
+ # Try both hyphenated and underscored keys for backward compatibility
102
+ return config.get("file-placement", config.get("file_placement", config))
103
+
90
104
  def lint_path(self, file_path: Path) -> list[Violation]:
91
105
  """Lint a single file path.
92
106
 
@@ -18,6 +18,7 @@ Implementation: Uses re.search() for pattern matching with IGNORECASE flag
18
18
  """
19
19
 
20
20
  import re
21
+ from collections.abc import Sequence
21
22
  from re import Pattern
22
23
 
23
24
 
@@ -42,24 +43,40 @@ class PatternMatcher:
42
43
  return self._compiled_patterns[pattern]
43
44
 
44
45
  def match_deny_patterns(
45
- self, path_str: str, deny_patterns: list[dict[str, str]]
46
+ self, path_str: str, deny_patterns: Sequence[dict[str, str] | str]
46
47
  ) -> tuple[bool, str | None]:
47
48
  """Check if path matches any deny patterns.
48
49
 
49
50
  Args:
50
51
  path_str: File path to check
51
- deny_patterns: List of deny pattern dicts with 'pattern' and 'reason'
52
+ deny_patterns: List of deny patterns (either dicts with 'pattern'/'reason'
53
+ or plain regex strings for backward compatibility)
52
54
 
53
55
  Returns:
54
56
  Tuple of (is_denied, reason)
55
57
  """
56
58
  for deny_item in deny_patterns:
57
- compiled = self._get_compiled(deny_item["pattern"])
59
+ pattern, reason = self._extract_pattern_and_reason(deny_item)
60
+ compiled = self._get_compiled(pattern)
58
61
  if compiled.search(path_str):
59
- reason = deny_item.get("reason", "File not allowed in this location")
60
62
  return True, reason
61
63
  return False, None
62
64
 
65
+ def _extract_pattern_and_reason(self, deny_item: dict[str, str] | str) -> tuple[str, str]:
66
+ """Extract pattern and reason from a deny item.
67
+
68
+ Args:
69
+ deny_item: Either a dict with 'pattern' key or a plain string pattern
70
+
71
+ Returns:
72
+ Tuple of (pattern, reason)
73
+ """
74
+ if isinstance(deny_item, str):
75
+ return deny_item, "File not allowed in this location"
76
+ return deny_item["pattern"], deny_item.get(
77
+ "reason", deny_item.get("message", "File not allowed in this location")
78
+ )
79
+
63
80
  def match_allow_patterns(self, path_str: str, allow_patterns: list[str]) -> bool:
64
81
  """Check if path matches any allow patterns.
65
82
 
@@ -21,6 +21,20 @@ import re
21
21
  from typing import Any
22
22
 
23
23
 
24
+ def _extract_pattern(deny_item: dict[str, str] | str) -> str:
25
+ """Extract pattern from a deny item.
26
+
27
+ Args:
28
+ deny_item: Either a dict with 'pattern' key or a plain string pattern
29
+
30
+ Returns:
31
+ The pattern string
32
+ """
33
+ if isinstance(deny_item, str):
34
+ return deny_item
35
+ return deny_item.get("pattern", "")
36
+
37
+
24
38
  class PatternValidator:
25
39
  """Validates regex patterns in file placement configuration."""
26
40
 
@@ -32,15 +46,15 @@ class PatternValidator:
32
46
  """Validate all regex patterns in configuration.
33
47
 
34
48
  Args:
35
- config: Full configuration dict
49
+ config: File placement configuration dict (already unwrapped)
36
50
 
37
51
  Raises:
38
52
  ValueError: If any regex pattern is invalid
39
53
  """
40
- fp_config = config.get("file-placement", {})
41
- self._validate_directory_patterns(fp_config)
42
- self._validate_global_patterns(fp_config)
43
- self._validate_global_deny_patterns(fp_config)
54
+ # Config is already unwrapped from file-placement key by FilePlacementLinter
55
+ self._validate_directory_patterns(config)
56
+ self._validate_global_patterns(config)
57
+ self._validate_global_deny_patterns(config)
44
58
 
45
59
  def _validate_pattern(self, pattern: str) -> None:
46
60
  """Validate a single regex pattern.
@@ -74,7 +88,7 @@ class PatternValidator:
74
88
  """
75
89
  if "deny" in rules:
76
90
  for deny_item in rules["deny"]:
77
- pattern = deny_item.get("pattern", "")
91
+ pattern = _extract_pattern(deny_item)
78
92
  self._validate_pattern(pattern)
79
93
 
80
94
  def _validate_directory_patterns(self, fp_config: dict[str, Any]) -> None:
@@ -106,5 +120,5 @@ class PatternValidator:
106
120
  """
107
121
  if "global_deny" in fp_config:
108
122
  for deny_item in fp_config["global_deny"]:
109
- pattern = deny_item.get("pattern", "")
123
+ pattern = _extract_pattern(deny_item)
110
124
  self._validate_pattern(pattern)
@@ -177,14 +177,14 @@ class RuleChecker:
177
177
  return [violation] if violation else []
178
178
 
179
179
  def _check_global_deny(
180
- self, path_str: str, rel_path: Path, global_deny: list[dict[str, str]]
180
+ self, path_str: str, rel_path: Path, global_deny: list[dict[str, str] | str]
181
181
  ) -> list[Violation]:
182
182
  """Check file against global deny patterns.
183
183
 
184
184
  Args:
185
185
  path_str: Normalized path string
186
186
  rel_path: Relative path object
187
- global_deny: Global deny patterns
187
+ global_deny: Global deny patterns (dicts with pattern/reason or plain strings)
188
188
 
189
189
  Returns:
190
190
  List of violations
@@ -0,0 +1,43 @@
1
+ """
2
+ Purpose: Lazy-ignores linter package exports
3
+
4
+ Scope: Detect unjustified linting suppressions in code files
5
+
6
+ Overview: Package providing lazy-ignores linter functionality. Detects when AI agents add
7
+ linting suppressions (noqa, type:ignore, pylint:disable, nosec, thailint:ignore, etc.)
8
+ or test skips (pytest.mark.skip, it.skip, describe.skip) without proper justification
9
+ in the file header's Suppressions section. Enforces header-based declaration model
10
+ where all suppressions must be documented with human approval.
11
+
12
+ Dependencies: src.core for base types, re for pattern matching
13
+
14
+ Exports: IgnoreType, IgnoreDirective, SuppressionEntry, LazyIgnoresConfig,
15
+ PythonIgnoreDetector, TypeScriptIgnoreDetector, TestSkipDetector, SuppressionsParser
16
+
17
+ Interfaces: LazyIgnoresConfig.from_dict() for YAML configuration loading
18
+
19
+ Implementation: Enum and dataclass definitions for ignore directive representation
20
+ """
21
+
22
+ from .config import LazyIgnoresConfig
23
+ from .header_parser import SuppressionsParser
24
+ from .linter import LazyIgnoresRule
25
+ from .python_analyzer import PythonIgnoreDetector
26
+ from .skip_detector import TestSkipDetector
27
+ from .types import IgnoreDirective, IgnoreType, SuppressionEntry
28
+ from .typescript_analyzer import TypeScriptIgnoreDetector
29
+ from .violation_builder import build_orphaned_violation, build_unjustified_violation
30
+
31
+ __all__ = [
32
+ "IgnoreType",
33
+ "IgnoreDirective",
34
+ "SuppressionEntry",
35
+ "LazyIgnoresConfig",
36
+ "PythonIgnoreDetector",
37
+ "TypeScriptIgnoreDetector",
38
+ "TestSkipDetector",
39
+ "SuppressionsParser",
40
+ "LazyIgnoresRule",
41
+ "build_unjustified_violation",
42
+ "build_orphaned_violation",
43
+ ]
@@ -0,0 +1,66 @@
1
+ """
2
+ Purpose: Configuration for lazy-ignores linter
3
+
4
+ Scope: All configurable options for ignore detection
5
+
6
+ Overview: Provides LazyIgnoresConfig dataclass with pattern-specific toggles for each
7
+ ignore type (noqa, type:ignore, pylint, nosec, typescript, eslint, thailint). Includes
8
+ orphaned detection toggle and file pattern ignores. Configuration can be loaded from
9
+ dictionary (YAML) with sensible defaults for all options.
10
+
11
+ Dependencies: dataclasses, typing
12
+
13
+ Exports: LazyIgnoresConfig
14
+
15
+ Interfaces: LazyIgnoresConfig.from_dict() for YAML configuration loading
16
+
17
+ Implementation: Dataclass with factory defaults and validation in from_dict
18
+
19
+ Suppressions:
20
+ too-many-instance-attributes: Configuration dataclass requires many toggles
21
+ """
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+
27
+ @dataclass
28
+ class LazyIgnoresConfig: # pylint: disable=too-many-instance-attributes
29
+ """Configuration for the lazy-ignores linter."""
30
+
31
+ # Pattern detection toggles
32
+ check_noqa: bool = True
33
+ check_type_ignore: bool = True
34
+ check_pylint_disable: bool = True
35
+ check_nosec: bool = True
36
+ check_ts_ignore: bool = True
37
+ check_eslint_disable: bool = True
38
+ check_thailint_ignore: bool = True
39
+ check_test_skips: bool = True
40
+
41
+ # Orphaned detection
42
+ check_orphaned: bool = True # Header entries without matching ignores
43
+
44
+ # File patterns to ignore
45
+ ignore_patterns: list[str] = field(
46
+ default_factory=lambda: [
47
+ "tests/**", # Don't enforce in test files by default
48
+ "**/__pycache__/**",
49
+ ]
50
+ )
51
+
52
+ @classmethod
53
+ def from_dict(cls, config_dict: dict[str, Any]) -> "LazyIgnoresConfig":
54
+ """Create config from dictionary."""
55
+ return cls(
56
+ check_noqa=config_dict.get("check_noqa", True),
57
+ check_type_ignore=config_dict.get("check_type_ignore", True),
58
+ check_pylint_disable=config_dict.get("check_pylint_disable", True),
59
+ check_nosec=config_dict.get("check_nosec", True),
60
+ check_ts_ignore=config_dict.get("check_ts_ignore", True),
61
+ check_eslint_disable=config_dict.get("check_eslint_disable", True),
62
+ check_thailint_ignore=config_dict.get("check_thailint_ignore", True),
63
+ check_test_skips=config_dict.get("check_test_skips", True),
64
+ check_orphaned=config_dict.get("check_orphaned", True),
65
+ ignore_patterns=config_dict.get("ignore_patterns", []),
66
+ )
@@ -0,0 +1,121 @@
1
+ """
2
+ Purpose: Shared utility functions for creating IgnoreDirective objects
3
+
4
+ Scope: Common directive creation and path normalization for ignore detectors
5
+
6
+ Overview: Provides shared utility functions used across Python, TypeScript, and test skip
7
+ detectors. Centralizes logic for normalizing file paths, extracting rule IDs from
8
+ regex matches, and creating IgnoreDirective objects to avoid code duplication.
9
+
10
+ Dependencies: re for match handling, pathlib for file paths, types module for dataclasses
11
+
12
+ Exports: normalize_path, extract_rule_ids, create_directive, create_directive_no_rules
13
+
14
+ Interfaces: Pure utility functions with no state
15
+
16
+ Implementation: Simple helper functions for directive creation
17
+ """
18
+
19
+ import re
20
+ from pathlib import Path
21
+
22
+ from src.linters.lazy_ignores.types import IgnoreDirective, IgnoreType
23
+
24
+
25
+ def normalize_path(file_path: Path | str | None) -> Path:
26
+ """Normalize file path to Path object.
27
+
28
+ Args:
29
+ file_path: Path object, string path, or None
30
+
31
+ Returns:
32
+ Path object, defaults to Path("unknown") if None
33
+ """
34
+ if file_path is None:
35
+ return Path("unknown")
36
+ if isinstance(file_path, str):
37
+ return Path(file_path)
38
+ return file_path
39
+
40
+
41
+ def _get_captured_group(match: re.Match[str]) -> str | None:
42
+ """Get the first captured group from a regex match if it exists.
43
+
44
+ Args:
45
+ match: Regex match object
46
+
47
+ Returns:
48
+ Captured group text or None if no capture groups
49
+ """
50
+ if match.lastindex is None or match.lastindex < 1:
51
+ return None
52
+ return match.group(1)
53
+
54
+
55
+ def extract_rule_ids(match: re.Match[str]) -> list[str]:
56
+ """Extract rule IDs from regex match group 1.
57
+
58
+ Args:
59
+ match: Regex match object with optional group 1 containing rule IDs
60
+
61
+ Returns:
62
+ List of rule ID strings, empty if no specific rules
63
+ """
64
+ group = _get_captured_group(match)
65
+ if not group:
66
+ return []
67
+
68
+ ids = [rule_id.strip() for rule_id in group.split(",")]
69
+ return [rule_id for rule_id in ids if rule_id]
70
+
71
+
72
+ def create_directive(
73
+ match: re.Match[str],
74
+ ignore_type: IgnoreType,
75
+ line_num: int,
76
+ file_path: Path,
77
+ rule_ids: tuple[str, ...] | None = None,
78
+ ) -> IgnoreDirective:
79
+ """Create an IgnoreDirective from a regex match.
80
+
81
+ Args:
82
+ match: Regex match object
83
+ ignore_type: Type of ignore pattern
84
+ line_num: 1-indexed line number
85
+ file_path: Path to source file
86
+ rule_ids: Optional tuple of rule IDs; if None, extracts from match group 1
87
+
88
+ Returns:
89
+ IgnoreDirective for this match
90
+ """
91
+ if rule_ids is None:
92
+ rule_ids = tuple(extract_rule_ids(match))
93
+
94
+ return IgnoreDirective(
95
+ ignore_type=ignore_type,
96
+ rule_ids=rule_ids,
97
+ line=line_num,
98
+ column=match.start() + 1,
99
+ raw_text=match.group(0).strip(),
100
+ file_path=file_path,
101
+ )
102
+
103
+
104
+ def create_directive_no_rules(
105
+ match: re.Match[str],
106
+ ignore_type: IgnoreType,
107
+ line_num: int,
108
+ file_path: Path,
109
+ ) -> IgnoreDirective:
110
+ """Create an IgnoreDirective without rule IDs (for patterns like test skips).
111
+
112
+ Args:
113
+ match: Regex match object
114
+ ignore_type: Type of ignore pattern
115
+ line_num: 1-indexed line number
116
+ file_path: Path to source file
117
+
118
+ Returns:
119
+ IgnoreDirective with empty rule_ids tuple
120
+ """
121
+ return create_directive(match, ignore_type, line_num, file_path, rule_ids=())
@@ -0,0 +1,177 @@
1
+ """
2
+ Purpose: Parse Suppressions section from file headers
3
+
4
+ Scope: Python docstrings and TypeScript JSDoc comment header parsing
5
+
6
+ Overview: Provides SuppressionsParser class for extracting the Suppressions section from
7
+ file headers. Parses Python triple-quoted docstrings and TypeScript JSDoc comments.
8
+ Extracts rule IDs and justifications, normalizing rule IDs for case-insensitive matching.
9
+ Returns dictionary mapping normalized rule IDs to their justifications.
10
+
11
+ Dependencies: re for pattern matching, Language enum for type safety
12
+
13
+ Exports: SuppressionsParser
14
+
15
+ Interfaces: parse(header: str) -> dict[str, str], extract_header(code: str, language: Language)
16
+
17
+ Implementation: Regex-based section extraction with line-by-line entry parsing
18
+ """
19
+
20
+ import re
21
+
22
+ from src.core.constants import Language
23
+
24
+
25
+ class SuppressionsParser:
26
+ """Parses Suppressions section from file headers."""
27
+
28
+ # Pattern to find Suppressions section (case-insensitive)
29
+ # Matches "Suppressions:" followed by indented lines
30
+ SUPPRESSIONS_SECTION = re.compile(
31
+ r"Suppressions:\s*\n((?:[ \t]+\S.*\n?)+)",
32
+ re.MULTILINE | re.IGNORECASE,
33
+ )
34
+
35
+ # Pattern for JSDoc-style suppressions (* prefixed lines)
36
+ JSDOC_SUPPRESSIONS_SECTION = re.compile(
37
+ r"Suppressions:\s*\n((?:\s*\*\s+\S.*\n?)+)",
38
+ re.MULTILINE | re.IGNORECASE,
39
+ )
40
+
41
+ # Pattern to parse individual entries (rule_id: justification)
42
+ # Rule IDs can contain colons (e.g., type:ignore[arg-type])
43
+ # Handles list prefixes: "- ", "* ", "• " and plain indented entries
44
+ # Justification must start with word char or underscore to avoid matching continuation lines
45
+ ENTRY_PATTERN = re.compile(
46
+ r"^\s*[-*•]?\s*(.+):\s+([A-Za-z_].*)$",
47
+ re.MULTILINE,
48
+ )
49
+
50
+ def parse(self, header: str) -> dict[str, str]:
51
+ """Parse Suppressions section, return rule_id -> justification mapping.
52
+
53
+ Args:
54
+ header: File header content (docstring or JSDoc)
55
+
56
+ Returns:
57
+ Dictionary mapping normalized rule IDs to justification strings
58
+ """
59
+ # Try standard Python-style first, then JSDoc-style
60
+ section_match = self.SUPPRESSIONS_SECTION.search(header)
61
+ if not section_match:
62
+ section_match = self.JSDOC_SUPPRESSIONS_SECTION.search(header)
63
+
64
+ if not section_match:
65
+ return {}
66
+
67
+ entries: dict[str, str] = {}
68
+ section_content = section_match.group(1)
69
+
70
+ for match in self.ENTRY_PATTERN.finditer(section_content):
71
+ rule_id = match.group(1).strip()
72
+ justification = match.group(2).strip()
73
+
74
+ # Skip entries with empty justification
75
+ if justification:
76
+ normalized_id = self.normalize_rule_id(rule_id)
77
+ entries[normalized_id] = justification
78
+
79
+ return entries
80
+
81
+ def normalize_rule_id(self, rule_id: str) -> str:
82
+ """Normalize rule ID for case-insensitive matching.
83
+
84
+ Strips common list prefixes (-, *, •) and normalizes to lowercase.
85
+
86
+ Args:
87
+ rule_id: Original rule ID string
88
+
89
+ Returns:
90
+ Normalized rule ID (lowercase, no list prefix)
91
+ """
92
+ normalized = rule_id.lower().strip()
93
+ # Strip common list prefixes (bullet points)
94
+ if normalized.startswith(("- ", "* ", "• ")):
95
+ normalized = normalized[2:]
96
+ elif normalized.startswith(("-", "*", "•")):
97
+ normalized = normalized[1:].lstrip()
98
+ return normalized
99
+
100
+ def extract_header(self, code: str, language: str | Language = Language.PYTHON) -> str:
101
+ """Extract the header section from code.
102
+
103
+ Args:
104
+ code: Full source code
105
+ language: Programming language (Language enum or string)
106
+
107
+ Returns:
108
+ Header content as string, or empty string if not found
109
+ """
110
+ lang = Language(language) if isinstance(language, str) else language
111
+ if lang == Language.PYTHON:
112
+ return self._extract_python_header(code)
113
+ if lang in (Language.TYPESCRIPT, Language.JAVASCRIPT):
114
+ return self._extract_ts_header(code)
115
+ return ""
116
+
117
+ def _extract_python_header(self, code: str) -> str:
118
+ """Extract Python docstring header.
119
+
120
+ Args:
121
+ code: Python source code
122
+
123
+ Returns:
124
+ Docstring content or empty string
125
+ """
126
+ # Match triple-quoted docstring at start of file
127
+ # Skip leading whitespace, comments, and encoding declarations
128
+ stripped = self._skip_leading_comments(code)
129
+
130
+ # Try double quotes first
131
+ match = re.match(r'^"""(.*?)"""', stripped, re.DOTALL)
132
+ if match:
133
+ return match.group(0)
134
+
135
+ # Try single quotes
136
+ match = re.match(r"^'''(.*?)'''", stripped, re.DOTALL)
137
+ if match:
138
+ return match.group(0)
139
+
140
+ return ""
141
+
142
+ def _skip_leading_comments(self, code: str) -> str:
143
+ """Skip leading comments and empty lines to find docstring.
144
+
145
+ Args:
146
+ code: Python source code
147
+
148
+ Returns:
149
+ Code with leading comments/empty lines removed
150
+ """
151
+ lines = code.split("\n")
152
+ for i, line in enumerate(lines):
153
+ stripped = line.strip()
154
+ # Skip empty lines
155
+ if not stripped:
156
+ continue
157
+ # Skip comment lines (including pylint/noqa/type comments)
158
+ if stripped.startswith("#"):
159
+ continue
160
+ # Found non-comment, non-empty line - return from here
161
+ return "\n".join(lines[i:])
162
+ return ""
163
+
164
+ def _extract_ts_header(self, code: str) -> str:
165
+ """Extract TypeScript/JavaScript JSDoc header.
166
+
167
+ Args:
168
+ code: TypeScript/JavaScript source code
169
+
170
+ Returns:
171
+ JSDoc comment content or empty string
172
+ """
173
+ stripped = code.lstrip()
174
+ match = re.match(r"^/\*\*(.*?)\*/", stripped, re.DOTALL)
175
+ if match:
176
+ return match.group(0)
177
+ return ""