thailint 0.12.0__py3-none-any.whl → 0.13.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 (121) hide show
  1. src/analyzers/__init__.py +4 -3
  2. src/analyzers/ast_utils.py +54 -0
  3. src/analyzers/typescript_base.py +4 -0
  4. src/cli/__init__.py +3 -0
  5. src/cli/config.py +12 -12
  6. src/cli/config_merge.py +241 -0
  7. src/cli/linters/__init__.py +3 -0
  8. src/cli/linters/code_patterns.py +113 -5
  9. src/cli/linters/code_smells.py +4 -0
  10. src/cli/linters/documentation.py +3 -0
  11. src/cli/linters/structure.py +3 -0
  12. src/cli/linters/structure_quality.py +3 -0
  13. src/cli_main.py +3 -0
  14. src/config.py +2 -1
  15. src/core/base.py +3 -2
  16. src/core/cli_utils.py +3 -1
  17. src/core/config_parser.py +5 -2
  18. src/core/constants.py +54 -0
  19. src/core/linter_utils.py +4 -0
  20. src/core/rule_discovery.py +5 -1
  21. src/core/violation_builder.py +3 -0
  22. src/linter_config/directive_markers.py +109 -0
  23. src/linter_config/ignore.py +225 -383
  24. src/linter_config/pattern_utils.py +65 -0
  25. src/linter_config/rule_matcher.py +89 -0
  26. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  27. src/linters/collection_pipeline/ast_utils.py +40 -0
  28. src/linters/collection_pipeline/config.py +12 -0
  29. src/linters/collection_pipeline/continue_analyzer.py +2 -8
  30. src/linters/collection_pipeline/detector.py +262 -32
  31. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  32. src/linters/collection_pipeline/linter.py +18 -35
  33. src/linters/collection_pipeline/suggestion_builder.py +68 -1
  34. src/linters/dry/base_token_analyzer.py +16 -9
  35. src/linters/dry/block_filter.py +7 -4
  36. src/linters/dry/cache.py +7 -2
  37. src/linters/dry/config.py +7 -1
  38. src/linters/dry/constant_matcher.py +34 -25
  39. src/linters/dry/file_analyzer.py +4 -2
  40. src/linters/dry/inline_ignore.py +7 -16
  41. src/linters/dry/linter.py +48 -25
  42. src/linters/dry/python_analyzer.py +18 -10
  43. src/linters/dry/python_constant_extractor.py +51 -52
  44. src/linters/dry/single_statement_detector.py +14 -12
  45. src/linters/dry/token_hasher.py +115 -115
  46. src/linters/dry/typescript_analyzer.py +11 -6
  47. src/linters/dry/typescript_constant_extractor.py +4 -0
  48. src/linters/dry/typescript_statement_detector.py +208 -208
  49. src/linters/dry/typescript_value_extractor.py +3 -0
  50. src/linters/dry/violation_filter.py +1 -4
  51. src/linters/dry/violation_generator.py +1 -4
  52. src/linters/file_header/atemporal_detector.py +4 -0
  53. src/linters/file_header/base_parser.py +4 -0
  54. src/linters/file_header/bash_parser.py +4 -0
  55. src/linters/file_header/field_validator.py +5 -8
  56. src/linters/file_header/linter.py +19 -12
  57. src/linters/file_header/markdown_parser.py +6 -0
  58. src/linters/file_placement/config_loader.py +3 -1
  59. src/linters/file_placement/linter.py +22 -8
  60. src/linters/file_placement/pattern_matcher.py +21 -4
  61. src/linters/file_placement/pattern_validator.py +21 -7
  62. src/linters/file_placement/rule_checker.py +2 -2
  63. src/linters/lazy_ignores/__init__.py +43 -0
  64. src/linters/lazy_ignores/config.py +66 -0
  65. src/linters/lazy_ignores/directive_utils.py +121 -0
  66. src/linters/lazy_ignores/header_parser.py +177 -0
  67. src/linters/lazy_ignores/linter.py +158 -0
  68. src/linters/lazy_ignores/matcher.py +135 -0
  69. src/linters/lazy_ignores/python_analyzer.py +201 -0
  70. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  71. src/linters/lazy_ignores/skip_detector.py +298 -0
  72. src/linters/lazy_ignores/types.py +67 -0
  73. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  74. src/linters/lazy_ignores/violation_builder.py +131 -0
  75. src/linters/lbyl/__init__.py +29 -0
  76. src/linters/lbyl/config.py +63 -0
  77. src/linters/lbyl/pattern_detectors/__init__.py +25 -0
  78. src/linters/lbyl/pattern_detectors/base.py +46 -0
  79. src/linters/magic_numbers/context_analyzer.py +227 -229
  80. src/linters/magic_numbers/linter.py +20 -15
  81. src/linters/magic_numbers/python_analyzer.py +4 -16
  82. src/linters/magic_numbers/typescript_analyzer.py +9 -16
  83. src/linters/method_property/config.py +4 -0
  84. src/linters/method_property/linter.py +5 -4
  85. src/linters/method_property/python_analyzer.py +5 -4
  86. src/linters/method_property/violation_builder.py +3 -0
  87. src/linters/nesting/typescript_analyzer.py +6 -12
  88. src/linters/nesting/typescript_function_extractor.py +0 -4
  89. src/linters/print_statements/linter.py +6 -4
  90. src/linters/print_statements/python_analyzer.py +85 -81
  91. src/linters/print_statements/typescript_analyzer.py +6 -15
  92. src/linters/srp/heuristics.py +4 -4
  93. src/linters/srp/linter.py +12 -12
  94. src/linters/srp/violation_builder.py +0 -4
  95. src/linters/stateless_class/linter.py +30 -36
  96. src/linters/stateless_class/python_analyzer.py +11 -20
  97. src/linters/stringly_typed/config.py +4 -5
  98. src/linters/stringly_typed/context_filter.py +410 -410
  99. src/linters/stringly_typed/function_call_violation_builder.py +93 -95
  100. src/linters/stringly_typed/linter.py +48 -16
  101. src/linters/stringly_typed/python/analyzer.py +5 -1
  102. src/linters/stringly_typed/python/call_tracker.py +8 -5
  103. src/linters/stringly_typed/python/comparison_tracker.py +10 -5
  104. src/linters/stringly_typed/python/condition_extractor.py +3 -0
  105. src/linters/stringly_typed/python/conditional_detector.py +4 -1
  106. src/linters/stringly_typed/python/match_analyzer.py +8 -2
  107. src/linters/stringly_typed/python/validation_detector.py +3 -0
  108. src/linters/stringly_typed/storage.py +14 -14
  109. src/linters/stringly_typed/typescript/call_tracker.py +9 -3
  110. src/linters/stringly_typed/typescript/comparison_tracker.py +9 -3
  111. src/linters/stringly_typed/violation_generator.py +288 -259
  112. src/orchestrator/core.py +13 -4
  113. src/templates/thailint_config_template.yaml +166 -0
  114. src/utils/project_root.py +3 -0
  115. thailint-0.13.0.dist-info/METADATA +184 -0
  116. thailint-0.13.0.dist-info/RECORD +189 -0
  117. thailint-0.12.0.dist-info/METADATA +0 -1667
  118. thailint-0.12.0.dist-info/RECORD +0 -164
  119. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/WHEEL +0 -0
  120. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/entry_points.txt +0 -0
  121. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/licenses/LICENSE +0 -0
