thailint 0.9.0__py3-none-any.whl → 0.11.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 (69) 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 +343 -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 +375 -0
  14. src/cli_main.py +34 -0
  15. src/config.py +2 -3
  16. src/core/rule_discovery.py +43 -10
  17. src/core/types.py +13 -0
  18. src/core/violation_utils.py +69 -0
  19. src/linter_config/ignore.py +32 -16
  20. src/linters/collection_pipeline/__init__.py +90 -0
  21. src/linters/collection_pipeline/config.py +63 -0
  22. src/linters/collection_pipeline/continue_analyzer.py +100 -0
  23. src/linters/collection_pipeline/detector.py +130 -0
  24. src/linters/collection_pipeline/linter.py +437 -0
  25. src/linters/collection_pipeline/suggestion_builder.py +63 -0
  26. src/linters/dry/block_filter.py +99 -9
  27. src/linters/dry/cache.py +94 -6
  28. src/linters/dry/config.py +47 -10
  29. src/linters/dry/constant.py +92 -0
  30. src/linters/dry/constant_matcher.py +214 -0
  31. src/linters/dry/constant_violation_builder.py +98 -0
  32. src/linters/dry/linter.py +89 -48
  33. src/linters/dry/python_analyzer.py +44 -431
  34. src/linters/dry/python_constant_extractor.py +101 -0
  35. src/linters/dry/single_statement_detector.py +415 -0
  36. src/linters/dry/token_hasher.py +5 -5
  37. src/linters/dry/typescript_analyzer.py +63 -382
  38. src/linters/dry/typescript_constant_extractor.py +134 -0
  39. src/linters/dry/typescript_statement_detector.py +255 -0
  40. src/linters/dry/typescript_value_extractor.py +66 -0
  41. src/linters/file_header/linter.py +9 -13
  42. src/linters/file_placement/linter.py +30 -10
  43. src/linters/file_placement/pattern_matcher.py +19 -5
  44. src/linters/magic_numbers/linter.py +8 -67
  45. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  46. src/linters/nesting/linter.py +12 -9
  47. src/linters/print_statements/linter.py +7 -24
  48. src/linters/srp/class_analyzer.py +9 -9
  49. src/linters/srp/heuristics.py +6 -5
  50. src/linters/srp/linter.py +4 -5
  51. src/linters/stateless_class/linter.py +2 -2
  52. src/linters/stringly_typed/__init__.py +23 -0
  53. src/linters/stringly_typed/config.py +165 -0
  54. src/linters/stringly_typed/python/__init__.py +29 -0
  55. src/linters/stringly_typed/python/analyzer.py +198 -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/orchestrator/core.py +241 -12
  63. {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/METADATA +116 -3
  64. {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/RECORD +67 -29
  65. thailint-0.11.0.dist-info/entry_points.txt +4 -0
  66. src/cli.py +0 -2014
  67. thailint-0.9.0.dist-info/entry_points.txt +0 -4
  68. {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/WHEEL +0 -0
  69. {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -24,7 +24,7 @@ from typing import Any
24
24
  from src.core.base import BaseLintContext, MultiLanguageLintRule
25
25
  from src.core.linter_utils import load_linter_config
26
26
  from src.core.types import Violation
27
- from src.linter_config.ignore import IgnoreDirectiveParser
27
+ from src.linter_config.ignore import get_ignore_parser
28
28
 
29
29
  from .config import NestingConfig
30
30
  from .python_analyzer import PythonNestingAnalyzer
@@ -37,8 +37,11 @@ class NestingDepthRule(MultiLanguageLintRule):
37
37
 
38
38
  def __init__(self) -> None:
39
39
  """Initialize the nesting depth rule."""
40
- self._ignore_parser = IgnoreDirectiveParser()
40
+ self._ignore_parser = get_ignore_parser()
41
41
  self._violation_builder = NestingViolationBuilder(self.rule_id)
42
+ # Singleton analyzers for performance (avoid recreating per-file)
43
+ self._python_analyzer = PythonNestingAnalyzer()
44
+ self._typescript_analyzer = TypeScriptNestingAnalyzer()
42
45
 
43
46
  @property
44
47
  def rule_id(self) -> str:
@@ -108,9 +111,8 @@ class NestingDepthRule(MultiLanguageLintRule):
108
111
  except SyntaxError as e:
109
112
  return [self._violation_builder.create_syntax_error_violation(e, context)]
110
113
 
111
- analyzer = PythonNestingAnalyzer()
112
- functions = analyzer.find_all_functions(tree)
113
- return self._process_python_functions(functions, analyzer, config, context)
114
+ functions = self._python_analyzer.find_all_functions(tree)
115
+ return self._process_python_functions(functions, self._python_analyzer, config, context)
114
116
 
115
117
  def _process_typescript_functions(
116
118
  self, functions: list, analyzer: Any, config: NestingConfig, context: BaseLintContext
@@ -149,13 +151,14 @@ class NestingDepthRule(MultiLanguageLintRule):
149
151
  Returns:
150
152
  List of violations found in TypeScript code
151
153
  """
152
- analyzer = TypeScriptNestingAnalyzer()
153
- root_node = analyzer.parse_typescript(context.file_content or "")
154
+ root_node = self._typescript_analyzer.parse_typescript(context.file_content or "")
154
155
  if root_node is None:
155
156
  return []
156
157
 
157
- functions = analyzer.find_all_functions(root_node)
158
- return self._process_typescript_functions(functions, analyzer, config, context)
158
+ functions = self._typescript_analyzer.find_all_functions(root_node)
159
+ return self._process_typescript_functions(
160
+ functions, self._typescript_analyzer, config, context
161
+ )
159
162
 
160
163
  def _should_ignore(self, violation: Violation, context: BaseLintContext) -> bool:
161
164
  """Check if violation should be ignored based on inline directives.
@@ -29,7 +29,8 @@ from pathlib import Path
29
29
  from src.core.base import BaseLintContext, MultiLanguageLintRule
30
30
  from src.core.linter_utils import load_linter_config
31
31
  from src.core.types import Violation
32
- from src.linter_config.ignore import IgnoreDirectiveParser
32
+ from src.core.violation_utils import get_violation_line, has_python_noqa, has_typescript_noqa
33
+ from src.linter_config.ignore import get_ignore_parser
33
34
 
34
35
  from .config import PrintStatementConfig
35
36
  from .python_analyzer import PythonPrintStatementAnalyzer
@@ -42,7 +43,7 @@ class PrintStatementRule(MultiLanguageLintRule): # thailint: ignore[srp]
42
43
 
43
44
  def __init__(self) -> None:
44
45
  """Initialize the print statements rule."""
45
- self._ignore_parser = IgnoreDirectiveParser()
46
+ self._ignore_parser = get_ignore_parser()
46
47
  self._violation_builder = ViolationBuilder(self.rule_id)
47
48
 
48
49
  @property
@@ -255,27 +256,16 @@ class PrintStatementRule(MultiLanguageLintRule): # thailint: ignore[srp]
255
256
  Returns:
256
257
  True if line has generic ignore directive
257
258
  """
258
- line_text = self._get_violation_line(violation, context)
259
+ line_text = get_violation_line(violation, context)
259
260
  if line_text is None:
260
261
  return False
261
262
  return self._has_generic_ignore_directive(line_text)
262
263
 
263
- def _get_violation_line(self, violation: Violation, context: BaseLintContext) -> str | None:
264
- """Get the line text for a violation."""
265
- if not context.file_content:
266
- return None
267
-
268
- lines = context.file_content.splitlines()
269
- if violation.line <= 0 or violation.line > len(lines):
270
- return None
271
-
272
- return lines[violation.line - 1].lower()
273
-
274
264
  def _has_generic_ignore_directive(self, line_text: str) -> bool:
275
265
  """Check if line has generic ignore directive."""
276
266
  if self._has_generic_thailint_ignore(line_text):
277
267
  return True
278
- return self._has_noqa_directive(line_text)
268
+ return has_python_noqa(line_text)
279
269
 
280
270
  def _has_generic_thailint_ignore(self, line_text: str) -> bool:
281
271
  """Check for generic thailint: ignore (no brackets)."""
@@ -284,10 +274,6 @@ class PrintStatementRule(MultiLanguageLintRule): # thailint: ignore[srp]
284
274
  after_ignore = line_text.split("# thailint: ignore")[1].split("#")[0]
285
275
  return "[" not in after_ignore
286
276
 
287
- def _has_noqa_directive(self, line_text: str) -> bool:
288
- """Check for noqa-style comments."""
289
- return "# noqa" in line_text
290
-
291
277
  def _check_typescript(
292
278
  self, context: BaseLintContext, config: PrintStatementConfig
293
279
  ) -> list[Violation]:
@@ -400,7 +386,7 @@ class PrintStatementRule(MultiLanguageLintRule): # thailint: ignore[srp]
400
386
  Returns:
401
387
  True if line has ignore directive
402
388
  """
403
- line_text = self._get_violation_line(violation, context)
389
+ line_text = get_violation_line(violation, context)
404
390
  if line_text is None:
405
391
  return False
406
392
  return self._has_typescript_ignore_directive(line_text)
@@ -422,7 +408,4 @@ class PrintStatementRule(MultiLanguageLintRule): # thailint: ignore[srp]
422
408
  if "[" not in after_ignore:
423
409
  return True
424
410
 
425
- if "// noqa" in line_text:
426
- return True
427
-
428
- return False
411
+ return has_typescript_noqa(line_text)
@@ -32,8 +32,10 @@ class ClassAnalyzer:
32
32
  """Coordinates class analysis for Python and TypeScript."""
33
33
 
34
34
  def __init__(self) -> None:
35
- """Initialize the class analyzer."""
36
- pass # Coordinates analysis between language-specific analyzers
35
+ """Initialize the class analyzer with singleton analyzers."""
36
+ # Singleton analyzers for performance (avoid recreating per-file)
37
+ self._python_analyzer = PythonSRPAnalyzer()
38
+ self._typescript_analyzer = TypeScriptSRPAnalyzer()
37
39
 
38
40
  def analyze_python(
39
41
  self, context: BaseLintContext, config: SRPConfig
@@ -51,10 +53,9 @@ class ClassAnalyzer:
51
53
  if isinstance(tree, list): # Syntax error violations
52
54
  return tree
53
55
 
54
- analyzer = PythonSRPAnalyzer()
55
- classes = analyzer.find_all_classes(tree)
56
+ classes = self._python_analyzer.find_all_classes(tree)
56
57
  return [
57
- analyzer.analyze_class(class_node, context.file_content or "", config)
58
+ self._python_analyzer.analyze_class(class_node, context.file_content or "", config)
58
59
  for class_node in classes
59
60
  ]
60
61
 
@@ -70,14 +71,13 @@ class ClassAnalyzer:
70
71
  Returns:
71
72
  List of class metrics dicts
72
73
  """
73
- analyzer = TypeScriptSRPAnalyzer()
74
- root_node = analyzer.parse_typescript(context.file_content or "")
74
+ root_node = self._typescript_analyzer.parse_typescript(context.file_content or "")
75
75
  if not root_node:
76
76
  return []
77
77
 
78
- classes = analyzer.find_all_classes(root_node)
78
+ classes = self._typescript_analyzer.find_all_classes(root_node)
79
79
  return [
80
- analyzer.analyze_class(class_node, context.file_content or "", config)
80
+ self._typescript_analyzer.analyze_class(class_node, context.file_content or "", config)
81
81
  for class_node in classes
82
82
  ]
83
83
 
@@ -33,9 +33,10 @@ def count_methods(class_node: ast.ClassDef) -> int:
33
33
  Number of methods in the class
34
34
  """
35
35
  methods = 0
36
- for node in class_node.body:
37
- if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
38
- continue
36
+ func_nodes = (
37
+ n for n in class_node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
38
+ )
39
+ for node in func_nodes:
39
40
  # Don't count @property decorators as methods
40
41
  if not has_property_decorator(node):
41
42
  methods += 1
@@ -56,8 +57,8 @@ def count_loc(class_node: ast.ClassDef, source: str) -> int:
56
57
  end_line = class_node.end_lineno or start_line
57
58
  lines = source.split("\n")[start_line - 1 : end_line]
58
59
 
59
- # Filter out blank lines and comments
60
- code_lines = [line for line in lines if line.strip() and not line.strip().startswith("#")]
60
+ # Filter out blank lines and comments (using walrus operator to avoid double strip)
61
+ code_lines = [s for line in lines if (s := line.strip()) and not s.startswith("#")]
61
62
  return len(code_lines)
62
63
 
63
64
 
src/linters/srp/linter.py CHANGED
@@ -21,7 +21,7 @@ Implementation: Composition pattern with helper classes, heuristic-based SRP ana
21
21
  from src.core.base import BaseLintContext, MultiLanguageLintRule
22
22
  from src.core.linter_utils import load_linter_config
23
23
  from src.core.types import Violation
24
- from src.linter_config.ignore import IgnoreDirectiveParser
24
+ from src.linter_config.ignore import get_ignore_parser
25
25
 
26
26
  from .class_analyzer import ClassAnalyzer
27
27
  from .config import SRPConfig
@@ -34,7 +34,7 @@ class SRPRule(MultiLanguageLintRule):
34
34
 
35
35
  def __init__(self) -> None:
36
36
  """Initialize the SRP rule."""
37
- self._ignore_parser = IgnoreDirectiveParser()
37
+ self._ignore_parser = get_ignore_parser()
38
38
  self._class_analyzer = ClassAnalyzer()
39
39
  self._violation_builder = ViolationBuilder()
40
40
 
@@ -171,9 +171,8 @@ class SRPRule(MultiLanguageLintRule):
171
171
  List of violations
172
172
  """
173
173
  violations = []
174
- for metrics in metrics_list:
175
- if not isinstance(metrics, dict):
176
- continue
174
+ valid_metrics = (m for m in metrics_list if isinstance(m, dict))
175
+ for metrics in valid_metrics:
177
176
  violation = self._create_violation_if_needed(metrics, config, context)
178
177
  if violation:
179
178
  violations.append(violation)
@@ -26,7 +26,7 @@ from pathlib import Path
26
26
 
27
27
  from src.core.base import BaseLintContext, BaseLintRule
28
28
  from src.core.types import Severity, Violation
29
- from src.linter_config.ignore import IgnoreDirectiveParser
29
+ from src.linter_config.ignore import get_ignore_parser
30
30
 
31
31
  from .config import StatelessClassConfig
32
32
  from .python_analyzer import ClassInfo, StatelessClassAnalyzer
@@ -37,7 +37,7 @@ class StatelessClassRule(BaseLintRule): # thailint: ignore[srp,dry]
37
37
 
38
38
  def __init__(self) -> None:
39
39
  """Initialize the rule with analyzer and ignore parser."""
40
- self._ignore_parser = IgnoreDirectiveParser()
40
+ self._ignore_parser = get_ignore_parser()
41
41
 
42
42
  @property
43
43
  def rule_id(self) -> str:
@@ -0,0 +1,23 @@
1
+ """
2
+ Purpose: Stringly-typed linter package exports
3
+
4
+ Scope: Public API for stringly-typed linter module
5
+
6
+ Overview: Provides the public interface for the stringly-typed linter package. Exports
7
+ StringlyTypedConfig for configuration of the linter. The stringly-typed linter detects
8
+ code patterns where plain strings are used instead of proper enums or typed alternatives,
9
+ helping identify potential type safety improvements. This module serves as the entry
10
+ point for users of the stringly-typed linter.
11
+
12
+ Dependencies: .config for StringlyTypedConfig
13
+
14
+ Exports: StringlyTypedConfig dataclass
15
+
16
+ Interfaces: Configuration loading via StringlyTypedConfig.from_dict()
17
+
18
+ Implementation: Module-level exports with __all__ definition
19
+ """
20
+
21
+ from src.linters.stringly_typed.config import StringlyTypedConfig
22
+
23
+ __all__ = ["StringlyTypedConfig"]
@@ -0,0 +1,165 @@
1
+ """
2
+ Purpose: Configuration dataclass for stringly-typed linter
3
+
4
+ Scope: Define configurable options for stringly-typed pattern detection
5
+
6
+ Overview: Provides StringlyTypedConfig for customizing linter behavior including minimum
7
+ occurrences required to flag patterns, enum value thresholds, cross-file detection
8
+ settings, and ignore patterns. The stringly-typed linter detects code patterns where
9
+ plain strings are used instead of proper enums or typed alternatives. Integrates with
10
+ the orchestrator's configuration system to allow users to customize detection via
11
+ .thailint.yaml configuration files. Follows the same configuration pattern as other
12
+ thai-lint linters.
13
+
14
+ Dependencies: dataclasses, typing
15
+
16
+ Exports: StringlyTypedConfig dataclass, default constants
17
+
18
+ Interfaces: StringlyTypedConfig.from_dict() class method for configuration loading
19
+
20
+ Implementation: Dataclass with sensible defaults, validation in __post_init__, and config
21
+ loading from dictionary with language-specific override support. Pylint
22
+ too-many-instance-attributes suppressed because configuration dataclasses inherently
23
+ require multiple cohesive fields (8 attributes for detection thresholds, filtering,
24
+ cross-file settings). Splitting would reduce cohesion without benefit. This follows
25
+ the established pattern in DRYConfig which has the same suppression.
26
+ """
27
+
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ # Default thresholds
32
+ DEFAULT_MIN_OCCURRENCES = 2
33
+ DEFAULT_MIN_VALUES_FOR_ENUM = 2
34
+ DEFAULT_MAX_VALUES_FOR_ENUM = 6
35
+
36
+
37
+ @dataclass
38
+ class StringlyTypedConfig: # pylint: disable=too-many-instance-attributes
39
+ """Configuration for stringly-typed linter.
40
+
41
+ Note: Pylint too-many-instance-attributes disabled. This is a configuration
42
+ dataclass serving as a data container for related stringly-typed linter settings.
43
+ All 8 attributes are cohesively related (detection thresholds, filtering options,
44
+ cross-file settings, exclusion patterns). Splitting would reduce cohesion and make
45
+ configuration loading more complex without meaningful benefit. This follows the
46
+ established pattern in DRYConfig.
47
+ """
48
+
49
+ enabled: bool = True
50
+ """Whether the linter is enabled."""
51
+
52
+ min_occurrences: int = DEFAULT_MIN_OCCURRENCES
53
+ """Minimum number of cross-file occurrences required to flag a violation."""
54
+
55
+ min_values_for_enum: int = DEFAULT_MIN_VALUES_FOR_ENUM
56
+ """Minimum number of unique string values to suggest an enum."""
57
+
58
+ max_values_for_enum: int = DEFAULT_MAX_VALUES_FOR_ENUM
59
+ """Maximum number of unique string values to suggest an enum (above this, not enum-worthy)."""
60
+
61
+ require_cross_file: bool = True
62
+ """Whether to require cross-file occurrences to flag violations."""
63
+
64
+ ignore: list[str] = field(default_factory=list)
65
+ """File patterns to ignore."""
66
+
67
+ allowed_string_sets: list[list[str]] = field(default_factory=list)
68
+ """String sets that are allowed and should not be flagged."""
69
+
70
+ exclude_variables: list[str] = field(default_factory=list)
71
+ """Variable names to exclude from detection."""
72
+
73
+ def __post_init__(self) -> None:
74
+ """Validate configuration values."""
75
+ if self.min_occurrences < 1:
76
+ raise ValueError(f"min_occurrences must be at least 1, got {self.min_occurrences}")
77
+ if self.min_values_for_enum < 2:
78
+ raise ValueError(
79
+ f"min_values_for_enum must be at least 2, got {self.min_values_for_enum}"
80
+ )
81
+ if self.max_values_for_enum < self.min_values_for_enum:
82
+ raise ValueError(
83
+ f"max_values_for_enum ({self.max_values_for_enum}) must be >= "
84
+ f"min_values_for_enum ({self.min_values_for_enum})"
85
+ )
86
+
87
+ @classmethod
88
+ def from_dict(
89
+ cls, config: dict[str, Any], language: str | None = None
90
+ ) -> "StringlyTypedConfig":
91
+ """Load configuration from dictionary.
92
+
93
+ Args:
94
+ config: Dictionary containing configuration values
95
+ language: Programming language for language-specific overrides
96
+
97
+ Returns:
98
+ StringlyTypedConfig instance with values from dictionary
99
+ """
100
+ # Check for language-specific overrides first
101
+ if language and language in config:
102
+ lang_config = config[language]
103
+ return cls._from_merged_config(config, lang_config)
104
+
105
+ return cls._from_base_config(config)
106
+
107
+ @classmethod
108
+ def _from_base_config(cls, config: dict[str, Any]) -> "StringlyTypedConfig":
109
+ """Create config from base configuration dictionary.
110
+
111
+ Args:
112
+ config: Base configuration dictionary
113
+
114
+ Returns:
115
+ StringlyTypedConfig instance
116
+ """
117
+ return cls(
118
+ enabled=config.get("enabled", True),
119
+ min_occurrences=config.get("min_occurrences", DEFAULT_MIN_OCCURRENCES),
120
+ min_values_for_enum=config.get("min_values_for_enum", DEFAULT_MIN_VALUES_FOR_ENUM),
121
+ max_values_for_enum=config.get("max_values_for_enum", DEFAULT_MAX_VALUES_FOR_ENUM),
122
+ require_cross_file=config.get("require_cross_file", True),
123
+ ignore=config.get("ignore", []),
124
+ allowed_string_sets=config.get("allowed_string_sets", []),
125
+ exclude_variables=config.get("exclude_variables", []),
126
+ )
127
+
128
+ @classmethod
129
+ def _from_merged_config(
130
+ cls, base_config: dict[str, Any], lang_config: dict[str, Any]
131
+ ) -> "StringlyTypedConfig":
132
+ """Create config with language-specific overrides merged.
133
+
134
+ Args:
135
+ base_config: Base configuration dictionary
136
+ lang_config: Language-specific configuration overrides
137
+
138
+ Returns:
139
+ StringlyTypedConfig instance with merged values
140
+ """
141
+ return cls(
142
+ enabled=lang_config.get("enabled", base_config.get("enabled", True)),
143
+ min_occurrences=lang_config.get(
144
+ "min_occurrences",
145
+ base_config.get("min_occurrences", DEFAULT_MIN_OCCURRENCES),
146
+ ),
147
+ min_values_for_enum=lang_config.get(
148
+ "min_values_for_enum",
149
+ base_config.get("min_values_for_enum", DEFAULT_MIN_VALUES_FOR_ENUM),
150
+ ),
151
+ max_values_for_enum=lang_config.get(
152
+ "max_values_for_enum",
153
+ base_config.get("max_values_for_enum", DEFAULT_MAX_VALUES_FOR_ENUM),
154
+ ),
155
+ require_cross_file=lang_config.get(
156
+ "require_cross_file", base_config.get("require_cross_file", True)
157
+ ),
158
+ ignore=lang_config.get("ignore", base_config.get("ignore", [])),
159
+ allowed_string_sets=lang_config.get(
160
+ "allowed_string_sets", base_config.get("allowed_string_sets", [])
161
+ ),
162
+ exclude_variables=lang_config.get(
163
+ "exclude_variables", base_config.get("exclude_variables", [])
164
+ ),
165
+ )
@@ -0,0 +1,29 @@
1
+ """
2
+ Purpose: Python-specific detection for stringly-typed patterns
3
+
4
+ Scope: Python AST analysis for membership validation and equality chain detection
5
+
6
+ Overview: Exposes Python analysis components for detecting stringly-typed patterns in Python
7
+ source code. Includes validation_detector for finding 'x in ("a", "b")' patterns,
8
+ conditional_detector for finding if/elif chains and match statements, and analyzer
9
+ for coordinating detection across Python files. Uses AST traversal to identify where
10
+ plain strings are used instead of proper enums or typed alternatives.
11
+
12
+ Dependencies: ast module for Python AST parsing
13
+
14
+ Exports: MembershipValidationDetector, ConditionalPatternDetector, PythonStringlyTypedAnalyzer
15
+
16
+ Interfaces: Detector and analyzer classes for Python stringly-typed pattern detection
17
+
18
+ Implementation: AST NodeVisitor pattern for traversing Python syntax trees
19
+ """
20
+
21
+ from .analyzer import PythonStringlyTypedAnalyzer
22
+ from .conditional_detector import ConditionalPatternDetector
23
+ from .validation_detector import MembershipValidationDetector
24
+
25
+ __all__ = [
26
+ "ConditionalPatternDetector",
27
+ "MembershipValidationDetector",
28
+ "PythonStringlyTypedAnalyzer",
29
+ ]
@@ -0,0 +1,198 @@
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 and ConditionalPatternDetector to find if/elif
9
+ chains and match statements. Returns unified AnalysisResult objects. Handles AST
10
+ parsing errors gracefully and provides a single entry point for Python analysis.
11
+ Supports configuration options for filtering and thresholds.
12
+
13
+ Dependencies: ast module, MembershipValidationDetector, ConditionalPatternDetector,
14
+ StringlyTypedConfig
15
+
16
+ Exports: PythonStringlyTypedAnalyzer class, AnalysisResult dataclass
17
+
18
+ Interfaces: PythonStringlyTypedAnalyzer.analyze(code, file_path) -> list[AnalysisResult]
19
+
20
+ Implementation: Facade pattern coordinating multiple detectors with unified result format
21
+ """
22
+
23
+ import ast
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from ..config import StringlyTypedConfig
28
+ from .conditional_detector import ConditionalPatternDetector, EqualityChainPattern
29
+ from .validation_detector import MembershipPattern, MembershipValidationDetector
30
+
31
+
32
+ @dataclass
33
+ class AnalysisResult:
34
+ """Represents a stringly-typed pattern detected in Python code.
35
+
36
+ Provides a unified representation of detected patterns from all detectors,
37
+ including pattern type, string values, location, and contextual information.
38
+ """
39
+
40
+ pattern_type: str
41
+ """Type of pattern detected: 'membership_validation', 'equality_chain', etc."""
42
+
43
+ string_values: set[str]
44
+ """Set of string values used in the pattern."""
45
+
46
+ file_path: Path
47
+ """Path to the file containing the pattern."""
48
+
49
+ line_number: int
50
+ """Line number where the pattern occurs (1-indexed)."""
51
+
52
+ column: int
53
+ """Column number where the pattern starts (0-indexed)."""
54
+
55
+ variable_name: str | None
56
+ """Variable name involved in the pattern, if identifiable."""
57
+
58
+ details: str
59
+ """Human-readable description of the detected pattern."""
60
+
61
+
62
+ class PythonStringlyTypedAnalyzer:
63
+ """Analyzes Python code for stringly-typed patterns.
64
+
65
+ Coordinates detection of various stringly-typed patterns including membership
66
+ validation ('x in ("a", "b")') and equality chains ('if x == "a" elif x == "b"').
67
+ Provides configuration-aware analysis with filtering support.
68
+ """
69
+
70
+ def __init__(self, config: StringlyTypedConfig | None = None) -> None:
71
+ """Initialize the analyzer with optional configuration.
72
+
73
+ Args:
74
+ config: Configuration for stringly-typed detection. Uses defaults if None.
75
+ """
76
+ self.config = config or StringlyTypedConfig()
77
+ self._membership_detector = MembershipValidationDetector()
78
+ self._conditional_detector = ConditionalPatternDetector()
79
+
80
+ def analyze(self, code: str, file_path: Path) -> list[AnalysisResult]:
81
+ """Analyze Python code for stringly-typed patterns.
82
+
83
+ Args:
84
+ code: Python source code to analyze
85
+ file_path: Path to the file being analyzed
86
+
87
+ Returns:
88
+ List of AnalysisResult instances for each detected pattern
89
+ """
90
+ tree = self._parse_code(code)
91
+ if tree is None:
92
+ return []
93
+
94
+ results: list[AnalysisResult] = []
95
+
96
+ # Detect membership validation patterns
97
+ membership_patterns = self._membership_detector.find_patterns(tree)
98
+ results.extend(
99
+ self._convert_membership_pattern(pattern, file_path) for pattern in membership_patterns
100
+ )
101
+
102
+ # Detect equality chain patterns
103
+ conditional_patterns = self._conditional_detector.find_patterns(tree)
104
+ results.extend(
105
+ self._convert_conditional_pattern(pattern, file_path)
106
+ for pattern in conditional_patterns
107
+ )
108
+
109
+ return results
110
+
111
+ def _parse_code(self, code: str) -> ast.AST | None:
112
+ """Parse Python source code into an AST.
113
+
114
+ Args:
115
+ code: Python source code to parse
116
+
117
+ Returns:
118
+ AST if parsing succeeds, None if parsing fails
119
+ """
120
+ try:
121
+ return ast.parse(code)
122
+ except SyntaxError:
123
+ return None
124
+
125
+ def _convert_membership_pattern(
126
+ self, pattern: MembershipPattern, file_path: Path
127
+ ) -> AnalysisResult:
128
+ """Convert a MembershipPattern to unified AnalysisResult.
129
+
130
+ Args:
131
+ pattern: Detected membership pattern
132
+ file_path: Path to the file containing the pattern
133
+
134
+ Returns:
135
+ AnalysisResult representing the pattern
136
+ """
137
+ values_str = ", ".join(sorted(pattern.string_values))
138
+ var_info = f" on '{pattern.variable_name}'" if pattern.variable_name else ""
139
+ details = (
140
+ f"Membership validation{var_info} with {len(pattern.string_values)} "
141
+ f"string values ({pattern.operator}): {values_str}"
142
+ )
143
+
144
+ return AnalysisResult(
145
+ pattern_type="membership_validation",
146
+ string_values=pattern.string_values,
147
+ file_path=file_path,
148
+ line_number=pattern.line_number,
149
+ column=pattern.column,
150
+ variable_name=pattern.variable_name,
151
+ details=details,
152
+ )
153
+
154
+ def _convert_conditional_pattern(
155
+ self, pattern: EqualityChainPattern, file_path: Path
156
+ ) -> AnalysisResult:
157
+ """Convert an EqualityChainPattern to unified AnalysisResult.
158
+
159
+ Args:
160
+ pattern: Detected equality chain pattern
161
+ file_path: Path to the file containing the pattern
162
+
163
+ Returns:
164
+ AnalysisResult representing the pattern
165
+ """
166
+ values_str = ", ".join(sorted(pattern.string_values))
167
+ var_info = f" on '{pattern.variable_name}'" if pattern.variable_name else ""
168
+ pattern_label = self._get_pattern_label(pattern.pattern_type)
169
+ details = (
170
+ f"{pattern_label}{var_info} with {len(pattern.string_values)} "
171
+ f"string values: {values_str}"
172
+ )
173
+
174
+ return AnalysisResult(
175
+ pattern_type=pattern.pattern_type,
176
+ string_values=pattern.string_values,
177
+ file_path=file_path,
178
+ line_number=pattern.line_number,
179
+ column=pattern.column,
180
+ variable_name=pattern.variable_name,
181
+ details=details,
182
+ )
183
+
184
+ def _get_pattern_label(self, pattern_type: str) -> str:
185
+ """Get human-readable label for a pattern type.
186
+
187
+ Args:
188
+ pattern_type: The pattern type string
189
+
190
+ Returns:
191
+ Human-readable label for the pattern
192
+ """
193
+ labels = {
194
+ "equality_chain": "Equality chain",
195
+ "or_combined": "Or-combined comparison",
196
+ "match_statement": "Match statement",
197
+ }
198
+ return labels.get(pattern_type, "Conditional pattern")