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,376 @@
1
+ """
2
+ Purpose: Violation generation from cross-file stringly-typed patterns
3
+
4
+ Scope: Generates violations from duplicate pattern hashes, function call patterns, and
5
+ scattered string comparisons
6
+
7
+ Overview: Handles violation generation for stringly-typed patterns that appear across multiple
8
+ files. Queries storage for duplicate hashes, retrieves patterns for each hash, builds
9
+ violations with cross-references to other files, and filters patterns based on enum value
10
+ thresholds. Delegates function call violation generation to FunctionCallViolationBuilder.
11
+ Generates violations for scattered string comparisons (e.g., `if env == "production"`)
12
+ where a variable is compared to multiple unique string values across files.
13
+ Applies inline ignore directives via IgnoreChecker to filter suppressed violations.
14
+ Separates violation generation logic from main linter rule to maintain SRP compliance.
15
+
16
+ Dependencies: StringlyTypedStorage, StoredPattern, StoredComparison, StringlyTypedConfig,
17
+ Violation, Severity, FunctionCallViolationBuilder, IgnoreChecker
18
+
19
+ Exports: ViolationGenerator class
20
+
21
+ Interfaces: ViolationGenerator.generate_violations(storage, rule_id, config) -> list[Violation]
22
+
23
+ Implementation: Queries storage, validates pattern thresholds, builds violations with
24
+ cross-file references, delegates function call violations to builder, generates
25
+ comparison violations from scattered string comparisons, filters by ignore directives
26
+ """
27
+
28
+ from src.core.types import Severity, Violation
29
+
30
+ from .config import StringlyTypedConfig
31
+ from .context_filter import FunctionCallFilter
32
+ from .function_call_violation_builder import FunctionCallViolationBuilder
33
+ from .ignore_checker import IgnoreChecker
34
+ from .ignore_utils import is_ignored
35
+ from .storage import StoredComparison, StoredPattern, StringlyTypedStorage
36
+
37
+
38
+ def _filter_by_ignore(violations: list[Violation], ignore: list[str]) -> list[Violation]:
39
+ """Filter violations by ignore patterns."""
40
+ if not ignore:
41
+ return violations
42
+ return [v for v in violations if not is_ignored(v.file_path, ignore)]
43
+
44
+
45
+ def _is_allowed_value_set(values: set[str], config: StringlyTypedConfig) -> bool:
46
+ """Check if a set of values is in the allowed list."""
47
+ return any(values == set(allowed) for allowed in config.allowed_string_sets)
48
+
49
+
50
+ class ViolationGenerator: # thailint: ignore srp
51
+ """Generates violations from cross-file stringly-typed patterns."""
52
+
53
+ def __init__(self) -> None:
54
+ """Initialize with helper builders and filters."""
55
+ self._call_builder = FunctionCallViolationBuilder()
56
+ self._call_filter = FunctionCallFilter()
57
+ self._ignore_checker = IgnoreChecker()
58
+
59
+ def generate_violations(
60
+ self,
61
+ storage: StringlyTypedStorage,
62
+ rule_id: str,
63
+ config: StringlyTypedConfig,
64
+ ) -> list[Violation]:
65
+ """Generate violations from storage.
66
+
67
+ Args:
68
+ storage: Pattern storage instance
69
+ rule_id: Rule identifier for violations
70
+ config: Stringly-typed configuration with thresholds
71
+
72
+ Returns:
73
+ List of violations for patterns appearing in multiple files
74
+ """
75
+ violations: list[Violation] = []
76
+ pattern_violations, covered_vars = self._generate_pattern_violations(
77
+ storage, rule_id, config
78
+ )
79
+ violations.extend(pattern_violations)
80
+ violations.extend(self._generate_function_call_violations(storage, config))
81
+ violations.extend(self._generate_comparison_violations(storage, config, covered_vars))
82
+
83
+ # Apply path-based ignore patterns from config
84
+ violations = _filter_by_ignore(violations, config.ignore)
85
+
86
+ # Apply inline ignore directives (# thailint: ignore[stringly-typed])
87
+ violations = self._ignore_checker.filter_violations(violations)
88
+
89
+ return violations
90
+
91
+ def _generate_pattern_violations(
92
+ self,
93
+ storage: StringlyTypedStorage,
94
+ rule_id: str,
95
+ config: StringlyTypedConfig,
96
+ ) -> tuple[list[Violation], set[str]]:
97
+ """Generate violations for duplicate validation patterns.
98
+
99
+ Returns:
100
+ Tuple of (violations list, set of variable names covered by these violations)
101
+ """
102
+ duplicate_hashes = storage.get_duplicate_hashes(min_files=config.min_occurrences)
103
+ violations: list[Violation] = []
104
+ covered_variables: set[str] = set()
105
+
106
+ for hash_value in duplicate_hashes:
107
+ patterns = storage.get_patterns_by_hash(hash_value)
108
+ self._process_pattern_group(patterns, config, rule_id, violations, covered_variables)
109
+
110
+ return violations, covered_variables
111
+
112
+ def _process_pattern_group( # pylint: disable=too-many-arguments,too-many-positional-arguments
113
+ self,
114
+ patterns: list[StoredPattern],
115
+ config: StringlyTypedConfig,
116
+ rule_id: str,
117
+ violations: list[Violation],
118
+ covered_variables: set[str],
119
+ ) -> None:
120
+ """Process a group of patterns with the same hash."""
121
+ if self._should_skip_patterns(patterns, config):
122
+ return
123
+ violations.extend(self._build_violation(p, patterns, rule_id) for p in patterns)
124
+ # Track variable names to avoid duplicate comparison violations
125
+ for pattern in patterns:
126
+ if pattern.variable_name:
127
+ covered_variables.add(pattern.variable_name)
128
+
129
+ def _generate_function_call_violations(
130
+ self,
131
+ storage: StringlyTypedStorage,
132
+ config: StringlyTypedConfig,
133
+ ) -> list[Violation]:
134
+ """Generate violations for function call patterns."""
135
+ min_files = config.min_occurrences if config.require_cross_file else 1
136
+ limited_funcs = storage.get_limited_value_functions(
137
+ min_values=config.min_values_for_enum,
138
+ max_values=config.max_values_for_enum,
139
+ min_files=min_files,
140
+ )
141
+
142
+ violations: list[Violation] = []
143
+ for function_name, param_index, unique_values in limited_funcs:
144
+ if _is_allowed_value_set(unique_values, config):
145
+ continue
146
+ # Apply context-aware filtering to reduce false positives
147
+ if not self._call_filter.should_include(function_name, param_index, unique_values):
148
+ continue
149
+ calls = storage.get_calls_by_function(function_name, param_index)
150
+ violations.extend(self._call_builder.build_violations(calls, unique_values))
151
+
152
+ return violations
153
+
154
+ def _should_skip_patterns(
155
+ self, patterns: list[StoredPattern], config: StringlyTypedConfig
156
+ ) -> bool:
157
+ """Check if pattern group should be skipped based on config."""
158
+ if not patterns:
159
+ return True
160
+ first = patterns[0]
161
+ if not self._is_enum_candidate(first, config):
162
+ return True
163
+ if self._is_pattern_allowed(first, config):
164
+ return True
165
+ # Skip if all values match excluded patterns (numeric strings, etc.)
166
+ if self._call_filter.are_all_values_excluded(set(first.string_values)):
167
+ return True
168
+ return False
169
+
170
+ def _is_enum_candidate(self, pattern: StoredPattern, config: StringlyTypedConfig) -> bool:
171
+ """Check if pattern's value count is within enum range."""
172
+ value_count = len(pattern.string_values)
173
+ return config.min_values_for_enum <= value_count <= config.max_values_for_enum
174
+
175
+ def _is_pattern_allowed(self, pattern: StoredPattern, config: StringlyTypedConfig) -> bool:
176
+ """Check if pattern's string set is in allowed list."""
177
+ return _is_allowed_value_set(set(pattern.string_values), config)
178
+
179
+ def _build_violation(
180
+ self, pattern: StoredPattern, all_patterns: list[StoredPattern], rule_id: str
181
+ ) -> Violation:
182
+ """Build a violation for a pattern with cross-references."""
183
+ message = self._build_message(pattern, all_patterns)
184
+ suggestion = self._build_suggestion(pattern)
185
+
186
+ return Violation(
187
+ rule_id=rule_id,
188
+ file_path=str(pattern.file_path),
189
+ line=pattern.line_number,
190
+ column=pattern.column,
191
+ message=message,
192
+ severity=Severity.ERROR,
193
+ suggestion=suggestion,
194
+ )
195
+
196
+ def _build_message(self, pattern: StoredPattern, all_patterns: list[StoredPattern]) -> str:
197
+ """Build violation message with cross-file references."""
198
+ file_count = len({p.file_path for p in all_patterns})
199
+ values_str = ", ".join(f"'{v}'" for v in sorted(pattern.string_values))
200
+ other_refs = self._build_cross_references(pattern, all_patterns)
201
+
202
+ message = (
203
+ f"Stringly-typed pattern with values [{values_str}] appears in {file_count} files."
204
+ )
205
+ if other_refs:
206
+ message += f" Also found in: {other_refs}."
207
+
208
+ return message
209
+
210
+ def _build_cross_references(
211
+ self, pattern: StoredPattern, all_patterns: list[StoredPattern]
212
+ ) -> str:
213
+ """Build cross-reference string for other files."""
214
+ refs = [
215
+ f"{other.file_path.name}:{other.line_number}"
216
+ for other in all_patterns
217
+ if other.file_path != pattern.file_path
218
+ ]
219
+ return ", ".join(refs)
220
+
221
+ def _build_suggestion(self, pattern: StoredPattern) -> str:
222
+ """Build fix suggestion for the pattern."""
223
+ values_count = len(pattern.string_values)
224
+ var_info = f" for '{pattern.variable_name}'" if pattern.variable_name else ""
225
+
226
+ return (
227
+ f"Consider defining an enum or type union{var_info} with the "
228
+ f"{values_count} possible values instead of using string literals."
229
+ )
230
+
231
+ def _generate_comparison_violations(
232
+ self,
233
+ storage: StringlyTypedStorage,
234
+ config: StringlyTypedConfig,
235
+ covered_variables: set[str] | None = None,
236
+ ) -> list[Violation]:
237
+ """Generate violations for scattered string comparisons.
238
+
239
+ Finds variables that are compared to multiple unique string values across
240
+ files (e.g., `if env == "production"` in one file and `if env == "staging"`
241
+ in another), suggesting they should use enums instead.
242
+
243
+ Args:
244
+ storage: Pattern storage instance
245
+ config: Stringly-typed configuration
246
+ covered_variables: Variable names already flagged by pattern violations (to deduplicate)
247
+ """
248
+ covered_variables = covered_variables or set()
249
+ min_files = config.min_occurrences if config.require_cross_file else 1
250
+ variables = storage.get_variables_with_multiple_values(
251
+ min_values=config.min_values_for_enum,
252
+ min_files=min_files,
253
+ )
254
+
255
+ violations: list[Violation] = []
256
+ for variable_name, unique_values in variables:
257
+ self._process_variable_comparisons(
258
+ variable_name, unique_values, storage, config, covered_variables, violations
259
+ )
260
+
261
+ return violations
262
+
263
+ def _process_variable_comparisons( # pylint: disable=too-many-arguments,too-many-positional-arguments
264
+ self,
265
+ variable_name: str,
266
+ unique_values: set[str],
267
+ storage: StringlyTypedStorage,
268
+ config: StringlyTypedConfig,
269
+ covered_variables: set[str],
270
+ violations: list[Violation],
271
+ ) -> None:
272
+ """Process comparisons for a single variable."""
273
+ # Skip if already covered by equality chain or validation pattern
274
+ if variable_name in covered_variables:
275
+ return
276
+ # Skip false positive variable patterns (.value, .method, etc.)
277
+ if self._should_skip_variable(variable_name):
278
+ return
279
+ if self._should_skip_comparison(unique_values, config):
280
+ return
281
+ comparisons = storage.get_comparisons_by_variable(variable_name)
282
+ violations.extend(
283
+ self._build_comparison_violation(c, comparisons, unique_values) for c in comparisons
284
+ )
285
+
286
+ def _should_skip_comparison(self, unique_values: set[str], config: StringlyTypedConfig) -> bool:
287
+ """Check if a comparison pattern should be skipped based on config."""
288
+ if len(unique_values) > config.max_values_for_enum:
289
+ return True
290
+ if _is_allowed_value_set(unique_values, config):
291
+ return True
292
+ if self._call_filter.are_all_values_excluded(unique_values):
293
+ return True
294
+ return False
295
+
296
+ def _should_skip_variable(self, variable_name: str) -> bool:
297
+ """Check if a variable name indicates a false positive comparison.
298
+
299
+ Excludes:
300
+ - Variables ending with .value (enum value access)
301
+ - HTTP method variables (request.method, etc.)
302
+ - Variables that are likely test fixtures (underscore prefix patterns)
303
+ """
304
+ # Enum value access - already using an enum
305
+ if variable_name.endswith(".value"):
306
+ return True
307
+ # HTTP method - standard protocol strings
308
+ if variable_name.endswith(".method"):
309
+ return True
310
+ # Test assertion patterns (underscore prefix is common in comprehensions/lambdas)
311
+ if variable_name.startswith("_."):
312
+ return True
313
+ return False
314
+
315
+ def _build_comparison_violation(
316
+ self,
317
+ comparison: StoredComparison,
318
+ all_comparisons: list[StoredComparison],
319
+ unique_values: set[str],
320
+ ) -> Violation:
321
+ """Build a violation for a scattered string comparison."""
322
+ message = self._build_comparison_message(comparison, all_comparisons, unique_values)
323
+ suggestion = self._build_comparison_suggestion(comparison, unique_values)
324
+
325
+ return Violation(
326
+ rule_id="stringly-typed.scattered-comparison",
327
+ file_path=str(comparison.file_path),
328
+ line=comparison.line_number,
329
+ column=comparison.column,
330
+ message=message,
331
+ severity=Severity.ERROR,
332
+ suggestion=suggestion,
333
+ )
334
+
335
+ def _build_comparison_message(
336
+ self,
337
+ comparison: StoredComparison,
338
+ all_comparisons: list[StoredComparison],
339
+ unique_values: set[str],
340
+ ) -> str:
341
+ """Build violation message for scattered comparison."""
342
+ file_count = len({c.file_path for c in all_comparisons})
343
+ values_str = ", ".join(f"'{v}'" for v in sorted(unique_values))
344
+ other_refs = self._build_comparison_cross_references(comparison, all_comparisons)
345
+
346
+ message = (
347
+ f"Variable '{comparison.variable_name}' is compared to {len(unique_values)} "
348
+ f"different string values [{values_str}] across {file_count} file(s)."
349
+ )
350
+ if other_refs:
351
+ message += f" Also compared in: {other_refs}."
352
+
353
+ return message
354
+
355
+ def _build_comparison_cross_references(
356
+ self,
357
+ comparison: StoredComparison,
358
+ all_comparisons: list[StoredComparison],
359
+ ) -> str:
360
+ """Build cross-reference string for other comparison locations."""
361
+ refs = [
362
+ f"{other.file_path.name}:{other.line_number}"
363
+ for other in all_comparisons
364
+ if other.file_path != comparison.file_path
365
+ ]
366
+ return ", ".join(refs)
367
+
368
+ def _build_comparison_suggestion(
369
+ self, comparison: StoredComparison, unique_values: set[str]
370
+ ) -> str:
371
+ """Build fix suggestion for scattered comparison."""
372
+ return (
373
+ f"Consider defining an enum for '{comparison.variable_name}' with the "
374
+ f"{len(unique_values)} possible values instead of using string literals "
375
+ f"in scattered comparisons."
376
+ )