thailint 0.5.0__py3-none-any.whl → 0.15.3__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 (204) hide show
  1. src/__init__.py +1 -0
  2. src/analyzers/__init__.py +4 -3
  3. src/analyzers/ast_utils.py +54 -0
  4. src/analyzers/rust_base.py +155 -0
  5. src/analyzers/rust_context.py +141 -0
  6. src/analyzers/typescript_base.py +4 -0
  7. src/cli/__init__.py +30 -0
  8. src/cli/__main__.py +22 -0
  9. src/cli/config.py +480 -0
  10. src/cli/config_merge.py +241 -0
  11. src/cli/linters/__init__.py +67 -0
  12. src/cli/linters/code_patterns.py +270 -0
  13. src/cli/linters/code_smells.py +342 -0
  14. src/cli/linters/documentation.py +83 -0
  15. src/cli/linters/performance.py +287 -0
  16. src/cli/linters/shared.py +331 -0
  17. src/cli/linters/structure.py +327 -0
  18. src/cli/linters/structure_quality.py +328 -0
  19. src/cli/main.py +120 -0
  20. src/cli/utils.py +395 -0
  21. src/cli_main.py +37 -0
  22. src/config.py +38 -25
  23. src/core/base.py +7 -2
  24. src/core/cli_utils.py +19 -2
  25. src/core/config_parser.py +5 -2
  26. src/core/constants.py +54 -0
  27. src/core/linter_utils.py +95 -6
  28. src/core/python_lint_rule.py +101 -0
  29. src/core/registry.py +1 -1
  30. src/core/rule_discovery.py +147 -84
  31. src/core/types.py +13 -0
  32. src/core/violation_builder.py +78 -15
  33. src/core/violation_utils.py +69 -0
  34. src/formatters/__init__.py +22 -0
  35. src/formatters/sarif.py +202 -0
  36. src/linter_config/directive_markers.py +109 -0
  37. src/linter_config/ignore.py +254 -395
  38. src/linter_config/loader.py +45 -12
  39. src/linter_config/pattern_utils.py +65 -0
  40. src/linter_config/rule_matcher.py +89 -0
  41. src/linters/collection_pipeline/__init__.py +90 -0
  42. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  43. src/linters/collection_pipeline/ast_utils.py +40 -0
  44. src/linters/collection_pipeline/config.py +75 -0
  45. src/linters/collection_pipeline/continue_analyzer.py +94 -0
  46. src/linters/collection_pipeline/detector.py +360 -0
  47. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  48. src/linters/collection_pipeline/linter.py +420 -0
  49. src/linters/collection_pipeline/suggestion_builder.py +130 -0
  50. src/linters/cqs/__init__.py +54 -0
  51. src/linters/cqs/config.py +55 -0
  52. src/linters/cqs/function_analyzer.py +201 -0
  53. src/linters/cqs/input_detector.py +139 -0
  54. src/linters/cqs/linter.py +159 -0
  55. src/linters/cqs/output_detector.py +84 -0
  56. src/linters/cqs/python_analyzer.py +54 -0
  57. src/linters/cqs/types.py +82 -0
  58. src/linters/cqs/typescript_cqs_analyzer.py +61 -0
  59. src/linters/cqs/typescript_function_analyzer.py +192 -0
  60. src/linters/cqs/typescript_input_detector.py +203 -0
  61. src/linters/cqs/typescript_output_detector.py +117 -0
  62. src/linters/cqs/violation_builder.py +94 -0
  63. src/linters/dry/base_token_analyzer.py +16 -9
  64. src/linters/dry/block_filter.py +120 -20
  65. src/linters/dry/block_grouper.py +4 -0
  66. src/linters/dry/cache.py +104 -10
  67. src/linters/dry/cache_query.py +4 -0
  68. src/linters/dry/config.py +54 -11
  69. src/linters/dry/constant.py +92 -0
  70. src/linters/dry/constant_matcher.py +223 -0
  71. src/linters/dry/constant_violation_builder.py +98 -0
  72. src/linters/dry/duplicate_storage.py +5 -4
  73. src/linters/dry/file_analyzer.py +4 -2
  74. src/linters/dry/inline_ignore.py +7 -16
  75. src/linters/dry/linter.py +183 -48
  76. src/linters/dry/python_analyzer.py +60 -439
  77. src/linters/dry/python_constant_extractor.py +100 -0
  78. src/linters/dry/single_statement_detector.py +417 -0
  79. src/linters/dry/token_hasher.py +116 -112
  80. src/linters/dry/typescript_analyzer.py +68 -382
  81. src/linters/dry/typescript_constant_extractor.py +138 -0
  82. src/linters/dry/typescript_statement_detector.py +255 -0
  83. src/linters/dry/typescript_value_extractor.py +70 -0
  84. src/linters/dry/violation_builder.py +4 -0
  85. src/linters/dry/violation_filter.py +5 -4
  86. src/linters/dry/violation_generator.py +71 -14
  87. src/linters/file_header/atemporal_detector.py +68 -50
  88. src/linters/file_header/base_parser.py +93 -0
  89. src/linters/file_header/bash_parser.py +66 -0
  90. src/linters/file_header/config.py +90 -16
  91. src/linters/file_header/css_parser.py +70 -0
  92. src/linters/file_header/field_validator.py +36 -33
  93. src/linters/file_header/linter.py +140 -144
  94. src/linters/file_header/markdown_parser.py +130 -0
  95. src/linters/file_header/python_parser.py +14 -58
  96. src/linters/file_header/typescript_parser.py +73 -0
  97. src/linters/file_header/violation_builder.py +13 -12
  98. src/linters/file_placement/config_loader.py +3 -1
  99. src/linters/file_placement/directory_matcher.py +4 -0
  100. src/linters/file_placement/linter.py +66 -34
  101. src/linters/file_placement/pattern_matcher.py +41 -6
  102. src/linters/file_placement/pattern_validator.py +31 -12
  103. src/linters/file_placement/rule_checker.py +12 -7
  104. src/linters/lazy_ignores/__init__.py +43 -0
  105. src/linters/lazy_ignores/config.py +74 -0
  106. src/linters/lazy_ignores/directive_utils.py +164 -0
  107. src/linters/lazy_ignores/header_parser.py +177 -0
  108. src/linters/lazy_ignores/linter.py +158 -0
  109. src/linters/lazy_ignores/matcher.py +168 -0
  110. src/linters/lazy_ignores/python_analyzer.py +209 -0
  111. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  112. src/linters/lazy_ignores/skip_detector.py +298 -0
  113. src/linters/lazy_ignores/types.py +71 -0
  114. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  115. src/linters/lazy_ignores/violation_builder.py +135 -0
  116. src/linters/lbyl/__init__.py +31 -0
  117. src/linters/lbyl/config.py +63 -0
  118. src/linters/lbyl/linter.py +67 -0
  119. src/linters/lbyl/pattern_detectors/__init__.py +53 -0
  120. src/linters/lbyl/pattern_detectors/base.py +63 -0
  121. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  122. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  123. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  124. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  125. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  126. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  127. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  128. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  129. src/linters/lbyl/python_analyzer.py +215 -0
  130. src/linters/lbyl/violation_builder.py +354 -0
  131. src/linters/magic_numbers/context_analyzer.py +227 -225
  132. src/linters/magic_numbers/linter.py +28 -82
  133. src/linters/magic_numbers/python_analyzer.py +4 -16
  134. src/linters/magic_numbers/typescript_analyzer.py +9 -12
  135. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  136. src/linters/method_property/__init__.py +49 -0
  137. src/linters/method_property/config.py +138 -0
  138. src/linters/method_property/linter.py +414 -0
  139. src/linters/method_property/python_analyzer.py +473 -0
  140. src/linters/method_property/violation_builder.py +119 -0
  141. src/linters/nesting/linter.py +24 -16
  142. src/linters/nesting/python_analyzer.py +4 -0
  143. src/linters/nesting/typescript_analyzer.py +6 -12
  144. src/linters/nesting/violation_builder.py +1 -0
  145. src/linters/performance/__init__.py +91 -0
  146. src/linters/performance/config.py +43 -0
  147. src/linters/performance/constants.py +49 -0
  148. src/linters/performance/linter.py +149 -0
  149. src/linters/performance/python_analyzer.py +365 -0
  150. src/linters/performance/regex_analyzer.py +312 -0
  151. src/linters/performance/regex_linter.py +139 -0
  152. src/linters/performance/typescript_analyzer.py +236 -0
  153. src/linters/performance/violation_builder.py +160 -0
  154. src/linters/print_statements/config.py +7 -12
  155. src/linters/print_statements/linter.py +26 -43
  156. src/linters/print_statements/python_analyzer.py +91 -93
  157. src/linters/print_statements/typescript_analyzer.py +15 -25
  158. src/linters/print_statements/violation_builder.py +12 -14
  159. src/linters/srp/class_analyzer.py +11 -7
  160. src/linters/srp/heuristics.py +56 -22
  161. src/linters/srp/linter.py +15 -16
  162. src/linters/srp/python_analyzer.py +55 -20
  163. src/linters/srp/typescript_metrics_calculator.py +110 -50
  164. src/linters/stateless_class/__init__.py +25 -0
  165. src/linters/stateless_class/config.py +58 -0
  166. src/linters/stateless_class/linter.py +349 -0
  167. src/linters/stateless_class/python_analyzer.py +290 -0
  168. src/linters/stringly_typed/__init__.py +36 -0
  169. src/linters/stringly_typed/config.py +189 -0
  170. src/linters/stringly_typed/context_filter.py +451 -0
  171. src/linters/stringly_typed/function_call_violation_builder.py +135 -0
  172. src/linters/stringly_typed/ignore_checker.py +100 -0
  173. src/linters/stringly_typed/ignore_utils.py +51 -0
  174. src/linters/stringly_typed/linter.py +376 -0
  175. src/linters/stringly_typed/python/__init__.py +33 -0
  176. src/linters/stringly_typed/python/analyzer.py +348 -0
  177. src/linters/stringly_typed/python/call_tracker.py +175 -0
  178. src/linters/stringly_typed/python/comparison_tracker.py +257 -0
  179. src/linters/stringly_typed/python/condition_extractor.py +134 -0
  180. src/linters/stringly_typed/python/conditional_detector.py +179 -0
  181. src/linters/stringly_typed/python/constants.py +21 -0
  182. src/linters/stringly_typed/python/match_analyzer.py +94 -0
  183. src/linters/stringly_typed/python/validation_detector.py +189 -0
  184. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  185. src/linters/stringly_typed/storage.py +620 -0
  186. src/linters/stringly_typed/storage_initializer.py +45 -0
  187. src/linters/stringly_typed/typescript/__init__.py +28 -0
  188. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  189. src/linters/stringly_typed/typescript/call_tracker.py +335 -0
  190. src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
  191. src/linters/stringly_typed/violation_generator.py +419 -0
  192. src/orchestrator/core.py +252 -14
  193. src/orchestrator/language_detector.py +5 -3
  194. src/templates/thailint_config_template.yaml +196 -0
  195. src/utils/project_root.py +3 -0
  196. thailint-0.15.3.dist-info/METADATA +187 -0
  197. thailint-0.15.3.dist-info/RECORD +226 -0
  198. thailint-0.15.3.dist-info/entry_points.txt +4 -0
  199. src/cli.py +0 -1665
  200. thailint-0.5.0.dist-info/METADATA +0 -1286
  201. thailint-0.5.0.dist-info/RECORD +0 -96
  202. thailint-0.5.0.dist-info/entry_points.txt +0 -4
  203. {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +0 -0
  204. {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,348 @@
1
+ """
2
+ Purpose: Coordinate Python stringly-typed pattern detection
3
+
4
+ Scope: Orchestrate detection of all stringly-typed patterns in Python files
5
+
6
+ Overview: Provides PythonStringlyTypedAnalyzer class that coordinates detection of
7
+ stringly-typed patterns across Python source files. Uses MembershipValidationDetector
8
+ to find 'x in ("a", "b")' patterns, ConditionalPatternDetector to find if/elif chains
9
+ and match statements, and FunctionCallTracker to find function calls with string
10
+ arguments. Returns unified AnalysisResult objects for validation patterns and
11
+ FunctionCallResult objects for function calls. Handles AST parsing errors gracefully
12
+ and provides a single entry point for Python analysis. Supports configuration options
13
+ for filtering and thresholds.
14
+
15
+ Dependencies: ast module, MembershipValidationDetector, ConditionalPatternDetector,
16
+ FunctionCallTracker, StringlyTypedConfig
17
+
18
+ Exports: PythonStringlyTypedAnalyzer class, AnalysisResult dataclass, FunctionCallResult dataclass,
19
+ ComparisonResult dataclass
20
+
21
+ Interfaces: PythonStringlyTypedAnalyzer.analyze(code, file_path) -> list[AnalysisResult],
22
+ PythonStringlyTypedAnalyzer.analyze_function_calls(code, file_path) -> list[FunctionCallResult]
23
+
24
+ Implementation: Facade pattern coordinating multiple detectors with unified result format
25
+
26
+ Suppressions:
27
+ - srp: Analyzer coordinates multiple detectors (membership, conditional, call tracker).
28
+ Facade pattern justifies combining orchestration methods.
29
+ """
30
+
31
+ import ast
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ from ..config import StringlyTypedConfig
36
+ from .call_tracker import FunctionCallPattern, FunctionCallTracker
37
+ from .comparison_tracker import ComparisonPattern, ComparisonTracker
38
+ from .conditional_detector import ConditionalPatternDetector, EqualityChainPattern
39
+ from .validation_detector import MembershipPattern, MembershipValidationDetector
40
+
41
+
42
+ @dataclass
43
+ class AnalysisResult:
44
+ """Represents a stringly-typed pattern detected in Python code.
45
+
46
+ Provides a unified representation of detected patterns from all detectors,
47
+ including pattern type, string values, location, and contextual information.
48
+ """
49
+
50
+ pattern_type: str
51
+ """Type of pattern detected: 'membership_validation', 'equality_chain', etc."""
52
+
53
+ string_values: set[str]
54
+ """Set of string values used in the pattern."""
55
+
56
+ file_path: Path
57
+ """Path to the file containing the pattern."""
58
+
59
+ line_number: int
60
+ """Line number where the pattern occurs (1-indexed)."""
61
+
62
+ column: int
63
+ """Column number where the pattern starts (0-indexed)."""
64
+
65
+ variable_name: str | None
66
+ """Variable name involved in the pattern, if identifiable."""
67
+
68
+ details: str
69
+ """Human-readable description of the detected pattern."""
70
+
71
+
72
+ @dataclass
73
+ class FunctionCallResult:
74
+ """Represents a function call with a string argument.
75
+
76
+ Provides information about a single function call with a string literal
77
+ argument, enabling aggregation across files to detect limited value sets.
78
+ """
79
+
80
+ function_name: str
81
+ """Fully qualified function name (e.g., 'process' or 'obj.method')."""
82
+
83
+ param_index: int
84
+ """Index of the parameter receiving the string value (0-indexed)."""
85
+
86
+ string_value: str
87
+ """The string literal value passed to the function."""
88
+
89
+ file_path: Path
90
+ """Path to the file containing the call."""
91
+
92
+ line_number: int
93
+ """Line number where the call occurs (1-indexed)."""
94
+
95
+ column: int
96
+ """Column number where the call starts (0-indexed)."""
97
+
98
+
99
+ @dataclass
100
+ class ComparisonResult:
101
+ """Represents a string comparison found in Python code.
102
+
103
+ Provides information about a comparison like `if env == "production"` to
104
+ enable cross-file aggregation for detecting scattered comparisons that
105
+ suggest missing enums.
106
+ """
107
+
108
+ variable_name: str
109
+ """Variable name being compared (e.g., 'env' or 'self.status')."""
110
+
111
+ compared_value: str
112
+ """The string literal value being compared to."""
113
+
114
+ operator: str
115
+ """The comparison operator ('==' or '!=')."""
116
+
117
+ file_path: Path
118
+ """Path to the file containing the comparison."""
119
+
120
+ line_number: int
121
+ """Line number where the comparison occurs (1-indexed)."""
122
+
123
+ column: int
124
+ """Column number where the comparison starts (0-indexed)."""
125
+
126
+
127
+ class PythonStringlyTypedAnalyzer: # thailint: ignore[srp]
128
+ """Analyzes Python code for stringly-typed patterns.
129
+
130
+ Coordinates detection of various stringly-typed patterns including membership
131
+ validation ('x in ("a", "b")'), equality chains ('if x == "a" elif x == "b"'),
132
+ and function calls with string arguments ('process("active")').
133
+ Provides configuration-aware analysis with filtering support.
134
+
135
+ Note: Method count exceeds SRP limit due to analyzer coordination role. Multiple
136
+ analysis methods are required for different pattern types (membership, conditional,
137
+ function calls, comparisons) and their converters.
138
+ """
139
+
140
+ def __init__(self, config: StringlyTypedConfig | None = None) -> None:
141
+ """Initialize the analyzer with optional configuration.
142
+
143
+ Args:
144
+ config: Configuration for stringly-typed detection. Uses defaults if None.
145
+ """
146
+ self.config = config or StringlyTypedConfig()
147
+ self._membership_detector = MembershipValidationDetector()
148
+ self._conditional_detector = ConditionalPatternDetector()
149
+ self._call_tracker = FunctionCallTracker()
150
+ self._comparison_tracker = ComparisonTracker()
151
+
152
+ def analyze(self, code: str, file_path: Path) -> list[AnalysisResult]:
153
+ """Analyze Python code for stringly-typed patterns.
154
+
155
+ Args:
156
+ code: Python source code to analyze
157
+ file_path: Path to the file being analyzed
158
+
159
+ Returns:
160
+ List of AnalysisResult instances for each detected pattern
161
+ """
162
+ tree = self._parse_code(code)
163
+ if tree is None:
164
+ return []
165
+
166
+ results: list[AnalysisResult] = []
167
+
168
+ # Detect membership validation patterns
169
+ membership_patterns = self._membership_detector.find_patterns(tree)
170
+ results.extend(
171
+ self._convert_membership_pattern(pattern, file_path) for pattern in membership_patterns
172
+ )
173
+
174
+ # Detect equality chain patterns
175
+ conditional_patterns = self._conditional_detector.find_patterns(tree)
176
+ results.extend(
177
+ self._convert_conditional_pattern(pattern, file_path)
178
+ for pattern in conditional_patterns
179
+ )
180
+
181
+ return results
182
+
183
+ def _parse_code(self, code: str) -> ast.AST | None:
184
+ """Parse Python source code into an AST.
185
+
186
+ Args:
187
+ code: Python source code to parse
188
+
189
+ Returns:
190
+ AST if parsing succeeds, None if parsing fails
191
+ """
192
+ try:
193
+ return ast.parse(code)
194
+ except SyntaxError:
195
+ return None
196
+
197
+ def _convert_membership_pattern(
198
+ self, pattern: MembershipPattern, file_path: Path
199
+ ) -> AnalysisResult:
200
+ """Convert a MembershipPattern to unified AnalysisResult.
201
+
202
+ Args:
203
+ pattern: Detected membership pattern
204
+ file_path: Path to the file containing the pattern
205
+
206
+ Returns:
207
+ AnalysisResult representing the pattern
208
+ """
209
+ values_str = ", ".join(sorted(pattern.string_values))
210
+ var_info = f" on '{pattern.variable_name}'" if pattern.variable_name else ""
211
+ details = (
212
+ f"Membership validation{var_info} with {len(pattern.string_values)} "
213
+ f"string values ({pattern.operator}): {values_str}"
214
+ )
215
+
216
+ return AnalysisResult(
217
+ pattern_type="membership_validation",
218
+ string_values=pattern.string_values,
219
+ file_path=file_path,
220
+ line_number=pattern.line_number,
221
+ column=pattern.column,
222
+ variable_name=pattern.variable_name,
223
+ details=details,
224
+ )
225
+
226
+ def _convert_conditional_pattern(
227
+ self, pattern: EqualityChainPattern, file_path: Path
228
+ ) -> AnalysisResult:
229
+ """Convert an EqualityChainPattern to unified AnalysisResult.
230
+
231
+ Args:
232
+ pattern: Detected equality chain pattern
233
+ file_path: Path to the file containing the pattern
234
+
235
+ Returns:
236
+ AnalysisResult representing the pattern
237
+ """
238
+ values_str = ", ".join(sorted(pattern.string_values))
239
+ var_info = f" on '{pattern.variable_name}'" if pattern.variable_name else ""
240
+ pattern_label = self._get_pattern_label(pattern.pattern_type)
241
+ details = (
242
+ f"{pattern_label}{var_info} with {len(pattern.string_values)} "
243
+ f"string values: {values_str}"
244
+ )
245
+
246
+ return AnalysisResult(
247
+ pattern_type=pattern.pattern_type,
248
+ string_values=pattern.string_values,
249
+ file_path=file_path,
250
+ line_number=pattern.line_number,
251
+ column=pattern.column,
252
+ variable_name=pattern.variable_name,
253
+ details=details,
254
+ )
255
+
256
+ def _get_pattern_label(self, pattern_type: str) -> str:
257
+ """Get human-readable label for a pattern type.
258
+
259
+ Args:
260
+ pattern_type: The pattern type string
261
+
262
+ Returns:
263
+ Human-readable label for the pattern
264
+ """
265
+ labels = {
266
+ "equality_chain": "Equality chain",
267
+ "or_combined": "Or-combined comparison",
268
+ "match_statement": "Match statement",
269
+ }
270
+ return labels.get(pattern_type, "Conditional pattern")
271
+
272
+ def analyze_function_calls(self, code: str, file_path: Path) -> list[FunctionCallResult]:
273
+ """Analyze Python code for function calls with string arguments.
274
+
275
+ Args:
276
+ code: Python source code to analyze
277
+ file_path: Path to the file being analyzed
278
+
279
+ Returns:
280
+ List of FunctionCallResult instances for each detected call
281
+ """
282
+ tree = self._parse_code(code)
283
+ if tree is None:
284
+ return []
285
+
286
+ call_patterns = self._call_tracker.find_patterns(tree)
287
+ return [self._convert_call_pattern(pattern, file_path) for pattern in call_patterns]
288
+
289
+ def _convert_call_pattern(
290
+ self, pattern: FunctionCallPattern, file_path: Path
291
+ ) -> FunctionCallResult:
292
+ """Convert a FunctionCallPattern to FunctionCallResult.
293
+
294
+ Args:
295
+ pattern: Detected function call pattern
296
+ file_path: Path to the file containing the call
297
+
298
+ Returns:
299
+ FunctionCallResult representing the call
300
+ """
301
+ return FunctionCallResult(
302
+ function_name=pattern.function_name,
303
+ param_index=pattern.param_index,
304
+ string_value=pattern.string_value,
305
+ file_path=file_path,
306
+ line_number=pattern.line_number,
307
+ column=pattern.column,
308
+ )
309
+
310
+ def analyze_comparisons(self, code: str, file_path: Path) -> list[ComparisonResult]:
311
+ """Analyze Python code for string comparisons.
312
+
313
+ Args:
314
+ code: Python source code to analyze
315
+ file_path: Path to the file being analyzed
316
+
317
+ Returns:
318
+ List of ComparisonResult instances for each detected comparison
319
+ """
320
+ tree = self._parse_code(code)
321
+ if tree is None:
322
+ return []
323
+
324
+ comparison_patterns = self._comparison_tracker.find_patterns(tree)
325
+ return [
326
+ self._convert_comparison_pattern(pattern, file_path) for pattern in comparison_patterns
327
+ ]
328
+
329
+ def _convert_comparison_pattern(
330
+ self, pattern: ComparisonPattern, file_path: Path
331
+ ) -> ComparisonResult:
332
+ """Convert a ComparisonPattern to ComparisonResult.
333
+
334
+ Args:
335
+ pattern: Detected comparison pattern
336
+ file_path: Path to the file containing the comparison
337
+
338
+ Returns:
339
+ ComparisonResult representing the comparison
340
+ """
341
+ return ComparisonResult(
342
+ variable_name=pattern.variable_name,
343
+ compared_value=pattern.compared_value,
344
+ operator=pattern.operator,
345
+ file_path=file_path,
346
+ line_number=pattern.line_number,
347
+ column=pattern.column,
348
+ )
@@ -0,0 +1,175 @@
1
+ """
2
+ Purpose: Detect function calls with string literal arguments in Python AST
3
+
4
+ Scope: Find function and method calls that consistently receive string arguments
5
+
6
+ Overview: Provides FunctionCallTracker class that traverses Python AST to find function
7
+ and method calls where string literals are passed as arguments. Tracks the function
8
+ name, parameter index, and string value to enable cross-file aggregation. When a
9
+ function is called with the same set of limited string values across files, it
10
+ suggests the parameter should be an enum. Handles both simple function calls
11
+ (foo("value")) and method calls (obj.method("value")).
12
+
13
+ Dependencies: ast module for AST parsing, dataclasses for pattern structure,
14
+ src.core.constants for MAX_ATTRIBUTE_CHAIN_DEPTH
15
+
16
+ Exports: FunctionCallTracker class, FunctionCallPattern dataclass
17
+
18
+ Interfaces: FunctionCallTracker.find_patterns(tree) -> list[FunctionCallPattern]
19
+
20
+ Implementation: AST NodeVisitor pattern with Call node handling for string arguments
21
+
22
+ Suppressions:
23
+ - invalid-name: visit_Call follows AST NodeVisitor method naming convention
24
+ """
25
+
26
+ import ast
27
+ from dataclasses import dataclass
28
+
29
+ from src.core.constants import MAX_ATTRIBUTE_CHAIN_DEPTH
30
+
31
+
32
+ @dataclass
33
+ class FunctionCallPattern:
34
+ """Represents a function call with a string literal argument.
35
+
36
+ Captures information about a function or method call where a string literal
37
+ is passed as an argument, enabling cross-file analysis to detect limited
38
+ value sets that should be enums.
39
+ """
40
+
41
+ function_name: str
42
+ """Fully qualified function name (e.g., 'process' or 'obj.method')."""
43
+
44
+ param_index: int
45
+ """Index of the parameter receiving the string value (0-indexed)."""
46
+
47
+ string_value: str
48
+ """The string literal value passed to the function."""
49
+
50
+ line_number: int
51
+ """Line number where the call occurs (1-indexed)."""
52
+
53
+ column: int
54
+ """Column number where the call starts (0-indexed)."""
55
+
56
+
57
+ class FunctionCallTracker(ast.NodeVisitor):
58
+ """Tracks function calls with string literal arguments.
59
+
60
+ Finds patterns like 'process("active")' and 'obj.set_status("pending")' where
61
+ string literals are used for arguments that could be enums.
62
+ """
63
+
64
+ def __init__(self) -> None:
65
+ """Initialize the tracker."""
66
+ self.patterns: list[FunctionCallPattern] = []
67
+
68
+ def find_patterns(self, tree: ast.AST) -> list[FunctionCallPattern]:
69
+ """Find all function calls with string arguments in the AST.
70
+
71
+ Args:
72
+ tree: The AST to analyze
73
+
74
+ Returns:
75
+ List of FunctionCallPattern instances for each detected call
76
+ """
77
+ self.patterns = []
78
+ self.visit(tree)
79
+ return self.patterns
80
+
81
+ def visit_Call(self, node: ast.Call) -> None: # pylint: disable=invalid-name
82
+ """Visit a Call node to check for string arguments.
83
+
84
+ Handles both simple function calls and method calls, extracting
85
+ the function name and any string literal arguments.
86
+
87
+ Args:
88
+ node: The Call node to analyze
89
+ """
90
+ function_name = self._extract_function_name(node.func)
91
+ if function_name is None:
92
+ self.generic_visit(node)
93
+ return
94
+
95
+ self._check_positional_args(node, function_name)
96
+ self.generic_visit(node)
97
+
98
+ def _extract_function_name(self, func_node: ast.expr) -> str | None:
99
+ """Extract the function name from a call expression.
100
+
101
+ Handles simple names (foo) and attribute access (obj.method).
102
+
103
+ Args:
104
+ func_node: The function expression node
105
+
106
+ Returns:
107
+ Function name string or None if not extractable
108
+ """
109
+ if isinstance(func_node, ast.Name):
110
+ return func_node.id
111
+ if isinstance(func_node, ast.Attribute):
112
+ return self._extract_attribute_name(func_node)
113
+ return None
114
+
115
+ def _extract_attribute_name(self, node: ast.Attribute) -> str | None:
116
+ """Extract function name from an attribute access.
117
+
118
+ Builds qualified names like 'obj.method' or 'a.b.method'.
119
+
120
+ Args:
121
+ node: The Attribute node
122
+
123
+ Returns:
124
+ Qualified function name or None if too complex
125
+ """
126
+ parts: list[str] = [node.attr]
127
+ current = node.value
128
+ depth = 0
129
+
130
+ while depth < MAX_ATTRIBUTE_CHAIN_DEPTH:
131
+ if isinstance(current, ast.Name):
132
+ parts.append(current.id)
133
+ break
134
+ if isinstance(current, ast.Attribute):
135
+ parts.append(current.attr)
136
+ current = current.value
137
+ depth += 1
138
+ else:
139
+ # Complex expression (call result, subscript, etc.)
140
+ # Use placeholder to maintain function identity
141
+ parts.append("_")
142
+ break
143
+
144
+ return ".".join(reversed(parts))
145
+
146
+ def _check_positional_args(self, node: ast.Call, function_name: str) -> None:
147
+ """Check positional arguments for string literals.
148
+
149
+ Args:
150
+ node: The Call node
151
+ function_name: Extracted function name
152
+ """
153
+ for param_index, arg in enumerate(node.args):
154
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
155
+ self._add_pattern(node, function_name, param_index, arg.value)
156
+
157
+ def _add_pattern(
158
+ self, node: ast.Call, function_name: str, param_index: int, string_value: str
159
+ ) -> None:
160
+ """Create and add a function call pattern to results.
161
+
162
+ Args:
163
+ node: The Call node containing the pattern
164
+ function_name: Name of the function being called
165
+ param_index: Index of the string argument
166
+ string_value: The string literal value
167
+ """
168
+ pattern = FunctionCallPattern(
169
+ function_name=function_name,
170
+ param_index=param_index,
171
+ string_value=string_value,
172
+ line_number=node.lineno,
173
+ column=node.col_offset,
174
+ )
175
+ self.patterns.append(pattern)