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.
- src/analyzers/__init__.py +4 -3
- src/analyzers/ast_utils.py +54 -0
- src/analyzers/typescript_base.py +4 -0
- src/cli/__init__.py +3 -0
- src/cli/config.py +12 -12
- src/cli/config_merge.py +241 -0
- src/cli/linters/__init__.py +3 -0
- src/cli/linters/code_patterns.py +113 -5
- src/cli/linters/code_smells.py +4 -0
- src/cli/linters/documentation.py +3 -0
- src/cli/linters/structure.py +3 -0
- src/cli/linters/structure_quality.py +3 -0
- src/cli_main.py +3 -0
- src/config.py +2 -1
- src/core/base.py +3 -2
- src/core/cli_utils.py +3 -1
- src/core/config_parser.py +5 -2
- src/core/constants.py +54 -0
- src/core/linter_utils.py +4 -0
- src/core/rule_discovery.py +5 -1
- src/core/violation_builder.py +3 -0
- src/linter_config/directive_markers.py +109 -0
- src/linter_config/ignore.py +225 -383
- src/linter_config/pattern_utils.py +65 -0
- src/linter_config/rule_matcher.py +89 -0
- src/linters/collection_pipeline/any_all_analyzer.py +281 -0
- src/linters/collection_pipeline/ast_utils.py +40 -0
- src/linters/collection_pipeline/config.py +12 -0
- src/linters/collection_pipeline/continue_analyzer.py +2 -8
- src/linters/collection_pipeline/detector.py +262 -32
- src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
- src/linters/collection_pipeline/linter.py +18 -35
- src/linters/collection_pipeline/suggestion_builder.py +68 -1
- src/linters/dry/base_token_analyzer.py +16 -9
- src/linters/dry/block_filter.py +7 -4
- src/linters/dry/cache.py +7 -2
- src/linters/dry/config.py +7 -1
- src/linters/dry/constant_matcher.py +34 -25
- src/linters/dry/file_analyzer.py +4 -2
- src/linters/dry/inline_ignore.py +7 -16
- src/linters/dry/linter.py +48 -25
- src/linters/dry/python_analyzer.py +18 -10
- src/linters/dry/python_constant_extractor.py +51 -52
- src/linters/dry/single_statement_detector.py +14 -12
- src/linters/dry/token_hasher.py +115 -115
- src/linters/dry/typescript_analyzer.py +11 -6
- src/linters/dry/typescript_constant_extractor.py +4 -0
- src/linters/dry/typescript_statement_detector.py +208 -208
- src/linters/dry/typescript_value_extractor.py +3 -0
- src/linters/dry/violation_filter.py +1 -4
- src/linters/dry/violation_generator.py +1 -4
- src/linters/file_header/atemporal_detector.py +4 -0
- src/linters/file_header/base_parser.py +4 -0
- src/linters/file_header/bash_parser.py +4 -0
- src/linters/file_header/field_validator.py +5 -8
- src/linters/file_header/linter.py +19 -12
- src/linters/file_header/markdown_parser.py +6 -0
- src/linters/file_placement/config_loader.py +3 -1
- src/linters/file_placement/linter.py +22 -8
- src/linters/file_placement/pattern_matcher.py +21 -4
- src/linters/file_placement/pattern_validator.py +21 -7
- src/linters/file_placement/rule_checker.py +2 -2
- src/linters/lazy_ignores/__init__.py +43 -0
- src/linters/lazy_ignores/config.py +66 -0
- src/linters/lazy_ignores/directive_utils.py +121 -0
- src/linters/lazy_ignores/header_parser.py +177 -0
- src/linters/lazy_ignores/linter.py +158 -0
- src/linters/lazy_ignores/matcher.py +135 -0
- src/linters/lazy_ignores/python_analyzer.py +201 -0
- src/linters/lazy_ignores/rule_id_utils.py +180 -0
- src/linters/lazy_ignores/skip_detector.py +298 -0
- src/linters/lazy_ignores/types.py +67 -0
- src/linters/lazy_ignores/typescript_analyzer.py +146 -0
- src/linters/lazy_ignores/violation_builder.py +131 -0
- src/linters/lbyl/__init__.py +29 -0
- src/linters/lbyl/config.py +63 -0
- src/linters/lbyl/pattern_detectors/__init__.py +25 -0
- src/linters/lbyl/pattern_detectors/base.py +46 -0
- src/linters/magic_numbers/context_analyzer.py +227 -229
- src/linters/magic_numbers/linter.py +20 -15
- src/linters/magic_numbers/python_analyzer.py +4 -16
- src/linters/magic_numbers/typescript_analyzer.py +9 -16
- src/linters/method_property/config.py +4 -0
- src/linters/method_property/linter.py +5 -4
- src/linters/method_property/python_analyzer.py +5 -4
- src/linters/method_property/violation_builder.py +3 -0
- src/linters/nesting/typescript_analyzer.py +6 -12
- src/linters/nesting/typescript_function_extractor.py +0 -4
- src/linters/print_statements/linter.py +6 -4
- src/linters/print_statements/python_analyzer.py +85 -81
- src/linters/print_statements/typescript_analyzer.py +6 -15
- src/linters/srp/heuristics.py +4 -4
- src/linters/srp/linter.py +12 -12
- src/linters/srp/violation_builder.py +0 -4
- src/linters/stateless_class/linter.py +30 -36
- src/linters/stateless_class/python_analyzer.py +11 -20
- src/linters/stringly_typed/config.py +4 -5
- src/linters/stringly_typed/context_filter.py +410 -410
- src/linters/stringly_typed/function_call_violation_builder.py +93 -95
- src/linters/stringly_typed/linter.py +48 -16
- src/linters/stringly_typed/python/analyzer.py +5 -1
- src/linters/stringly_typed/python/call_tracker.py +8 -5
- src/linters/stringly_typed/python/comparison_tracker.py +10 -5
- src/linters/stringly_typed/python/condition_extractor.py +3 -0
- src/linters/stringly_typed/python/conditional_detector.py +4 -1
- src/linters/stringly_typed/python/match_analyzer.py +8 -2
- src/linters/stringly_typed/python/validation_detector.py +3 -0
- src/linters/stringly_typed/storage.py +14 -14
- src/linters/stringly_typed/typescript/call_tracker.py +9 -3
- src/linters/stringly_typed/typescript/comparison_tracker.py +9 -3
- src/linters/stringly_typed/violation_generator.py +288 -259
- src/orchestrator/core.py +13 -4
- src/templates/thailint_config_template.yaml +166 -0
- src/utils/project_root.py +3 -0
- thailint-0.13.0.dist-info/METADATA +184 -0
- thailint-0.13.0.dist-info/RECORD +189 -0
- thailint-0.12.0.dist-info/METADATA +0 -1667
- thailint-0.12.0.dist-info/RECORD +0 -164
- {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/WHEEL +0 -0
- {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/entry_points.txt +0 -0
- {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)
|
src/analyzers/typescript_base.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|
src/cli/config_merge.py
ADDED
|
@@ -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)
|
src/cli/linters/__init__.py
CHANGED
|
@@ -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
|
src/cli/linters/code_patterns.py
CHANGED
|
@@ -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,
|
|
9
|
-
functions
|
|
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)
|
src/cli/linters/code_smells.py
CHANGED
|
@@ -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
|
|
src/cli/linters/documentation.py
CHANGED
|
@@ -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/linters/structure.py
CHANGED
|
@@ -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
|
|
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 ==
|
|
180
|
+
if context.language == Language.PYTHON:
|
|
180
181
|
return self._check_python(context, config)
|
|
181
182
|
|
|
182
|
-
if context.language in (
|
|
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
|
|
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)
|