thailint 0.1.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/core/types.py ADDED
@@ -0,0 +1,83 @@
1
+ """
2
+ Purpose: Core type definitions for the linter framework
3
+
4
+ Scope: Fundamental data types used across all linter components and rules
5
+
6
+ Overview: Defines the essential data structures that form the foundation of the linter
7
+ framework, including violation reporting and severity classification. Provides the
8
+ Violation dataclass for representing linting issues with complete location and context
9
+ information, and the Severity enum implementing a binary error model (violations are
10
+ either errors or not violations). These types are used throughout the framework by
11
+ rules, orchestrators, and output formatters to maintain consistent violation reporting
12
+ and severity handling across all linting operations.
13
+
14
+ Dependencies: dataclasses for Violation structure, enum for Severity classification
15
+
16
+ Exports: Severity enum (ERROR level), Violation dataclass with serialization support
17
+
18
+ Interfaces: Violation.to_dict() -> dict for JSON serialization, Severity.ERROR constant
19
+
20
+ Implementation: Binary severity model (errors only), dataclass-based violation structure
21
+ with comprehensive field set (rule_id, file_path, line, column, message, severity, suggestion)
22
+ """
23
+
24
+ from dataclasses import dataclass
25
+ from enum import Enum
26
+
27
+
28
+ class Severity(Enum):
29
+ """Binary severity model - errors only.
30
+
31
+ Following the design principle that linting violations are either
32
+ errors that must be fixed or they're not violations at all.
33
+ No warnings, info, or other severity levels.
34
+ """
35
+
36
+ ERROR = "error"
37
+
38
+
39
+ @dataclass
40
+ class Violation:
41
+ """Represents a linting violation.
42
+
43
+ A violation contains all the information needed to report a linting
44
+ issue to the user, including location, message, and optional suggestion
45
+ for how to fix it.
46
+ """
47
+
48
+ rule_id: str
49
+ """Unique identifier of the rule that detected this violation."""
50
+
51
+ file_path: str
52
+ """Path to the file containing the violation."""
53
+
54
+ line: int
55
+ """Line number where the violation occurs (1-indexed)."""
56
+
57
+ column: int
58
+ """Column number where the violation occurs (0-indexed)."""
59
+
60
+ message: str
61
+ """Human-readable description of the violation."""
62
+
63
+ severity: Severity = Severity.ERROR
64
+ """Severity level of the violation (always ERROR in binary model)."""
65
+
66
+ suggestion: str | None = None
67
+ """Optional suggestion for how to fix the violation."""
68
+
69
+ def to_dict(self) -> dict[str, str | int | None]:
70
+ """Convert violation to dictionary for JSON serialization.
71
+
72
+ Returns:
73
+ Dictionary representation of the violation with all fields.
74
+ """
75
+ return {
76
+ "rule_id": self.rule_id,
77
+ "file_path": self.file_path,
78
+ "line": self.line,
79
+ "column": self.column,
80
+ "message": self.message,
81
+ "severity": self.severity.value,
82
+ "suggestion": self.suggestion,
83
+ }
@@ -0,0 +1,13 @@
1
+ """Linter configuration system.
2
+
3
+ This package provides configuration loading and ignore directive parsing
4
+ for the thai-lint linter framework.
5
+ """
6
+
7
+ from .ignore import IgnoreDirectiveParser
8
+ from .loader import LinterConfigLoader
9
+
10
+ __all__ = [
11
+ "IgnoreDirectiveParser",
12
+ "LinterConfigLoader",
13
+ ]
@@ -0,0 +1,403 @@
1
+ """
2
+ Purpose: Comprehensive 5-level ignore directive parser for suppressing linting violations
3
+
4
+ Scope: Multi-level ignore system across repository, directory, file, method, and line scopes
5
+
6
+ Overview: Implements a sophisticated ignore directive system that allows developers to suppress
7
+ linting violations at five different granularity levels, from entire repository patterns down
8
+ to individual lines of code. Repository level uses .thailintignore file with gitignore-style
9
+ glob patterns for excluding files like build artifacts and dependencies. File level scans the
10
+ first 10 lines for ignore-file directives (performance optimization). Method level supports
11
+ ignore-next-line directives placed before functions. Line level enables inline ignore comments
12
+ at the end of code lines. All levels support rule-specific ignores using bracket syntax
13
+ [rule-id] and wildcard rule matching (literals.* matches literals.magic-number). The
14
+ should_ignore_violation() method provides unified checking across all levels, integrating
15
+ with the violation reporting system to filter out suppressed violations before displaying
16
+ results to users.
17
+
18
+ Dependencies: fnmatch for gitignore-style pattern matching, re for regex-based directive parsing,
19
+ pathlib for file operations, Violation type for violation checking
20
+
21
+ Exports: IgnoreDirectiveParser class
22
+
23
+ Interfaces: is_ignored(file_path: Path) -> bool for repo-level checking,
24
+ has_file_ignore(file_path: Path, rule_id: str | None) -> bool for file-level,
25
+ has_line_ignore(code: str, line_num: int, rule_id: str | None) -> bool for line-level,
26
+ should_ignore_violation(violation: Violation, file_content: str) -> bool for unified checking
27
+
28
+ Implementation: Gitignore-style pattern matching with fnmatch, first-10-lines scanning for
29
+ performance, regex-based directive parsing with rule ID extraction, wildcard rule matching
30
+ with prefix comparison, graceful error handling for malformed directives
31
+ """
32
+
33
+ import fnmatch
34
+ import re
35
+ from pathlib import Path
36
+ from typing import TYPE_CHECKING
37
+
38
+ if TYPE_CHECKING:
39
+ from src.core.types import Violation
40
+
41
+
42
+ class IgnoreDirectiveParser:
43
+ """Parse and check ignore directives at all 5 levels.
44
+
45
+ Provides comprehensive ignore checking for repository-level patterns,
46
+ file-level directives, and inline code comments.
47
+ """
48
+
49
+ def __init__(self, project_root: Path | None = None):
50
+ """Initialize parser.
51
+
52
+ Args:
53
+ project_root: Root directory of the project. Defaults to current directory.
54
+ """
55
+ self.project_root = project_root or Path.cwd()
56
+ self.repo_patterns = self._load_repo_ignores()
57
+
58
+ def _load_repo_ignores(self) -> list[str]:
59
+ """Load .thailintignore file patterns.
60
+
61
+ Returns:
62
+ List of gitignore-style patterns.
63
+ """
64
+ ignore_file = self.project_root / ".thailintignore"
65
+ if not ignore_file.exists():
66
+ return []
67
+
68
+ patterns = []
69
+ for line in ignore_file.read_text(encoding="utf-8").splitlines():
70
+ line = line.strip()
71
+ # Skip comments and blank lines
72
+ if line and not line.startswith("#"):
73
+ patterns.append(line)
74
+ return patterns
75
+
76
+ def is_ignored(self, file_path: Path) -> bool:
77
+ """Check if file matches repository-level ignore patterns.
78
+
79
+ Args:
80
+ file_path: Path to check against ignore patterns.
81
+
82
+ Returns:
83
+ True if file should be ignored.
84
+ """
85
+ # Convert to string relative to project root if possible
86
+ try:
87
+ relative_path = file_path.relative_to(self.project_root)
88
+ path_str = str(relative_path)
89
+ except ValueError:
90
+ # Path is not relative to project root
91
+ path_str = str(file_path)
92
+
93
+ for pattern in self.repo_patterns:
94
+ if self._matches_pattern(path_str, pattern):
95
+ return True
96
+ return False
97
+
98
+ def _matches_pattern(self, path: str, pattern: str) -> bool:
99
+ """Check if path matches gitignore-style pattern.
100
+
101
+ Args:
102
+ path: File path to check.
103
+ pattern: Gitignore-style pattern.
104
+
105
+ Returns:
106
+ True if path matches pattern.
107
+ """
108
+ # Handle directory patterns (trailing /)
109
+ if pattern.endswith("/"):
110
+ # Match directory and all its contents
111
+ dir_pattern = pattern.rstrip("/")
112
+ # Check if path starts with the directory
113
+ path_parts = Path(path).parts
114
+ if dir_pattern in path_parts:
115
+ return True
116
+ # Also check direct match
117
+ if fnmatch.fnmatch(path, dir_pattern + "*"):
118
+ return True
119
+
120
+ # Standard fnmatch for file patterns
121
+ return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(str(Path(path)), pattern)
122
+
123
+ def _has_ignore_directive_marker(self, line: str) -> bool:
124
+ """Check if line contains an ignore directive marker."""
125
+ return "# thailint: ignore-file" in line or "# design-lint: ignore-file" in line
126
+
127
+ def _check_specific_rule_ignore(self, line: str, rule_id: str) -> bool:
128
+ """Check if line ignores a specific rule."""
129
+ match = re.search(r"ignore-file\[([^\]]+)\]", line)
130
+ if match:
131
+ ignored_rules = [r.strip() for r in match.group(1).split(",")]
132
+ return any(self._rule_matches(rule_id, r) for r in ignored_rules)
133
+ return False
134
+
135
+ def _check_general_ignore(self, line: str) -> bool:
136
+ """Check if line has general ignore directive (no specific rules)."""
137
+ return "ignore-file[" not in line
138
+
139
+ def _read_file_first_lines(self, file_path: Path) -> list[str]:
140
+ """Read first 10 lines of file, return empty list on error."""
141
+ if not file_path.exists():
142
+ return []
143
+ try:
144
+ content = file_path.read_text(encoding="utf-8")
145
+ return content.splitlines()[:10]
146
+ except (UnicodeDecodeError, OSError):
147
+ return []
148
+
149
+ def _check_line_for_ignore(self, line: str, rule_id: str | None) -> bool:
150
+ """Check if line has matching ignore directive."""
151
+ if not self._has_ignore_directive_marker(line):
152
+ return False
153
+ if rule_id:
154
+ return self._check_specific_rule_ignore(line, rule_id)
155
+ return self._check_general_ignore(line)
156
+
157
+ def has_file_ignore(self, file_path: Path, rule_id: str | None = None) -> bool:
158
+ """Check for file-level ignore directive.
159
+
160
+ Scans the first 10 lines of the file for ignore directives.
161
+
162
+ Args:
163
+ file_path: Path to file to check.
164
+ rule_id: Optional specific rule ID to check for.
165
+
166
+ Returns:
167
+ True if file has ignore directive (general or for specific rule).
168
+ """
169
+ first_lines = self._read_file_first_lines(file_path)
170
+ return any(self._check_line_for_ignore(line, rule_id) for line in first_lines)
171
+
172
+ def _has_line_ignore_marker(self, code: str) -> bool:
173
+ """Check if code line has ignore marker."""
174
+ return (
175
+ "# thailint: ignore" in code
176
+ or "# design-lint: ignore" in code
177
+ or "// thailint: ignore" in code
178
+ or "// design-lint: ignore" in code
179
+ )
180
+
181
+ def _check_specific_rule_in_line(self, code: str, rule_id: str) -> bool:
182
+ """Check if line's ignore directive matches specific rule."""
183
+ # Check for bracket syntax: # thailint: ignore[rule1, rule2]
184
+ bracket_match = re.search(r"ignore\[([^\]]+)\]", code)
185
+ if bracket_match:
186
+ return self._check_bracket_rules(bracket_match.group(1), rule_id)
187
+
188
+ # Check for space-separated syntax: # thailint: ignore rule1 rule2
189
+ space_match = re.search(r"ignore\s+([^\s#]+(?:\s+[^\s#]+)*)", code)
190
+ if space_match:
191
+ return self._check_space_separated_rules(space_match.group(1), rule_id)
192
+
193
+ # No specific rules - check for "ignore-all"
194
+ return "ignore-all" in code
195
+
196
+ def _check_bracket_rules(self, rules_text: str, rule_id: str) -> bool:
197
+ """Check if bracketed rules match the rule ID."""
198
+ ignored_rules = [r.strip() for r in rules_text.split(",")]
199
+ return any(self._rule_matches(rule_id, r) for r in ignored_rules)
200
+
201
+ def _check_space_separated_rules(self, rules_text: str, rule_id: str) -> bool:
202
+ """Check if space-separated rules match the rule ID."""
203
+ ignored_rules = [r.strip() for r in re.split(r"[,\s]+", rules_text) if r.strip()]
204
+ return any(self._rule_matches(rule_id, r) for r in ignored_rules)
205
+
206
+ def has_line_ignore(self, code: str, line_num: int, rule_id: str | None = None) -> bool:
207
+ """Check for line-level ignore directive.
208
+
209
+ Args:
210
+ code: Line of code to check.
211
+ line_num: Line number (currently unused, for API compatibility).
212
+ rule_id: Optional specific rule ID to check for.
213
+
214
+ Returns:
215
+ True if line has ignore directive.
216
+ """
217
+ if not self._has_line_ignore_marker(code):
218
+ return False
219
+
220
+ if rule_id:
221
+ return self._check_specific_rule_in_line(code, rule_id)
222
+ return True
223
+
224
+ def _rule_matches(self, rule_id: str, pattern: str) -> bool:
225
+ """Check if rule ID matches pattern (supports wildcards and prefixes).
226
+
227
+ Args:
228
+ rule_id: Rule ID to check (e.g., "nesting.excessive-depth").
229
+ pattern: Pattern with optional wildcard (e.g., "nesting.*" or "nesting").
230
+
231
+ Returns:
232
+ True if rule matches pattern.
233
+ """
234
+ if pattern.endswith("*"):
235
+ # Wildcard match: literals.* matches literals.magic-number
236
+ prefix = pattern[:-1]
237
+ return rule_id.startswith(prefix)
238
+
239
+ # Exact match
240
+ if rule_id == pattern:
241
+ return True
242
+
243
+ # Prefix match: "nesting" matches "nesting.excessive-depth"
244
+ if rule_id.startswith(pattern + "."):
245
+ return True
246
+
247
+ return False
248
+
249
+ def _has_ignore_next_line_marker(self, prev_line: str) -> bool:
250
+ """Check if line has ignore-next-line marker."""
251
+ return (
252
+ "# thailint: ignore-next-line" in prev_line
253
+ or "# design-lint: ignore-next-line" in prev_line
254
+ )
255
+
256
+ def _matches_ignore_next_line_rules(self, prev_line: str, rule_id: str) -> bool:
257
+ """Check if ignore-next-line directive matches the rule."""
258
+ match = re.search(r"ignore-next-line\[([^\]]+)\]", prev_line)
259
+ if match:
260
+ ignored_rules = [r.strip() for r in match.group(1).split(",")]
261
+ return any(self._rule_matches(rule_id, r) for r in ignored_rules)
262
+ return True
263
+
264
+ def _is_valid_prev_line_index(self, lines: list[str], violation: "Violation") -> bool:
265
+ """Check if previous line index is valid."""
266
+ if violation.line <= 1 or violation.line > len(lines) + 1:
267
+ return False
268
+ prev_line_idx = violation.line - 2
269
+ return 0 <= prev_line_idx < len(lines)
270
+
271
+ def _check_prev_line_ignore(self, lines: list[str], violation: "Violation") -> bool:
272
+ """Check if previous line has ignore-next-line directive."""
273
+ if not self._is_valid_prev_line_index(lines, violation):
274
+ return False
275
+
276
+ prev_line_idx = violation.line - 2
277
+ prev_line = lines[prev_line_idx]
278
+ if not self._has_ignore_next_line_marker(prev_line):
279
+ return False
280
+
281
+ return self._matches_ignore_next_line_rules(prev_line, violation.rule_id)
282
+
283
+ def _check_current_line_ignore(self, lines: list[str], violation: "Violation") -> bool:
284
+ """Check if current line has inline ignore directive."""
285
+ if violation.line <= 0 or violation.line > len(lines):
286
+ return False
287
+
288
+ current_line = lines[violation.line - 1] # Convert to 0-indexed
289
+ return self.has_line_ignore(current_line, violation.line, violation.rule_id)
290
+
291
+ def should_ignore_violation(self, violation: "Violation", file_content: str) -> bool:
292
+ """Check if a violation should be ignored based on all levels."""
293
+ file_path = Path(violation.file_path)
294
+
295
+ # Repository and file level checks
296
+ if self._is_ignored_at_file_level(file_path, violation.rule_id):
297
+ return True
298
+
299
+ # Line-based checks
300
+ return self._is_ignored_in_content(file_content, violation)
301
+
302
+ def _is_ignored_at_file_level(self, file_path: Path, rule_id: str) -> bool:
303
+ """Check repository and file level ignores."""
304
+ if self.is_ignored(file_path):
305
+ return True
306
+ return self.has_file_ignore(file_path, rule_id)
307
+
308
+ def _is_ignored_in_content(self, file_content: str, violation: "Violation") -> bool:
309
+ """Check content-based ignores (block, line, method level)."""
310
+ lines = file_content.splitlines()
311
+
312
+ if self._check_block_ignore(lines, violation):
313
+ return True
314
+ if self._check_prev_line_ignore(lines, violation):
315
+ return True
316
+ if self._check_current_line_ignore(lines, violation):
317
+ return True
318
+
319
+ return False
320
+
321
+ def _check_block_ignore(self, lines: list[str], violation: "Violation") -> bool:
322
+ """Check if violation is within an ignore-start/ignore-end block."""
323
+ if violation.line <= 0 or violation.line > len(lines):
324
+ return False
325
+
326
+ block_state = {"in_block": False, "rules": set()}
327
+
328
+ for i, line in enumerate(lines):
329
+ if self._process_block_line(line, i + 1, violation, block_state):
330
+ return True
331
+
332
+ return False
333
+
334
+ def _process_block_line(
335
+ self, line: str, line_num: int, violation: "Violation", block_state: dict
336
+ ) -> bool:
337
+ """Process a single line for block ignore checking."""
338
+ if "ignore-start" in line:
339
+ block_state["rules"] = self._parse_ignore_start_rules(line)
340
+ block_state["in_block"] = True
341
+ return False
342
+
343
+ if self._is_block_end_matching(
344
+ line, block_state["in_block"], line_num, violation, block_state["rules"]
345
+ ):
346
+ return True
347
+
348
+ if self._is_violation_line_ignored(
349
+ line_num, violation, block_state["in_block"], block_state["rules"]
350
+ ):
351
+ return True
352
+
353
+ if "ignore-end" in line:
354
+ block_state["in_block"] = False
355
+ block_state["rules"] = set()
356
+
357
+ return False
358
+
359
+ def _is_block_end_matching( # pylint: disable=too-many-arguments,too-many-positional-arguments
360
+ self,
361
+ line: str,
362
+ in_ignore_block: bool,
363
+ line_num: int,
364
+ violation: "Violation",
365
+ current_ignored_rules: set[str],
366
+ ) -> bool:
367
+ """Check if ignore-end matches and violation was in the block."""
368
+ if "ignore-end" not in line:
369
+ return False
370
+ if not in_ignore_block or line_num <= violation.line:
371
+ return False
372
+ return self._rules_match_violation(current_ignored_rules, violation.rule_id)
373
+
374
+ def _is_violation_line_ignored(
375
+ self,
376
+ line_num: int,
377
+ violation: "Violation",
378
+ in_ignore_block: bool,
379
+ current_ignored_rules: set[str],
380
+ ) -> bool:
381
+ """Check if current line is the violation line in an ignore block."""
382
+ if line_num != violation.line or not in_ignore_block:
383
+ return False
384
+ return self._rules_match_violation(current_ignored_rules, violation.rule_id)
385
+
386
+ def _parse_ignore_start_rules(self, line: str) -> set[str]:
387
+ """Extract rule names from ignore-start directive."""
388
+ match = re.search(r"ignore-start\s+([^\s#]+(?:\s+[^\s#]+)*)", line)
389
+ if match:
390
+ rules_text = match.group(1).strip()
391
+ rules = [r.strip() for r in re.split(r"[,\s]+", rules_text) if r.strip()]
392
+ return set(rules)
393
+ return {"*"}
394
+
395
+ def _rules_match_violation(self, ignored_rules: set[str], rule_id: str) -> bool:
396
+ """Check if any of the ignored rules match the violation rule ID."""
397
+ if "*" in ignored_rules:
398
+ return True
399
+ return any(self._rule_matches(rule_id, pattern) for pattern in ignored_rules)
400
+
401
+
402
+ # Alias for backwards compatibility
403
+ IgnoreParser = IgnoreDirectiveParser
@@ -0,0 +1,77 @@
1
+ """
2
+ Purpose: Multi-format configuration loader for linter settings and rule configuration
3
+
4
+ Scope: Linter configuration management supporting YAML and JSON formats
5
+
6
+ Overview: Loads linter configuration from .thailint.yaml or .thailint.json files with graceful
7
+ fallback to sensible defaults when config files don't exist or cannot be read. Supports both
8
+ YAML and JSON formats to accommodate different user preferences and tooling requirements,
9
+ using format detection based on file extension. Provides unified configuration structure for
10
+ rule settings, ignore patterns, and linter behavior across all deployment modes (CLI, library,
11
+ Docker). Returns empty defaults (empty rules dict, empty ignore list) when config files are
12
+ missing, allowing the linter to function without configuration. Validates file formats and
13
+ raises clear errors with specific exception types for malformed YAML or JSON, helping users
14
+ quickly identify and fix configuration syntax issues.
15
+
16
+ Dependencies: PyYAML for YAML parsing with safe_load(), json (stdlib) for JSON parsing,
17
+ pathlib for file path handling
18
+
19
+ Exports: LinterConfigLoader class
20
+
21
+ Interfaces: load(config_path: Path) -> dict[str, Any] for loading config files,
22
+ get_defaults() -> dict[str, Any] for default configuration structure
23
+
24
+ Implementation: Extension-based format detection (.yaml/.yml vs .json), yaml.safe_load()
25
+ for security, empty dict handling for null YAML, ValueError for unsupported formats
26
+ """
27
+
28
+ import json
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import yaml
33
+
34
+
35
+ class LinterConfigLoader:
36
+ """Load linter configuration from YAML or JSON files.
37
+
38
+ Supports loading from .thailint.yaml, .thailint.json, or custom paths.
39
+ Provides sensible defaults when config files don't exist.
40
+ """
41
+
42
+ def load(self, config_path: Path) -> dict[str, Any]:
43
+ """Load configuration from file.
44
+
45
+ Args:
46
+ config_path: Path to YAML or JSON config file.
47
+
48
+ Returns:
49
+ Configuration dictionary.
50
+
51
+ Raises:
52
+ ValueError: If file format is unsupported.
53
+ yaml.YAMLError: If YAML is malformed.
54
+ json.JSONDecodeError: If JSON is malformed.
55
+ """
56
+ if not config_path.exists():
57
+ return self.get_defaults()
58
+
59
+ suffix = config_path.suffix.lower()
60
+
61
+ with config_path.open(encoding="utf-8") as f:
62
+ if suffix in [".yaml", ".yml"]:
63
+ return yaml.safe_load(f) or {}
64
+ if suffix == ".json":
65
+ return json.load(f)
66
+ raise ValueError(f"Unsupported config format: {suffix}")
67
+
68
+ def get_defaults(self) -> dict[str, Any]:
69
+ """Get default configuration.
70
+
71
+ Returns:
72
+ Default configuration with empty rules and ignore lists.
73
+ """
74
+ return {
75
+ "rules": {},
76
+ "ignore": [],
77
+ }
@@ -0,0 +1,4 @@
1
+ """
2
+ Purpose: Linters package initialization
3
+ Scope: Export linter modules
4
+ """
@@ -0,0 +1,31 @@
1
+ """
2
+ Purpose: File placement linter module
3
+ Scope: File organization and placement validation
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .linter import FilePlacementLinter, FilePlacementRule
10
+
11
+ __all__ = ["FilePlacementLinter", "FilePlacementRule", "lint"]
12
+
13
+
14
+ def lint(path: Path | str, config: dict[str, Any] | None = None) -> list:
15
+ """Lint a file or directory using file placement rules.
16
+
17
+ Args:
18
+ path: Path to file or directory to lint
19
+ config: Configuration dict (compatible with FilePlacementLinter)
20
+
21
+ Returns:
22
+ List of violations
23
+ """
24
+ path_obj = Path(path) if isinstance(path, str) else path
25
+ linter = FilePlacementLinter(config_obj=config or {})
26
+
27
+ if path_obj.is_file():
28
+ return linter.lint_path(path_obj)
29
+ if path_obj.is_dir():
30
+ return linter.lint_directory(path_obj)
31
+ return []