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
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
 
@@ -32,6 +34,10 @@ class ConfigError(Exception):
32
34
  """Configuration-related errors."""
33
35
 
34
36
 
37
+ # Default configuration constants
38
+ DEFAULT_MAX_RETRIES = 3
39
+ DEFAULT_TIMEOUT_SECONDS = 30
40
+
35
41
  # Default configuration values
36
42
  DEFAULT_CONFIG: dict[str, Any] = {
37
43
  "app_name": "{{PROJECT_NAME}}",
@@ -39,8 +45,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
39
45
  "log_level": "INFO",
40
46
  "output_format": "text",
41
47
  "greeting": "Hello",
42
- "max_retries": 3,
43
- "timeout": 30,
48
+ "max_retries": DEFAULT_MAX_RETRIES,
49
+ "timeout": DEFAULT_TIMEOUT_SECONDS,
44
50
  }
45
51
 
46
52
  # Configuration file search paths (in priority order)
@@ -103,7 +109,7 @@ def _load_from_default_locations() -> dict[str, Any]:
103
109
  loaded_config = _try_load_from_location(location)
104
110
  if loaded_config:
105
111
  return loaded_config
106
- logger.info("No config file found, using defaults")
112
+ logger.debug("No CLI config file found, using defaults")
107
113
  return DEFAULT_CONFIG.copy()
108
114
 
109
115
 
@@ -134,23 +140,6 @@ def load_config(config_path: Path | None = None) -> dict[str, Any]:
134
140
  return _load_from_default_locations()
135
141
 
136
142
 
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
143
  def _load_config_file(path: Path) -> dict[str, Any]:
155
144
  """
156
145
  Load config from YAML or JSON file based on extension.
@@ -165,23 +154,13 @@ def _load_config_file(path: Path) -> dict[str, Any]:
165
154
  ConfigError: If file cannot be parsed.
166
155
  """
167
156
  try:
168
- return _parse_config_by_extension(path)
169
- except ConfigError:
170
- raise
157
+ return parse_config_file(path)
158
+ except ConfigParseError as e:
159
+ raise ConfigError(str(e)) from e
171
160
  except Exception as e:
172
161
  raise ConfigError(f"Failed to load config from {path}: {e}") from e
173
162
 
174
163
 
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
164
  def _validate_before_save(config: dict[str, Any]) -> None:
186
165
  """Validate config before saving."""
187
166
  is_valid, errors = validate_config(config)
src/core/base.py CHANGED
@@ -8,14 +8,17 @@ Overview: Establishes the contract that all linting plugins must follow through
8
8
  Defines BaseLintRule which all concrete linting rules inherit from, specifying required
9
9
  properties (rule_id, rule_name, description) and the check() method for violation detection.
10
10
  Provides BaseLintContext as the interface for accessing file information during analysis,
11
- exposing file_path, file_content, and language properties. These abstractions enable the
12
- rule registry to discover and instantiate rules dynamically without tight coupling, supporting
13
- the extensible plugin system where new rules can be added by simply placing them in the
14
- appropriate directory structure.
11
+ exposing file_path, file_content, and language properties. Includes MultiLanguageLintRule
12
+ intermediate class implementing template method pattern for language dispatch, eliminating
13
+ code duplication across multi-language linters (nesting, srp, magic_numbers). These
14
+ abstractions enable the rule registry to discover and instantiate rules dynamically without
15
+ tight coupling, supporting the extensible plugin system where new rules can be added by
16
+ simply placing them in the appropriate directory structure.
15
17
 
16
18
  Dependencies: abc for abstract base class support, pathlib for Path types, Violation from types
17
19
 
18
- Exports: BaseLintRule (abstract rule interface), BaseLintContext (abstract context interface)
20
+ Exports: BaseLintRule (abstract rule interface), BaseLintContext (abstract context interface),
21
+ MultiLanguageLintRule (template method base for multi-language linters)
19
22
 
20
23
  Interfaces: BaseLintRule.check(context) -> list[Violation], BaseLintContext properties
21
24
  (file_path, file_content, language), all abstract methods must be implemented by subclasses
@@ -26,6 +29,7 @@ Implementation: ABC-based interface definitions with @abstractmethod decorators,
26
29
 
27
30
  from abc import ABC, abstractmethod
28
31
  from pathlib import Path
32
+ from typing import Any
29
33
 
30
34
  from .types import Violation
31
35
 
@@ -120,3 +124,96 @@ class BaseLintRule(ABC):
120
124
  List of violations found. Empty list if no violations.
