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.
- src/analyzers/__init__.py +4 -3
- src/analyzers/ast_utils.py +54 -0
- src/analyzers/typescript_base.py +4 -0
- src/cli/__init__.py +3 -0
- src/cli/config.py +12 -12
- src/cli/config_merge.py +241 -0
- src/cli/linters/__init__.py +3 -0
- src/cli/linters/code_patterns.py +113 -5
- src/cli/linters/code_smells.py +4 -0
- src/cli/linters/documentation.py +3 -0
- src/cli/linters/structure.py +3 -0
- src/cli/linters/structure_quality.py +3 -0
- src/cli_main.py +3 -0
- src/config.py +2 -1
- src/core/base.py +3 -2
- src/core/cli_utils.py +3 -1
- src/core/config_parser.py +5 -2
- src/core/constants.py +54 -0
- src/core/linter_utils.py +4 -0
- src/core/rule_discovery.py +5 -1
- src/core/violation_builder.py +3 -0
- src/linter_config/directive_markers.py +109 -0
- src/linter_config/ignore.py +225 -383
- src/linter_config/pattern_utils.py +65 -0
- src/linter_config/rule_matcher.py +89 -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 +12 -0
- src/linters/collection_pipeline/continue_analyzer.py +2 -8
- src/linters/collection_pipeline/detector.py +262 -32
- src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
- src/linters/collection_pipeline/linter.py +18 -35
- src/linters/collection_pipeline/suggestion_builder.py +68 -1
- src/linters/dry/base_token_analyzer.py +16 -9
- src/linters/dry/block_filter.py +7 -4
- src/linters/dry/cache.py +7 -2
- src/linters/dry/config.py +7 -1
- src/linters/dry/constant_matcher.py +34 -25
- src/linters/dry/file_analyzer.py +4 -2
- src/linters/dry/inline_ignore.py +7 -16
- src/linters/dry/linter.py +48 -25
- src/linters/dry/python_analyzer.py +18 -10
- src/linters/dry/python_constant_extractor.py +51 -52
- src/linters/dry/single_statement_detector.py +14 -12
- src/linters/dry/token_hasher.py +115 -115
- src/linters/dry/typescript_analyzer.py +11 -6
- src/linters/dry/typescript_constant_extractor.py +4 -0
- src/linters/dry/typescript_statement_detector.py +208 -208
- src/linters/dry/typescript_value_extractor.py +3 -0
- src/linters/dry/violation_filter.py +1 -4
- src/linters/dry/violation_generator.py +1 -4
- src/linters/file_header/atemporal_detector.py +4 -0
- src/linters/file_header/base_parser.py +4 -0
- src/linters/file_header/bash_parser.py +4 -0
- src/linters/file_header/field_validator.py +5 -8
- src/linters/file_header/linter.py +19 -12
- src/linters/file_header/markdown_parser.py +6 -0
- src/linters/file_placement/config_loader.py +3 -1
- src/linters/file_placement/linter.py +22 -8
- src/linters/file_placement/pattern_matcher.py +21 -4
- src/linters/file_placement/pattern_validator.py +21 -7
- src/linters/file_placement/rule_checker.py +2 -2
- src/linters/lazy_ignores/__init__.py +43 -0
- src/linters/lazy_ignores/config.py +66 -0
- src/linters/lazy_ignores/directive_utils.py +121 -0
- src/linters/lazy_ignores/header_parser.py +177 -0
- src/linters/lazy_ignores/linter.py +158 -0
- src/linters/lazy_ignores/matcher.py +135 -0
- src/linters/lazy_ignores/python_analyzer.py +201 -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 +67 -0
- src/linters/lazy_ignores/typescript_analyzer.py +146 -0
- src/linters/lazy_ignores/violation_builder.py +131 -0
- src/linters/lbyl/__init__.py +29 -0
- src/linters/lbyl/config.py +63 -0
- src/linters/lbyl/pattern_detectors/__init__.py +25 -0
- src/linters/lbyl/pattern_detectors/base.py +46 -0
- src/linters/magic_numbers/context_analyzer.py +227 -229
- src/linters/magic_numbers/linter.py +20 -15
- src/linters/magic_numbers/python_analyzer.py +4 -16
- src/linters/magic_numbers/typescript_analyzer.py +9 -16
- src/linters/method_property/config.py +4 -0
- src/linters/method_property/linter.py +5 -4
- src/linters/method_property/python_analyzer.py +5 -4
- src/linters/method_property/violation_builder.py +3 -0
- src/linters/nesting/typescript_analyzer.py +6 -12
- src/linters/nesting/typescript_function_extractor.py +0 -4
- src/linters/print_statements/linter.py +6 -4
- src/linters/print_statements/python_analyzer.py +85 -81
- src/linters/print_statements/typescript_analyzer.py +6 -15
- src/linters/srp/heuristics.py +4 -4
- src/linters/srp/linter.py +12 -12
- src/linters/srp/violation_builder.py +0 -4
- src/linters/stateless_class/linter.py +30 -36
- src/linters/stateless_class/python_analyzer.py +11 -20
- src/linters/stringly_typed/config.py +4 -5
- src/linters/stringly_typed/context_filter.py +410 -410
- src/linters/stringly_typed/function_call_violation_builder.py +93 -95
- src/linters/stringly_typed/linter.py +48 -16
- src/linters/stringly_typed/python/analyzer.py +5 -1
- src/linters/stringly_typed/python/call_tracker.py +8 -5
- src/linters/stringly_typed/python/comparison_tracker.py +10 -5
- src/linters/stringly_typed/python/condition_extractor.py +3 -0
- src/linters/stringly_typed/python/conditional_detector.py +4 -1
- src/linters/stringly_typed/python/match_analyzer.py +8 -2
- src/linters/stringly_typed/python/validation_detector.py +3 -0
- src/linters/stringly_typed/storage.py +14 -14
- src/linters/stringly_typed/typescript/call_tracker.py +9 -3
- src/linters/stringly_typed/typescript/comparison_tracker.py +9 -3
- src/linters/stringly_typed/violation_generator.py +288 -259
- src/orchestrator/core.py +13 -4
- src/templates/thailint_config_template.yaml +166 -0
- src/utils/project_root.py +3 -0
- thailint-0.13.0.dist-info/METADATA +184 -0
- thailint-0.13.0.dist-info/RECORD +189 -0
- thailint-0.12.0.dist-info/METADATA +0 -1667
- thailint-0.12.0.dist-info/RECORD +0 -164
- {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/WHEEL +0 -0
- {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
41
|
-
self._validate_directory_patterns(
|
|
42
|
-
self._validate_global_patterns(
|
|
43
|
-
self._validate_global_deny_patterns(
|
|
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
|
|
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
|
|
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 ""
|