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.
- src/__init__.py +1 -0
- src/cli/__init__.py +27 -0
- src/cli/__main__.py +22 -0
- src/cli/config.py +478 -0
- src/cli/linters/__init__.py +58 -0
- src/cli/linters/code_patterns.py +372 -0
- src/cli/linters/code_smells.py +343 -0
- src/cli/linters/documentation.py +155 -0
- src/cli/linters/shared.py +89 -0
- src/cli/linters/structure.py +313 -0
- src/cli/linters/structure_quality.py +316 -0
- src/cli/main.py +120 -0
- src/cli/utils.py +375 -0
- src/cli_main.py +34 -0
- src/config.py +2 -3
- src/core/rule_discovery.py +43 -10
- src/core/types.py +13 -0
- src/core/violation_utils.py +69 -0
- src/linter_config/ignore.py +32 -16
- src/linters/collection_pipeline/__init__.py +90 -0
- src/linters/collection_pipeline/config.py +63 -0
- src/linters/collection_pipeline/continue_analyzer.py +100 -0
- src/linters/collection_pipeline/detector.py +130 -0
- src/linters/collection_pipeline/linter.py +437 -0
- src/linters/collection_pipeline/suggestion_builder.py +63 -0
- src/linters/dry/block_filter.py +99 -9
- src/linters/dry/cache.py +94 -6
- src/linters/dry/config.py +47 -10
- src/linters/dry/constant.py +92 -0
- src/linters/dry/constant_matcher.py +214 -0
- src/linters/dry/constant_violation_builder.py +98 -0
- src/linters/dry/linter.py +89 -48
- src/linters/dry/python_analyzer.py +44 -431
- src/linters/dry/python_constant_extractor.py +101 -0
- src/linters/dry/single_statement_detector.py +415 -0
- src/linters/dry/token_hasher.py +5 -5
- src/linters/dry/typescript_analyzer.py +63 -382
- src/linters/dry/typescript_constant_extractor.py +134 -0
- src/linters/dry/typescript_statement_detector.py +255 -0
- src/linters/dry/typescript_value_extractor.py +66 -0
- src/linters/file_header/linter.py +9 -13
- src/linters/file_placement/linter.py +30 -10
- src/linters/file_placement/pattern_matcher.py +19 -5
- src/linters/magic_numbers/linter.py +8 -67
- src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
- src/linters/nesting/linter.py +12 -9
- src/linters/print_statements/linter.py +7 -24
- src/linters/srp/class_analyzer.py +9 -9
- src/linters/srp/heuristics.py +6 -5
- src/linters/srp/linter.py +4 -5
- src/linters/stateless_class/linter.py +2 -2
- src/linters/stringly_typed/__init__.py +23 -0
- src/linters/stringly_typed/config.py +165 -0
- src/linters/stringly_typed/python/__init__.py +29 -0
- src/linters/stringly_typed/python/analyzer.py +198 -0
- src/linters/stringly_typed/python/condition_extractor.py +131 -0
- src/linters/stringly_typed/python/conditional_detector.py +176 -0
- src/linters/stringly_typed/python/constants.py +21 -0
- src/linters/stringly_typed/python/match_analyzer.py +88 -0
- src/linters/stringly_typed/python/validation_detector.py +186 -0
- src/linters/stringly_typed/python/variable_extractor.py +96 -0
- src/orchestrator/core.py +241 -12
- {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/METADATA +116 -3
- {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/RECORD +67 -29
- thailint-0.11.0.dist-info/entry_points.txt +4 -0
- src/cli.py +0 -2014
- thailint-0.9.0.dist-info/entry_points.txt +0 -4
- {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/WHEEL +0 -0
- {thailint-0.9.0.dist-info → thailint-0.11.0.dist-info}/licenses/LICENSE +0 -0
src/linters/nesting/linter.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
112
|
-
functions
|
|
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
|
-
|
|
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 =
|
|
158
|
-
return self._process_typescript_functions(
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
classes = analyzer.find_all_classes(tree)
|
|
56
|
+
classes = self._python_analyzer.find_all_classes(tree)
|
|
56
57
|
return [
|
|
57
|
-
|
|
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
|
-
|
|
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 =
|
|
78
|
+
classes = self._typescript_analyzer.find_all_classes(root_node)
|
|
79
79
|
return [
|
|
80
|
-
|
|
80
|
+
self._typescript_analyzer.analyze_class(class_node, context.file_content or "", config)
|
|
81
81
|
for class_node in classes
|
|
82
82
|
]
|
|
83
83
|
|
src/linters/srp/heuristics.py
CHANGED
|
@@ -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
|
-
|
|
37
|
-
if
|
|
38
|
-
|
|
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 = [
|
|
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
|
|
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 =
|
|
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
|
|
175
|
-
|
|
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
|
|
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 =
|
|
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")
|