src/analyzers/__init__.py CHANGED
@@ -9,15 +9,16 @@ Overview: Package containing base analyzer classes for different programming lan
9
9
  (TypeScriptBaseAnalyzer, etc.) that linter-specific analyzers extend. Centralizes
10
10
  language parsing infrastructure to improve maintainability and consistency.
11
11
 
12
- Dependencies: tree-sitter, language-specific tree-sitter bindings
12
+ Dependencies: tree-sitter, language-specific tree-sitter bindings, ast module
13
13
 
14
- Exports: TypeScriptBaseAnalyzer
14
+ Exports: TypeScriptBaseAnalyzer, build_parent_map
15
15
 
16
16
  Interfaces: Base analyzer classes with parse(), walk_tree(), and extract() methods
17
17
 
18
18
  Implementation: Composition-based design for linter analyzers to use base utilities
19
19
  """
20
20
 
21
+ from .ast_utils import build_parent_map
21
22
  from .typescript_base import TypeScriptBaseAnalyzer
22
23
 
23
- __all__ = ["TypeScriptBaseAnalyzer"]
24
+ __all__ = ["TypeScriptBaseAnalyzer", "build_parent_map"]
@@ -0,0 +1,54 @@
1
+ """
2
+ Purpose: Common Python AST utilities for linter analyzers
3
+
4
+ Scope: Shared AST traversal utilities for Python code analysis
5
+
6
+ Overview: Provides common AST utility functions used across multiple Python linters.
7
+ Centralizes shared patterns like parent map building to eliminate code duplication.
8
+ The build_parent_map function creates a dictionary mapping AST nodes to their parents,
9
+ enabling upward tree traversal for context detection.
10
+
11
+ Dependencies: ast module for AST node types
12
+
13
+ Exports: build_parent_map
14
+
15
+ Interfaces: build_parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]
16
+
17
+ Implementation: Recursive AST traversal with parent tracking
18
+ """
19
+
20
+ import ast
21
+
22
+
23
+ def build_parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]:
24
+ """Build a map of AST nodes to their parent nodes.
25
+
26
+ Enables upward tree traversal for context detection (e.g., finding if a node
27
+ is inside a particular block type).
28
+
29
+ Args:
30
+ tree: Root AST node to build map from
31
+
32
+ Returns:
33
+ Dictionary mapping each node to its parent node
34
+ """
35
+ parent_map: dict[ast.AST, ast.AST] = {}
36
+ _build_parent_map_recursive(tree, None, parent_map)
37
+ return parent_map
38
+
39
+
40
+ def _build_parent_map_recursive(
41
+ node: ast.AST, parent: ast.AST | None, parent_map: dict[ast.AST, ast.AST]
42
+ ) -> None:
43
+ """Recursively build parent map.
44
+
45
+ Args:
46
+ node: Current AST node
47
+ parent: Parent of current node
48
+ parent_map: Dictionary to populate
49
+ """
50
+ if parent is not None:
51
+ parent_map[node] = parent
52
+
53
+ for child in ast.iter_child_nodes(node):
54
+ _build_parent_map_recursive(child, node, parent_map)
@@ -18,6 +18,10 @@ Exports: TypeScriptBaseAnalyzer class with parsing and traversal utilities
18
18
  Interfaces: parse_typescript(code), walk_tree(node, node_type), extract_node_text(node)
19
19
 
20
20
  Implementation: Tree-sitter parser singleton, recursive AST traversal, composition pattern
21
+
22
+ Suppressions:
23
+ - type:ignore[assignment]: Tree-sitter TS_PARSER fallback when import fails
24
+ - type:ignore[assignment,misc]: Tree-sitter Node type alias (optional dependency fallback)
21
25
  """
