thailint 0.10.0__py3-none-any.whl → 0.12.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/__init__.py +1 -0
- src/cli/__init__.py +27 -0
- src/cli/__main__.py +22 -0
- src/cli/config.py +478 -0
- src/cli/linters/__init__.py +58 -0
- src/cli/linters/code_patterns.py +372 -0
- src/cli/linters/code_smells.py +450 -0
- src/cli/linters/documentation.py +155 -0
- src/cli/linters/shared.py +89 -0
- src/cli/linters/structure.py +313 -0
- src/cli/linters/structure_quality.py +316 -0
- src/cli/main.py +120 -0
- src/cli/utils.py +395 -0
- src/cli_main.py +34 -0
- src/core/types.py +13 -0
- src/core/violation_utils.py +69 -0
- src/linter_config/ignore.py +32 -16
- src/linters/collection_pipeline/linter.py +2 -2
- src/linters/dry/block_filter.py +97 -1
- src/linters/dry/cache.py +94 -6
- src/linters/dry/config.py +47 -10
- src/linters/dry/constant.py +92 -0
- src/linters/dry/constant_matcher.py +214 -0
- src/linters/dry/constant_violation_builder.py +98 -0
- src/linters/dry/linter.py +89 -48
- src/linters/dry/python_analyzer.py +12 -415
- src/linters/dry/python_constant_extractor.py +101 -0
- src/linters/dry/single_statement_detector.py +415 -0
- src/linters/dry/token_hasher.py +5 -5
- src/linters/dry/typescript_analyzer.py +5 -354
- src/linters/dry/typescript_constant_extractor.py +134 -0
- src/linters/dry/typescript_statement_detector.py +255 -0
- src/linters/dry/typescript_value_extractor.py +66 -0
- src/linters/file_header/linter.py +2 -2
- src/linters/file_placement/linter.py +2 -2
- src/linters/file_placement/pattern_matcher.py +19 -5
- src/linters/magic_numbers/linter.py +8 -67
- src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
- src/linters/nesting/linter.py +12 -9
- src/linters/print_statements/linter.py +7 -24
- src/linters/srp/class_analyzer.py +9 -9
- src/linters/srp/heuristics.py +2 -2
- src/linters/srp/linter.py +2 -2
- src/linters/stateless_class/linter.py +2 -2
- src/linters/stringly_typed/__init__.py +36 -0
- src/linters/stringly_typed/config.py +190 -0
- src/linters/stringly_typed/context_filter.py +451 -0
- src/linters/stringly_typed/function_call_violation_builder.py +137 -0
- src/linters/stringly_typed/ignore_checker.py +102 -0
- src/linters/stringly_typed/ignore_utils.py +51 -0
- src/linters/stringly_typed/linter.py +344 -0
- src/linters/stringly_typed/python/__init__.py +33 -0
- src/linters/stringly_typed/python/analyzer.py +344 -0
- src/linters/stringly_typed/python/call_tracker.py +172 -0
- src/linters/stringly_typed/python/comparison_tracker.py +252 -0
- src/linters/stringly_typed/python/condition_extractor.py +131 -0
- src/linters/stringly_typed/python/conditional_detector.py +176 -0
- src/linters/stringly_typed/python/constants.py +21 -0
- src/linters/stringly_typed/python/match_analyzer.py +88 -0
- src/linters/stringly_typed/python/validation_detector.py +186 -0
- src/linters/stringly_typed/python/variable_extractor.py +96 -0
- src/linters/stringly_typed/storage.py +630 -0
- src/linters/stringly_typed/storage_initializer.py +45 -0
- src/linters/stringly_typed/typescript/__init__.py +28 -0
- src/linters/stringly_typed/typescript/analyzer.py +157 -0
- src/linters/stringly_typed/typescript/call_tracker.py +329 -0
- src/linters/stringly_typed/typescript/comparison_tracker.py +372 -0
- src/linters/stringly_typed/violation_generator.py +376 -0
- src/orchestrator/core.py +241 -12
- {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/METADATA +9 -3
- {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/RECORD +74 -28
- thailint-0.12.0.dist-info/entry_points.txt +4 -0
- src/cli.py +0 -2141
- thailint-0.10.0.dist-info/entry_points.txt +0 -4
- {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/WHEEL +0 -0
- {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Fuzzy matching for constant names across files
|
|
3
|
+
|
|
4
|
+
Scope: Constant name matching with word-set and edit distance algorithms
|
|
5
|
+
|
|
6
|
+
Overview: Implements fuzzy matching strategies to identify related constants across files. Uses
|
|
7
|
+
two matching strategies: word-set matching (same words in different order, e.g., API_TIMEOUT
|
|
8
|
+
and TIMEOUT_API) and edit distance matching (typos within Levenshtein distance <= 2, e.g.,
|
|
9
|
+
MAX_RETRYS and MAX_RETRIES). Single-word constants (e.g., MAX, TIMEOUT) only use exact
|
|
10
|
+
matching to avoid false positives. Groups related constants into ConstantGroup instances
|
|
11
|
+
for violation reporting.
|
|
12
|
+
|
|
13
|
+
Dependencies: ConstantInfo, ConstantLocation, ConstantGroup from constant module
|
|
14
|
+
|
|
15
|
+
Exports: ConstantMatcher class
|
|
16
|
+
|
|
17
|
+
Interfaces: ConstantMatcher.find_groups(constants) -> list[ConstantGroup]
|
|
18
|
+
|
|
19
|
+
Implementation: Union-Find algorithm for grouping, word-set hashing, Levenshtein distance calculation
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from itertools import combinations
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .constant import ConstantGroup, ConstantInfo, ConstantLocation
|
|
27
|
+
|
|
28
|
+
# Maximum edit distance for fuzzy matching
|
|
29
|
+
MAX_EDIT_DISTANCE = 2
|
|
30
|
+
|
|
31
|
+
# Antonym pairs that should not be fuzzy-matched
|
|
32
|
+
# If one name contains a word from the left side and the other contains the right side,
|
|
33
|
+
# they represent different concepts and should not be grouped together
|
|
34
|
+
ANTONYM_PAIRS = frozenset(
|
|
35
|
+
(
|
|
36
|
+
frozenset(("max", "min")),
|
|
37
|
+
frozenset(("start", "end")),
|
|
38
|
+
frozenset(("first", "last")),
|
|
39
|
+
frozenset(("before", "after")),
|
|
40
|
+
frozenset(("open", "close")),
|
|
41
|
+
frozenset(("read", "write")),
|
|
42
|
+
frozenset(("get", "set")),
|
|
43
|
+
frozenset(("push", "pop")),
|
|
44
|
+
frozenset(("add", "remove")),
|
|
45
|
+
frozenset(("create", "delete")),
|
|
46
|
+
frozenset(("enable", "disable")),
|
|
47
|
+
frozenset(("show", "hide")),
|
|
48
|
+
frozenset(("up", "down")),
|
|
49
|
+
frozenset(("left", "right")),
|
|
50
|
+
frozenset(("top", "bottom")),
|
|
51
|
+
frozenset(("prev", "next")),
|
|
52
|
+
frozenset(("success", "failure")),
|
|
53
|
+
frozenset(("true", "false")),
|
|
54
|
+
frozenset(("on", "off")),
|
|
55
|
+
frozenset(("in", "out")),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Minimum length for constant names (exclude single-letter type params like P, T, K, V)
|
|
60
|
+
MIN_CONSTANT_NAME_LENGTH = 2
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UnionFind:
|
|
64
|
+
"""Union-Find data structure for grouping."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, items: list[str]) -> None:
|
|
67
|
+
"""Initialize with list of items."""
|
|
68
|
+
self._parent = {item: item for item in items}
|
|
69
|
+
|
|
70
|
+
def find(self, x: str) -> str:
|
|
71
|
+
"""Find root with path compression."""
|
|
72
|
+
if self._parent[x] != x:
|
|
73
|
+
self._parent[x] = self.find(self._parent[x])
|
|
74
|
+
return self._parent[x]
|
|
75
|
+
|
|
76
|
+
def union(self, x: str, y: str) -> None:
|
|
77
|
+
"""Merge two sets."""
|
|
78
|
+
px, py = self.find(x), self.find(y)
|
|
79
|
+
if px != py:
|
|
80
|
+
self._parent[px] = py
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ConstantMatcher:
|
|
84
|
+
"""Fuzzy matching for constant names."""
|
|
85
|
+
|
|
86
|
+
def find_groups(self, constants: list[tuple[Path, ConstantInfo]]) -> list[ConstantGroup]:
|
|
87
|
+
"""Find groups of related constants."""
|
|
88
|
+
if not constants:
|
|
89
|
+
return []
|
|
90
|
+
locations = _build_locations(constants)
|
|
91
|
+
exact_groups = _group_by_exact_name(locations)
|
|
92
|
+
return self._merge_fuzzy_groups(exact_groups)
|
|
93
|
+
|
|
94
|
+
def _merge_fuzzy_groups(self, groups: dict[str, ConstantGroup]) -> list[ConstantGroup]:
|
|
95
|
+
"""Merge groups that match via fuzzy matching."""
|
|
96
|
+
names = list(groups.keys())
|
|
97
|
+
uf = UnionFind(names)
|
|
98
|
+
_union_matching_pairs(names, uf, self._is_fuzzy_match)
|
|
99
|
+
return _build_merged_groups(names, groups, uf)
|
|
100
|
+
|
|
101
|
+
def _is_fuzzy_match(self, name1: str, name2: str) -> bool:
|
|
102
|
+
"""Check if two constant names should be considered a match."""
|
|
103
|
+
if name1 == name2:
|
|
104
|
+
return True
|
|
105
|
+
return _is_fuzzy_similar(name1, name2)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_locations(constants: list[tuple[Path, ConstantInfo]]) -> list[ConstantLocation]:
|
|
109
|
+
"""Build location list from constants."""
|
|
110
|
+
return [
|
|
111
|
+
ConstantLocation(
|
|
112
|
+
file_path=file_path, line_number=info.line_number, name=info.name, value=info.value
|
|
113
|
+
)
|
|
114
|
+
for file_path, info in constants
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _group_by_exact_name(locations: list[ConstantLocation]) -> dict[str, ConstantGroup]:
|
|
119
|
+
"""Group locations by exact constant name."""
|
|
120
|
+
groups: dict[str, ConstantGroup] = {}
|
|
121
|
+
for loc in locations:
|
|
122
|
+
if loc.name not in groups:
|
|
123
|
+
groups[loc.name] = ConstantGroup(
|
|
124
|
+
canonical_name=loc.name, locations=[], all_names=set(), is_fuzzy_match=False
|
|
125
|
+
)
|
|
126
|
+
groups[loc.name].add_location(loc)
|
|
127
|
+
return groups
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _union_matching_pairs(
|
|
131
|
+
names: list[str], uf: UnionFind, is_match: Callable[[str, str], bool]
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Union all pairs of names that match."""
|
|
134
|
+
for name1, name2 in combinations(names, 2):
|
|
135
|
+
if is_match(name1, name2):
|
|
136
|
+
uf.union(name1, name2)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_merged_groups(
|
|
140
|
+
names: list[str], groups: dict[str, ConstantGroup], uf: UnionFind
|
|
141
|
+
) -> list[ConstantGroup]:
|
|
142
|
+
"""Build merged groups from union-find structure."""
|
|
143
|
+
merged: dict[str, ConstantGroup] = {}
|
|
144
|
+
for name in names:
|
|
145
|
+
root = uf.find(name)
|
|
146
|
+
if root not in merged:
|
|
147
|
+
merged[root] = ConstantGroup(
|
|
148
|
+
canonical_name=root, locations=[], all_names=set(), is_fuzzy_match=False
|
|
149
|
+
)
|
|
150
|
+
for loc in groups[name].locations:
|
|
151
|
+
merged[root].add_location(loc)
|
|
152
|
+
if name != root:
|
|
153
|
+
merged[root].is_fuzzy_match = True
|
|
154
|
+
return list(merged.values())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _get_words(name: str) -> list[str]:
|
|
158
|
+
"""Split constant name into lowercase words."""
|
|
159
|
+
return [w.lower() for w in name.split("_") if w]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_fuzzy_similar(name1: str, name2: str) -> bool:
|
|
163
|
+
"""Check if two names are fuzzy similar (word-set or edit distance)."""
|
|
164
|
+
words1, words2 = _get_words(name1), _get_words(name2)
|
|
165
|
+
if not _has_enough_words(words1, words2):
|
|
166
|
+
return False
|
|
167
|
+
if _has_antonym_conflict(set(words1), set(words2)):
|
|
168
|
+
return False
|
|
169
|
+
return _word_set_match(words1, words2) or _edit_distance_match(name1, name2)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _has_enough_words(words1: list[str], words2: list[str]) -> bool:
|
|
173
|
+
"""Check if both word lists have at least 2 words for fuzzy matching."""
|
|
174
|
+
return len(words1) >= 2 and len(words2) >= 2
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _word_set_match(words1: list[str], words2: list[str]) -> bool:
|
|
178
|
+
"""Check if two word lists contain the same words."""
|
|
179
|
+
return set(words1) == set(words2)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _has_antonym_conflict(set1: set[str], set2: set[str]) -> bool:
|
|
183
|
+
"""Check if word sets contain conflicting antonyms (e.g., MAX vs MIN)."""
|
|
184
|
+
return any(_is_antonym_split(pair, set1, set2) for pair in ANTONYM_PAIRS)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_antonym_split(pair: frozenset[str], set1: set[str], set2: set[str]) -> bool:
|
|
188
|
+
"""Check if one set has one word of the pair and the other has the opposite."""
|
|
189
|
+
pair_list = tuple(pair)
|
|
190
|
+
word_a, word_b = pair_list[0], pair_list[1]
|
|
191
|
+
return (word_a in set1 and word_b in set2) or (word_b in set1 and word_a in set2)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _edit_distance_match(name1: str, name2: str) -> bool:
|
|
195
|
+
"""Check if names match within edit distance threshold."""
|
|
196
|
+
return _levenshtein_distance(name1.lower(), name2.lower()) <= MAX_EDIT_DISTANCE
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _levenshtein_distance(s1: str, s2: str) -> int:
|
|
200
|
+
"""Calculate Levenshtein distance between two strings."""
|
|
201
|
+
if len(s1) < len(s2):
|
|
202
|
+
return _levenshtein_distance(s2, s1) # pylint: disable=arguments-out-of-order
|
|
203
|
+
if len(s2) == 0:
|
|
204
|
+
return len(s1)
|
|
205
|
+
previous_row = list(range(len(s2) + 1))
|
|
206
|
+
for i, c1 in enumerate(s1):
|
|
207
|
+
current_row = [i + 1]
|
|
208
|
+
for j, c2 in enumerate(s2):
|
|
209
|
+
insertions = previous_row[j + 1] + 1
|
|
210
|
+
deletions = current_row[j] + 1
|
|
211
|
+
substitutions = previous_row[j] + (c1 != c2)
|
|
212
|
+
current_row.append(min(insertions, deletions, substitutions))
|
|
213
|
+
previous_row = current_row
|
|
214
|
+
return previous_row[-1]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Build violation messages for duplicate constants
|
|
3
|
+
|
|
4
|
+
Scope: Violation message formatting for constant duplication detection
|
|
5
|
+
|
|
6
|
+
Overview: Formats detailed violation messages for duplicate constant detection. Creates messages
|
|
7
|
+
that include the constant name(s), all file locations with line numbers, and the values
|
|
8
|
+
assigned at each location. Distinguishes between exact matches (same constant name) and
|
|
9
|
+
fuzzy matches (similar names like API_TIMEOUT and TIMEOUT_API). Provides actionable guidance
|
|
10
|
+
to consolidate constants into a shared module.
|
|
11
|
+
|
|
12
|
+
Dependencies: ConstantGroup from constant module, Violation from core.types
|
|
13
|
+
|
|
14
|
+
Exports: ConstantViolationBuilder class
|
|
15
|
+
|
|
16
|
+
Interfaces: ConstantViolationBuilder.build_violations(groups, rule_id) -> list[Violation]
|
|
17
|
+
|
|
18
|
+
Implementation: Message template formatting with location enumeration and fuzzy match indication
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from src.core.types import Severity, Violation
|
|
22
|
+
|
|
23
|
+
from .constant import ConstantGroup, ConstantLocation
|
|
24
|
+
|
|
25
|
+
# Maximum other locations to show in violation message
|
|
26
|
+
MAX_DISPLAYED_LOCATIONS = 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConstantViolationBuilder:
|
|
30
|
+
"""Builds violation messages for duplicate constants."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, min_occurrences: int = 2) -> None:
|
|
33
|
+
"""Initialize with minimum occurrence threshold."""
|
|
34
|
+
self.min_occurrences = min_occurrences
|
|
35
|
+
|
|
36
|
+
def build_violations(self, groups: list[ConstantGroup], rule_id: str) -> list[Violation]:
|
|
37
|
+
"""Build violations from constant groups."""
|
|
38
|
+
violations = []
|
|
39
|
+
for group in groups:
|
|
40
|
+
if group.file_count >= self.min_occurrences:
|
|
41
|
+
violations.extend(self._violations_for_group(group, rule_id))
|
|
42
|
+
return violations
|
|
43
|
+
|
|
44
|
+
def _violations_for_group(self, group: ConstantGroup, rule_id: str) -> list[Violation]:
|
|
45
|
+
"""Create violations for all locations in a group."""
|
|
46
|
+
return [
|
|
47
|
+
Violation(
|
|
48
|
+
rule_id=rule_id,
|
|
49
|
+
file_path=str(loc.file_path),
|
|
50
|
+
line=loc.line_number,
|
|
51
|
+
column=1,
|
|
52
|
+
message=self._format_message(group, loc),
|
|
53
|
+
severity=Severity.ERROR,
|
|
54
|
+
)
|
|
55
|
+
for loc in group.locations
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def _format_message(self, group: ConstantGroup, current: ConstantLocation) -> str:
|
|
59
|
+
"""Format the violation message based on match type."""
|
|
60
|
+
others = _get_other_locations(group, current)
|
|
61
|
+
locations_text = _format_locations_text(others)
|
|
62
|
+
if group.is_fuzzy_match:
|
|
63
|
+
names_str = " ≈ ".join(f"'{n}'" for n in sorted(group.all_names))
|
|
64
|
+
return (
|
|
65
|
+
f"Similar constants found: {names_str} in {group.file_count} files. "
|
|
66
|
+
f"{locations_text} "
|
|
67
|
+
f"These appear to represent the same concept - consider standardizing the name."
|
|
68
|
+
)
|
|
69
|
+
return (
|
|
70
|
+
f"Duplicate constant '{group.canonical_name}' defined in {group.file_count} files. "
|
|
71
|
+
f"{locations_text} "
|
|
72
|
+
f"Consider consolidating to a shared constants module."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_other_locations(group: ConstantGroup, current: ConstantLocation) -> list[ConstantLocation]:
|
|
77
|
+
"""Get locations excluding current (module-level helper)."""
|
|
78
|
+
return [
|
|
79
|
+
loc
|
|
80
|
+
for loc in group.locations
|
|
81
|
+
if loc.file_path != current.file_path or loc.line_number != current.line_number
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _format_locations_text(others: list[ConstantLocation]) -> str:
|
|
86
|
+
"""Format other locations as text (module-level helper)."""
|
|
87
|
+
if not others:
|
|
88
|
+
return ""
|
|
89
|
+
parts = [_format_single_location(loc) for loc in others[:MAX_DISPLAYED_LOCATIONS]]
|
|
90
|
+
result = "Also found in: " + ", ".join(parts)
|
|
91
|
+
extra = len(others) - MAX_DISPLAYED_LOCATIONS
|
|
92
|
+
return result + (f" and {extra} more." if extra > 0 else ".")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _format_single_location(loc: ConstantLocation) -> str:
|
|
96
|
+
"""Format a single location for display (module-level helper)."""
|
|
97
|
+
value_str = f" = {loc.value}" if loc.value else ""
|
|
98
|
+
return f"{loc.file_path.name}:{loc.line_number} ({loc.name}{value_str})"
|
src/linters/dry/linter.py
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Purpose: Main DRY linter rule implementation with stateful caching
|
|
3
3
|
|
|
4
|
-
Scope: DRYRule class implementing BaseLintRule interface for duplicate code detection
|
|
4
|
+
Scope: DRYRule class implementing BaseLintRule interface for duplicate code and constant detection
|
|
5
5
|
|
|
6
6
|
Overview: Implements DRY linter rule following BaseLintRule interface with stateful caching design.
|
|
7
7
|
Orchestrates duplicate detection by delegating to specialized classes: ConfigLoader for config,
|
|
8
8
|
StorageInitializer for storage setup, FileAnalyzer for file analysis, and ViolationGenerator
|
|
9
|
-
for violation creation.
|
|
9
|
+
for violation creation. Also supports duplicate constant detection (opt-in) to identify when
|
|
10
|
+
the same constant is defined in multiple files. Maintains minimal orchestration logic to comply
|
|
11
|
+
with SRP.
|
|
10
12
|
|
|
11
13
|
Dependencies: BaseLintRule, BaseLintContext, ConfigLoader, StorageInitializer, FileAnalyzer,
|
|
12
|
-
DuplicateStorage, ViolationGenerator
|
|
14
|
+
DuplicateStorage, ViolationGenerator, PythonConstantExtractor, TypeScriptConstantExtractor,
|
|
15
|
+
ConstantMatcher, ConstantViolationBuilder
|
|
13
16
|
|
|
14
17
|
Exports: DRYRule class
|
|
15
18
|
|
|
@@ -22,7 +25,6 @@ from __future__ import annotations
|
|
|
22
25
|
|
|
23
26
|
from dataclasses import dataclass
|
|
24
27
|
from pathlib import Path
|
|
25
|
-
from typing import TYPE_CHECKING
|
|
26
28
|
|
|
27
29
|
from src.core.base import BaseLintContext, BaseLintRule
|
|
28
30
|
from src.core.linter_utils import should_process_file
|
|
@@ -30,18 +32,20 @@ from src.core.types import Violation
|
|
|
30
32
|
|
|
31
33
|
from .config import DRYConfig
|
|
32
34
|
from .config_loader import ConfigLoader
|
|
35
|
+
from .constant import ConstantInfo
|
|
36
|
+
from .constant_matcher import ConstantMatcher
|
|
37
|
+
from .constant_violation_builder import ConstantViolationBuilder
|
|
33
38
|
from .duplicate_storage import DuplicateStorage
|
|
34
39
|
from .file_analyzer import FileAnalyzer
|
|
35
40
|
from .inline_ignore import InlineIgnoreParser
|
|
41
|
+
from .python_constant_extractor import PythonConstantExtractor
|
|
36
42
|
from .storage_initializer import StorageInitializer
|
|
43
|
+
from .typescript_constant_extractor import TypeScriptConstantExtractor
|
|
37
44
|
from .violation_generator import ViolationGenerator
|
|
38
45
|
|
|
39
|
-
if TYPE_CHECKING:
|
|
40
|
-
from .cache import CodeBlock
|
|
41
|
-
|
|
42
46
|
|
|
43
47
|
@dataclass
|
|
44
|
-
class DRYComponents:
|
|
48
|
+
class DRYComponents: # pylint: disable=too-many-instance-attributes
|
|
45
49
|
"""Component dependencies for DRY linter."""
|
|
46
50
|
|
|
47
51
|
config_loader: ConfigLoader
|
|
@@ -49,6 +53,10 @@ class DRYComponents:
|
|
|
49
53
|
file_analyzer: FileAnalyzer
|
|
50
54
|
violation_generator: ViolationGenerator
|
|
51
55
|
inline_ignore: InlineIgnoreParser
|
|
56
|
+
python_extractor: PythonConstantExtractor
|
|
57
|
+
typescript_extractor: TypeScriptConstantExtractor
|
|
58
|
+
constant_matcher: ConstantMatcher
|
|
59
|
+
constant_violation_builder: ConstantViolationBuilder
|
|
52
60
|
|
|
53
61
|
|
|
54
62
|
class DRYRule(BaseLintRule):
|
|
@@ -61,6 +69,9 @@ class DRYRule(BaseLintRule):
|
|
|
61
69
|
self._config: DRYConfig | None = None
|
|
62
70
|
self._file_analyzer: FileAnalyzer | None = None
|
|
63
71
|
|
|
72
|
+
# Collected constants for cross-file detection: list of (file_path, ConstantInfo)
|
|
73
|
+
self._constants: list[tuple[Path, ConstantInfo]] = []
|
|
74
|
+
|
|
64
75
|
# Helper components grouped to reduce instance attributes
|
|
65
76
|
self._helpers = DRYComponents(
|
|
66
77
|
config_loader=ConfigLoader(),
|
|
@@ -68,6 +79,10 @@ class DRYRule(BaseLintRule):
|
|
|
68
79
|
file_analyzer=FileAnalyzer(), # Placeholder, will be replaced with configured one
|
|
69
80
|
violation_generator=ViolationGenerator(),
|
|
70
81
|
inline_ignore=InlineIgnoreParser(),
|
|
82
|
+
python_extractor=PythonConstantExtractor(),
|
|
83
|
+
typescript_extractor=TypeScriptConstantExtractor(),
|
|
84
|
+
constant_matcher=ConstantMatcher(),
|
|
85
|
+
constant_violation_builder=ConstantViolationBuilder(),
|
|
71
86
|
)
|
|
72
87
|
|
|
73
88
|
@property
|
|
@@ -86,14 +101,7 @@ class DRYRule(BaseLintRule):
|
|
|
86
101
|
return "Detects duplicate code blocks across the project"
|
|
87
102
|
|
|
88
103
|
def check(self, context: BaseLintContext) -> list[Violation]:
|
|
89
|
-
"""Analyze file and store blocks (collection phase).
|
|
90
|
-
|
|
91
|
-
Args:
|
|
92
|
-
context: Lint context with file information
|
|
93
|
-
|
|
94
|
-
Returns:
|
|
95
|
-
Empty list (violations returned in finalize())
|
|
96
|
-
"""
|
|
104
|
+
"""Analyze file and store blocks (collection phase)."""
|
|
97
105
|
if not should_process_file(context):
|
|
98
106
|
return []
|
|
99
107
|
|
|
@@ -101,18 +109,18 @@ class DRYRule(BaseLintRule):
|
|
|
101
109
|
if not config.enabled:
|
|
102
110
|
return []
|
|
103
111
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
self._config = self._config or config
|
|
113
|
+
self._process_file(context, config)
|
|
114
|
+
return []
|
|
107
115
|
|
|
108
|
-
|
|
116
|
+
def _process_file(self, context: BaseLintContext, config: DRYConfig) -> None:
|
|
117
|
+
"""Process a single file for duplicates and constants."""
|
|
109
118
|
file_path = Path(context.file_path) # type: ignore[arg-type]
|
|
110
119
|
self._helpers.inline_ignore.parse_file(file_path, context.file_content or "")
|
|
111
|
-
|
|
112
120
|
self._ensure_storage_initialized(context, config)
|
|
113
121
|
self._analyze_and_store(context, config)
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
if config.detect_duplicate_constants:
|
|
123
|
+
self._extract_and_store_constants(context)
|
|
116
124
|
|
|
117
125
|
def _ensure_storage_initialized(self, context: BaseLintContext, config: DRYConfig) -> None:
|
|
118
126
|
"""Initialize storage and file analyzer on first call."""
|
|
@@ -124,40 +132,73 @@ class DRYRule(BaseLintRule):
|
|
|
124
132
|
|
|
125
133
|
def _analyze_and_store(self, context: BaseLintContext, config: DRYConfig) -> None:
|
|
126
134
|
"""Analyze file and store blocks."""
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
blocks = self._file_analyzer.analyze(
|
|
136
|
-
file_path, context.file_content, context.language, config
|
|
135
|
+
if not self._can_analyze(context):
|
|
136
|
+
return
|
|
137
|
+
file_path = Path(context.file_path) # type: ignore[arg-type]
|
|
138
|
+
blocks = self._file_analyzer.analyze( # type: ignore[union-attr]
|
|
139
|
+
file_path,
|
|
140
|
+
context.file_content, # type: ignore[arg-type]
|
|
141
|
+
context.language,
|
|
142
|
+
config,
|
|
137
143
|
)
|
|
138
|
-
|
|
139
144
|
if blocks:
|
|
140
|
-
self.
|
|
145
|
+
self._storage.add_blocks(file_path, blocks) # type: ignore[union-attr]
|
|
146
|
+
|
|
147
|
+
def _can_analyze(self, context: BaseLintContext) -> bool:
|
|
148
|
+
"""Check if context is ready for analysis."""
|
|
149
|
+
return (
|
|
150
|
+
context.file_path is not None
|
|
151
|
+
and context.file_content is not None
|
|
152
|
+
and self._file_analyzer is not None
|
|
153
|
+
and self._storage is not None
|
|
154
|
+
)
|
|
141
155
|
|
|
142
|
-
def
|
|
143
|
-
"""
|
|
144
|
-
if
|
|
145
|
-
|
|
156
|
+
def _extract_and_store_constants(self, context: BaseLintContext) -> None:
|
|
157
|
+
"""Extract constants from file and store for cross-file detection."""
|
|
158
|
+
if context.file_path is None or context.file_content is None:
|
|
159
|
+
return
|
|
160
|
+
file_path = Path(context.file_path)
|
|
161
|
+
extractor = _get_extractor_for_language(context.language, self._helpers)
|
|
162
|
+
if extractor:
|
|
163
|
+
self._constants.extend((file_path, c) for c in extractor.extract(context.file_content))
|
|
146
164
|
|
|
147
165
|
def finalize(self) -> list[Violation]:
|
|
148
|
-
"""Generate violations after all files processed.
|
|
149
|
-
|
|
150
|
-
Returns:
|
|
151
|
-
List of all violations found across all files
|
|
152
|
-
"""
|
|
166
|
+
"""Generate violations after all files processed."""
|
|
153
167
|
if not self._storage or not self._config:
|
|
154
168
|
return []
|
|
155
|
-
|
|
156
169
|
violations = self._helpers.violation_generator.generate_violations(
|
|
157
170
|
self._storage, self.rule_id, self._config, self._helpers.inline_ignore
|
|
158
171
|
)
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
if self._config.detect_duplicate_constants and self._constants:
|
|
173
|
+
violations.extend(
|
|
174
|
+
_generate_constant_violations(
|
|
175
|
+
self._constants, self._config, self._helpers, self.rule_id
|
|
176
|
+
)
|
|
177
|
+
)
|
|
161
178
|
self._helpers.inline_ignore.clear()
|
|
162
|
-
|
|
179
|
+
self._constants = []
|
|
163
180
|
return violations
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _get_extractor_for_language(
|
|
184
|
+
language: str | None, helpers: DRYComponents
|
|
185
|
+
) -> PythonConstantExtractor | TypeScriptConstantExtractor | None:
|
|
186
|
+
"""Get the appropriate constant extractor for a language."""
|
|
187
|
+
extractors: dict[str, PythonConstantExtractor | TypeScriptConstantExtractor] = {
|
|
188
|
+
"python": helpers.python_extractor,
|
|
189
|
+
"typescript": helpers.typescript_extractor,
|
|
190
|
+
"javascript": helpers.typescript_extractor,
|
|
191
|
+
}
|
|
192
|
+
return extractors.get(language or "")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _generate_constant_violations(
|
|
196
|
+
constants: list[tuple[Path, ConstantInfo]],
|
|
197
|
+
config: DRYConfig,
|
|
198
|
+
helpers: DRYComponents,
|
|
199
|
+
rule_id: str,
|
|
200
|
+
) -> list[Violation]:
|
|
201
|
+
"""Generate violations for duplicate constants."""
|
|
202
|
+
groups = helpers.constant_matcher.find_groups(constants)
|
|
203
|
+
helpers.constant_violation_builder.min_occurrences = config.min_constant_occurrences
|
|
204
|
+
return helpers.constant_violation_builder.build_violations(groups, rule_id)
|