thailint 0.1.6__py3-none-any.whl → 0.2.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 +7 -2
- src/analyzers/__init__.py +23 -0
- src/analyzers/typescript_base.py +148 -0
- src/api.py +1 -1
- src/cli.py +498 -141
- src/config.py +6 -31
- src/core/base.py +12 -0
- src/core/cli_utils.py +206 -0
- src/core/config_parser.py +99 -0
- src/core/linter_utils.py +168 -0
- src/core/registry.py +17 -92
- src/core/rule_discovery.py +132 -0
- src/core/violation_builder.py +122 -0
- src/linter_config/ignore.py +112 -40
- src/linter_config/loader.py +3 -13
- src/linters/dry/__init__.py +23 -0
- src/linters/dry/base_token_analyzer.py +76 -0
- src/linters/dry/block_filter.py +262 -0
- src/linters/dry/block_grouper.py +59 -0
- src/linters/dry/cache.py +218 -0
- src/linters/dry/cache_query.py +61 -0
- src/linters/dry/config.py +130 -0
- src/linters/dry/config_loader.py +44 -0
- src/linters/dry/deduplicator.py +120 -0
- src/linters/dry/duplicate_storage.py +126 -0
- src/linters/dry/file_analyzer.py +127 -0
- src/linters/dry/inline_ignore.py +140 -0
- src/linters/dry/linter.py +170 -0
- src/linters/dry/python_analyzer.py +517 -0
- src/linters/dry/storage_initializer.py +51 -0
- src/linters/dry/token_hasher.py +115 -0
- src/linters/dry/typescript_analyzer.py +590 -0
- src/linters/dry/violation_builder.py +74 -0
- src/linters/dry/violation_filter.py +91 -0
- src/linters/dry/violation_generator.py +174 -0
- src/linters/file_placement/config_loader.py +86 -0
- src/linters/file_placement/directory_matcher.py +80 -0
- src/linters/file_placement/linter.py +252 -472
- src/linters/file_placement/path_resolver.py +61 -0
- src/linters/file_placement/pattern_matcher.py +55 -0
- src/linters/file_placement/pattern_validator.py +106 -0
- src/linters/file_placement/rule_checker.py +229 -0
- src/linters/file_placement/violation_factory.py +177 -0
- src/linters/nesting/config.py +13 -3
- src/linters/nesting/linter.py +76 -152
- src/linters/nesting/typescript_analyzer.py +38 -102
- src/linters/nesting/typescript_function_extractor.py +130 -0
- src/linters/nesting/violation_builder.py +139 -0
- src/linters/srp/__init__.py +99 -0
- src/linters/srp/class_analyzer.py +113 -0
- src/linters/srp/config.py +76 -0
- src/linters/srp/heuristics.py +89 -0
- src/linters/srp/linter.py +225 -0
- src/linters/srp/metrics_evaluator.py +47 -0
- src/linters/srp/python_analyzer.py +72 -0
- src/linters/srp/typescript_analyzer.py +75 -0
- src/linters/srp/typescript_metrics_calculator.py +90 -0
- src/linters/srp/violation_builder.py +117 -0
- src/orchestrator/core.py +42 -7
- src/utils/__init__.py +4 -0
- src/utils/project_root.py +84 -0
- {thailint-0.1.6.dist-info → thailint-0.2.0.dist-info}/METADATA +414 -63
- thailint-0.2.0.dist-info/RECORD +75 -0
- src/.ai/layout.yaml +0 -48
- thailint-0.1.6.dist-info/RECORD +0 -28
- {thailint-0.1.6.dist-info → thailint-0.2.0.dist-info}/LICENSE +0 -0
- {thailint-0.1.6.dist-info → thailint-0.2.0.dist-info}/WHEEL +0 -0
- {thailint-0.1.6.dist-info → thailint-0.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Inline ignore directive parsing for DRY linter
|
|
3
|
+
|
|
4
|
+
Scope: Parses and tracks inline ignore directives in source files
|
|
5
|
+
|
|
6
|
+
Overview: Parses source code for inline ignore directives (# dry: ignore-block, # dry: ignore-next).
|
|
7
|
+
Tracks line ranges that should be ignored based on directives. Used by violation_generator
|
|
8
|
+
to filter violations that fall within ignored ranges. Supports both block-level ignores
|
|
9
|
+
(entire block) and next-line ignores (next statement only).
|
|
10
|
+
|
|
11
|
+
Dependencies: None (standalone utility)
|
|
12
|
+
|
|
13
|
+
Exports: InlineIgnoreParser class
|
|
14
|
+
|
|
15
|
+
Interfaces: InlineIgnoreParser.parse(content) -> dict, should_ignore(file_path, line) -> bool
|
|
16
|
+
|
|
17
|
+
Implementation: Regex-based comment parsing, line range tracking
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InlineIgnoreParser:
|
|
25
|
+
"""Parses inline ignore directives from source code."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
"""Initialize parser with ignore ranges tracking."""
|
|
29
|
+
self._ignore_ranges: dict[str, list[tuple[int, int]]] = {}
|
|
30
|
+
|
|
31
|
+
def parse_file(self, file_path: Path, content: str) -> None:
|
|
32
|
+
"""Parse file for ignore directives and store ranges.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
file_path: Path to the file
|
|
36
|
+
content: File content to parse
|
|
37
|
+
"""
|
|
38
|
+
lines = content.split("\n")
|
|
39
|
+
ranges = self._extract_ignore_ranges(lines)
|
|
40
|
+
|
|
41
|
+
if ranges:
|
|
42
|
+
self._ignore_ranges[str(file_path)] = ranges
|
|
43
|
+
|
|
44
|
+
def _extract_ignore_ranges(self, lines: list[str]) -> list[tuple[int, int]]:
|
|
45
|
+
"""Extract ignore ranges from lines.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
lines: List of lines to process
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of (start, end) tuples for ignore ranges
|
|
52
|
+
"""
|
|
53
|
+
ranges = []
|
|
54
|
+
|
|
55
|
+
for i, line in enumerate(lines, start=1):
|
|
56
|
+
ignore_range = self._parse_ignore_directive(line, i, len(lines))
|
|
57
|
+
if ignore_range:
|
|
58
|
+
ranges.append(ignore_range)
|
|
59
|
+
|
|
60
|
+
return ranges
|
|
61
|
+
|
|
62
|
+
def _parse_ignore_directive(
|
|
63
|
+
self, line: str, line_num: int, total_lines: int
|
|
64
|
+
) -> tuple[int, int] | None:
|
|
65
|
+
"""Parse a single line for ignore directives.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
line: Line content
|
|
69
|
+
line_num: Current line number
|
|
70
|
+
total_lines: Total number of lines
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
(start, end) tuple if directive found, None otherwise
|
|
74
|
+
"""
|
|
75
|
+
# Check for ignore-block directive
|
|
76
|
+
if re.search(r"#\s*dry:\s*ignore-block", line):
|
|
77
|
+
start = line_num + 1
|
|
78
|
+
end = min(line_num + 10, total_lines)
|
|
79
|
+
return (start, end)
|
|
80
|
+
|
|
81
|
+
# Check for ignore-next directive
|
|
82
|
+
if re.search(r"#\s*dry:\s*ignore-next", line):
|
|
83
|
+
return (line_num + 1, line_num + 1)
|
|
84
|
+
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def should_ignore(self, file_path: str, line: int, end_line: int | None = None) -> bool:
|
|
88
|
+
"""Check if a line or range should be ignored.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
file_path: Path to the file
|
|
92
|
+
line: Starting line number to check
|
|
93
|
+
end_line: Optional ending line number (for range check)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if line/range should be ignored
|
|
97
|
+
"""
|
|
98
|
+
ranges = self._ignore_ranges.get(str(Path(file_path)), [])
|
|
99
|
+
if not ranges:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
if end_line is not None:
|
|
103
|
+
return self._check_range_overlap(line, end_line, ranges)
|
|
104
|
+
|
|
105
|
+
return self._check_single_line(line, ranges)
|
|
106
|
+
|
|
107
|
+
def _check_range_overlap(self, line: int, end_line: int, ranges: list[tuple[int, int]]) -> bool:
|
|
108
|
+
"""Check if range overlaps with any ignore range.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
line: Starting line number
|
|
112
|
+
end_line: Ending line number
|
|
113
|
+
ranges: List of ignore ranges
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if ranges overlap
|
|
117
|
+
"""
|
|
118
|
+
for ign_start, ign_end in ranges:
|
|
119
|
+
if line <= ign_end and end_line >= ign_start:
|
|
120
|
+
return True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
def _check_single_line(self, line: int, ranges: list[tuple[int, int]]) -> bool:
|
|
124
|
+
"""Check if single line is in any ignore range.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
line: Line number to check
|
|
128
|
+
ranges: List of ignore ranges
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if line is in any range
|
|
132
|
+
"""
|
|
133
|
+
for start, end in ranges:
|
|
134
|
+
if start <= line <= end:
|
|
135
|
+
return True
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def clear(self) -> None:
|
|
139
|
+
"""Clear all stored ignore ranges."""
|
|
140
|
+
self._ignore_ranges.clear()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Main DRY linter rule implementation with stateful caching
|
|
3
|
+
|
|
4
|
+
Scope: DRYRule class implementing BaseLintRule interface for duplicate code detection
|
|
5
|
+
|
|
6
|
+
Overview: Implements DRY linter rule following BaseLintRule interface with stateful caching design.
|
|
7
|
+
Orchestrates duplicate detection by delegating to specialized classes: ConfigLoader for config,
|
|
8
|
+
StorageInitializer for storage setup, FileAnalyzer for file analysis, and ViolationGenerator
|
|
9
|
+
for violation creation. Maintains minimal orchestration logic to comply with SRP (8 methods total).
|
|
10
|
+
|
|
11
|
+
Dependencies: BaseLintRule, BaseLintContext, ConfigLoader, StorageInitializer, FileAnalyzer,
|
|
12
|
+
DuplicateStorage, ViolationGenerator
|
|
13
|
+
|
|
14
|
+
Exports: DRYRule class
|
|
15
|
+
|
|
16
|
+
Interfaces: DRYRule.check(context) -> list[Violation], finalize() -> list[Violation]
|
|
17
|
+
|
|
18
|
+
Implementation: Delegates all logic to helper classes, maintains only orchestration and state
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from src.core.base import BaseLintContext, BaseLintRule
|
|
28
|
+
from src.core.linter_utils import should_process_file
|
|
29
|
+
from src.core.types import Violation
|
|
30
|
+
|
|
31
|
+
from .config import DRYConfig
|
|
32
|
+
from .config_loader import ConfigLoader
|
|
33
|
+
from .duplicate_storage import DuplicateStorage
|
|
34
|
+
from .file_analyzer import FileAnalyzer
|
|
35
|
+
from .inline_ignore import InlineIgnoreParser
|
|
36
|
+
from .storage_initializer import StorageInitializer
|
|
37
|
+
from .violation_generator import ViolationGenerator
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from .cache import CodeBlock, DRYCache
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DRYComponents:
|
|
45
|
+
"""Component dependencies for DRY linter."""
|
|
46
|
+
|
|
47
|
+
config_loader: ConfigLoader
|
|
48
|
+
storage_initializer: StorageInitializer
|
|
49
|
+
file_analyzer: FileAnalyzer
|
|
50
|
+
violation_generator: ViolationGenerator
|
|
51
|
+
inline_ignore: InlineIgnoreParser
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DRYRule(BaseLintRule):
|
|
55
|
+
"""Detects duplicate code across project files."""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
"""Initialize the DRY rule with helper components."""
|
|
59
|
+
self._storage: DuplicateStorage | None = None
|
|
60
|
+
self._initialized = False
|
|
61
|
+
self._config: DRYConfig | None = None
|
|
62
|
+
self._file_analyzer: FileAnalyzer | None = None
|
|
63
|
+
|
|
64
|
+
# Helper components grouped to reduce instance attributes
|
|
65
|
+
self._helpers = DRYComponents(
|
|
66
|
+
config_loader=ConfigLoader(),
|
|
67
|
+
storage_initializer=StorageInitializer(),
|
|
68
|
+
file_analyzer=FileAnalyzer(), # Placeholder, will be replaced with configured one
|
|
69
|
+
violation_generator=ViolationGenerator(),
|
|
70
|
+
inline_ignore=InlineIgnoreParser(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def rule_id(self) -> str:
|
|
75
|
+
"""Unique identifier for this rule."""
|
|
76
|
+
return "dry.duplicate-code"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def rule_name(self) -> str:
|
|
80
|
+
"""Human-readable name for this rule."""
|
|
81
|
+
return "Duplicate Code"
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def description(self) -> str:
|
|
85
|
+
"""Description of what this rule checks."""
|
|
86
|
+
return "Detects duplicate code blocks across the project"
|
|
87
|
+
|
|
88
|
+
def check(self, context: BaseLintContext) -> list[Violation]:
|
|
89
|
+
"""Analyze file and store blocks (collection phase).
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
context: Lint context with file information
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Empty list (violations returned in finalize())
|
|
96
|
+
"""
|
|
97
|
+
if not should_process_file(context):
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
config = self._helpers.config_loader.load_config(context)
|
|
101
|
+
if not config.enabled:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
# Store config for finalize()
|
|
105
|
+
if self._config is None:
|
|
106
|
+
self._config = config
|
|
107
|
+
|
|
108
|
+
# Parse inline ignore directives from this file
|
|
109
|
+
file_path = Path(context.file_path) # type: ignore[arg-type]
|
|
110
|
+
self._helpers.inline_ignore.parse_file(file_path, context.file_content or "")
|
|
111
|
+
|
|
112
|
+
self._ensure_storage_initialized(context, config)
|
|
113
|
+
self._analyze_and_store(context, config)
|
|
114
|
+
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
def _ensure_storage_initialized(self, context: BaseLintContext, config: DRYConfig) -> None:
|
|
118
|
+
"""Initialize storage and file analyzer on first call."""
|
|
119
|
+
if not self._initialized:
|
|
120
|
+
self._storage = self._helpers.storage_initializer.initialize(context, config)
|
|
121
|
+
# Create file analyzer with config for filter configuration
|
|
122
|
+
self._file_analyzer = FileAnalyzer(config)
|
|
123
|
+
self._initialized = True
|
|
124
|
+
|
|
125
|
+
def _analyze_and_store(self, context: BaseLintContext, config: DRYConfig) -> None:
|
|
126
|
+
"""Analyze file and store blocks."""
|
|
127
|
+
# Guaranteed by _should_process_file check
|
|
128
|
+
if context.file_path is None or context.file_content is None:
|
|
129
|
+
return # Should never happen due to should_process_file check
|
|
130
|
+
|
|
131
|
+
if not self._file_analyzer:
|
|
132
|
+
return # Should never happen after initialization
|
|
133
|
+
|
|
134
|
+
file_path = Path(context.file_path)
|
|
135
|
+
cache = self._get_cache()
|
|
136
|
+
blocks = self._file_analyzer.analyze_or_load(
|
|
137
|
+
file_path, context.file_content, context.language, config, cache
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if blocks:
|
|
141
|
+
self._store_blocks(file_path, blocks)
|
|
142
|
+
|
|
143
|
+
def _get_cache(self) -> DRYCache | None:
|
|
144
|
+
"""Get cache from storage if available."""
|
|
145
|
+
if not self._storage:
|
|
146
|
+
return None
|
|
147
|
+
return self._storage._cache # pylint: disable=protected-access
|
|
148
|
+
|
|
149
|
+
def _store_blocks(self, file_path: Path, blocks: list[CodeBlock]) -> None:
|
|
150
|
+
"""Store blocks in memory if storage available."""
|
|
151
|
+
if self._storage:
|
|
152
|
+
self._storage.add_blocks_to_memory(file_path, blocks)
|
|
153
|
+
|
|
154
|
+
def finalize(self) -> list[Violation]:
|
|
155
|
+
"""Generate violations after all files processed.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of all violations found across all files
|
|
159
|
+
"""
|
|
160
|
+
if not self._storage or not self._config:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
violations = self._helpers.violation_generator.generate_violations(
|
|
164
|
+
self._storage, self.rule_id, self._config, self._helpers.inline_ignore
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Clear inline ignore cache for next run
|
|
168
|
+
self._helpers.inline_ignore.clear()
|
|
169
|
+
|
|
170
|
+
return violations
|