22
26
 
23
27
  from typing import Any
src/cli/__init__.py CHANGED
@@ -16,6 +16,9 @@ Exports: cli (main Click command group with all commands registered)
16
16
  Interfaces: Single import point for CLI access via 'from src.cli import cli'
17
17
 
18
18
  Implementation: Imports submodules to trigger command registration via Click decorators
19
+
20
+ Suppressions:
21
+ - F401: Module re-exports required for public API interface
19
22
  """
20
23
 
21
24
  # Import the CLI group from main module
src/cli/config.py CHANGED
@@ -26,9 +26,11 @@ import sys
26
26
  from pathlib import Path
27
27
 
28
28
  import click
29
+ import yaml
29
30
 
30
31
  from src.config import ConfigError, save_config, validate_config
31
32
 
33
+ from .config_merge import perform_merge
32
34
  from .main import cli
33
35
 
34
36
  # Configure module logger
@@ -98,8 +100,6 @@ def _format_config_json(cfg: dict) -> None:
98
100
 
99
101
  def _format_config_yaml(cfg: dict) -> None:
100
102
  """Format configuration as YAML."""
101
- import yaml
102
-
103
103
  click.echo(yaml.dump(cfg, default_flow_style=False, sort_keys=False))
104
104
 
105
105
 
@@ -285,6 +285,9 @@ def init_config(preset: str, non_interactive: bool, force: bool, output: str) ->
285
285
  Creates a richly-commented configuration file with sensible defaults
286
286
  and optional customizations for different strictness levels.
287
287
 
288
+ If a config file already exists, missing linter sections will be added
289
+ without modifying existing settings. Use --force to completely overwrite.
290
+
288
291
  For AI agents, use --non-interactive mode:
289
292
  thailint init-config --non-interactive --preset lenient
290
293
 
@@ -308,7 +311,7 @@ def init_config(preset: str, non_interactive: bool, force: bool, output: str) ->
308
311
  thailint init-config --preset lenient
309
312
 
310
313
  \\b
311
- # Overwrite existing config
314
+ # Overwrite existing config (replaces entire file)
312
315
  thailint init-config --force
313
316
 
314
317
  \\b
@@ -317,19 +320,16 @@ def init_config(preset: str, non_interactive: bool, force: bool, output: str) ->
317
320
  """