121
125
  """
122
126
  raise NotImplementedError("Subclasses must implement check")
127
+
128
+ def finalize(self) -> list[Violation]:
129
+ """Finalize analysis after all files processed.
130
+
131
+ Optional hook called after all files have been processed via check().
132
+ Useful for rules that need to perform cross-file analysis or aggregate
133
+ results (e.g., DRY linter querying for duplicates across all files).
134
+
135
+ Returns:
136
+ List of violations found during finalization. Empty list by default.
137
+ """
138
+ return []
139
+
140
+
141
+ class MultiLanguageLintRule(BaseLintRule):
142
+ """Base class for linting rules that support multiple programming languages.
143
+
144
+ Provides language dispatch pattern to eliminate code duplication across multi-language
145
+ linters. Subclasses implement language-specific checking methods rather than handling
146
+ dispatch logic themselves.
147
+
148
+ Subclasses must implement:
149
+ - _check_python(context, config) for Python language support
150
+ - _check_typescript(context, config) for TypeScript/JavaScript support
151
+ - _load_config(context) for configuration loading
152
+ """
153
+
154
+ def check(self, context: BaseLintContext) -> list[Violation]:
155
+ """Check for violations with automatic language dispatch.
156
+
157
+ Dispatches to language-specific checking methods based on context.language.
158
+ Handles common patterns like file content validation and config loading.
159
+
160
+ Args:
161
+ context: Lint context with file information
162
+
163
+ Returns:
164
+ List of violations found
165
+ """
166
+ from .linter_utils import has_file_content
167
+
168
+ if not has_file_content(context):
169
+ return []
170
+
171
+ config = self._load_config(context)
172
+ if not config.enabled:
173
+ return []
174
+
175
+ if context.language == "python":
176
+ return self._check_python(context, config)
177
+
178
+ if context.language in ("typescript", "javascript"):
179
+ return self._check_typescript(context, config)
180
+
181
+ return []
182
+
183
+ @abstractmethod
184
+ def _load_config(self, context: BaseLintContext) -> Any:
185
+ """Load configuration from context.
186
+
187
+ Args:
188
+ context: Lint context
189
+
190
+ Returns:
191
+ Configuration object with at minimum an 'enabled' attribute
192
+ """
193
+ raise NotImplementedError("Subclasses must implement _load_config")
194
+
195
+ @abstractmethod
196
+ def _check_python(self, context: BaseLintContext, config: Any) -> list[Violation]:
197
+ """Check Python code for violations.
198
+
199
+ Args:
200
+ context: Lint context with Python file information
201
+ config: Loaded configuration
202
+
203
+ Returns:
204
+ List of violations found in Python code
205
+ """
206
+ raise NotImplementedError("Subclasses must implement _check_python")
207
+
208
+ @abstractmethod
209
+ def _check_typescript(self, context: BaseLintContext, config: Any) -> list[Violation]:
210
+ """Check TypeScript/JavaScript code for violations.
211
+
212
+ Args:
213
+ context: Lint context with TypeScript/JavaScript file information
214
+ config: Loaded configuration
215
+
216
+ Returns:
217
+ List of violations found in TypeScript/JavaScript code
218
+ """
219
+ raise NotImplementedError("Subclasses must implement _check_typescript")
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,126 @@
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 _normalize_config_keys(config: dict[str, Any]) -> dict[str, Any]:
76
+ """Normalize configuration keys from hyphens to underscores.
77
+
78
+ Converts top-level keys like "magic-numbers" to "magic_numbers" to match
79
+ internal linter expectations while maintaining backward compatibility with
80
+ both formats in config files.
81
+
82
+ Args:
83
+ config: Configuration dictionary with potentially hyphenated keys
84
+
85
+ Returns:
86
+ Configuration dictionary with normalized (underscored) keys
87
+ """
88
+ normalized = {}
89
+ for key, value in config.items():
90
+ # Replace hyphens with underscores in keys
91
+ normalized_key = key.replace("-", "_")
92
+ normalized[normalized_key] = value
93
+ return normalized
94
+
95
+
96
+ def parse_config_file(path: Path, encoding: str = "utf-8") -> dict[str, Any]:
97
+ """Parse configuration file based on extension.
98
+
99
+ Supports .yaml, .yml, and .json formats. Automatically detects format
100
+ from file extension and uses appropriate parser. Normalizes hyphenated
101
+ keys (e.g., "magic-numbers") to underscored keys (e.g., "magic_numbers")
102
+ for internal consistency.
103
+
104
+ Args:
105
+ path: Path to configuration file.
106
+ encoding: File encoding (default: utf-8).
107
+
108
+ Returns:
109
+ Parsed configuration dictionary with normalized keys.
110
+
111
+ Raises:
112
+ ConfigParseError: If file format is unsupported or parsing fails.
113
+ """
114
+ suffix = path.suffix.lower()
115
+
116
+ if suffix not in [".yaml", ".yml", ".json"]:
117
+ raise ConfigParseError(f"Unsupported config format: {suffix}")
118
+
119
+ with path.open(encoding=encoding) as f:
120
+ if suffix in [".yaml", ".yml"]:
121
+ config = parse_yaml(f, path)
122
+ else:
123
+ config = parse_json(f, path)
124
+
125
+ # Normalize keys from hyphens to underscores
126
+ return _normalize_config_keys(config)