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.
Files changed (76) hide show
  1. src/__init__.py +1 -0
  2. src/cli/__init__.py +27 -0
  3. src/cli/__main__.py +22 -0
  4. src/cli/config.py +478 -0
  5. src/cli/linters/__init__.py +58 -0
  6. src/cli/linters/code_patterns.py +372 -0
  7. src/cli/linters/code_smells.py +450 -0
  8. src/cli/linters/documentation.py +155 -0
  9. src/cli/linters/shared.py +89 -0
  10. src/cli/linters/structure.py +313 -0
  11. src/cli/linters/structure_quality.py +316 -0
  12. src/cli/main.py +120 -0
  13. src/cli/utils.py +395 -0
  14. src/cli_main.py +34 -0
  15. src/core/types.py +13 -0
  16. src/core/violation_utils.py +69 -0
  17. src/linter_config/ignore.py +32 -16
  18. src/linters/collection_pipeline/linter.py +2 -2
  19. src/linters/dry/block_filter.py +97 -1
  20. src/linters/dry/cache.py +94 -6
  21. src/linters/dry/config.py +47 -10
  22. src/linters/dry/constant.py +92 -0
  23. src/linters/dry/constant_matcher.py +214 -0
  24. src/linters/dry/constant_violation_builder.py +98 -0
  25. src/linters/dry/linter.py +89 -48
  26. src/linters/dry/python_analyzer.py +12 -415
  27. src/linters/dry/python_constant_extractor.py +101 -0
  28. src/linters/dry/single_statement_detector.py +415 -0
  29. src/linters/dry/token_hasher.py +5 -5
  30. src/linters/dry/typescript_analyzer.py +5 -354
  31. src/linters/dry/typescript_constant_extractor.py +134 -0
  32. src/linters/dry/typescript_statement_detector.py +255 -0
  33. src/linters/dry/typescript_value_extractor.py +66 -0
  34. src/linters/file_header/linter.py +2 -2
  35. src/linters/file_placement/linter.py +2 -2
  36. src/linters/file_placement/pattern_matcher.py +19 -5
  37. src/linters/magic_numbers/linter.py +8 -67
  38. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  39. src/linters/nesting/linter.py +12 -9
  40. src/linters/print_statements/linter.py +7 -24
  41. src/linters/srp/class_analyzer.py +9 -9
  42. src/linters/srp/heuristics.py +2 -2
  43. src/linters/srp/linter.py +2 -2
  44. src/linters/stateless_class/linter.py +2 -2
  45. src/linters/stringly_typed/__init__.py +36 -0
  46. src/linters/stringly_typed/config.py +190 -0
  47. src/linters/stringly_typed/context_filter.py +451 -0
  48. src/linters/stringly_typed/function_call_violation_builder.py +137 -0
  49. src/linters/stringly_typed/ignore_checker.py +102 -0
  50. src/linters/stringly_typed/ignore_utils.py +51 -0
  51. src/linters/stringly_typed/linter.py +344 -0
  52. src/linters/stringly_typed/python/__init__.py +33 -0
  53. src/linters/stringly_typed/python/analyzer.py +344 -0
  54. src/linters/stringly_typed/python/call_tracker.py +172 -0
  55. src/linters/stringly_typed/python/comparison_tracker.py +252 -0
  56. src/linters/stringly_typed/python/condition_extractor.py +131 -0
  57. src/linters/stringly_typed/python/conditional_detector.py +176 -0
  58. src/linters/stringly_typed/python/constants.py +21 -0
  59. src/linters/stringly_typed/python/match_analyzer.py +88 -0
  60. src/linters/stringly_typed/python/validation_detector.py +186 -0
  61. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  62. src/linters/stringly_typed/storage.py +630 -0
  63. src/linters/stringly_typed/storage_initializer.py +45 -0
  64. src/linters/stringly_typed/typescript/__init__.py +28 -0
  65. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  66. src/linters/stringly_typed/typescript/call_tracker.py +329 -0
  67. src/linters/stringly_typed/typescript/comparison_tracker.py +372 -0
  68. src/linters/stringly_typed/violation_generator.py +376 -0
  69. src/orchestrator/core.py +241 -12
  70. {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/METADATA +9 -3
  71. {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/RECORD +74 -28
  72. thailint-0.12.0.dist-info/entry_points.txt +4 -0
  73. src/cli.py +0 -2141
  74. thailint-0.10.0.dist-info/entry_points.txt +0 -4
  75. {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/WHEEL +0 -0
  76. {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. Maintains minimal orchestration logic to comply with SRP (8 methods total).
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
- # Store config for finalize()
105
- if self._config is None:
106
- self._config = config
112
+ self._config = self._config or config
113
+ self._process_file(context, config)
114
+ return []
107
115
 
108
- # Parse inline ignore directives from this file
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
- return []
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
- # Guaranteed by _should_process_file check
128
- if context.file_path is None or context.file_content is None:
129
- return # Should never happen due to should_process_file check
130
-
131
- if not self._file_analyzer:
132
- return # Should never happen after initialization
133
-
134
- file_path = Path(context.file_path)
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._store_blocks(file_path, blocks)
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 _store_blocks(self, file_path: Path, blocks: list[CodeBlock]) -> None:
143
- """Store blocks in SQLite if storage available."""
144
- if self._storage:
145
- self._storage.add_blocks(file_path, blocks)
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
- # Clear inline ignore cache for next run
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)