318
321
  output_path = Path(output)
319
322
 
320
- # Check if file exists (unless --force)
321
- if output_path.exists() and not force:
322
- click.echo(f"Error: {output} already exists", err=True)
323
- click.echo("", err=True)
324
- click.echo("Use --force to overwrite:", err=True)
325
- click.echo(" thailint init-config --force", err=True)
326
- sys.exit(1)
327
-
328
323
  # Interactive mode: Ask user for preferences
329
324
  if not non_interactive:
330
325
  preset = _run_interactive_preset_selection(preset)
331
326
 
332
- # Generate config based on preset
327
+ # If file exists and not forcing overwrite, merge missing sections
328
+ if output_path.exists() and not force:
329
+ perform_merge(output_path, preset, output, _generate_config_content)
330
+ return
331
+
332
+ # Generate full config based on preset
333
333
  config_content = _generate_config_content(preset)
334
334
 
335
335
  # Write config file
@@ -0,0 +1,241 @@
1
+ """
2
+ Purpose: Configuration merge utilities for init-config command
3
+
4
+ Scope: Functions for merging missing linter sections into existing config files
5
+
6
+ Overview: Provides utilities for the init-config command to add missing linter sections
7
+ to existing configuration files without overwriting user customizations. Handles
8
+ template parsing, section extraction, missing section identification, and content
9
+ merging while preserving comments and formatting.
10
+
11
+ Dependencies: re for pattern matching, yaml for config parsing, click for output,
12
+ pathlib for file operations
13
+
14
+ Exports: perform_merge, LINTER_SECTIONS
15
+
16
+ Interfaces: perform_merge(output_path, preset, output, generate_config_fn) -> None
17
+
18
+ Implementation: Text-based parsing and merging to preserve YAML comments
19
+ """
20
+
21
+ import re
22
+ import sys
23
+ from collections.abc import Callable
24
+ from pathlib import Path
25
+
26
+ import click
27
+ import yaml
28
+
29
+ # Known linter section names that should be in the template
30
+ LINTER_SECTIONS = [
31
+ "magic-numbers",
32
+ "nesting",
33
+ "srp",
34
+ "dry",
35
+ "file-placement",
36
+ "print-statements",
37
+ "stringly-typed",
38
+ "file-header",
39
+ "method-property",
40
+ "stateless-class",
41
+ "pipeline",
42
+ "lazy-ignores",
43
+ ]
44
+
45
+
46
+ def _is_section_header_line(line: str) -> bool:
47
+ """Check if line is a section header (# ==== style comment)."""
48
+ return line.startswith("# ===")
49
+
50
+
51
+ def _get_linter_section_name(line: str) -> str | None:
52
+ """Extract linter section name from line if it's a known linter section."""
53
+ section_match = re.match(r"^([a-z][a-z0-9-]*):$", line)
54
+ if section_match and section_match.group(1) in LINTER_SECTIONS:
55
+ return section_match.group(1)
56
+ return None
57
+
58
+
59
+ def _is_buffer_line(line: str) -> bool:
60
+ """Check if line should be buffered (comment or empty)."""
61
+ stripped = line.strip()
62
+ return stripped.startswith("#") or stripped == ""
63
+
64
+
65
+ def _save_current_section(
66
+ sections: dict[str, str], current_section: str | None, current_content: list[str]
67
+ ) -> None:
68
+ """Save current section to sections dict if valid."""
69
+ if current_section and current_content:
70
+ sections[current_section] = "\n".join(current_content)
71
+
72
+
73
+ def _handle_section_header(
74
+ line: str, sections: dict[str, str], current_section: str | None, current_content: list[str]
75
+ ) -> tuple[str | None, list[str], list[str]]:
76
+ """Handle a section header line (# === style)."""
77
+ _save_current_section(sections, current_section, current_content)
78
+ return None, [], [line]
79
+
80
+
81
+ def _start_linter_section(
82
+ section_name: str, line: str, header_buffer: list[str]
83
+ ) -> tuple[str | None, list[str], list[str]]:
84
+ """Start a new linter section with the header buffer and section line."""
85
+ return section_name, header_buffer + [line], []
86
+
87
+
88
+ def _handle_content_line(
89
+ line: str, current_section: str | None, current_content: list[str], header_buffer: list[str]
90
+ ) -> tuple[str | None, list[str], list[str]]:
91
+ """Handle a regular content line."""
92
+ if current_section:
93
+ current_content.append(line)
94
+ return current_section, current_content, header_buffer
95
+ if _is_buffer_line(line):
96
+ header_buffer.append(line)
97
+ return current_section, current_content, header_buffer
98
+ return current_section, current_content, []
99
+
100
+
101
+ def _process_template_line(
102
+ line: str,
103
+ sections: dict[str, str],
104
+ current_section: str | None,
105
+ current_content: list[str],
106
+ header_buffer: list[str],
107
+ ) -> tuple[str | None, list[str], list[str]]:
108
+ """Process a single template line and update state."""
109
+ if _is_section_header_line(line):
110
+ return _handle_section_header(line, sections, current_section, current_content)
111
+
112
+ section_name = _get_linter_section_name(line)
113
+ if section_name:
114
+ _save_current_section(sections, current_section, current_content)
115
+ return _start_linter_section(section_name, line, header_buffer)
116
+
117
+ return _handle_content_line(line, current_section, current_content, header_buffer)
118
+
119
+
120
+ def extract_linter_sections(template: str) -> dict[str, str]:
121
+ """Extract each linter section from template as text blocks.
122
+
123
+ Args:
124
+ template: Full template content
125
+
126
+ Returns:
127
+ Dict mapping section name to section content (with header comments)
128
+ """
129
+ sections: dict[str, str] = {}
130
+ lines = template.split("\n")
131
+ current_section: str | None = None
132
+ current_content: list[str] = []
133
+ header_buffer: list[str] = []
134
+
135
+ for line in lines:
136
+ current_section, current_content, header_buffer = _process_template_line(
137
+ line, sections, current_section, current_content, header_buffer
138
+ )
139
+
140
+ # Save last section
141
+ if current_section and current_content:
142
+ sections[current_section] = "\n".join(current_content)
143
+
144
+ return sections
145
+
146
+
147
+ def identify_missing_sections(existing_config: dict, all_sections: list[str]) -> list[str]:
148
+ """Identify which linter sections are missing from existing config.
149
+
150
+ Args:
151
+ existing_config: Parsed existing config dict
152
+ all_sections: List of all linter section names
153
+
154
+ Returns:
155
+ List of section names missing from existing config
156
+ """
157
+ return [s for s in all_sections if s not in existing_config]
158
+
159
+
160
+ def _find_global_settings_position(content: str) -> int:
161
+ """Find position of GLOBAL SETTINGS section in content."""
162
+ marker = "# ============================================================================\n# GLOBAL SETTINGS"
163
+ return content.find(marker)
164
+
165
+
166
+ def _insert_before_global_settings(content: str, sections_text: str, insert_pos: int) -> str:
167
+ """Insert sections before GLOBAL SETTINGS marker."""
168
+ return content[:insert_pos] + sections_text + "\n\n" + content[insert_pos:]
169
+
170
+
171
+ def merge_config_sections(existing_content: str, missing_sections: dict[str, str]) -> str:
172
+ """Merge missing sections into existing config content.
173
+
174
+ Args:
175
+ existing_content: Original config file content
176
+ missing_sections: Dict of section name -> section content to add
177
+
178
+ Returns:
179
+ Merged config content with missing sections appended
180
+ """
181
+ if not missing_sections:
182
+ return existing_content
183
+
184
+ sections_text = "\n".join(missing_sections.values())
185
+ insert_pos = _find_global_settings_position(existing_content)
186
+
187
+ if insert_pos > 0:
188
+ return _insert_before_global_settings(existing_content, sections_text, insert_pos)
189
+ return existing_content.rstrip() + "\n\n" + sections_text + "\n"
190
+
191
+
192
+ def _parse_existing_config(content: str, output: str) -> dict:
193
+ """Parse existing config file content as YAML."""
194
+ try:
195
+ return yaml.safe_load(content) or {}
196
+ except yaml.YAMLError:
197
+ click.echo(f"Error: Could not parse {output} as YAML", err=True)
198
+ click.echo("Use --force to overwrite with a fresh config", err=True)
199
+ sys.exit(1)
200
+
201
+
202
+ def _build_missing_sections_dict(
203
+ missing_names: list[str], template_sections: dict[str, str]
204
+ ) -> dict[str, str]:
205
+ """Build dict of missing section name -> content."""
206
+ return {name: template_sections[name] for name in missing_names if name in template_sections}
207
+
208
+
209
+ def _report_merge_results(missing_names: list[str], output: str) -> None:
210
+ """Report which sections were added."""
211
+ click.echo(f"Added {len(missing_names)} missing linter section(s) to {output}:")
212
+ for name in missing_names:
213
+ click.echo(f" - {name}")
214
+
215
+
216
+ def perform_merge(
217
+ output_path: Path, preset: str, output: str, generate_config_fn: Callable[[str], str]
218
+ ) -> None:
219
+ """Merge missing linter sections into existing config.
220
+
221
+ Args:
222
+ output_path: Path to existing config file
223
+ preset: Preset to use for missing sections
224
+ output: Output filename for display
225
+ generate_config_fn: Function to generate config content from preset
226
+ """
227
+ existing_content = output_path.read_text(encoding="utf-8")
228
+ existing_config = _parse_existing_config(existing_content, output)
229
+
230
+ template_sections = extract_linter_sections(generate_config_fn(preset))
231
+ missing_names = identify_missing_sections(existing_config, list(template_sections.keys()))
232
+
233
+ if not missing_names:
234
+ click.echo(f"{output} already contains all linter sections")
235
+ return
236
+
237
+ missing_sections = _build_missing_sections_dict(missing_names, template_sections)
238
+ merged_content = merge_config_sections(existing_content, missing_sections)
239
+ output_path.write_text(merged_content, encoding="utf-8")
240
+
241
+ _report_merge_results(missing_names, output)
@@ -16,6 +16,9 @@ Exports: All linter command functions for reference and testing
16
16
  Interfaces: Click command decorators, integration with main CLI group
