thailint 0.1.5__py3-none-any.whl → 0.5.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 +1111 -144
- src/config.py +12 -33
- src/core/base.py +102 -5
- src/core/cli_utils.py +206 -0
- src/core/config_parser.py +126 -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 +265 -0
- src/linters/dry/block_grouper.py +59 -0
- src/linters/dry/cache.py +172 -0
- src/linters/dry/cache_query.py +61 -0
- src/linters/dry/config.py +134 -0
- src/linters/dry/config_loader.py +44 -0
- src/linters/dry/deduplicator.py +120 -0
- src/linters/dry/duplicate_storage.py +63 -0
- src/linters/dry/file_analyzer.py +90 -0
- src/linters/dry/inline_ignore.py +140 -0
- src/linters/dry/linter.py +163 -0
- src/linters/dry/python_analyzer.py +668 -0
- src/linters/dry/storage_initializer.py +42 -0
- src/linters/dry/token_hasher.py +169 -0
- src/linters/dry/typescript_analyzer.py +592 -0
- src/linters/dry/violation_builder.py +74 -0
- src/linters/dry/violation_filter.py +94 -0
- src/linters/dry/violation_generator.py +174 -0
- src/linters/file_header/__init__.py +24 -0
- src/linters/file_header/atemporal_detector.py +87 -0
- src/linters/file_header/config.py +66 -0
- src/linters/file_header/field_validator.py +69 -0
- src/linters/file_header/linter.py +313 -0
- src/linters/file_header/python_parser.py +86 -0
- src/linters/file_header/violation_builder.py +78 -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 +262 -471
- 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/magic_numbers/__init__.py +48 -0
- src/linters/magic_numbers/config.py +82 -0
- src/linters/magic_numbers/context_analyzer.py +247 -0
- src/linters/magic_numbers/linter.py +516 -0
- src/linters/magic_numbers/python_analyzer.py +76 -0
- src/linters/magic_numbers/typescript_analyzer.py +218 -0
- src/linters/magic_numbers/violation_builder.py +98 -0
- src/linters/nesting/__init__.py +6 -2
- src/linters/nesting/config.py +17 -4
- src/linters/nesting/linter.py +81 -168
- src/linters/nesting/typescript_analyzer.py +39 -102
- src/linters/nesting/typescript_function_extractor.py +130 -0
- src/linters/nesting/violation_builder.py +139 -0
- src/linters/print_statements/__init__.py +53 -0
- src/linters/print_statements/config.py +83 -0
- src/linters/print_statements/linter.py +430 -0
- src/linters/print_statements/python_analyzer.py +155 -0
- src/linters/print_statements/typescript_analyzer.py +135 -0
- src/linters/print_statements/violation_builder.py +98 -0
- src/linters/srp/__init__.py +99 -0
- src/linters/srp/class_analyzer.py +113 -0
- src/linters/srp/config.py +82 -0
- src/linters/srp/heuristics.py +89 -0
- src/linters/srp/linter.py +234 -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 +54 -9
- src/templates/thailint_config_template.yaml +158 -0
- src/utils/__init__.py +4 -0
- src/utils/project_root.py +203 -0
- thailint-0.5.0.dist-info/METADATA +1286 -0
- thailint-0.5.0.dist-info/RECORD +96 -0
- {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/WHEEL +1 -1
- src/.ai/layout.yaml +0 -48
- thailint-0.1.5.dist-info/METADATA +0 -629
- thailint-0.1.5.dist-info/RECORD +0 -28
- {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/entry_points.txt +0 -0
- {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Violation creation with suggestions for SRP linter
|
|
3
|
+
|
|
4
|
+
Scope: Builds Violation objects with contextual messages and refactoring suggestions
|
|
5
|
+
|
|
6
|
+
Overview: Provides violation building functionality for the SRP linter. Creates violations
|
|
7
|
+
from class metrics and issue descriptions, generates contextual error messages, and
|
|
8
|
+
provides actionable refactoring suggestions based on issue types (methods, lines, keywords).
|
|
9
|
+
Isolates violation construction and suggestion generation from metrics evaluation and
|
|
10
|
+
class analysis to maintain single responsibility.
|
|
11
|
+
|
|
12
|
+
Dependencies: BaseLintContext, Violation, Severity, typing, src.core.violation_builder
|
|
13
|
+
|
|
14
|
+
Exports: ViolationBuilder
|
|
15
|
+
|
|
16
|
+
Interfaces: build_violation(metrics, issues, rule_id, context) -> Violation
|
|
17
|
+
|
|
18
|
+
Implementation: Formats messages from metrics, generates targeted suggestions per issue type,
|
|
19
|
+
extends BaseViolationBuilder for consistent violation construction
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from src.core.base import BaseLintContext
|
|
25
|
+
from src.core.types import Severity, Violation
|
|
26
|
+
from src.core.violation_builder import BaseViolationBuilder, ViolationInfo
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ViolationBuilder(BaseViolationBuilder):
|
|
30
|
+
"""Builds SRP violations with messages and suggestions."""
|
|
31
|
+
|
|
32
|
+
def build_violation(
|
|
33
|
+
self,
|
|
34
|
+
metrics: dict[str, Any],
|
|
35
|
+
issues: list[str],
|
|
36
|
+
rule_id: str,
|
|
37
|
+
context: BaseLintContext,
|
|
38
|
+
) -> Violation:
|
|
39
|
+
"""Build violation from metrics and issues.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
metrics: Class metrics dictionary
|
|
43
|
+
issues: List of issue descriptions
|
|
44
|
+
rule_id: Rule identifier
|
|
45
|
+
context: Lint context
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Violation with message and suggestion
|
|
49
|
+
"""
|
|
50
|
+
message = f"Class '{metrics['class_name']}' may violate SRP: {', '.join(issues)}"
|
|
51
|
+
suggestion = self._generate_suggestion(issues)
|
|
52
|
+
|
|
53
|
+
info = ViolationInfo(
|
|
54
|
+
rule_id=rule_id,
|
|
55
|
+
file_path=str(context.file_path or ""),
|
|
56
|
+
line=metrics["line"],
|
|
57
|
+
column=metrics["column"],
|
|
58
|
+
message=message,
|
|
59
|
+
severity=Severity.ERROR,
|
|
60
|
+
suggestion=suggestion,
|
|
61
|
+
)
|
|
62
|
+
return self.build(info)
|
|
63
|
+
|
|
64
|
+
def _generate_suggestion(self, issues: list[str]) -> str:
|
|
65
|
+
"""Generate refactoring suggestion based on issues.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
issues: List of issue descriptions
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Suggestion string with refactoring advice
|
|
72
|
+
"""
|
|
73
|
+
suggestions = [
|
|
74
|
+
self._suggest_for_methods(issues),
|
|
75
|
+
self._suggest_for_lines(issues),
|
|
76
|
+
self._suggest_for_keywords(issues),
|
|
77
|
+
]
|
|
78
|
+
return ". ".join(filter(None, suggestions))
|
|
79
|
+
|
|
80
|
+
def _suggest_for_methods(self, issues: list[str]) -> str:
|
|
81
|
+
"""Suggest fix for too many methods.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
issues: List of issue descriptions
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Suggestion string or empty string
|
|
88
|
+
"""
|
|
89
|
+
if any("methods" in issue for issue in issues):
|
|
90
|
+
return "Consider extracting related methods into separate classes"
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
def _suggest_for_lines(self, issues: list[str]) -> str:
|
|
94
|
+
"""Suggest fix for too many lines.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
issues: List of issue descriptions
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Suggestion string or empty string
|
|
101
|
+
"""
|
|
102
|
+
if any("lines" in issue for issue in issues):
|
|
103
|
+
return "Consider breaking the class into smaller, focused classes"
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
def _suggest_for_keywords(self, issues: list[str]) -> str:
|
|
107
|
+
"""Suggest fix for responsibility keywords.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
issues: List of issue descriptions
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Suggestion string or empty string
|
|
114
|
+
"""
|
|
115
|
+
if any("keyword" in issue for issue in issues):
|
|
116
|
+
return "Avoid generic names like Manager, Handler, Processor"
|
|
117
|
+
return ""
|
src/orchestrator/core.py
CHANGED
|
@@ -89,26 +89,32 @@ class Orchestrator:
|
|
|
89
89
|
detection to provide comprehensive linting of files and directories.
|
|
90
90
|
"""
|
|
91
91
|
|
|
92
|
-
def __init__(self, project_root: Path | None = None):
|
|
92
|
+
def __init__(self, project_root: Path | None = None, config: dict | None = None):
|
|
93
93
|
"""Initialize orchestrator.
|
|
94
94
|
|
|
95
95
|
Args:
|
|
96
96
|
project_root: Root directory of project. Defaults to current directory.
|
|
97
|
+
config: Optional pre-loaded configuration dict. If provided, skips config file loading.
|
|
97
98
|
"""
|
|
98
99
|
self.project_root = project_root or Path.cwd()
|
|
99
100
|
self.registry = RuleRegistry()
|
|
100
101
|
self.config_loader = LinterConfigLoader()
|
|
101
102
|
self.ignore_parser = IgnoreDirectiveParser(self.project_root)
|
|
102
103
|
|
|
103
|
-
#
|
|
104
|
-
|
|
104
|
+
# Performance optimization: Defer rule discovery until first file is linted
|
|
105
|
+
# This eliminates ~0.077s overhead for commands that don't need rules (--help, config, etc.)
|
|
106
|
+
self._rules_discovered = False
|
|
105
107
|
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
# Use provided config or load from project root
|
|
109
|
+
if config is not None:
|
|
110
|
+
self.config = config
|
|
111
|
+
else:
|
|
112
|
+
# Load configuration from project root
|
|
113
|
+
config_path = self.project_root / ".thailint.yaml"
|
|
114
|
+
if not config_path.exists():
|
|
115
|
+
config_path = self.project_root / ".thailint.json"
|
|
110
116
|
|
|
111
|
-
|
|
117
|
+
self.config = self.config_loader.load(config_path)
|
|
112
118
|
|
|
113
119
|
def lint_file(self, file_path: Path) -> list[Violation]:
|
|
114
120
|
"""Lint a single file.
|
|
@@ -124,10 +130,33 @@ class Orchestrator:
|
|
|
124
130
|
|
|
125
131
|
language = detect_language(file_path)
|
|
126
132
|
rules = self._get_rules_for_file(file_path, language)
|
|
127
|
-
|
|
133
|
+
|
|
134
|
+
# Add project_root to metadata for rules that need it (e.g., DRY linter cache)
|
|
135
|
+
metadata = {**self.config, "_project_root": self.project_root}
|
|
136
|
+
context = FileLintContext(file_path, language, metadata=metadata)
|
|
128
137
|
|
|
129
138
|
return self._execute_rules(rules, context)
|
|
130
139
|
|
|
140
|
+
def lint_files(self, file_paths: list[Path]) -> list[Violation]:
|
|
141
|
+
"""Lint multiple files.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
file_paths: List of file paths to lint.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of violations found across all files.
|
|
148
|
+
"""
|
|
149
|
+
violations = []
|
|
150
|
+
|
|
151
|
+
for file_path in file_paths:
|
|
152
|
+
violations.extend(self.lint_file(file_path))
|
|
153
|
+
|
|
154
|
+
# Call finalize() on all rules after processing all files
|
|
155
|
+
for rule in self.registry.list_all():
|
|
156
|
+
violations.extend(rule.finalize())
|
|
157
|
+
|
|
158
|
+
return violations
|
|
159
|
+
|
|
131
160
|
def _execute_rules(
|
|
132
161
|
self, rules: list[BaseLintRule], context: BaseLintContext
|
|
133
162
|
) -> list[Violation]:
|
|
@@ -150,6 +179,9 @@ class Orchestrator:
|
|
|
150
179
|
"""Safely check a rule, returning empty list on error."""
|
|
151
180
|
try:
|
|
152
181
|
return rule.check(context)
|
|
182
|
+
except ValueError:
|
|
183
|
+
# Re-raise configuration validation errors (these are user-facing)
|
|
184
|
+
raise
|
|
153
185
|
except Exception: # nosec B112
|
|
154
186
|
# Skip rules that fail (defensive programming)
|
|
155
187
|
return []
|
|
@@ -171,8 +203,18 @@ class Orchestrator:
|
|
|
171
203
|
if file_path.is_file():
|
|
172
204
|
violations.extend(self.lint_file(file_path))
|
|
173
205
|
|
|
206
|
+
# Call finalize() on all rules after processing all files
|
|
207
|
+
for rule in self.registry.list_all():
|
|
208
|
+
violations.extend(rule.finalize())
|
|
209
|
+
|
|
174
210
|
return violations
|
|
175
211
|
|
|
212
|
+
def _ensure_rules_discovered(self) -> None:
|
|
213
|
+
"""Ensure rules have been discovered and registered (lazy initialization)."""
|
|
214
|
+
if not self._rules_discovered:
|
|
215
|
+
self.registry.discover_rules("src.linters")
|
|
216
|
+
self._rules_discovered = True
|
|
217
|
+
|
|
176
218
|
def _get_rules_for_file(self, file_path: Path, language: str) -> list[BaseLintRule]:
|
|
177
219
|
"""Get rules applicable to this file.
|
|
178
220
|
|
|
@@ -183,6 +225,9 @@ class Orchestrator:
|
|
|
183
225
|
Returns:
|
|
184
226
|
List of rules to execute against this file.
|
|
185
227
|
"""
|
|
228
|
+
# Lazy initialization: discover rules on first lint operation
|
|
229
|
+
self._ensure_rules_discovered()
|
|
230
|
+
|
|
186
231
|
# For now, return all registered rules
|
|
187
232
|
# Future: filter by language, configuration, etc.
|
|
188
233
|
return self.registry.list_all()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# thai-lint Configuration File
|
|
2
|
+
# Generated by: thailint init-config
|
|
3
|
+
#
|
|
4
|
+
# For non-interactive mode (AI agents): thailint init-config --non-interactive
|
|
5
|
+
#
|
|
6
|
+
# Full documentation: https://github.com/your-org/thai-lint
|
|
7
|
+
|
|
8
|
+
# ============================================================================
|
|
9
|
+
# MAGIC NUMBERS LINTER
|
|
10
|
+
# ============================================================================
|
|
11
|
+
# Detects unnamed numeric literals that should be extracted as constants
|
|
12
|
+
#
|
|
13
|
+
# Preset: {{PRESET}}
|
|
14
|
+
#
|
|
15
|
+
magic-numbers:
|
|
16
|
+
enabled: true
|
|
17
|
+
|
|
18
|
+
# Numbers that are acceptable without being named constants
|
|
19
|
+
# Default: [-1, 0, 1, 2, 3, 4, 5, 10, 100, 1000]
|
|
20
|
+
allowed_numbers: {{ALLOWED_NUMBERS}}
|
|
21
|
+
|
|
22
|
+
# Maximum integer allowed in range() or enumerate() without flagging
|
|
23
|
+
# Default: 10
|
|
24
|
+
max_small_integer: {{MAX_SMALL_INTEGER}}
|
|
25
|
+
|
|
26
|
+
# -------------------------------------------------------------------------
|
|
27
|
+
# OPTIONAL: Uncomment to add time conversions (lenient mode)
|
|
28
|
+
# -------------------------------------------------------------------------
|
|
29
|
+
# allowed_numbers: [-1, 0, 1, 2, 3, 4, 5, 10, 60, 100, 1000, 3600]
|
|
30
|
+
|
|
31
|
+
# -------------------------------------------------------------------------
|
|
32
|
+
# OPTIONAL: Uncomment to add common HTTP status codes
|
|
33
|
+
# -------------------------------------------------------------------------
|
|
34
|
+
# allowed_numbers: [-1, 0, 1, 2, 3, 4, 5, 10, 100, 200, 201, 204, 400, 401, 403, 404, 500, 502, 503, 1000]
|
|
35
|
+
|
|
36
|
+
# -------------------------------------------------------------------------
|
|
37
|
+
# OPTIONAL: Uncomment to add decimal proportions (0.0-1.0)
|
|
38
|
+
# -------------------------------------------------------------------------
|
|
39
|
+
# allowed_numbers: [-1, 0, 1, 2, 3, 4, 5, 10, 100, 1000, 0.0, 0.1, 0.2, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.75, 0.8, 0.9, 1.0]
|
|
40
|
+
|
|
41
|
+
# ============================================================================
|
|
42
|
+
# NESTING LINTER
|
|
43
|
+
# ============================================================================
|
|
44
|
+
# Checks for excessive nesting depth (if/for/while/try statements)
|
|
45
|
+
#
|
|
46
|
+
nesting:
|
|
47
|
+
enabled: true
|
|
48
|
+
|
|
49
|
+
# Maximum nesting depth allowed
|
|
50
|
+
# Default: 4
|
|
51
|
+
max_nesting_depth: 4
|
|
52
|
+
|
|
53
|
+
# ============================================================================
|
|
54
|
+
# SINGLE RESPONSIBILITY PRINCIPLE (SRP) LINTER
|
|
55
|
+
# ============================================================================
|
|
56
|
+
# Detects classes that may have too many responsibilities
|
|
57
|
+
#
|
|
58
|
+
srp:
|
|
59
|
+
enabled: true
|
|
60
|
+
|
|
61
|
+
# Maximum methods per class
|
|
62
|
+
# Default: 7
|
|
63
|
+
max_methods: 7
|
|
64
|
+
|
|
65
|
+
# Maximum lines of code per class
|
|
66
|
+
# Default: 200
|
|
67
|
+
max_loc: 200
|
|
68
|
+
|
|
69
|
+
# ============================================================================
|
|
70
|
+
# DRY (DON'T REPEAT YOURSELF) LINTER
|
|
71
|
+
# ============================================================================
|
|
72
|
+
# Detects duplicate code blocks
|
|
73
|
+
#
|
|
74
|
+
dry:
|
|
75
|
+
enabled: true
|
|
76
|
+
|
|
77
|
+
# Minimum lines for a block to be considered duplicate
|
|
78
|
+
# Default: 6
|
|
79
|
+
min_duplicate_lines: 6
|
|
80
|
+
|
|
81
|
+
# Enable SQLite caching for faster incremental scans
|
|
82
|
+
# Default: true
|
|
83
|
+
cache_enabled: true
|
|
84
|
+
|
|
85
|
+
# Cache file location (relative to project root)
|
|
86
|
+
# Default: .thailint-cache/dry.db
|
|
87
|
+
cache_path: .thailint-cache/dry.db
|
|
88
|
+
|
|
89
|
+
# ============================================================================
|
|
90
|
+
# FILE PLACEMENT LINTER
|
|
91
|
+
# ============================================================================
|
|
92
|
+
# Ensures files are in appropriate directories
|
|
93
|
+
#
|
|
94
|
+
file-placement:
|
|
95
|
+
enabled: true
|
|
96
|
+
|
|
97
|
+
# Rules for file placement
|
|
98
|
+
rules:
|
|
99
|
+
# Test files should be in tests/ directory
|
|
100
|
+
- pattern: "test_*.py"
|
|
101
|
+
required_dir: "tests/"
|
|
102
|
+
message: "Test files must be in tests/ directory"
|
|
103
|
+
|
|
104
|
+
# Config files should be in config/ or root
|
|
105
|
+
- pattern: "*config*.py"
|
|
106
|
+
required_dir: ["config/", "./"]
|
|
107
|
+
message: "Config files should be in config/ or project root"
|
|
108
|
+
|
|
109
|
+
# ============================================================================
|
|
110
|
+
# PRINT STATEMENTS LINTER
|
|
111
|
+
# ============================================================================
|
|
112
|
+
# Detects print()/console.* statements that should use proper logging
|
|
113
|
+
#
|
|
114
|
+
print-statements:
|
|
115
|
+
enabled: true
|
|
116
|
+
|
|
117
|
+
# Allow print() in if __name__ == "__main__": blocks (Python only)
|
|
118
|
+
# Default: true
|
|
119
|
+
allow_in_scripts: true
|
|
120
|
+
|
|
121
|
+
# Console methods to detect in TypeScript/JavaScript
|
|
122
|
+
# Default: [log, warn, error, debug, info]
|
|
123
|
+
console_methods:
|
|
124
|
+
- log
|
|
125
|
+
- warn
|
|
126
|
+
- error
|
|
127
|
+
- debug
|
|
128
|
+
- info
|
|
129
|
+
|
|
130
|
+
# File patterns to ignore (glob syntax)
|
|
131
|
+
# ignore:
|
|
132
|
+
# - "scripts/**"
|
|
133
|
+
# - "**/debug.py"
|
|
134
|
+
|
|
135
|
+
# ============================================================================
|
|
136
|
+
# GLOBAL SETTINGS
|
|
137
|
+
# ============================================================================
|
|
138
|
+
#
|
|
139
|
+
# Exclude patterns (files/directories to ignore)
|
|
140
|
+
exclude:
|
|
141
|
+
- ".git/"
|
|
142
|
+
- ".venv/"
|
|
143
|
+
- "venv/"
|
|
144
|
+
- "node_modules/"
|
|
145
|
+
- "__pycache__/"
|
|
146
|
+
- "*.pyc"
|
|
147
|
+
- ".pytest_cache/"
|
|
148
|
+
- "dist/"
|
|
149
|
+
- "build/"
|
|
150
|
+
- ".eggs/"
|
|
151
|
+
|
|
152
|
+
# Output format (text or json)
|
|
153
|
+
# Default: text
|
|
154
|
+
output_format: text
|
|
155
|
+
|
|
156
|
+
# Exit with error code if violations found
|
|
157
|
+
# Default: true
|
|
158
|
+
fail_on_violations: true
|
src/utils/__init__.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Project root detection utility.
|
|
2
|
+
|
|
3
|
+
Purpose: Centralized project root detection for consistent file placement
|
|
4
|
+
Scope: Single source of truth for finding project root directory
|
|
5
|
+
|
|
6
|
+
Overview: Uses pyprojroot package to provide reliable project root detection across
|
|
7
|
+
different environments (development, CI/CD, user installations). Falls back to
|
|
8
|
+
manual detection if pyprojroot is not available (e.g., in test environments).
|
|
9
|
+
Searches for standard project markers like .git, .thailint.yaml, and pyproject.toml.
|
|
10
|
+
|
|
11
|
+
Dependencies: pyprojroot (optional, with manual fallback)
|
|
12
|
+
|
|
13
|
+
Exports: is_project_root(), get_project_root()
|
|
14
|
+
|
|
15
|
+
Interfaces: Path-based functions for checking and finding project roots
|
|
16
|
+
|
|
17
|
+
Implementation: pyprojroot delegation with manual fallback for test environments
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# Try to import pyprojroot, but don't fail if it's not available
|
|
23
|
+
try:
|
|
24
|
+
from pyprojroot import find_root
|
|
25
|
+
|
|
26
|
+
HAS_PYPROJROOT = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
HAS_PYPROJROOT = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _has_marker(path: Path, marker_name: str, is_dir: bool = False) -> bool:
|
|
32
|
+
"""Check if a directory contains a specific marker.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: Directory path to check
|
|
36
|
+
marker_name: Name of marker file or directory
|
|
37
|
+
is_dir: True if marker is a directory, False if it's a file
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
True if marker exists, False otherwise
|
|
41
|
+
"""
|
|
42
|
+
marker_path = path / marker_name
|
|
43
|
+
if is_dir:
|
|
44
|
+
return marker_path.is_dir()
|
|
45
|
+
return marker_path.is_file()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_project_root(path: Path) -> bool:
|
|
49
|
+
"""Check if a directory is a project root.
|
|
50
|
+
|
|
51
|
+
Uses pyprojroot if available, otherwise checks for common project markers
|
|
52
|
+
like .git, .thailint.yaml, or pyproject.toml.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
path: Directory path to check
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if the directory is a project root, False otherwise
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
>>> is_project_root(Path("/home/user/myproject"))
|
|
62
|
+
True
|
|
63
|
+
>>> is_project_root(Path("/home/user/myproject/src"))
|
|
64
|
+
False
|
|
65
|
+
"""
|
|
66
|
+
if not path.exists() or not path.is_dir():
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
if HAS_PYPROJROOT:
|
|
70
|
+
return _check_root_with_pyprojroot(path)
|
|
71
|
+
|
|
72
|
+
return _check_root_with_markers(path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_root_with_pyprojroot(path: Path) -> bool:
|
|
76
|
+
"""Check if path is project root using pyprojroot.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path: Directory path to check
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if path is a project root, False otherwise
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
# Find root from this path - if it equals this path, it's a root
|
|
86
|
+
found_root = find_root(path)
|
|
87
|
+
return found_root == path.resolve()
|
|
88
|
+
except (OSError, RuntimeError):
|
|
89
|
+
# pyprojroot couldn't find a root
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _check_root_with_markers(path: Path) -> bool:
|
|
94
|
+
"""Check if path contains project root markers.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: Directory path to check
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if path contains .git, .thailint.yaml, or pyproject.toml
|
|
101
|
+
"""
|
|
102
|
+
return (
|
|
103
|
+
_has_marker(path, ".git", is_dir=True)
|
|
104
|
+
or _has_marker(path, ".thailint.yaml", is_dir=False)
|
|
105
|
+
or _has_marker(path, "pyproject.toml", is_dir=False)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _try_find_with_criterion(criterion: object, start_path: Path) -> Path | None:
|
|
110
|
+
"""Try to find project root with a specific criterion.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
criterion: pyprojroot criterion function (e.g., has_dir(".git"))
|
|
114
|
+
start_path: Path to start searching from
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Found project root or None if not found
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
return find_root(criterion, start=start_path) # type: ignore[arg-type]
|
|
121
|
+
except (OSError, RuntimeError):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _find_root_manual(start_path: Path) -> Path:
|
|
126
|
+
"""Manually find project root by walking up directory tree.
|
|
127
|
+
|
|
128
|
+
Fallback implementation when pyprojroot is not available.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
start_path: Directory to start searching from
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Path to project root, or start_path if no markers found
|
|
135
|
+
"""
|
|
136
|
+
current = start_path.resolve()
|
|
137
|
+
|
|
138
|
+
# Walk up the directory tree
|
|
139
|
+
for parent in [current] + list(current.parents):
|
|
140
|
+
# Check for project markers
|
|
141
|
+
if (
|
|
142
|
+
_has_marker(parent, ".git", is_dir=True)
|
|
143
|
+
or _has_marker(parent, ".thailint.yaml", is_dir=False)
|
|
144
|
+
or _has_marker(parent, "pyproject.toml", is_dir=False)
|
|
145
|
+
):
|
|
146
|
+
return parent
|
|
147
|
+
|
|
148
|
+
# No markers found, return start path
|
|
149
|
+
return current
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_project_root(start_path: Path | None = None) -> Path:
|
|
153
|
+
"""Find project root by walking up the directory tree.
|
|
154
|
+
|
|
155
|
+
This is the single source of truth for project root detection.
|
|
156
|
+
All code that needs to find the project root should use this function.
|
|
157
|
+
|
|
158
|
+
Uses pyprojroot if available, otherwise uses manual detection searching for
|
|
159
|
+
standard project markers (.git directory, pyproject.toml, .thailint.yaml, etc)
|
|
160
|
+
starting from start_path and walking upward.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
start_path: Directory to start searching from. If None, uses current working directory.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Path to project root directory. If no root markers found, returns the start_path.
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
>>> root = get_project_root()
|
|
170
|
+
>>> config_file = root / ".thailint.yaml"
|
|
171
|
+
"""
|
|
172
|
+
if start_path is None:
|
|
173
|
+
start_path = Path.cwd()
|
|
174
|
+
|
|
175
|
+
current = start_path.resolve()
|
|
176
|
+
|
|
177
|
+
if HAS_PYPROJROOT:
|
|
178
|
+
return _find_root_with_pyprojroot(current)
|
|
179
|
+
|
|
180
|
+
# Manual fallback for test environments
|
|
181
|
+
return _find_root_manual(current)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _find_root_with_pyprojroot(current: Path) -> Path:
|
|
185
|
+
"""Find project root using pyprojroot library.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
current: Current path to start searching from
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Path to project root, or current if no markers found
|
|
192
|
+
"""
|
|
193
|
+
from pyprojroot import has_dir, has_file
|
|
194
|
+
|
|
195
|
+
# Search for project root markers in priority order
|
|
196
|
+
# Try .git first (most reliable), then .thailint.yaml, then pyproject.toml
|
|
197
|
+
for criterion in [has_dir(".git"), has_file(".thailint.yaml"), has_file("pyproject.toml")]:
|
|
198
|
+
root = _try_find_with_criterion(criterion, current)
|
|
199
|
+
if root is not None:
|
|
200
|
+
return root
|
|
201
|
+
|
|
202
|
+
# No markers found, return start path
|
|
203
|
+
return current
|