thailint 0.1.6__py3-none-any.whl → 0.2.1__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 (68) hide show
  1. src/__init__.py +7 -2
  2. src/analyzers/__init__.py +23 -0
  3. src/analyzers/typescript_base.py +148 -0
  4. src/api.py +1 -1
  5. src/cli.py +524 -141
  6. src/config.py +6 -31
  7. src/core/base.py +12 -0
  8. src/core/cli_utils.py +206 -0
  9. src/core/config_parser.py +99 -0
  10. src/core/linter_utils.py +168 -0
  11. src/core/registry.py +17 -92
  12. src/core/rule_discovery.py +132 -0
  13. src/core/violation_builder.py +122 -0
  14. src/linter_config/ignore.py +112 -40
  15. src/linter_config/loader.py +3 -13
  16. src/linters/dry/__init__.py +23 -0
  17. src/linters/dry/base_token_analyzer.py +76 -0
  18. src/linters/dry/block_filter.py +262 -0
  19. src/linters/dry/block_grouper.py +59 -0
  20. src/linters/dry/cache.py +218 -0
  21. src/linters/dry/cache_query.py +61 -0
  22. src/linters/dry/config.py +130 -0
  23. src/linters/dry/config_loader.py +44 -0
  24. src/linters/dry/deduplicator.py +120 -0
  25. src/linters/dry/duplicate_storage.py +126 -0
  26. src/linters/dry/file_analyzer.py +127 -0
  27. src/linters/dry/inline_ignore.py +140 -0
  28. src/linters/dry/linter.py +170 -0
  29. src/linters/dry/python_analyzer.py +517 -0
  30. src/linters/dry/storage_initializer.py +51 -0
  31. src/linters/dry/token_hasher.py +115 -0
  32. src/linters/dry/typescript_analyzer.py +590 -0
  33. src/linters/dry/violation_builder.py +74 -0
  34. src/linters/dry/violation_filter.py +91 -0
  35. src/linters/dry/violation_generator.py +174 -0
  36. src/linters/file_placement/config_loader.py +86 -0
  37. src/linters/file_placement/directory_matcher.py +80 -0
  38. src/linters/file_placement/linter.py +252 -472
  39. src/linters/file_placement/path_resolver.py +61 -0
  40. src/linters/file_placement/pattern_matcher.py +55 -0
  41. src/linters/file_placement/pattern_validator.py +106 -0
  42. src/linters/file_placement/rule_checker.py +229 -0
  43. src/linters/file_placement/violation_factory.py +177 -0
  44. src/linters/nesting/config.py +13 -3
  45. src/linters/nesting/linter.py +76 -152
  46. src/linters/nesting/typescript_analyzer.py +38 -102
  47. src/linters/nesting/typescript_function_extractor.py +130 -0
  48. src/linters/nesting/violation_builder.py +139 -0
  49. src/linters/srp/__init__.py +99 -0
  50. src/linters/srp/class_analyzer.py +113 -0
  51. src/linters/srp/config.py +76 -0
  52. src/linters/srp/heuristics.py +89 -0
  53. src/linters/srp/linter.py +225 -0
  54. src/linters/srp/metrics_evaluator.py +47 -0
  55. src/linters/srp/python_analyzer.py +72 -0
  56. src/linters/srp/typescript_analyzer.py +75 -0
  57. src/linters/srp/typescript_metrics_calculator.py +90 -0
  58. src/linters/srp/violation_builder.py +117 -0
  59. src/orchestrator/core.py +42 -7
  60. src/utils/__init__.py +4 -0
  61. src/utils/project_root.py +84 -0
  62. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/METADATA +414 -63
  63. thailint-0.2.1.dist-info/RECORD +75 -0
  64. src/.ai/layout.yaml +0 -48
  65. thailint-0.1.6.dist-info/RECORD +0 -28
  66. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/LICENSE +0 -0
  67. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/WHEEL +0 -0
  68. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,91 @@
