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.
Files changed (91) 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 +1111 -144
  6. src/config.py +12 -33
  7. src/core/base.py +102 -5
  8. src/core/cli_utils.py +206 -0
  9. src/core/config_parser.py +126 -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 +265 -0
  19. src/linters/dry/block_grouper.py +59 -0
  20. src/linters/dry/cache.py +172 -0
  21. src/linters/dry/cache_query.py +61 -0
  22. src/linters/dry/config.py +134 -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 +63 -0
  26. src/linters/dry/file_analyzer.py +90 -0
  27. src/linters/dry/inline_ignore.py +140 -0
  28. src/linters/dry/linter.py +163 -0
  29. src/linters/dry/python_analyzer.py +668 -0
  30. src/linters/dry/storage_initializer.py +42 -0
  31. src/linters/dry/token_hasher.py +169 -0
  32. src/linters/dry/typescript_analyzer.py +592 -0
  33. src/linters/dry/violation_builder.py +74 -0
  34. src/linters/dry/violation_filter.py +94 -0
  35. src/linters/dry/violation_generator.py +174 -0
  36. src/linters/file_header/__init__.py +24 -0
  37. src/linters/file_header/atemporal_detector.py +87 -0
  38. src/linters/file_header/config.py +66 -0
  39. src/linters/file_header/field_validator.py +69 -0
  40. src/linters/file_header/linter.py +313 -0
  41. src/linters/file_header/python_parser.py +86 -0
  42. src/linters/file_header/violation_builder.py +78 -0
  43. src/linters/file_placement/config_loader.py +86 -0
  44. src/linters/file_placement/directory_matcher.py +80 -0
  45. src/linters/file_placement/linter.py +262 -471
  46. src/linters/file_placement/path_resolver.py +61 -0
  47. src/linters/file_placement/pattern_matcher.py +55 -0
  48. src/linters/file_placement/pattern_validator.py +106 -0
  49. src/linters/file_placement/rule_checker.py +229 -0
  50. src/linters/file_placement/violation_factory.py +177 -0
  51. src/linters/magic_numbers/__init__.py +48 -0
  52. src/linters/magic_numbers/config.py +82 -0
  53. src/linters/magic_numbers/context_analyzer.py +247 -0
  54. src/linters/magic_numbers/linter.py +516 -0
  55. src/linters/magic_numbers/python_analyzer.py +76 -0
  56. src/linters/magic_numbers/typescript_analyzer.py +218 -0
  57. src/linters/magic_numbers/violation_builder.py +98 -0
  58. src/linters/nesting/__init__.py +6 -2
  59. src/linters/nesting/config.py +17 -4
  60. src/linters/nesting/linter.py +81 -168
  61. src/linters/nesting/typescript_analyzer.py +39 -102
  62. src/linters/nesting/typescript_function_extractor.py +130 -0
  63. src/linters/nesting/violation_builder.py +139 -0
  64. src/linters/print_statements/__init__.py +53 -0
  65. src/linters/print_statements/config.py +83 -0
  66. src/linters/print_statements/linter.py +430 -0
  67. src/linters/print_statements/python_analyzer.py +155 -0
  68. src/linters/print_statements/typescript_analyzer.py +135 -0
  69. src/linters/print_statements/violation_builder.py +98 -0
  70. src/linters/srp/__init__.py +99 -0
  71. src/linters/srp/class_analyzer.py +113 -0
  72. src/linters/srp/config.py +82 -0
  73. src/linters/srp/heuristics.py +89 -0
  74. src/linters/srp/linter.py +234 -0
  75. src/linters/srp/metrics_evaluator.py +47 -0
  76. src/linters/srp/python_analyzer.py +72 -0
  77. src/linters/srp/typescript_analyzer.py +75 -0
  78. src/linters/srp/typescript_metrics_calculator.py +90 -0
  79. src/linters/srp/violation_builder.py +117 -0
  80. src/orchestrator/core.py +54 -9
  81. src/templates/thailint_config_template.yaml +158 -0
  82. src/utils/__init__.py +4 -0
  83. src/utils/project_root.py +203 -0
  84. thailint-0.5.0.dist-info/METADATA +1286 -0
  85. thailint-0.5.0.dist-info/RECORD +96 -0
  86. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/WHEEL +1 -1
  87. src/.ai/layout.yaml +0 -48
  88. thailint-0.1.5.dist-info/METADATA +0 -629
  89. thailint-0.1.5.dist-info/RECORD +0 -28
  90. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/entry_points.txt +0 -0
  91. {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
- # Auto-discover and register all linting rules from src.linters
104
- self.registry.discover_rules("src.linters")
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
- # Load configuration from project root
107
- config_path = self.project_root / ".thailint.yaml"
108
- if not config_path.exists():
109
- config_path = self.project_root / ".thailint.json"
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
- self.config = self.config_loader.load(config_path)
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
- context = FileLintContext(file_path, language, metadata=self.config)
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,4 @@
1
+ """Utils package for shared utilities.
2
+
3
+ This package provides common utility functions used across the linter framework.
4
+ """
@@ -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