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
@@ -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
 
@@ -57,8 +57,8 @@ def count_loc(class_node: ast.ClassDef, source: str) -> int:
57
57
  end_line = class_node.end_lineno or start_line
58
58
  lines = source.split("\n")[start_line - 1 : end_line]
59
59
 
60
- # Filter out blank lines and comments
61
- 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("#")]
62
62
  return len(code_lines)
63
63
 
64
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
 
@@ -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,36 @@
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 and StringlyTypedRule for linting. The stringly-typed
8
+ linter detects code patterns where plain strings are used instead of proper enums or typed
9
+ alternatives, helping identify potential type safety improvements. Supports cross-file
10
+ detection to find repeated string patterns across the codebase. Includes IgnoreChecker
11
+ for inline ignore directive support.
12
+
13
+ Dependencies: .config for StringlyTypedConfig, .linter for StringlyTypedRule,
14
+ .storage for StringlyTypedStorage, .ignore_checker for IgnoreChecker
15
+
16
+ Exports: StringlyTypedConfig, StringlyTypedRule, StringlyTypedStorage, StoredPattern,
17
+ IgnoreChecker
18
+
19
+ Interfaces: Configuration loading via StringlyTypedConfig.from_dict(),
20
+ StringlyTypedRule.check() and finalize() for linting, IgnoreChecker.filter_violations()
21
+
22
+ Implementation: Module-level exports with __all__ definition
23
+ """
24
+
25
+ from src.linters.stringly_typed.config import StringlyTypedConfig
26
+ from src.linters.stringly_typed.ignore_checker import IgnoreChecker
27
+ from src.linters.stringly_typed.linter import StringlyTypedRule
28
+ from src.linters.stringly_typed.storage import StoredPattern, StringlyTypedStorage
29
+
30
+ __all__ = [
31
+ "StringlyTypedConfig",
32
+ "IgnoreChecker",
33
+ "StringlyTypedRule",
34
+ "StringlyTypedStorage",
35
+ "StoredPattern",
36
+ ]
@@ -0,0 +1,190 @@
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
+ # Default ignore patterns - test directories are excluded by default
37
+ # because test fixtures commonly use string literals for mocking
38
+ DEFAULT_IGNORE_PATTERNS: list[str] = [
39
+ "**/tests/**",
40
+ "**/test/**",
41
+ "**/*_test.py",
42
+ "**/*_test.ts",
43
+ "**/*.test.ts",
44
+ "**/*.test.tsx",
45
+ "**/*.spec.ts",
46
+ "**/*.spec.tsx",
47
+ "**/*.stories.ts",
48
+ "**/*.stories.tsx",
49
+ "**/conftest.py",
50
+ "**/fixtures/**",
51
+ ]
52
+
53
+
54
+ @dataclass
55
+ class StringlyTypedConfig: # pylint: disable=too-many-instance-attributes
56
+ """Configuration for stringly-typed linter.
57
+
58
+ Note: Pylint too-many-instance-attributes disabled. This is a configuration
59
+ dataclass serving as a data container for related stringly-typed linter settings.
60
+ All 8 attributes are cohesively related (detection thresholds, filtering options,
61
+ cross-file settings, exclusion patterns). Splitting would reduce cohesion and make
62
+ configuration loading more complex without meaningful benefit. This follows the
63
+ established pattern in DRYConfig.
64
+ """
65
+
66
+ enabled: bool = True
67
+ """Whether the linter is enabled."""
68
+
69
+ min_occurrences: int = DEFAULT_MIN_OCCURRENCES
70
+ """Minimum number of cross-file occurrences required to flag a violation."""
71
+
72
+ min_values_for_enum: int = DEFAULT_MIN_VALUES_FOR_ENUM
73
+ """Minimum number of unique string values to suggest an enum."""
74
+
75
+ max_values_for_enum: int = DEFAULT_MAX_VALUES_FOR_ENUM
76
+ """Maximum number of unique string values to suggest an enum (above this, not enum-worthy)."""
77
+
78
+ require_cross_file: bool = True
79
+ """Whether to require cross-file occurrences to flag violations."""
80
+
81
+ ignore: list[str] = field(default_factory=list)
82
+ """File patterns to ignore. Defaults merged with test directories in from_dict."""
83
+
84
+ allowed_string_sets: list[list[str]] = field(default_factory=list)
85
+ """String sets that are allowed and should not be flagged."""
86
+
87
+ exclude_variables: list[str] = field(default_factory=list)
88
+ """Variable names to exclude from detection."""
89
+
90
+ def __post_init__(self) -> None:
91
+ """Validate configuration values."""
92
+ if self.min_occurrences < 1:
93
+ raise ValueError(f"min_occurrences must be at least 1, got {self.min_occurrences}")
94
+ if self.min_values_for_enum < 2:
95
+ raise ValueError(
96
+ f"min_values_for_enum must be at least 2, got {self.min_values_for_enum}"
97
+ )
98
+ if self.max_values_for_enum < self.min_values_for_enum:
99
+ raise ValueError(
100
+ f"max_values_for_enum ({self.max_values_for_enum}) must be >= "
101
+ f"min_values_for_enum ({self.min_values_for_enum})"
102
+ )
103
+
104
+ @classmethod
105
+ def from_dict(
106
+ cls, config: dict[str, Any], language: str | None = None
107
+ ) -> "StringlyTypedConfig":
108
+ """Load configuration from dictionary.
109
+
110
+ Args:
111
+ config: Dictionary containing configuration values
112
+ language: Programming language for language-specific overrides
113
+
114
+ Returns:
115
+ StringlyTypedConfig instance with values from dictionary
116
+ """
117
+ # Check for language-specific overrides first
118
+ if language and language in config:
119
+ lang_config = config[language]
120
+ return cls._from_merged_config(config, lang_config)
121
+
122
+ return cls._from_base_config(config)
123
+
124
+ @classmethod
125
+ def _from_base_config(cls, config: dict[str, Any]) -> "StringlyTypedConfig":
126
+ """Create config from base configuration dictionary.
127
+
128
+ Args:
129
+ config: Base configuration dictionary
130
+
131
+ Returns:
132
+ StringlyTypedConfig instance
133
+ """
134
+ # Merge user ignore patterns with defaults
135
+ user_ignore = config.get("ignore", [])
136
+ merged_ignore = DEFAULT_IGNORE_PATTERNS.copy() + user_ignore
137
+
138
+ return cls(
139
+ enabled=config.get("enabled", True),
140
+ min_occurrences=config.get("min_occurrences", DEFAULT_MIN_OCCURRENCES),
141
+ min_values_for_enum=config.get("min_values_for_enum", DEFAULT_MIN_VALUES_FOR_ENUM),
142
+ max_values_for_enum=config.get("max_values_for_enum", DEFAULT_MAX_VALUES_FOR_ENUM),
143
+ require_cross_file=config.get("require_cross_file", True),
144
+ ignore=merged_ignore,
145
+ allowed_string_sets=config.get("allowed_string_sets", []),
146
+ exclude_variables=config.get("exclude_variables", []),
147
+ )
148
+
149
+ @classmethod
150
+ def _from_merged_config(
151
+ cls, base_config: dict[str, Any], lang_config: dict[str, Any]
152
+ ) -> "StringlyTypedConfig":
153
+ """Create config with language-specific overrides merged.
154
+
155
+ Args:
156
+ base_config: Base configuration dictionary
157
+ lang_config: Language-specific configuration overrides
158
+
159
+ Returns:
160
+ StringlyTypedConfig instance with merged values
161
+ """
162
+ # Merge user ignore patterns with defaults
163
+ user_ignore = lang_config.get("ignore", base_config.get("ignore", []))
164
+ merged_ignore = DEFAULT_IGNORE_PATTERNS.copy() + user_ignore
165
+
166
+ return cls(
167
+ enabled=lang_config.get("enabled", base_config.get("enabled", True)),
168
+ min_occurrences=lang_config.get(
169
+ "min_occurrences",
170
+ base_config.get("min_occurrences", DEFAULT_MIN_OCCURRENCES),
171
+ ),
172
+ min_values_for_enum=lang_config.get(
173
+ "min_values_for_enum",
174
+ base_config.get("min_values_for_enum", DEFAULT_MIN_VALUES_FOR_ENUM),
175
+ ),
176
+ max_values_for_enum=lang_config.get(
177
+ "max_values_for_enum",
178
+ base_config.get("max_values_for_enum", DEFAULT_MAX_VALUES_FOR_ENUM),
179
+ ),
180
+ require_cross_file=lang_config.get(
181
+ "require_cross_file", base_config.get("require_cross_file", True)
182
+ ),
183
+ ignore=merged_ignore,
184
+ allowed_string_sets=lang_config.get(
185
+ "allowed_string_sets", base_config.get("allowed_string_sets", [])
186
+ ),
187
+ exclude_variables=lang_config.get(
188
+ "exclude_variables", base_config.get("exclude_variables", [])
189
+ ),
190
+ )