thailint 0.1.6__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. src/__init__.py +7 -2
  2. src/analyzers/__init__.py +23 -0
  3. src/analyzers/typescript_base.py +148 -0
  4. src/api.py +1 -1
  5. src/cli.py +524 -141
  6. src/config.py +6 -31
  7. src/core/base.py +12 -0
  8. src/core/cli_utils.py +206 -0
  9. src/core/config_parser.py +99 -0
  10. src/core/linter_utils.py +168 -0
  11. src/core/registry.py +17 -92
  12. src/core/rule_discovery.py +132 -0
  13. src/core/violation_builder.py +122 -0
  14. src/linter_config/ignore.py +112 -40
  15. src/linter_config/loader.py +3 -13
  16. src/linters/dry/__init__.py +23 -0
  17. src/linters/dry/base_token_analyzer.py +76 -0
  18. src/linters/dry/block_filter.py +262 -0
  19. src/linters/dry/block_grouper.py +59 -0
  20. src/linters/dry/cache.py +218 -0
  21. src/linters/dry/cache_query.py +61 -0
  22. src/linters/dry/config.py +130 -0
  23. src/linters/dry/config_loader.py +44 -0
  24. src/linters/dry/deduplicator.py +120 -0
  25. src/linters/dry/duplicate_storage.py +126 -0
  26. src/linters/dry/file_analyzer.py +127 -0
  27. src/linters/dry/inline_ignore.py +140 -0
  28. src/linters/dry/linter.py +170 -0
  29. src/linters/dry/python_analyzer.py +517 -0
  30. src/linters/dry/storage_initializer.py +51 -0
  31. src/linters/dry/token_hasher.py +115 -0
  32. src/linters/dry/typescript_analyzer.py +590 -0
  33. src/linters/dry/violation_builder.py +74 -0
  34. src/linters/dry/violation_filter.py +91 -0
  35. src/linters/dry/violation_generator.py +174 -0
  36. src/linters/file_placement/config_loader.py +86 -0
  37. src/linters/file_placement/directory_matcher.py +80 -0
  38. src/linters/file_placement/linter.py +252 -472
  39. src/linters/file_placement/path_resolver.py +61 -0
  40. src/linters/file_placement/pattern_matcher.py +55 -0
  41. src/linters/file_placement/pattern_validator.py +106 -0
  42. src/linters/file_placement/rule_checker.py +229 -0
  43. src/linters/file_placement/violation_factory.py +177 -0
  44. src/linters/nesting/config.py +13 -3
  45. src/linters/nesting/linter.py +76 -152
  46. src/linters/nesting/typescript_analyzer.py +38 -102
  47. src/linters/nesting/typescript_function_extractor.py +130 -0
  48. src/linters/nesting/violation_builder.py +139 -0
  49. src/linters/srp/__init__.py +99 -0
  50. src/linters/srp/class_analyzer.py +113 -0
  51. src/linters/srp/config.py +76 -0
  52. src/linters/srp/heuristics.py +89 -0
  53. src/linters/srp/linter.py +225 -0
  54. src/linters/srp/metrics_evaluator.py +47 -0
  55. src/linters/srp/python_analyzer.py +72 -0
  56. src/linters/srp/typescript_analyzer.py +75 -0
  57. src/linters/srp/typescript_metrics_calculator.py +90 -0
  58. src/linters/srp/violation_builder.py +117 -0
  59. src/orchestrator/core.py +42 -7
  60. src/utils/__init__.py +4 -0
  61. src/utils/project_root.py +84 -0
  62. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/METADATA +414 -63
  63. thailint-0.2.1.dist-info/RECORD +75 -0
  64. src/.ai/layout.yaml +0 -48
  65. thailint-0.1.6.dist-info/RECORD +0 -28
  66. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/LICENSE +0 -0
  67. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/WHEEL +0 -0
  68. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/entry_points.txt +0 -0
src/config.py CHANGED
@@ -25,6 +25,8 @@ from typing import Any
25
25
 
26
26
  import yaml
27
27
 