1
+ """
2
+ Purpose: Violation overlap filtering
3
+
4
+ Scope: Filters overlapping violations within same file
5
+
6
+ Overview: Filters overlapping violations by comparing line ranges. When violations are close together
7
+ (within 3 lines), only the first one is kept. Used by ViolationDeduplicator to remove duplicate
8
+ reports from rolling hash windows.
9
+
10
+ Dependencies: Violation
11
+
12
+ Exports: ViolationFilter class
13
+
14
+ Interfaces: ViolationFilter.filter_overlapping(sorted_violations)
15
+
16
+ Implementation: Iterates through sorted violations, keeps first of each overlapping group
17
+ """
18
+
19
+ from src.core.types import Violation
20
+
21
+
22
+ class ViolationFilter:
23
+ """Filters overlapping violations."""
24
+
25
+ def filter_overlapping(self, sorted_violations: list[Violation]) -> list[Violation]:
26
+ """Filter overlapping violations, keeping first occurrence.
27
+
28
+ Args:
29
+ sorted_violations: Violations sorted by line number
30
+
31
+ Returns:
32
+ Filtered list with overlaps removed
33
+ """
34
+ kept: list[Violation] = []
35
+ for violation in sorted_violations:
36
+ if not self._overlaps_any(violation, kept):
37
+ kept.append(violation)
38
+ return kept
39
+
40
+ def _overlaps_any(self, violation: Violation, kept_violations: list[Violation]) -> bool:
41
+ """Check if violation overlaps with any kept violations.
42
+
43
+ Args:
44
+ violation: Violation to check
45
+ kept_violations: Previously kept violations
46
+
47
+ Returns:
48
+ True if violation overlaps with any kept violation
49
+ """
50
+ for kept in kept_violations:
51
+ if self._overlaps(violation, kept):
52
+ return True
53
+ return False
54
+
55
+ def _overlaps(self, v1: Violation, v2: Violation) -> bool:
56
+ """Check if two violations overlap.
57
+
58
+ Args:
59
+ v1: First violation (later line number)
60
+ v2: Second violation (earlier line number)
61
+
62
+ Returns:
63
+ True if violations overlap based on code block size
64
+ """
65
+ line1 = v1.line or 0
66
+ line2 = v2.line or 0
67
+
68
+ # Extract line count from message format: "Duplicate code (N lines, ...)"
69
+ line_count = self._extract_line_count(v1.message)
70
+
71
+ # Blocks overlap if their line ranges intersect
72
+ # Block at line2 covers [line2, line2 + line_count - 1]
73
+ # Block at line1 overlaps if line1 < line2 + line_count
74
+ return line1 < line2 + line_count
75
+
76
+ def _extract_line_count(self, message: str) -> int:
77
+ """Extract line count from violation message.
78
+
79
+ Args:
80
+ message: Violation message containing line count
81
+
82
+ Returns:
83
+ Number of lines in the duplicate code block (default 5 if not found)
84
+ """
85
+ # Message format: "Duplicate code (5 lines, 2 occurrences)..."
86
+ try:
87
+ start = message.index("(") + 1
88
+ end = message.index(" lines")
89
+ return int(message[start:end])
90
+ except (ValueError, IndexError):
91
+ return 5 # Default fallback
@@ -0,0 +1,174 @@
1
+ """
2
+ Purpose: Violation generation from duplicate code blocks
3
+
4
+ Scope: Generates violations from duplicate hashes
5
+
6
+ Overview: Handles violation generation for duplicate code blocks. Queries storage for duplicate
7
+ hashes, retrieves blocks for each hash, deduplicates overlapping blocks, builds violations
8
+ using ViolationBuilder, and filters violations based on ignore patterns. Separates violation
9
+ generation logic from main linter rule to maintain SRP compliance.
10
+
11
+ Dependencies: DuplicateStorage, ViolationDeduplicator, DRYViolationBuilder, Violation, DRYConfig
12
+
13
+ Exports: ViolationGenerator class
14
+
15
+ Interfaces: ViolationGenerator.generate_violations(storage, rule_id, config) -> list[Violation]
16
+
17
+ Implementation: Queries storage, deduplicates blocks, builds violations, filters by ignore patterns
18
+ """
19
+
20
+ from pathlib import Path
21
+
22
+ from src.core.types import Violation
23
+ from src.orchestrator.language_detector import detect_language
24
+
25
+ from .config import DRYConfig
26
+ from .deduplicator import ViolationDeduplicator
27
+ from .duplicate_storage import DuplicateStorage
28
+ from .inline_ignore import InlineIgnoreParser
29
+ from .violation_builder import DRYViolationBuilder
30
+
31
+
32
+ class ViolationGenerator:
33
+ """Generates violations from duplicate code blocks."""
34
+
35
+ def __init__(self) -> None:
36
+ """Initialize with deduplicator and violation builder."""
37
+ self._deduplicator = ViolationDeduplicator()
38
+ self._violation_builder = DRYViolationBuilder()
39
+
40
+ def generate_violations(
41
+ self,
42
+ storage: DuplicateStorage,
43
+ rule_id: str,
44
+ config: DRYConfig,
45
+ inline_ignore: InlineIgnoreParser,
46
+ ) -> list[Violation]:
47
+ """Generate violations from storage.
48
+
49
+ Args:
50
+ storage: Duplicate storage instance
51
+ rule_id: Rule identifier for violations
52
+ config: DRY configuration with ignore patterns
53
+ inline_ignore: Parser with inline ignore directives
54
+
55
+ Returns:
56
+ List of violations filtered by ignore patterns and inline directives
57
+ """
58
+ duplicate_hashes = storage.get_duplicate_hashes()
59
+ violations = []
60
+
61
+ for hash_value in duplicate_hashes:
62
+ blocks = storage.get_blocks_for_hash(hash_value)
63
+ dedup_blocks = self._deduplicator.deduplicate_blocks(blocks)
64
+
65
+ # Check min_occurrences threshold (language-aware)
66
+ if not self._meets_min_occurrences(dedup_blocks, config):
67
+ continue
68
+
69
+ for block in dedup_blocks:
70
+ violation = self._violation_builder.build_violation(block, dedup_blocks, rule_id)
71
+ violations.append(violation)
72
+
73
+ deduplicated = self._deduplicator.deduplicate_violations(violations)
74
+ pattern_filtered = self._filter_ignored(deduplicated, config.ignore_patterns)
75
+ return self._filter_inline_ignored(pattern_filtered, inline_ignore)
76
+
77
+ def _meets_min_occurrences(self, blocks: list, config: DRYConfig) -> bool:
78
+ """Check if blocks meet minimum occurrence threshold for the language.
79
+
80
+ Args:
81
+ blocks: List of duplicate code blocks
82
+ config: DRY configuration with min_occurrences settings
83
+
84
+ Returns:
85
+ True if blocks meet or exceed minimum occurrence threshold
86
+ """
87
+ if len(blocks) == 0:
88
+ return False
89
+
90
+ # Get language from first block's file extension
91
+ first_block = blocks[0]
92
+ language = detect_language(first_block.file_path)
93
+
94
+ # Get language-specific threshold
95
+ min_occurrences = config.get_min_occurrences_for_language(language)
96
+
97
+ return len(blocks) >= min_occurrences
98
+
99
+ def _filter_ignored(
100
+ self, violations: list[Violation], ignore_patterns: list[str]
101
+ ) -> list[Violation]:
102
+ """Filter violations based on ignore patterns.
103
+
104
+ Args:
105
+ violations: List of violations to filter
106
+ ignore_patterns: List of path patterns to ignore
107
+
108
+ Returns:
109
+ Filtered list of violations
110
+ """
111
+ if not ignore_patterns:
112
+ return violations
113
+
114
+ filtered = []
115
+ for violation in violations:
116
+ if not self._is_ignored(violation.file_path, ignore_patterns):
117
+ filtered.append(violation)
118
+ return filtered
119
+
120
+ def _is_ignored(self, file_path: str, ignore_patterns: list[str]) -> bool:
121
+ """Check if file path matches any ignore pattern.
122
+
123
+ Args:
124
+ file_path: Path to check
125
+ ignore_patterns: List of patterns to match against
126
+
127
+ Returns:
128
+ True if file should be ignored
129
+ """
130
+ path_str = str(Path(file_path))
131
+ for pattern in ignore_patterns:
132
+ if pattern in path_str:
133
+ return True
134
+ return False
135
+
136
+ def _filter_inline_ignored(
137
+ self, violations: list[Violation], inline_ignore: InlineIgnoreParser
138
+ ) -> list[Violation]:
139
+ """Filter violations based on inline ignore directives.
140
+
141
+ Args:
142
+ violations: List of violations to filter
143
+ inline_ignore: Parser with inline ignore directives
144
+
145
+ Returns:
146
+ Filtered list of violations
147
+ """
148
+ filtered = []
149
+ for violation in violations:
150
+ start_line = violation.line or 0
151
+ # Extract line count from message to calculate end_line
152
+ line_count = self._extract_line_count(violation.message)
153
+ end_line = start_line + line_count - 1
154
+
155
+ if not inline_ignore.should_ignore(violation.file_path, start_line, end_line):
156
+ filtered.append(violation)
157
+ return filtered
158
+
159
+ def _extract_line_count(self, message: str) -> int:
160
+ """Extract line count from violation message.
161
+
162
+ Args:
163
+ message: Violation message
164
+
165
+ Returns:
166
+ Number of lines (default 1)
167
+ """
168
+ # Message format: "Duplicate code (N lines, ...)"
169
+ try:
170
+ start = message.index("(") + 1
171
+ end = message.index(" lines")
172
+ return int(message[start:end])
173
+ except (ValueError, IndexError):
174
+ return 1
@@ -0,0 +1,86 @@
1
+ """
2
+ Purpose: Configuration file loading for file placement linter
3
+
4
+ Scope: Handles loading and parsing of JSON/YAML configuration files
5
+
6
+ Overview: Provides configuration file loading functionality for the file placement linter.
7
+ Supports both JSON and YAML config formats, handles path resolution relative to project
8
+ root, and provides safe defaults when config files are missing or invalid. Isolates
9
+ file I/O concerns from business logic to maintain single responsibility.
10
+
11
+ Dependencies: pathlib, json, yaml
12
+
13
+ Exports: ConfigLoader
14
+
15
+ Interfaces: load_config_file(config_file, project_root) -> dict
16
+
17
+ Implementation: Uses standard library JSON and PyYAML for parsing, returns empty dict on errors
18
+ """
19
+
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import yaml
25
+
26
+
27
+ class ConfigLoader:
28
+ """Loads configuration files for file placement linter."""
29
+
30
+ def __init__(self, project_root: Path):
31
+ """Initialize config loader.
32
+
33
+ Args:
34
+ project_root: Project root directory
35
+ """
36
+ self.project_root = project_root
37
+
38
+ def load_config_file(self, config_file: str) -> dict[str, Any]:
39
+ """Load configuration from file.
40
+
41
+ Args:
42
+ config_file: Path to config file
43
+
44
+ Returns:
45
+ Loaded configuration dict, or empty dict if file doesn't exist
46
+
47
+ Raises:
48
+ ValueError: If config file format is unsupported
49
+ """
50
+ config_path = self._resolve_path(config_file)
51
+ if not config_path.exists():
52
+ return {}
53
+ return self._parse_file(config_path)
54
+
55
+ def _resolve_path(self, config_file: str) -> Path:
56
+ """Resolve config file path relative to project root.
57
+
58
+ Args:
59
+ config_file: Config file path (relative or absolute)
60
+
61
+ Returns:
62
+ Resolved absolute path
63
+ """
64
+ config_path = Path(config_file)
65
+ if not config_path.is_absolute():
66
+ config_path = self.project_root / config_path
67
+ return config_path
68
+
69
+ def _parse_file(self, config_path: Path) -> dict[str, Any]:
70
+ """Parse config file based on extension.
71
+
72
+ Args:
73
+ config_path: Path to config file
74
+
75
+ Returns:
76
+ Parsed configuration dict
77
+
78
+ Raises:
79
+ ValueError: If file format is unsupported
80
+ """
81
+ with config_path.open(encoding="utf-8") as f:
82
+ if config_path.suffix in [".yaml", ".yml"]:
83
+ return yaml.safe_load(f) or {}
84
+ if config_path.suffix == ".json":
85
+ return json.load(f)
86
+ raise ValueError(f"Unsupported config format: {config_path.suffix}")
@@ -0,0 +1,80 @@
1
+ """
2
+ Purpose: Directory rule matching for file placement linter
3
+
4
+ Scope: Finds most specific directory rule matching a file path
5
+
6
+ Overview: Provides directory matching functionality for the file placement linter. Implements
7
+ most-specific-directory matching logic by comparing path prefixes and calculating directory
8
+ depth. Handles special case of root directory matching. Returns matched rule and path for
9
+ further processing. Isolates directory matching logic from rule checking and pattern matching.
10
+
11
+ Dependencies: typing
12
+
13
+ Exports: DirectoryMatcher
14
+
15
+ Interfaces: find_matching_rule(path_str, directories) -> (rule_dict, matched_path)
16
+
17
+ Implementation: Prefix matching with depth-based precedence, root directory special case
18
+ """
19
+
20
+ from typing import Any
21
+
22
+
23
+ class DirectoryMatcher:
24
+ """Finds matching directory rules based on path prefixes."""
25
+
26
+ def find_matching_rule(
27
+ self, path_str: str, directories: dict[str, Any]
28
+ ) -> tuple[dict[str, Any] | None, str | None]:
29
+ """Find most specific directory rule matching the path.
30
+
31
+ Args:
32
+ path_str: File path string
33
+ directories: Directory rules
34
+
35
+ Returns:
36
+ Tuple of (rule_dict, matched_path)
37
+ """
38
+ best_match = None
39
+ best_path = None
40
+ best_depth = -1
41
+
42
+ for dir_path, rules in directories.items():
43
+ matches, depth = self._check_path_match(dir_path, path_str)
44
+ if matches and depth > best_depth:
45
+ best_match = rules
46
+ best_path = dir_path
47
+ best_depth = depth
48
+
49
+ return best_match, best_path
50
+
51
+ def _check_path_match(self, dir_path: str, path_str: str) -> tuple[bool, int]:
52
+ """Check if path matches directory rule.
53
+
54
+ Args:
55
+ dir_path: Directory path pattern
56
+ path_str: File path string
57
+
58
+ Returns:
59
+ Tuple of (matches, depth) where depth is directory nesting level
60
+ """
61
+ if dir_path == "/":
62
+ return self._check_root_match(dir_path, path_str)
63
+ if path_str.startswith(dir_path):
64
+ depth = len(dir_path.split("/"))
65
+ return True, depth
66
+ return False, -1
67
+
68
+ def _check_root_match(self, dir_path: str, path_str: str) -> tuple[bool, int]:
69
+ """Check if path matches root directory rule.
70
+
71
+ Args:
72
+ dir_path: Directory path (should be "/")
73
+ path_str: File path string
74
+
75
+ Returns:
76
+ Tuple of (matches, depth)
77
+ """
78
+ if dir_path == "/" and "/" not in path_str:
79
+ return True, 0
80
+ return False, -1