17
17
 
18
18
  Implementation: Module imports trigger command registration via Click decorator side effects
19
+
20
+ Suppressions:
21
+ - F401: Module imports trigger Click command registration via decorator side effects
19
22
  """
20
23
 
21
24
  # Import all linter command modules to register them with the CLI
@@ -1,17 +1,17 @@
1
1
  """
2
- Purpose: CLI commands for code pattern linters (print-statements, method-property, stateless-class)
2
+ Purpose: CLI commands for code pattern linters (print-statements, method-property, stateless-class, lazy-ignores)
3
3
 
4
4
  Scope: Commands that detect code patterns and anti-patterns in Python code
5
5
 
6
6
  Overview: Provides CLI commands for code pattern linting: print-statements detects print() and
7
7
  console.log calls that should use proper logging, method-property finds methods that should be
8
- @property decorators, and stateless-class detects classes without state that should be module
9
- functions. Each command supports standard options (config, format, recursive) and integrates
10
- with the orchestrator for execution.
8
+ @property decorators, stateless-class detects classes without state that should be module
9
+ functions, and lazy-ignores detects unjustified linting suppressions. Each command supports
10
+ standard options (config, format, recursive) and integrates with the orchestrator for execution.
11
11
 
12
12
  Dependencies: click for CLI framework, src.cli.main for CLI group, src.cli.utils for shared utilities