28
+ from src.core.config_parser import ConfigParseError, parse_config_file
29
+
28
30
  logger = logging.getLogger(__name__)
29
31
 
30
32
 
@@ -103,7 +105,7 @@ def _load_from_default_locations() -> dict[str, Any]:
103
105
  loaded_config = _try_load_from_location(location)
104
106
  if loaded_config:
105
107
  return loaded_config
106
- logger.info("No config file found, using defaults")
108
+ logger.debug("No CLI config file found, using defaults")
107
109
  return DEFAULT_CONFIG.copy()
108
110
 
109
111
 
@@ -134,23 +136,6 @@ def load_config(config_path: Path | None = None) -> dict[str, Any]:
134
136
  return _load_from_default_locations()
135
137
 
136
138
 
137
- def _parse_yaml_file(f, path: Path) -> dict[str, Any]:
138
- """Parse YAML file and return data."""
139
- try:
140
- data = yaml.safe_load(f)
141
- return data if data is not None else {}
142
- except yaml.YAMLError as e:
143
- raise ConfigError(f"Invalid YAML in {path}: {e}") from e
144
-
145
-
146
- def _parse_json_file(f, path: Path) -> dict[str, Any]:
147
- """Parse JSON file and return data."""
148
- try:
149
- return json.load(f)
150
- except json.JSONDecodeError as e:
151
- raise ConfigError(f"Invalid JSON in {path}: {e}") from e
152
-
153
-
154
139
  def _load_config_file(path: Path) -> dict[str, Any]:
155
140
  """
156
141
  Load config from YAML or JSON file based on extension.
@@ -165,23 +150,13 @@ def _load_config_file(path: Path) -> dict[str, Any]:
165
150
  ConfigError: If file cannot be parsed.
166
151
  """
167
152
  try:
168
- return _parse_config_by_extension(path)
169
- except ConfigError:
170
- raise
153
+ return parse_config_file(path)
154
+ except ConfigParseError as e:
155
+ raise ConfigError(str(e)) from e
171
156
  except Exception as e:
172
157
  raise ConfigError(f"Failed to load config from {path}: {e}") from e
173
158
 
174
159
 
175
- def _parse_config_by_extension(path: Path) -> dict[str, Any]:
176
- """Parse config file based on its extension."""
177
- with path.open() as f:
178
- if path.suffix in [".yaml", ".yml"]:
179
- return _parse_yaml_file(f, path)
180
- if path.suffix == ".json":
181
- return _parse_json_file(f, path)
182
- raise ConfigError(f"Unsupported config format: {path.suffix}")
183
-
184
-
185
160
  def _validate_before_save(config: dict[str, Any]) -> None:
186
161
  """Validate config before saving."""
187
162
  is_valid, errors = validate_config(config)
src/core/base.py CHANGED
@@ -120,3 +120,15 @@ class BaseLintRule(ABC):
120
120
  List of violations found. Empty list if no violations.
