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
src/linters/dry/config.py
CHANGED
|
@@ -15,11 +15,16 @@ Exports: DRYConfig dataclass
|
|
|
15
15
|
Interfaces: DRYConfig.__init__, DRYConfig.from_dict(config: dict) -> DRYConfig
|
|
16
16
|
|
|
17
17
|
Implementation: Dataclass with field defaults, __post_init__ validation, and dict-based construction
|
|
18
|
+
|
|
19
|
+
Suppressions:
|
|
20
|
+
- too-many-instance-attributes: Configuration dataclass with related settings
|
|
18
21
|
"""
|
|
19
22
|
|
|
20
23
|
from dataclasses import dataclass, field
|
|
21
24
|
from typing import Any
|
|
22
25
|
|
|
26
|
+
from src.core.constants import StorageMode
|
|
27
|
+
|
|
23
28
|
# Default configuration constants
|
|
24
29
|
DEFAULT_MIN_DUPLICATE_LINES = 3
|
|
25
30
|
DEFAULT_MIN_DUPLICATE_TOKENS = 30
|
|
@@ -72,7 +77,8 @@ class DRYConfig: # pylint: disable=too-many-instance-attributes
|
|
|
72
77
|
def __post_init__(self) -> None:
|
|
73
78
|
"""Validate configuration values."""
|
|
74
79
|
self._validate_positive_fields()
|
|
75
|
-
|
|
80
|
+
valid_modes = (StorageMode.MEMORY, StorageMode.TEMPFILE)
|
|
81
|
+
if self.storage_mode not in valid_modes:
|
|
76
82
|
raise ValueError(
|
|
77
83
|
f"storage_mode must be 'memory' or 'tempfile', got '{self.storage_mode}'"
|
|
78
84
|
)
|
|
@@ -12,11 +12,14 @@ Overview: Implements fuzzy matching strategies to identify related constants acr
|
|
|
12
12
|
|
|
13
13
|
Dependencies: ConstantInfo, ConstantLocation, ConstantGroup from constant module
|
|
14
14
|
|
|
15
|
-
Exports:
|
|
15
|
+
Exports: find_constant_groups function
|
|
16
16
|
|
|
17
|
-
Interfaces:
|
|
17
|
+
Interfaces: find_constant_groups(constants) -> list[ConstantGroup]
|
|
18
18
|
|
|
19
19
|
Implementation: Union-Find algorithm for grouping, word-set hashing, Levenshtein distance calculation
|
|
20
|
+
|
|
21
|
+
Suppressions:
|
|
22
|
+
- arguments-out-of-order: Named arguments used for clarity in ConstantLocation
|
|
20
23
|
"""
|
|
21
24
|
|
|
22
25
|
from collections.abc import Callable
|
|
@@ -80,29 +83,35 @@ class UnionFind:
|
|
|
80
83
|
self._parent[px] = py
|
|
81
84
|
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
"""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
86
|
+
def find_constant_groups(constants: list[tuple[Path, ConstantInfo]]) -> list[ConstantGroup]:
|
|
87
|
+
"""Find groups of related constants.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
constants: List of (file_path, ConstantInfo) tuples
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List of ConstantGroup instances representing related constants
|
|
94
|
+
"""
|
|
95
|
+
if not constants:
|
|
96
|
+
return []
|
|
97
|
+
locations = _build_locations(constants)
|
|
98
|
+
exact_groups = _group_by_exact_name(locations)
|
|
99
|
+
return _merge_fuzzy_groups(exact_groups)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _merge_fuzzy_groups(groups: dict[str, ConstantGroup]) -> list[ConstantGroup]:
|
|
103
|
+
"""Merge groups that match via fuzzy matching."""
|
|
104
|
+
names = list(groups.keys())
|
|
105
|
+
uf = UnionFind(names)
|
|
106
|
+
_union_matching_pairs(names, uf, _is_fuzzy_match)
|
|
107
|
+
return _build_merged_groups(names, groups, uf)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_fuzzy_match(name1: str, name2: str) -> bool:
|
|
111
|
+
"""Check if two constant names should be considered a match."""
|
|
112
|
+
if name1 == name2:
|
|
113
|
+
return True
|
|
114
|
+
return _is_fuzzy_similar(name1, name2)
|
|
106
115
|
|
|
107
116
|
|
|
108
117
|
def _build_locations(constants: list[tuple[Path, ConstantInfo]]) -> list[ConstantLocation]:
|
src/linters/dry/file_analyzer.py
CHANGED
|
@@ -18,6 +18,8 @@ Implementation: Delegates to language-specific analyzers, always performs fresh
|
|
|
18
18
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
+
from src.core.constants import Language
|
|
22
|
+
|
|
21
23
|
from .block_filter import BlockFilterRegistry, create_default_registry
|
|
22
24
|
from .cache import CodeBlock
|
|
23
25
|
from .config import DRYConfig
|
|
@@ -83,8 +85,8 @@ class FileAnalyzer:
|
|
|
83
85
|
List of CodeBlock instances
|
|
84
86
|
"""
|
|
85
87
|
# Analyze file based on language
|
|
86
|
-
if language ==
|
|
88
|
+
if language == Language.PYTHON:
|
|
87
89
|
return self._python_analyzer.analyze(file_path, content, config)
|
|
88
|
-
if language in (
|
|
90
|
+
if language in (Language.TYPESCRIPT, Language.JAVASCRIPT):
|
|
89
91
|
return self._typescript_analyzer.analyze(file_path, content, config)
|
|
90
92
|
return []
|
src/linters/dry/inline_ignore.py
CHANGED
|
@@ -50,14 +50,11 @@ class InlineIgnoreParser:
|
|
|
50
50
|
Returns:
|
|
51
51
|
List of (start, end) tuples for ignore ranges
|
|
52
52
|
"""
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
ignore_range
|
|
57
|
-
|
|
58
|
-
ranges.append(ignore_range)
|
|
59
|
-
|
|
60
|
-
return ranges
|
|
53
|
+
return [
|
|
54
|
+
ignore_range
|
|
55
|
+
for i, line in enumerate(lines, start=1)
|
|
56
|
+
if (ignore_range := self._parse_ignore_directive(line, i, len(lines)))
|
|
57
|
+
]
|
|
61
58
|
|
|
62
59
|
def _parse_ignore_directive(
|
|
63
60
|
self, line: str, line_num: int, total_lines: int
|
|
@@ -115,10 +112,7 @@ class InlineIgnoreParser:
|
|
|
115
112
|
Returns:
|
|
116
113
|
True if ranges overlap
|
|
117
114
|
"""
|
|
118
|
-
for ign_start, ign_end in ranges
|
|
119
|
-
if line <= ign_end and end_line >= ign_start:
|
|
120
|
-
return True
|
|
121
|
-
return False
|
|
115
|
+
return any(line <= ign_end and end_line >= ign_start for ign_start, ign_end in ranges)
|
|
122
116
|
|
|
123
117
|
def _check_single_line(self, line: int, ranges: list[tuple[int, int]]) -> bool:
|
|
124
118
|
"""Check if single line is in any ignore range.
|
|
@@ -130,10 +124,7 @@ class InlineIgnoreParser:
|
|
|
130
124
|
Returns:
|
|
131
125
|
True if line is in any range
|
|
132
126
|
"""
|
|
133
|
-
for start, end in ranges
|
|
134
|
-
if start <= line <= end:
|
|
135
|
-
return True
|
|
136
|
-
return False
|
|
127
|
+
return any(start <= line <= end for start, end in ranges)
|
|
137
128
|
|
|
138
129
|
def clear(self) -> None:
|
|
139
130
|
"""Clear all stored ignore ranges."""
|
src/linters/dry/linter.py
CHANGED
|
@@ -11,18 +11,23 @@ Overview: Implements DRY linter rule following BaseLintRule interface with state
|
|
|
11
11
|
with SRP.
|
|
12
12
|
|
|
13
13
|
Dependencies: BaseLintRule, BaseLintContext, ConfigLoader, StorageInitializer, FileAnalyzer,
|
|
14
|
-
DuplicateStorage, ViolationGenerator,
|
|
15
|
-
|
|
14
|
+
DuplicateStorage, ViolationGenerator, extract_python_constants, TypeScriptConstantExtractor,
|
|
15
|
+
find_constant_groups, ConstantViolationBuilder
|
|
16
16
|
|
|
17
17
|
Exports: DRYRule class
|
|
18
18
|
|
|
19
19
|
Interfaces: DRYRule.check(context) -> list[Violation], finalize() -> list[Violation]
|
|
20
20
|
|
|
21
21
|
Implementation: Delegates all logic to helper classes, maintains only orchestration and state
|
|
22
|
+
|
|
23
|
+
Suppressions:
|
|
24
|
+
- too-many-instance-attributes: DRYComponents groups related helper dependencies
|
|
25
|
+
- B101: Type narrowing assertions after guards (storage initialized, file_path/content set)
|
|
22
26
|
"""
|
|
23
27
|
|
|
24
28
|
from __future__ import annotations
|
|
25
29
|
|
|
30
|
+
from collections.abc import Callable
|
|
26
31
|
from dataclasses import dataclass
|
|
27
32
|
from pathlib import Path
|
|
28
33
|
|
|
@@ -33,12 +38,12 @@ from src.core.types import Violation
|
|
|
33
38
|
from .config import DRYConfig
|
|
34
39
|
from .config_loader import ConfigLoader
|
|
35
40
|
from .constant import ConstantInfo
|
|
36
|
-
from .constant_matcher import
|
|
41
|
+
from .constant_matcher import find_constant_groups
|
|
37
42
|
from .constant_violation_builder import ConstantViolationBuilder
|
|
38
43
|
from .duplicate_storage import DuplicateStorage
|
|
39
44
|
from .file_analyzer import FileAnalyzer
|
|
40
45
|
from .inline_ignore import InlineIgnoreParser
|
|
41
|
-
from .python_constant_extractor import
|
|
46
|
+
from .python_constant_extractor import extract_python_constants
|
|
42
47
|
from .storage_initializer import StorageInitializer
|
|
43
48
|
from .typescript_constant_extractor import TypeScriptConstantExtractor
|
|
44
49
|
from .violation_generator import ViolationGenerator
|
|
@@ -53,9 +58,7 @@ class DRYComponents: # pylint: disable=too-many-instance-attributes
|
|
|
53
58
|
file_analyzer: FileAnalyzer
|
|
54
59
|
violation_generator: ViolationGenerator
|
|
55
60
|
inline_ignore: InlineIgnoreParser
|
|
56
|
-
python_extractor: PythonConstantExtractor
|
|
57
61
|
typescript_extractor: TypeScriptConstantExtractor
|
|
58
|
-
constant_matcher: ConstantMatcher
|
|
59
62
|
constant_violation_builder: ConstantViolationBuilder
|
|
60
63
|
|
|
61
64
|
|
|
@@ -79,12 +82,22 @@ class DRYRule(BaseLintRule):
|
|
|
79
82
|
file_analyzer=FileAnalyzer(), # Placeholder, will be replaced with configured one
|
|
80
83
|
violation_generator=ViolationGenerator(),
|
|
81
84
|
inline_ignore=InlineIgnoreParser(),
|
|
82
|
-
python_extractor=PythonConstantExtractor(),
|
|
83
85
|
typescript_extractor=TypeScriptConstantExtractor(),
|
|
84
|
-
constant_matcher=ConstantMatcher(),
|
|
85
86
|
constant_violation_builder=ConstantViolationBuilder(),
|
|
86
87
|
)
|
|
87
88
|
|
|
89
|
+
@property
|
|
90
|
+
def _active_storage(self) -> DuplicateStorage:
|
|
91
|
+
"""Get storage, asserting it has been initialized."""
|
|
92
|
+
assert self._storage is not None, "Storage not initialized" # nosec B101
|
|
93
|
+
return self._storage
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def _active_file_analyzer(self) -> FileAnalyzer:
|
|
97
|
+
"""Get file analyzer, asserting it has been initialized."""
|
|
98
|
+
assert self._file_analyzer is not None, "File analyzer not initialized" # nosec B101
|
|
99
|
+
return self._file_analyzer
|
|
100
|
+
|
|
88
101
|
@property
|
|
89
102
|
def rule_id(self) -> str:
|
|
90
103
|
"""Unique identifier for this rule."""
|
|
@@ -115,8 +128,12 @@ class DRYRule(BaseLintRule):
|
|
|
115
128
|
|
|
116
129
|
def _process_file(self, context: BaseLintContext, config: DRYConfig) -> None:
|
|
117
130
|
"""Process a single file for duplicates and constants."""
|
|
118
|
-
|
|
119
|
-
|
|
131
|
+
# should_process_file ensures file_path and file_content are set
|
|
132
|
+
assert context.file_path is not None # nosec B101
|
|
133
|
+
assert context.file_content is not None # nosec B101
|
|
134
|
+
|
|
135
|
+
file_path = context.file_path
|
|
136
|
+
self._helpers.inline_ignore.parse_file(file_path, context.file_content)
|
|
120
137
|
self._ensure_storage_initialized(context, config)
|
|
121
138
|
self._analyze_and_store(context, config)
|
|
122
139
|
if config.detect_duplicate_constants:
|
|
@@ -134,15 +151,18 @@ class DRYRule(BaseLintRule):
|
|
|
134
151
|
"""Analyze file and store blocks."""
|
|
135
152
|
if not self._can_analyze(context):
|
|
136
153
|
return
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
154
|
+
# _can_analyze ensures file_path and file_content are set
|
|
155
|
+
assert context.file_path is not None # nosec B101
|
|
156
|
+
assert context.file_content is not None # nosec B101
|
|
157
|
+
|
|
158
|
+
blocks = self._active_file_analyzer.analyze(
|
|
159
|
+
context.file_path,
|
|
160
|
+
context.file_content,
|
|
141
161
|
context.language,
|
|
142
162
|
config,
|
|
143
163
|
)
|
|
144
164
|
if blocks:
|
|
145
|
-
self.
|
|
165
|
+
self._active_storage.add_blocks(context.file_path, blocks)
|
|
146
166
|
|
|
147
167
|
def _can_analyze(self, context: BaseLintContext) -> bool:
|
|
148
168
|
"""Check if context is ready for analysis."""
|
|
@@ -158,9 +178,9 @@ class DRYRule(BaseLintRule):
|
|
|
158
178
|
if context.file_path is None or context.file_content is None:
|
|
159
179
|
return
|
|
160
180
|
file_path = Path(context.file_path)
|
|
161
|
-
|
|
162
|
-
if
|
|
163
|
-
self._constants.extend((file_path, c) for c in
|
|
181
|
+
extract_fn = _get_extractor_for_language(context.language, self._helpers)
|
|
182
|
+
if extract_fn:
|
|
183
|
+
self._constants.extend((file_path, c) for c in extract_fn(context.file_content))
|
|
164
184
|
|
|
165
185
|
def finalize(self) -> list[Violation]:
|
|
166
186
|
"""Generate violations after all files processed."""
|
|
@@ -180,14 +200,17 @@ class DRYRule(BaseLintRule):
|
|
|
180
200
|
return violations
|
|
181
201
|
|
|
182
202
|
|
|
203
|
+
ConstantExtractorFn = Callable[[str], list[ConstantInfo]]
|
|
204
|
+
|
|
205
|
+
|
|
183
206
|
def _get_extractor_for_language(
|
|
184
207
|
language: str | None, helpers: DRYComponents
|
|
185
|
-
) ->
|
|
186
|
-
"""Get the appropriate constant extractor for a language."""
|
|
187
|
-
extractors: dict[str,
|
|
188
|
-
"python":
|
|
189
|
-
"typescript": helpers.typescript_extractor,
|
|
190
|
-
"javascript": helpers.typescript_extractor,
|
|
208
|
+
) -> ConstantExtractorFn | None:
|
|
209
|
+
"""Get the appropriate constant extractor function for a language."""
|
|
210
|
+
extractors: dict[str, ConstantExtractorFn] = {
|
|
211
|
+
"python": extract_python_constants,
|
|
212
|
+
"typescript": helpers.typescript_extractor.extract,
|
|
213
|
+
"javascript": helpers.typescript_extractor.extract,
|
|
191
214
|
}
|
|
192
215
|
return extractors.get(language or "")
|
|
193
216
|
|
|
@@ -199,6 +222,6 @@ def _generate_constant_violations(
|
|
|
199
222
|
rule_id: str,
|
|
200
223
|
) -> list[Violation]:
|
|
201
224
|
"""Generate violations for duplicate constants."""
|
|
202
|
-
groups =
|
|
225
|
+
groups = find_constant_groups(constants)
|
|
203
226
|
helpers.constant_violation_builder.min_occurrences = config.min_constant_occurrences
|
|
204
227
|
return helpers.constant_violation_builder.build_violations(groups, rule_id)
|
|
@@ -8,7 +8,7 @@ Overview: Analyzes Python source files to extract code blocks for duplicate dete
|
|
|
8
8
|
Filters out docstrings at the tokenization level to prevent false positive duplication
|
|
9
9
|
detection on documentation strings.
|
|
10
10
|
|
|
11
|
-
Dependencies: BaseTokenAnalyzer, CodeBlock, DRYConfig, pathlib.Path, ast,
|
|
11
|
+
Dependencies: BaseTokenAnalyzer, CodeBlock, DRYConfig, pathlib.Path, ast, token_hasher module
|
|
12
12
|
|
|
13
13
|
Exports: PythonDuplicateAnalyzer class
|
|
14
14
|
|
|
@@ -17,6 +17,12 @@ Interfaces: PythonDuplicateAnalyzer.analyze(file_path: Path, content: str, confi
|
|
|
17
17
|
|
|
18
18
|
Implementation: Uses custom tokenizer that filters docstrings before hashing
|
|
19
19
|
|
|
20
|
+
Suppressions:
|
|
21
|
+
- too-many-arguments,too-many-positional-arguments: Line processing with related params
|
|
22
|
+
- type:ignore[arg-type]: ast.get_docstring returns str|None, typing limitation
|
|
23
|
+
- srp.violation: Complex AST analysis algorithm for duplicate detection. See SRP Exception below.
|
|
24
|
+
- nesting.excessive-depth: analyze method uses nested loops for docstring extraction.
|
|
25
|
+
|
|
20
26
|
SRP Exception: PythonDuplicateAnalyzer has 32 methods and 358 lines (exceeds max 8 methods/200 lines)
|
|
21
27
|
Justification: Complex AST analysis algorithm for duplicate code detection with sophisticated
|
|
22
28
|
false positive filtering. Methods form tightly coupled algorithm pipeline: docstring extraction,
|
|
@@ -31,6 +37,7 @@ SRP Exception: PythonDuplicateAnalyzer has 32 methods and 358 lines (exceeds max
|
|
|
31
37
|
import ast
|
|
32
38
|
from pathlib import Path
|
|
33
39
|
|
|
40
|
+
from . import token_hasher
|
|
34
41
|
from .base_token_analyzer import BaseTokenAnalyzer
|
|
35
42
|
from .block_filter import BlockFilterRegistry, create_default_registry
|
|
36
43
|
from .cache import CodeBlock
|
|
@@ -98,14 +105,15 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
98
105
|
content: str,
|
|
99
106
|
) -> list[CodeBlock]:
|
|
100
107
|
"""Filter hash windows and create valid CodeBlock instances."""
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
return [
|
|
109
|
+
block
|
|
110
|
+
for hash_val, start_line, end_line, snippet in windows
|
|
111
|
+
if (
|
|
112
|
+
block := self._create_block_if_valid(
|
|
113
|
+
file_path, content, hash_val, start_line, end_line, snippet
|
|
114
|
+
)
|
|
105
115
|
)
|
|
106
|
-
|
|
107
|
-
blocks.append(block)
|
|
108
|
-
return blocks
|
|
116
|
+
]
|
|
109
117
|
|
|
110
118
|
def _create_block_if_valid( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
111
119
|
self,
|
|
@@ -229,11 +237,11 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
229
237
|
Returns:
|
|
230
238
|
Tuple of (new_import_state, normalized_line or None if should skip)
|
|
231
239
|
"""
|
|
232
|
-
normalized =
|
|
240
|
+
normalized = token_hasher.normalize_line(line)
|
|
233
241
|
if not normalized:
|
|
234
242
|
return in_multiline_import, None
|
|
235
243
|
|
|
236
|
-
new_state, should_skip =
|
|
244
|
+
new_state, should_skip = token_hasher.should_skip_import_line(
|
|
237
245
|
normalized, in_multiline_import
|
|
238
246
|
)
|
|
239
247
|
if should_skip:
|
|
@@ -11,9 +11,9 @@ Overview: Extracts module-level constant definitions from Python source code usi
|
|
|
11
11
|
|
|
12
12
|
Dependencies: Python ast module, re for pattern matching, ConstantInfo from constant module
|
|
13
13
|
|
|
14
|
-
Exports:
|
|
14
|
+
Exports: extract_python_constants function
|
|
15
15
|
|
|
16
|
-
Interfaces:
|
|
16
|
+
Interfaces: extract_python_constants(content: str) -> list[ConstantInfo]
|
|
17
17
|
|
|
18
18
|
Implementation: AST-based parsing with module-level filtering and ALL_CAPS regex matching
|
|
19
19
|
"""
|
|
@@ -26,49 +26,55 @@ from .constant import CONSTANT_NAME_PATTERN, ConstantInfo
|
|
|
26
26
|
CONTAINER_REPRESENTATIONS = {ast.List: "[...]", ast.Dict: "{...}", ast.Tuple: "(...)"}
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
29
|
+
def extract_python_constants(content: str) -> list[ConstantInfo]:
|
|
30
|
+
"""Extract constants from Python source code.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
content: Python source code as string
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
List of ConstantInfo for module-level constants
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
tree = ast.parse(content)
|
|
40
|
+
except SyntaxError:
|
|
41
|
+
return []
|
|
42
|
+
constants: list[ConstantInfo] = []
|
|
43
|
+
for node in tree.body:
|
|
44
|
+
constants.extend(_extract_from_node(node))
|
|
45
|
+
return constants
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_from_node(node: ast.stmt) -> list[ConstantInfo]:
|
|
49
|
+
"""Extract constants from a single AST node."""
|
|
50
|
+
if isinstance(node, ast.Assign):
|
|
51
|
+
return _extract_from_assign(node)
|
|
52
|
+
if isinstance(node, ast.AnnAssign):
|
|
53
|
+
return _extract_from_ann_assign(node)
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _extract_from_assign(node: ast.Assign) -> list[ConstantInfo]:
|
|
58
|
+
"""Extract constants from a simple assignment."""
|
|
59
|
+
return [info for t in node.targets if (info := _to_const_info(t, node.value, node.lineno))]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_from_ann_assign(node: ast.AnnAssign) -> list[ConstantInfo]:
|
|
63
|
+
"""Extract constants from an annotated assignment."""
|
|
64
|
+
if node.value is None:
|
|
49
65
|
return []
|
|
66
|
+
info = _to_const_info(node.target, node.value, node.lineno)
|
|
67
|
+
return [info] if info else []
|
|
50
68
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return []
|
|
61
|
-
info = self._to_const_info(node.target, node.value, node.lineno)
|
|
62
|
-
return [info] if info else []
|
|
63
|
-
|
|
64
|
-
def _to_const_info(self, target: ast.expr, value: ast.expr, lineno: int) -> ConstantInfo | None:
|
|
65
|
-
"""Extract constant info from target and value."""
|
|
66
|
-
if not isinstance(target, ast.Name):
|
|
67
|
-
return None
|
|
68
|
-
name = target.id
|
|
69
|
-
if not _is_constant_name(name):
|
|
70
|
-
return None
|
|
71
|
-
return ConstantInfo(name=name, line_number=lineno, value=_get_value_string(value))
|
|
69
|
+
|
|
70
|
+
def _to_const_info(target: ast.expr, value: ast.expr, lineno: int) -> ConstantInfo | None:
|
|
71
|
+
"""Extract constant info from target and value."""
|
|
72
|
+
if not isinstance(target, ast.Name):
|
|
73
|
+
return None
|
|
74
|
+
name = target.id
|
|
75
|
+
if not _is_constant_name(name):
|
|
76
|
+
return None
|
|
77
|
+
return ConstantInfo(name=name, line_number=lineno, value=_get_value_string(value))
|
|
72
78
|
|
|
73
79
|
|
|
74
80
|
def _is_constant_name(name: str) -> bool:
|
|
@@ -78,8 +84,8 @@ def _is_constant_name(name: str) -> bool:
|
|
|
78
84
|
|
|
79
85
|
def _get_value_string(value: ast.expr) -> str | None:
|
|
80
86
|
"""Get string representation of a value expression."""
|
|
81
|
-
if isinstance(value,
|
|
82
|
-
return
|
|
87
|
+
if isinstance(value, ast.Constant):
|
|
88
|
+
return repr(value.value)
|
|
83
89
|
if isinstance(value, ast.Name):
|
|
84
90
|
return value.id
|
|
85
91
|
if isinstance(value, ast.Call):
|
|
@@ -87,13 +93,6 @@ def _get_value_string(value: ast.expr) -> str | None:
|
|
|
87
93
|
return CONTAINER_REPRESENTATIONS.get(type(value))
|
|
88
94
|
|
|
89
95
|
|
|
90
|
-
def _literal_repr(node: ast.expr) -> str:
|
|
91
|
-
"""Get repr of a literal node."""
|
|
92
|
-
if isinstance(node, ast.Constant):
|
|
93
|
-
return repr(node.value)
|
|
94
|
-
return repr(getattr(node, "n", None) or getattr(node, "s", None))
|
|
95
|
-
|
|
96
|
-
|
|
97
96
|
def _call_to_string(node: ast.Call) -> str:
|
|
98
97
|
"""Convert call expression to string."""
|
|
99
98
|
if isinstance(node.func, ast.Name):
|
|
@@ -18,6 +18,12 @@ Interfaces: SingleStatementDetector.is_single_statement(content, start_line, end
|
|
|
18
18
|
|
|
19
19
|
Implementation: AST walking with line-to-node index optimization for performance
|
|
20
20
|
|
|
21
|
+
Suppressions:
|
|
22
|
+
- type:ignore[attr-defined]: Tree-sitter Node.text attribute access (optional dependency)
|
|
23
|
+
- type:ignore[operator]: Tree-sitter Node comparison operations (optional dependency)
|
|
24
|
+
- too-many-arguments,too-many-positional-arguments: Builder pattern with related params
|
|
25
|
+
- srp.violation: Complex AST analysis algorithm for single-statement detection. See SRP Exception below.
|
|
26
|
+
|
|
21
27
|
SRP Exception: SingleStatementDetector has 33 methods and 308 lines (exceeds max 8 methods/200 lines)
|
|
22
28
|
Justification: Complex AST analysis algorithm for single-statement pattern detection with sophisticated
|
|
23
29
|
false positive filtering. Methods form tightly coupled algorithm pipeline: class field detection,
|
|
@@ -157,17 +163,13 @@ class SingleStatementDetector: # thailint: ignore[srp.violation]
|
|
|
157
163
|
self, nodes: set[ast.AST], start_line: int, end_line: int
|
|
158
164
|
) -> bool:
|
|
159
165
|
"""Check if any node matches single-statement pattern."""
|
|
160
|
-
for node in nodes
|
|
161
|
-
if self._is_single_statement_pattern(node, start_line, end_line):
|
|
162
|
-
return True
|
|
163
|
-
return False
|
|
166
|
+
return any(self._is_single_statement_pattern(node, start_line, end_line) for node in nodes)
|
|
164
167
|
|
|
165
168
|
def _check_nodes_via_walk(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
|
|
166
169
|
"""Check nodes using ast.walk() fallback."""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return False
|
|
170
|
+
return any(
|
|
171
|
+
self._node_matches_via_walk(node, start_line, end_line) for node in ast.walk(tree)
|
|
172
|
+
)
|
|
171
173
|
|
|
172
174
|
def _node_matches_via_walk(self, node: ast.AST, start_line: int, end_line: int) -> bool:
|
|
173
175
|
"""Check if a single node overlaps and matches pattern."""
|
|
@@ -368,10 +370,10 @@ class SingleStatementDetector: # thailint: ignore[srp.violation]
|
|
|
368
370
|
|
|
369
371
|
def has_decorators(tree: ast.Module, _lookback_start: int) -> bool:
|
|
370
372
|
"""Check if any function or class in the tree has decorators."""
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
return any(
|
|
374
|
+
isinstance(stmt, (ast.FunctionDef, ast.ClassDef)) and stmt.decorator_list
|
|
375
|
+
for stmt in tree.body
|
|
376
|
+
)
|
|
375
377
|
|
|
376
378
|
return self.check_ast_context(lines, start_line, end_line, 10, 10, has_decorators)
|
|
377
379
|
|