13
13
 
14
- Exports: print_statements command, method_property command, stateless_class command
14
+ Exports: print_statements command, method_property command, stateless_class command, lazy_ignores command
15
15
 
16
16
  Interfaces: Click CLI commands registered to main CLI group
17
17
 
@@ -19,6 +19,9 @@ Implementation: Click decorators for command definition, orchestrator-based lint
19
19
 
20
20
  SRP Exception: CLI command modules follow Click framework patterns requiring similar command
21
21
  structure across all linter commands. This is intentional design for consistency.
22
+
23
+ Suppressions:
24
+ - too-many-arguments,too-many-positional-arguments: Click commands require many parameters by framework design
22
25
  """
23
26
  # dry: ignore-block - CLI commands follow Click framework pattern with intentional repetition
24
27
 
@@ -370,3 +373,108 @@ def _execute_stateless_class_lint( # pylint: disable=too-many-arguments,too-man
370
373
 
371
374
  format_violations(stateless_class_violations, format)
372
375
  sys.exit(1 if stateless_class_violations else 0)
376
+
377
+
378
+ # =============================================================================
379
+ # Lazy Ignores Command
380
+ # =============================================================================
381
+
382
+
383
+ def _setup_lazy_ignores_orchestrator(
384
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
385
+ ) -> "Orchestrator":
386
+ """Set up orchestrator for lazy-ignores command."""
387
+ return setup_base_orchestrator(path_objs, config_file, verbose, project_root)
388
+
389
+
390
+ def _run_lazy_ignores_lint(
391
+ orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool
392
+ ) -> list[Violation]:
393
+ """Execute lazy-ignores lint on files or directories."""
394
+ all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive)
395
+ return [v for v in all_violations if v.rule_id.startswith("lazy-ignores")]
396
+
397
+
398
+ @cli.command("lazy-ignores")
399
+ @click.argument("paths", nargs=-1, type=click.Path())
400
+ @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
401
+ @format_option
402
+ @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
403
+ @click.pass_context
404
+ def lazy_ignores(
405
+ ctx: click.Context,
406
+ paths: tuple[str, ...],
407
+ config_file: str | None,
408
+ format: str,
409
+ recursive: bool,
410
+ ) -> None:
411
+ """Check for unjustified linting suppressions.
412
+
413
+ Detects ignore directives (noqa, type:ignore, pylint:disable, nosec) that lack
414
+ corresponding entries in the file header's Suppressions section. Enforces a
415
+ header-based suppression model requiring human approval for all linting bypasses.
416
+
417
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
418
+
419
+ Examples:
420
+
421
+ \b
422
+ # Check current directory (all files recursively)
423
+ thai-lint lazy-ignores
424
+
425
+ \b
426
+ # Check specific directory
427
+ thai-lint lazy-ignores src/
428
+
429
+ \b
430
+ # Check single file
431
+ thai-lint lazy-ignores src/routes.py
432
+
433
+ \b
434
+ # Check multiple files
435
+ thai-lint lazy-ignores src/routes.py src/utils.py
436
+
437
+ \b
438
+ # Get JSON output
439
+ thai-lint lazy-ignores --format json .
440
+
441
+ \b
442
+ # Get SARIF output for CI/CD integration
443
+ thai-lint lazy-ignores --format sarif src/
444
+
445
+ \b
446
+ # Use custom config file
447
+ thai-lint lazy-ignores --config .thailint.yaml src/
448
+ """
449
+ verbose: bool = ctx.obj.get("verbose", False)
450
+ project_root = get_project_root_from_context(ctx)
451
+
452
+ if not paths:
453
+ paths = (".",)
454
+
455
+ path_objs = [Path(p) for p in paths]
456
+
457
+ try:
458
+ _execute_lazy_ignores_lint(path_objs, config_file, format, recursive, verbose, project_root)
459
+ except Exception as e:
460
+ handle_linting_error(e, verbose)
461
+
462
+
463
+ def _execute_lazy_ignores_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
464
+ path_objs: list[Path],
465
+ config_file: str | None,
466
+ format: str,
467
+ recursive: bool,
468
+ verbose: bool,
469
+ project_root: Path | None = None,
470
+ ) -> NoReturn:
471
+ """Execute lazy-ignores lint."""
472
+ validate_paths_exist(path_objs)
473
+ orchestrator = _setup_lazy_ignores_orchestrator(path_objs, config_file, verbose, project_root)
474
+ lazy_ignores_violations = _run_lazy_ignores_lint(orchestrator, path_objs, recursive)
475
+
476
+ if verbose:
477
+ logger.info(f"Found {len(lazy_ignores_violations)} lazy-ignores violation(s)")
478
+
479
+ format_violations(lazy_ignores_violations, format)
480
+ sys.exit(1 if lazy_ignores_violations else 0)
@@ -20,6 +20,10 @@ Implementation: Click decorators for command definition, orchestrator-based lint
20
20
 
21
21
  SRP Exception: CLI command modules follow Click framework patterns requiring similar command
22
22
  structure across all linter commands. This is intentional design for consistency.
23
+
24
+ Suppressions:
25
+ too-many-arguments: Click commands require many parameters by framework design
26
+ too-many-positional-arguments: Click positional params match CLI arg structure
23
27
  """