121
121
  """
122
122
  raise NotImplementedError("Subclasses must implement check")
123
+
124
+ def finalize(self) -> list[Violation]:
125
+ """Finalize analysis after all files processed.
126
+
127
+ Optional hook called after all files have been processed via check().
128
+ Useful for rules that need to perform cross-file analysis or aggregate
129
+ results (e.g., DRY linter querying for duplicates across all files).
130
+
131
+ Returns:
132
+ List of violations found during finalization. Empty list by default.
133
+ """
134
+ return []
src/core/cli_utils.py ADDED
@@ -0,0 +1,206 @@
1
+ """
2
+ Purpose: Shared CLI utilities for common Click command patterns across all linters
3
+
4
+ Scope: CLI command decorators, config loading, and violation output formatting
5
+
6
+ Overview: Provides reusable utilities for CLI commands to eliminate duplication across linter
7
+ commands (dry, srp, nesting, file-placement). Includes common option decorators for consistent
8
+ CLI interfaces, configuration file loading helpers, and violation output formatting for both
9
+ text and JSON formats. Standardizes CLI patterns across all linter commands for maintainability
10
+ and consistency.
11
+
12
+ Dependencies: click for CLI framework, pathlib for file paths, json for JSON output
13
+
14
+ Exports: common_linter_options decorator, load_linter_config, format_violations
15
+
16
+ Interfaces: Click decorators, config dict, violation list formatting
17
+
18
+ Implementation: Decorator composition for Click options, shared formatting logic for CLI output
19
+ """
20
+
21
+ import json
22
+ import sys
23
+ from collections.abc import Callable
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import click
28
+
29
+
30
+ def common_linter_options(func: Callable) -> Callable:
31
+ """Add common linter CLI options to command.
32
+
33
+ Decorator that adds standard options used by all linter commands:
34
+ - path argument (defaults to current directory)
35
+ - --config/-c option for custom config file
36
+ - --format/-f option for output format (text or json)
37
+ - --recursive/--no-recursive option for directory traversal
38
+
39
+ Args:
40
+ func: Click command function to decorate
41
+
42
+ Returns:
43
+ Decorated function with common CLI options added
44
+ """
45
+ func = click.argument("path", type=click.Path(exists=True), default=".")(func)
46
+ func = click.option(
47
+ "--config", "-c", "config_file", type=click.Path(), help="Path to config file"
48
+ )(func)
49
+ func = click.option(
50
+ "--format",
51
+ "-f",
52
+ type=click.Choice(["text", "json"]),
53
+ default="text",
54
+ help="Output format",
55
+ )(func)
56
+ func = click.option(
57
+ "--recursive/--no-recursive", default=True, help="Scan directories recursively"
58
+ )(func)
59
+ return func
60
+
61
+
62
+ def load_linter_config(config_path: str | None) -> dict[str, Any]:
63
+ """Load linter configuration from file or return empty dict.
64
+
65
+ Args:
66
+ config_path: Path to config file (optional)
67
+
68
+ Returns:
69
+ Configuration dictionary, empty dict if no config provided
70
+
71
+ Raises:
72
+ SystemExit: If config file path provided but file not found
73
+ """
74
+ if not config_path:
75
+ return {}
76
+
77
+ config_file = Path(config_path)
78
+ _validate_config_file_exists(config_file, config_path)
79
+ return _load_config_by_format(config_file)
80
+
81
+
82
+ def _validate_config_file_exists(config_file: Path, config_path: str) -> None:
83
+ """Validate config file exists, exit if not found.
84
+
85
+ Args:
86
+ config_file: Path object for config file
87
+ config_path: Original config path string for error message
88
+
89
+ Raises:
90
+ SystemExit: If config file not found
91
+ """
92
+ if not config_file.exists():
93
+ click.echo(f"Error: Config file not found: {config_path}", err=True)
94
+ sys.exit(2)
95
+
96
+
97
+ def _load_config_by_format(config_file: Path) -> dict[str, Any]:
98
+ """Load config based on file extension.
99
+
100
+ Args:
101
+ config_file: Path to config file
102
+
103
+ Returns:
104
+ Loaded configuration dictionary
105
+ """
106
+ if config_file.suffix in {".yaml", ".yml"}:
107
+ return _load_yaml_config(config_file)
108
+ if config_file.suffix == ".json":
109
+ return _load_json_config(config_file)
110
+ # Fallback: attempt YAML
111
+ return _load_yaml_config(config_file)
112
+
113
+
114
+ def _load_yaml_config(config_file: Path) -> dict[str, Any]:
115
+ """Load YAML config file.
116
+
117
+ Args:
118
+ config_file: Path to YAML file
119
+
120
+ Returns:
121
+ Loaded configuration, empty dict if null
122
+ """
123
+ import yaml
124
+
125
+ with config_file.open("r", encoding="utf-8") as f:
126
+ result = yaml.safe_load(f)
127
+ return dict(result) if result is not None else {}
128
+
129
+
130
+ def _load_json_config(config_file: Path) -> dict[str, Any]:
131
+ """Load JSON config file.
132
+
133
+ Args:
134
+ config_file: Path to JSON file
135
+
136
+ Returns:
137
+ Loaded configuration
138
+ """
139
+ with config_file.open("r", encoding="utf-8") as f:
140
+ result = json.load(f)
141
+ return dict(result) if isinstance(result, dict) else {}
142
+
143
+
144
+ def format_violations(violations: list, output_format: str) -> None:
145
+ """Format and print violations to console.
146
+
147
+ Args:
148
+ violations: List of violation objects with rule_id, file_path, line, column, message, severity
149
+ output_format: Output format ("text" or "json")
150
+ """
151
+ if output_format == "json":
152
+ _output_json(violations)
153
+ else:
154
+ _output_text(violations)
155
+
156
+
157
+ def _output_json(violations: list) -> None:
158
+ """Output violations in JSON format.
159
+
160
+ Args:
161
+ violations: List of violation objects
162
+ """
163
+ output = {
164
+ "violations": [
165
+ {
166
+ "rule_id": v.rule_id,
167
+ "file_path": str(v.file_path),
168
+ "line": v.line,
169
+ "column": v.column,
170
+ "message": v.message,
171
+ "severity": v.severity.name,
172
+ }
173
+ for v in violations
174
+ ],
175
+ "total": len(violations),
176
+ }
177
+ click.echo(json.dumps(output, indent=2))
178
+
179
+
180
+ def _output_text(violations: list) -> None:
181
+ """Output violations in human-readable text format.
182
+
183
+ Args:
184
+ violations: List of violation objects
185
+ """
186
+ if not violations:
187
+ click.echo("✓ No violations found")
188
+ return
189
+
190
+ click.echo(f"Found {len(violations)} violation(s):\n")
191
+ for v in violations:
192
+ _print_violation(v)
193
+
194
+
195
+ def _print_violation(v: Any) -> None:
196
+ """Print single violation in text format.
197
+
198
+ Args:
199
+ v: Violation object with file_path, line, column, severity, rule_id, message
200
+ """
201
+ location = f"{v.file_path}:{v.line}" if v.line else str(v.file_path)
202
+ if v.column:
203
+ location += f":{v.column}"
204
+ click.echo(f" {location}")
205
+ click.echo(f" [{v.severity.name}] {v.rule_id}: {v.message}")
206
+ click.echo()
@@ -0,0 +1,99 @@
1
+ """
2
+ Purpose: Shared YAML/JSON configuration file parsing utilities
3
+
4
+ Scope: Common parsing logic for configuration files across the project
5
+
6
+ Overview: Provides reusable utilities for parsing YAML and JSON configuration files with
7
+ consistent error handling and format detection. Eliminates duplication between src/config.py
8
+ and src/linter_config/loader.py by centralizing the parsing logic in one place. Supports
9
+ extension-based format detection (.yaml, .yml, .json), safe YAML loading with yaml.safe_load(),
10
+ and proper null handling. Returns empty dictionaries for null YAML content to ensure consistent
11
+ behavior across all config loaders.
12
+
13
+ Dependencies: PyYAML for YAML parsing, json (stdlib) for JSON parsing, pathlib for file operations
14
+
15
+ Exports: parse_config_file(), parse_yaml(), parse_json()
16
+
17
+ Interfaces: parse_config_file(path: Path) -> dict[str, Any] for extension-based parsing,
18
+ parse_yaml(file_obj, path: Path) -> dict[str, Any] for YAML parsing,
19
+ parse_json(file_obj, path: Path) -> dict[str, Any] for JSON parsing
20
+
21
+ Implementation: yaml.safe_load() for security, json.load() for JSON, ConfigParseError for errors
22
+ """
23
+
24
+ import json
25
+ from pathlib import Path
26
+ from typing import Any, TextIO
27
+
28
+ import yaml
29
+
30
+
31
+ class ConfigParseError(Exception):
32
+ """Configuration file parsing errors."""
33
+
34
+
35
+ def parse_yaml(file_obj: TextIO, path: Path) -> dict[str, Any]:
36
+ """Parse YAML file content.
37
+
38
+ Args:
39
+ file_obj: Open file object to read from.
40
+ path: Path to file (for error messages).
41
+
42
+ Returns:
43
+ Parsed YAML data as dictionary.
44
+
45
+ Raises:
46
+ ConfigParseError: If YAML is malformed.
47
+ """
48
+ try:
49
+ data = yaml.safe_load(file_obj)
50
+ return data if data is not None else {}
51
+ except yaml.YAMLError as e:
52
+ raise ConfigParseError(f"Invalid YAML in {path}: {e}") from e
53
+
54
+
55
+ def parse_json(file_obj: TextIO, path: Path) -> dict[str, Any]:
56
+ """Parse JSON file content.
57
+
58
+ Args:
59
+ file_obj: Open file object to read from.
60
+ path: Path to file (for error messages).
61
+
62
+ Returns:
63
+ Parsed JSON data as dictionary.
64
+
65
+ Raises:
66
+ ConfigParseError: If JSON is malformed.
67
+ """
68
+ try:
69
+ result: dict[str, Any] = json.load(file_obj)
70
+ return result
71
+ except json.JSONDecodeError as e:
72
+ raise ConfigParseError(f"Invalid JSON in {path}: {e}") from e
73
+
74
+
75
+ def parse_config_file(path: Path, encoding: str = "utf-8") -> dict[str, Any]:
76
+ """Parse configuration file based on extension.
77
+
78
+ Supports .yaml, .yml, and .json formats. Automatically detects format
79
+ from file extension and uses appropriate parser.
80
+
81
+ Args:
82
+ path: Path to configuration file.
83
+ encoding: File encoding (default: utf-8).
84
+
85
+ Returns:
86
+ Parsed configuration dictionary.
87
+
88
+ Raises:
89
+ ConfigParseError: If file format is unsupported or parsing fails.
90
+ """
91
+ suffix = path.suffix.lower()
92
+
93
+ if suffix not in [".yaml", ".yml", ".json"]:
94
+ raise ConfigParseError(f"Unsupported config format: {suffix}")
95
+
96
+ with path.open(encoding=encoding) as f:
97
+ if suffix in [".yaml", ".yml"]:
98
+ return parse_yaml(f, path)
99
+ return parse_json(f, path)
@@ -0,0 +1,168 @@
1
+ """
2
+ Purpose: Shared utility functions for linter framework patterns
3
+
4
+ Scope: Common config loading, metadata access, and context validation utilities for all linters
5
+
6
+ Overview: Provides reusable helper functions to eliminate duplication across linter implementations.
7
+ Includes utilities for loading configuration from context metadata with language-specific overrides,
8
+ extracting metadata fields safely with type validation, and validating context state. Standardizes
9
+ common patterns used by srp, nesting, dry, and file_placement linters. Reduces boilerplate code
10
+ while maintaining type safety and proper error handling.
11
+
12
+ Dependencies: BaseLintContext from src.core.base
13
+
14
+ Exports: get_metadata, get_metadata_value, load_linter_config, has_file_content
15
+
16
+ Interfaces: All functions take BaseLintContext and return typed values (dict, str, bool, Any)
17
+
18
+ Implementation: Type-safe metadata access with fallbacks, generic config loading with language support
19
+ """
20
+
21
+ from typing import Any, Protocol, TypeVar
22
+
23
+ from src.core.base import BaseLintContext
24
+
25
+
26
+ # Protocol for config classes that support from_dict
27
+ class ConfigProtocol(Protocol):
28
+ """Protocol for configuration classes with from_dict class method."""
29
+
30
+ @classmethod
31
+ def from_dict(
32
+ cls, config_dict: dict[str, Any], language: str | None = None
33
+ ) -> "ConfigProtocol":
34
+ """Create config instance from dictionary."""
35
+
36
+
37
+ # Type variable for config classes
38
+ ConfigType = TypeVar("ConfigType", bound=ConfigProtocol) # pylint: disable=invalid-name
39
+
40
+
41
+ def get_metadata(context: BaseLintContext) -> dict[str, Any]:
42
+ """Get metadata dictionary from context with safe fallback.
43
+
44
+ Args:
45
+ context: Lint context containing optional metadata
46
+
47
+ Returns:
48
+ Metadata dictionary, or empty dict if not available
49
+ """
50
+ metadata = getattr(context, "metadata", None)
51
+ if metadata is None or not isinstance(metadata, dict):
52
+ return {}
53
+ return dict(metadata) # Explicit cast to satisfy type checker
54
+
55
+
56
+ def get_metadata_value(context: BaseLintContext, key: str, default: Any = None) -> Any:
57
+ """Get specific value from context metadata with safe fallback.
58
+
59
+ Args:
60
+ context: Lint context containing optional metadata
61
+ key: Metadata key to retrieve
62
+ default: Default value if key not found
63
+
64
+ Returns:
65
+ Metadata value or default
66
+ """
67
+ metadata = get_metadata(context)
68
+ return metadata.get(key, default)
69
+
70
+
71
+ def get_language(context: BaseLintContext) -> str | None:
72
+ """Get language from context.
73
+
74
+ Args:
75
+ context: Lint context containing optional language
76
+
77
+ Returns:
78
+ Language string or None
79
+ """
80
+ return getattr(context, "language", None)
81
+
82
+
83
+ def get_project_root(context: BaseLintContext) -> str | None:
84
+ """Get project root from context metadata.
85
+
86
+ Args:
87
+ context: Lint context containing optional metadata
88
+
89
+ Returns:
90
+ Project root path or None
91
+ """
92
+ metadata = get_metadata(context)
93
+ project_root = metadata.get("project_root")
94
+ return str(project_root) if project_root is not None else None
95
+
96
+
97
+ def load_linter_config(
98
+ context: BaseLintContext,
99
+ config_key: str,
100
+ config_class: type[ConfigType],
101
+ ) -> ConfigType:
102
+ """Load linter configuration from context metadata with language-specific overrides.
103
+
104
+ Args:
105
+ context: Lint context containing metadata
106
+ config_key: Key to look up in metadata (e.g., "srp", "nesting", "dry")
107
+ config_class: Configuration class with from_dict() class method
108
+
109
+ Returns:
110
+ Configuration instance (uses default config if metadata unavailable)
111
+
112
+ Example:
113
+ config = load_linter_config(context, "srp", SRPConfig)
114
+ """
115
+ metadata = get_metadata(context)
116
+ config_dict = metadata.get(config_key, {})
117
+
118
+ if not isinstance(config_dict, dict):
119
+ return config_class()
120
+
121
+ # Get language for language-specific thresholds
122
+ language = get_language(context)
123
+
124
+ # Call from_dict with language if config class supports it
125
+ # This works for SRPConfig, NestingConfig, etc.
126
+ try:
127
+ result = config_class.from_dict(config_dict, language=language)
128
+ return result # type: ignore[return-value]
129
+ except TypeError:
130
+ # Fallback for config classes that don't support language parameter
131
+ result_fallback = config_class.from_dict(config_dict)
132
+ return result_fallback # type: ignore[return-value]
133
+
134
+
135
+ def has_file_content(context: BaseLintContext) -> bool:
136
+ """Check if context has file content available.
137
+
138
+ Args:
139
+ context: Lint context to check
140
+
141
+ Returns:
142
+ True if file_content is not None
143
+ """
144
+ return context.file_content is not None
145
+
146
+
147
+ def has_file_path(context: BaseLintContext) -> bool:
148
+ """Check if context has file path available.
149
+
150
+ Args:
151
+ context: Lint context to check
152
+
153
+ Returns:
154
+ True if file_path is not None
155
+ """
156
+ return context.file_path is not None
157
+
158
+
159
+ def should_process_file(context: BaseLintContext) -> bool:
160
+ """Check if file should be processed (has both content and path).
161
+
162
+ Args:
163
+ context: Lint context to check
164
+
165
+ Returns:
166
+ True if file has both content and path available
167
+ """
168
+ return has_file_content(context) and has_file_path(context)