24
28
  # dry: ignore-block - CLI commands follow Click framework pattern with intentional repetition
25
29
 
@@ -19,6 +19,9 @@ Implementation: Click decorators for command definition, orchestrator-based lint
19
19
 
20
20
  SRP Exception: CLI command modules follow Click framework patterns requiring similar command
21
21
  structure across all linter commands. This is intentional design for consistency.
22
+
23
+ Suppressions:
24
+ - too-many-arguments,too-many-positional-arguments: Click commands require many parameters by framework design
22
25
  """
23
26
  # dry: ignore-block - CLI commands follow Click framework pattern with intentional repetition
24
27
 
@@ -19,6 +19,9 @@ Implementation: Click decorators for command definition, orchestrator-based lint
19
19
 
20
20
  SRP Exception: CLI command modules follow Click framework patterns requiring similar command
21
21
  structure across all linter commands. This is intentional design for consistency.
22
+
23
+ Suppressions:
24
+ - too-many-arguments,too-many-positional-arguments: Click commands require many parameters by framework design
22
25
  """
23
26
  # dry: ignore-block - CLI commands follow Click framework pattern with intentional repetition
24
27
 
@@ -19,6 +19,9 @@ Implementation: Click decorators for command definition, orchestrator-based lint
19
19
 
20
20
  SRP Exception: CLI command modules follow Click framework patterns requiring similar command
21
21
  structure across all linter commands. This is intentional design for consistency.
22
+
23
+ Suppressions:
24
+ - too-many-arguments,too-many-positional-arguments: Click commands require many parameters by framework design
22
25
  """
23
26
  # dry: ignore-block - CLI commands follow Click framework pattern with intentional repetition
24
27
 
src/cli_main.py CHANGED
@@ -17,6 +17,9 @@ Exports: cli (main command group with all commands registered)
17
17
  Interfaces: Click CLI commands, integration with Orchestrator for linting execution
18
18
 
19
19
  Implementation: Module imports trigger command registration via Click decorator side effects
20
+
21
+ Suppressions:
22
+ - F401: Module re-exports and imports trigger Click command registration via decorator side effects
20
23
  """
21
24
 
22
25
  # Import the main CLI group from the modular package
src/config.py CHANGED
@@ -26,6 +26,7 @@ from typing import Any
26
26
  import yaml
27
27
 
28
28
  from src.core.config_parser import ConfigParseError, parse_config_file
29
+ from src.core.constants import CONFIG_EXTENSIONS
29
30
 
30
31
  logger = logging.getLogger(__name__)
31
32
 
@@ -170,7 +171,7 @@ def _validate_before_save(config: dict[str, Any]) -> None:
170
171
 
171
172
  def _write_config_file(config: dict[str, Any], path: Path) -> None:
172
173
  """Write config to file based on extension."""
173
- if path.suffix in [".yaml", ".yml"]:
174
+ if path.suffix in CONFIG_EXTENSIONS:
174
175
  _write_yaml_config(config, path)
175
176
  elif path.suffix == ".json":
176
177
  _write_json_config(config, path)
src/core/base.py CHANGED
@@ -31,6 +31,7 @@ from abc import ABC, abstractmethod
31
31
  from pathlib import Path
32
32
  from typing import Any
33
33
 
34
+ from .constants import Language
34
35
  from .types import Violation
35
36
 
36
37
 
@@ -176,10 +177,10 @@ class MultiLanguageLintRule(BaseLintRule):
176
177
  if not config.enabled:
177
178
  return []
178
179
 
179
- if context.language == "python":
180
+ if context.language == Language.PYTHON:
180
181
  return self._check_python(context, config)
181
182
 
182
- if context.language in ("typescript", "javascript"):
183
+ if context.language in (Language.TYPESCRIPT, Language.JAVASCRIPT):
183
184
  return self._check_typescript(context, config)
184
185
 
185
186
  return []
src/core/cli_utils.py CHANGED
@@ -26,6 +26,8 @@ from typing import Any
26
26
 
27
27
  import click
28
28
 
29
+ from src.core.constants import CONFIG_EXTENSIONS
30
+
29
31
 
30
32
  def common_linter_options(func: Callable) -> Callable:
31
33
  """Add common linter CLI options to command.
@@ -103,7 +105,7 @@ def _load_config_by_format(config_file: Path) -> dict[str, Any]:
103
105
  Returns:
104
106
  Loaded configuration dictionary
105
107
  """
106
- if config_file.suffix in {".yaml", ".yml"}:
108
+ if config_file.suffix in CONFIG_EXTENSIONS:
107
109
  return _load_yaml_config(config_file)
108
110
  if config_file.suffix == ".json":
109
111
  return _load_json_config(config_file)