thailint 0.4.2__py3-none-any.whl → 0.4.4__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/cli.py +275 -40
- src/linters/dry/python_analyzer.py +61 -30
- src/orchestrator/core.py +12 -2
- src/utils/project_root.py +110 -12
- {thailint-0.4.2.dist-info → thailint-0.4.4.dist-info}/METADATA +83 -3
- {thailint-0.4.2.dist-info → thailint-0.4.4.dist-info}/RECORD +9 -9
- {thailint-0.4.2.dist-info → thailint-0.4.4.dist-info}/WHEEL +1 -1
- {thailint-0.4.2.dist-info → thailint-0.4.4.dist-info}/entry_points.txt +0 -0
- {thailint-0.4.2.dist-info → thailint-0.4.4.dist-info/licenses}/LICENSE +0 -0
src/cli.py
CHANGED
|
@@ -59,12 +59,145 @@ def setup_logging(verbose: bool = False):
|
|
|
59
59
|
)
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
def _determine_project_root(
|
|
63
|
+
explicit_root: str | None, config_path: str | None, verbose: bool
|
|
64
|
+
) -> Path:
|
|
65
|
+
"""Determine project root with precedence rules.
|
|
66
|
+
|
|
67
|
+
Precedence order:
|
|
68
|
+
1. Explicit --project-root (highest priority)
|
|
69
|
+
2. Inferred from --config path directory
|
|
70
|
+
3. Auto-detection via get_project_root() (fallback)
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
explicit_root: Explicitly specified project root path (from --project-root)
|
|
74
|
+
config_path: Config file path (from --config)
|
|
75
|
+
verbose: Whether verbose logging is enabled
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Path to determined project root
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
SystemExit: If explicit_root doesn't exist or is not a directory
|
|
82
|
+
"""
|
|
83
|
+
from src.utils.project_root import get_project_root
|
|
84
|
+
|
|
85
|
+
# Priority 1: Explicit --project-root
|
|
86
|
+
if explicit_root:
|
|
87
|
+
return _resolve_explicit_project_root(explicit_root, verbose)
|
|
88
|
+
|
|
89
|
+
# Priority 2: Infer from --config path
|
|
90
|
+
if config_path:
|
|
91
|
+
return _infer_root_from_config(config_path, verbose)
|
|
92
|
+
|
|
93
|
+
# Priority 3: Auto-detection (fallback)
|
|
94
|
+
return _autodetect_project_root(verbose, get_project_root)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_explicit_project_root(explicit_root: str, verbose: bool) -> Path:
|
|
98
|
+
"""Resolve and validate explicitly specified project root.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
explicit_root: Explicitly specified project root path
|
|
102
|
+
verbose: Whether verbose logging is enabled
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Resolved project root path
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
SystemExit: If explicit_root doesn't exist or is not a directory
|
|
109
|
+
"""
|
|
110
|
+
root = Path(explicit_root)
|
|
111
|
+
# Check existence before resolving to handle relative paths in test environments
|
|
112
|
+
if not root.exists():
|
|
113
|
+
click.echo(f"Error: Project root does not exist: {explicit_root}", err=True)
|
|
114
|
+
sys.exit(2)
|
|
115
|
+
if not root.is_dir():
|
|
116
|
+
click.echo(f"Error: Project root must be a directory: {explicit_root}", err=True)
|
|
117
|
+
sys.exit(2)
|
|
118
|
+
|
|
119
|
+
# Now resolve after validation
|
|
120
|
+
root = root.resolve()
|
|
121
|
+
|
|
122
|
+
if verbose:
|
|
123
|
+
logger.debug(f"Using explicit project root: {root}")
|
|
124
|
+
return root
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _infer_root_from_config(config_path: str, verbose: bool) -> Path:
|
|
128
|
+
"""Infer project root from config file path.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
config_path: Config file path
|
|
132
|
+
verbose: Whether verbose logging is enabled
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Inferred project root (parent directory of config file)
|
|
136
|
+
"""
|
|
137
|
+
config_file = Path(config_path).resolve()
|
|
138
|
+
inferred_root = config_file.parent
|
|
139
|
+
|
|
140
|
+
if verbose:
|
|
141
|
+
logger.debug(f"Inferred project root from config path: {inferred_root}")
|
|
142
|
+
return inferred_root
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _autodetect_project_root(verbose: bool, get_project_root) -> Path:
|
|
146
|
+
"""Auto-detect project root using project root detection.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
verbose: Whether verbose logging is enabled
|
|
150
|
+
get_project_root: Function to detect project root
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Auto-detected project root
|
|
154
|
+
"""
|
|
155
|
+
auto_root = get_project_root(None)
|
|
156
|
+
if verbose:
|
|
157
|
+
logger.debug(f"Auto-detected project root: {auto_root}")
|
|
158
|
+
return auto_root
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _get_project_root_from_context(ctx) -> Path:
|
|
162
|
+
"""Get or determine project root from Click context.
|
|
163
|
+
|
|
164
|
+
This function defers the actual determination until needed to avoid
|
|
165
|
+
importing pyprojroot in test environments where it may not be available.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
ctx: Click context containing CLI options
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Path to determined project root
|
|
172
|
+
"""
|
|
173
|
+
# Check if already determined and cached
|
|
174
|
+
if "project_root" in ctx.obj:
|
|
175
|
+
return ctx.obj["project_root"]
|
|
176
|
+
|
|
177
|
+
# Determine project root using stored CLI options
|
|
178
|
+
explicit_root = ctx.obj.get("cli_project_root")
|
|
179
|
+
config_path = ctx.obj.get("cli_config_path")
|
|
180
|
+
verbose = ctx.obj.get("verbose", False)
|
|
181
|
+
|
|
182
|
+
project_root = _determine_project_root(explicit_root, config_path, verbose)
|
|
183
|
+
|
|
184
|
+
# Cache for future use
|
|
185
|
+
ctx.obj["project_root"] = project_root
|
|
186
|
+
|
|
187
|
+
return project_root
|
|
188
|
+
|
|
189
|
+
|
|
62
190
|
@click.group()
|
|
63
191
|
@click.version_option(version=__version__)
|
|
64
192
|
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
|
|
65
193
|
@click.option("--config", "-c", type=click.Path(), help="Path to config file")
|
|
194
|
+
@click.option(
|
|
195
|
+
"--project-root",
|
|
196
|
+
type=click.Path(),
|
|
197
|
+
help="Explicitly specify project root directory (overrides auto-detection)",
|
|
198
|
+
)
|
|
66
199
|
@click.pass_context
|
|
67
|
-
def cli(ctx, verbose: bool, config: str | None):
|
|
200
|
+
def cli(ctx, verbose: bool, config: str | None, project_root: str | None):
|
|
68
201
|
"""
|
|
69
202
|
thai-lint - AI code linter and governance tool
|
|
70
203
|
|
|
@@ -85,6 +218,10 @@ def cli(ctx, verbose: bool, config: str | None):
|
|
|
85
218
|
# Lint with custom config
|
|
86
219
|
thai-lint file-placement --config .thailint.yaml src/
|
|
87
220
|
|
|
221
|
+
\b
|
|
222
|
+
# Specify project root explicitly (useful in Docker)
|
|
223
|
+
thai-lint --project-root /workspace/root magic-numbers backend/
|
|
224
|
+
|
|
88
225
|
\b
|
|
89
226
|
# Get JSON output
|
|
90
227
|
thai-lint file-placement --format json .
|
|
@@ -99,6 +236,11 @@ def cli(ctx, verbose: bool, config: str | None):
|
|
|
99
236
|
# Setup logging
|
|
100
237
|
setup_logging(verbose)
|
|
101
238
|
|
|
239
|
+
# Store CLI options for later project root determination
|
|
240
|
+
# (deferred to avoid pyprojroot import issues in test environments)
|
|
241
|
+
ctx.obj["cli_project_root"] = project_root
|
|
242
|
+
ctx.obj["cli_config_path"] = config
|
|
243
|
+
|
|
102
244
|
# Load configuration
|
|
103
245
|
try:
|
|
104
246
|
if config:
|
|
@@ -551,6 +693,7 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
|
|
|
551
693
|
thai-lint file-placement --rules '{"allow": [".*\\.py$"]}' .
|
|
552
694
|
"""
|
|
553
695
|
verbose = ctx.obj.get("verbose", False)
|
|
696
|
+
project_root = _get_project_root_from_context(ctx)
|
|
554
697
|
|
|
555
698
|
if not paths:
|
|
556
699
|
paths = (".",)
|
|
@@ -558,17 +701,19 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
|
|
|
558
701
|
path_objs = [Path(p) for p in paths]
|
|
559
702
|
|
|
560
703
|
try:
|
|
561
|
-
_execute_file_placement_lint(
|
|
704
|
+
_execute_file_placement_lint(
|
|
705
|
+
path_objs, config_file, rules, format, recursive, verbose, project_root
|
|
706
|
+
)
|
|
562
707
|
except Exception as e:
|
|
563
708
|
_handle_linting_error(e, verbose)
|
|
564
709
|
|
|
565
710
|
|
|
566
711
|
def _execute_file_placement_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
567
|
-
path_objs, config_file, rules, format, recursive, verbose
|
|
712
|
+
path_objs, config_file, rules, format, recursive, verbose, project_root=None
|
|
568
713
|
):
|
|
569
714
|
"""Execute file placement linting."""
|
|
570
715
|
_validate_paths_exist(path_objs)
|
|
571
|
-
orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose)
|
|
716
|
+
orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose, project_root)
|
|
572
717
|
all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
|
|
573
718
|
|
|
574
719
|
# Filter to only file-placement violations
|
|
@@ -627,26 +772,55 @@ def _find_project_root(start_path: Path) -> Path:
|
|
|
627
772
|
return get_project_root(start_path)
|
|
628
773
|
|
|
629
774
|
|
|
630
|
-
def _setup_orchestrator(path_objs, config_file, rules, verbose):
|
|
775
|
+
def _setup_orchestrator(path_objs, config_file, rules, verbose, project_root=None):
|
|
631
776
|
"""Set up and configure the orchestrator."""
|
|
632
777
|
from src.orchestrator.core import Orchestrator
|
|
633
778
|
from src.utils.project_root import get_project_root
|
|
634
779
|
|
|
780
|
+
# Use provided project_root or fall back to auto-detection
|
|
781
|
+
project_root = _get_or_detect_project_root(path_objs, project_root, get_project_root)
|
|
782
|
+
|
|
783
|
+
orchestrator = Orchestrator(project_root=project_root)
|
|
784
|
+
_apply_orchestrator_config(orchestrator, config_file, rules, verbose)
|
|
785
|
+
|
|
786
|
+
return orchestrator
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _get_or_detect_project_root(path_objs, project_root, get_project_root):
|
|
790
|
+
"""Get provided project root or auto-detect from paths.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
path_objs: List of path objects
|
|
794
|
+
project_root: Optionally provided project root
|
|
795
|
+
get_project_root: Function to detect project root
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
Project root path
|
|
799
|
+
"""
|
|
800
|
+
if project_root is not None:
|
|
801
|
+
return project_root
|
|
802
|
+
|
|
635
803
|
# Find actual project root (where .git or pyproject.toml exists)
|
|
636
804
|
# This ensures .artifacts/ is always created at project root, not in subdirectories
|
|
637
805
|
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
638
806
|
search_start = first_path if first_path.is_dir() else first_path.parent
|
|
639
|
-
|
|
807
|
+
return get_project_root(search_start)
|
|
640
808
|
|
|
641
|
-
orchestrator = Orchestrator(project_root=project_root)
|
|
642
809
|
|
|
810
|
+
def _apply_orchestrator_config(orchestrator, config_file, rules, verbose):
|
|
811
|
+
"""Apply configuration to orchestrator.
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
orchestrator: Orchestrator instance
|
|
815
|
+
config_file: Path to config file (optional)
|
|
816
|
+
rules: Inline JSON rules (optional)
|
|
817
|
+
verbose: Whether verbose logging is enabled
|
|
818
|
+
"""
|
|
643
819
|
if rules:
|
|
644
820
|
_apply_inline_rules(orchestrator, rules, verbose)
|
|
645
821
|
elif config_file:
|
|
646
822
|
_load_config_file(orchestrator, config_file, verbose)
|
|
647
823
|
|
|
648
|
-
return orchestrator
|
|
649
|
-
|
|
650
824
|
|
|
651
825
|
def _apply_inline_rules(orchestrator, rules, verbose):
|
|
652
826
|
"""Parse and apply inline JSON rules."""
|
|
@@ -759,13 +933,18 @@ def _execute_linting_on_paths(orchestrator, path_objs: list[Path], recursive: bo
|
|
|
759
933
|
return violations
|
|
760
934
|
|
|
761
935
|
|
|
762
|
-
def _setup_nesting_orchestrator(
|
|
936
|
+
def _setup_nesting_orchestrator(
|
|
937
|
+
path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
|
|
938
|
+
):
|
|
763
939
|
"""Set up orchestrator for nesting command."""
|
|
764
|
-
# Use first path to determine project root
|
|
765
|
-
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
766
|
-
project_root = first_path if first_path.is_dir() else first_path.parent
|
|
767
|
-
|
|
768
940
|
from src.orchestrator.core import Orchestrator
|
|
941
|
+
from src.utils.project_root import get_project_root
|
|
942
|
+
|
|
943
|
+
# Use provided project_root or fall back to auto-detection
|
|
944
|
+
if project_root is None:
|
|
945
|
+
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
946
|
+
search_start = first_path if first_path.is_dir() else first_path.parent
|
|
947
|
+
project_root = get_project_root(search_start)
|
|
769
948
|
|
|
770
949
|
orchestrator = Orchestrator(project_root=project_root)
|
|
771
950
|
|
|
@@ -780,14 +959,34 @@ def _apply_nesting_config_override(orchestrator, max_depth: int | None, verbose:
|
|
|
780
959
|
if max_depth is None:
|
|
781
960
|
return
|
|
782
961
|
|
|
962
|
+
# Ensure nesting config exists
|
|
783
963
|
if "nesting" not in orchestrator.config:
|
|
784
964
|
orchestrator.config["nesting"] = {}
|
|
785
|
-
|
|
965
|
+
|
|
966
|
+
nesting_config = orchestrator.config["nesting"]
|
|
967
|
+
|
|
968
|
+
# Set top-level max_nesting_depth
|
|
969
|
+
nesting_config["max_nesting_depth"] = max_depth
|
|
970
|
+
|
|
971
|
+
# Override language-specific configs to ensure CLI option takes precedence
|
|
972
|
+
_override_language_specific_nesting(nesting_config, max_depth)
|
|
786
973
|
|
|
787
974
|
if verbose:
|
|
788
975
|
logger.debug(f"Overriding max_nesting_depth to {max_depth}")
|
|
789
976
|
|
|
790
977
|
|
|
978
|
+
def _override_language_specific_nesting(nesting_config: dict, max_depth: int):
|
|
979
|
+
"""Override language-specific nesting depth configs.
|
|
980
|
+
|
|
981
|
+
Args:
|
|
982
|
+
nesting_config: Nesting configuration dictionary
|
|
983
|
+
max_depth: Maximum nesting depth to set
|
|
984
|
+
"""
|
|
985
|
+
for lang in ["python", "typescript", "javascript"]:
|
|
986
|
+
if lang in nesting_config:
|
|
987
|
+
nesting_config[lang]["max_nesting_depth"] = max_depth
|
|
988
|
+
|
|
989
|
+
|
|
791
990
|
def _run_nesting_lint(orchestrator, path_objs: list[Path], recursive: bool):
|
|
792
991
|
"""Execute nesting lint on files or directories."""
|
|
793
992
|
all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
|
|
@@ -851,6 +1050,7 @@ def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
|
851
1050
|
thai-lint nesting --config .thailint.yaml src/
|
|
852
1051
|
"""
|
|
853
1052
|
verbose = ctx.obj.get("verbose", False)
|
|
1053
|
+
project_root = _get_project_root_from_context(ctx)
|
|
854
1054
|
|
|
855
1055
|
# Default to current directory if no paths provided
|
|
856
1056
|
if not paths:
|
|
@@ -859,17 +1059,19 @@ def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
|
859
1059
|
path_objs = [Path(p) for p in paths]
|
|
860
1060
|
|
|
861
1061
|
try:
|
|
862
|
-
_execute_nesting_lint(
|
|
1062
|
+
_execute_nesting_lint(
|
|
1063
|
+
path_objs, config_file, format, max_depth, recursive, verbose, project_root
|
|
1064
|
+
)
|
|
863
1065
|
except Exception as e:
|
|
864
1066
|
_handle_linting_error(e, verbose)
|
|
865
1067
|
|
|
866
1068
|
|
|
867
1069
|
def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
868
|
-
path_objs, config_file, format, max_depth, recursive, verbose
|
|
1070
|
+
path_objs, config_file, format, max_depth, recursive, verbose, project_root=None
|
|
869
1071
|
):
|
|
870
1072
|
"""Execute nesting lint."""
|
|
871
1073
|
_validate_paths_exist(path_objs)
|
|
872
|
-
orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose)
|
|
1074
|
+
orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose, project_root)
|
|
873
1075
|
_apply_nesting_config_override(orchestrator, max_depth, verbose)
|
|
874
1076
|
nesting_violations = _run_nesting_lint(orchestrator, path_objs, recursive)
|
|
875
1077
|
|
|
@@ -880,12 +1082,18 @@ def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positi
|
|
|
880
1082
|
sys.exit(1 if nesting_violations else 0)
|
|
881
1083
|
|
|
882
1084
|
|
|
883
|
-
def _setup_srp_orchestrator(
|
|
1085
|
+
def _setup_srp_orchestrator(
|
|
1086
|
+
path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
|
|
1087
|
+
):
|
|
884
1088
|
"""Set up orchestrator for SRP command."""
|
|
885
|
-
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
886
|
-
project_root = first_path if first_path.is_dir() else first_path.parent
|
|
887
|
-
|
|
888
1089
|
from src.orchestrator.core import Orchestrator
|
|
1090
|
+
from src.utils.project_root import get_project_root
|
|
1091
|
+
|
|
1092
|
+
# Use provided project_root or fall back to auto-detection
|
|
1093
|
+
if project_root is None:
|
|
1094
|
+
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
1095
|
+
search_start = first_path if first_path.is_dir() else first_path.parent
|
|
1096
|
+
project_root = get_project_root(search_start)
|
|
889
1097
|
|
|
890
1098
|
orchestrator = Orchestrator(project_root=project_root)
|
|
891
1099
|
|
|
@@ -988,6 +1196,7 @@ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
|
988
1196
|
thai-lint srp --config .thailint.yaml src/
|
|
989
1197
|
"""
|
|
990
1198
|
verbose = ctx.obj.get("verbose", False)
|
|
1199
|
+
project_root = _get_project_root_from_context(ctx)
|
|
991
1200
|
|
|
992
1201
|
if not paths:
|
|
993
1202
|
paths = (".",)
|
|
@@ -995,17 +1204,19 @@ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
|
995
1204
|
path_objs = [Path(p) for p in paths]
|
|
996
1205
|
|
|
997
1206
|
try:
|
|
998
|
-
_execute_srp_lint(
|
|
1207
|
+
_execute_srp_lint(
|
|
1208
|
+
path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root
|
|
1209
|
+
)
|
|
999
1210
|
except Exception as e:
|
|
1000
1211
|
_handle_linting_error(e, verbose)
|
|
1001
1212
|
|
|
1002
1213
|
|
|
1003
1214
|
def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
1004
|
-
path_objs, config_file, format, max_methods, max_loc, recursive, verbose
|
|
1215
|
+
path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root=None
|
|
1005
1216
|
):
|
|
1006
1217
|
"""Execute SRP lint."""
|
|
1007
1218
|
_validate_paths_exist(path_objs)
|
|
1008
|
-
orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose)
|
|
1219
|
+
orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose, project_root)
|
|
1009
1220
|
_apply_srp_config_override(orchestrator, max_methods, max_loc, verbose)
|
|
1010
1221
|
srp_violations = _run_srp_lint(orchestrator, path_objs, recursive)
|
|
1011
1222
|
|
|
@@ -1085,6 +1296,7 @@ def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
|
1085
1296
|
thai-lint dry --format json .
|
|
1086
1297
|
"""
|
|
1087
1298
|
verbose = ctx.obj.get("verbose", False)
|
|
1299
|
+
project_root = _get_project_root_from_context(ctx)
|
|
1088
1300
|
|
|
1089
1301
|
if not paths:
|
|
1090
1302
|
paths = (".",)
|
|
@@ -1093,18 +1305,34 @@ def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
|
1093
1305
|
|
|
1094
1306
|
try:
|
|
1095
1307
|
_execute_dry_lint(
|
|
1096
|
-
path_objs,
|
|
1308
|
+
path_objs,
|
|
1309
|
+
config_file,
|
|
1310
|
+
format,
|
|
1311
|
+
min_lines,
|
|
1312
|
+
no_cache,
|
|
1313
|
+
clear_cache,
|
|
1314
|
+
recursive,
|
|
1315
|
+
verbose,
|
|
1316
|
+
project_root,
|
|
1097
1317
|
)
|
|
1098
1318
|
except Exception as e:
|
|
1099
1319
|
_handle_linting_error(e, verbose)
|
|
1100
1320
|
|
|
1101
1321
|
|
|
1102
1322
|
def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
1103
|
-
path_objs,
|
|
1323
|
+
path_objs,
|
|
1324
|
+
config_file,
|
|
1325
|
+
format,
|
|
1326
|
+
min_lines,
|
|
1327
|
+
no_cache,
|
|
1328
|
+
clear_cache,
|
|
1329
|
+
recursive,
|
|
1330
|
+
verbose,
|
|
1331
|
+
project_root=None,
|
|
1104
1332
|
):
|
|
1105
1333
|
"""Execute DRY linting."""
|
|
1106
1334
|
_validate_paths_exist(path_objs)
|
|
1107
|
-
orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose)
|
|
1335
|
+
orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose, project_root)
|
|
1108
1336
|
_apply_dry_config_override(orchestrator, min_lines, no_cache, verbose)
|
|
1109
1337
|
|
|
1110
1338
|
if clear_cache:
|
|
@@ -1119,14 +1347,16 @@ def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional
|
|
|
1119
1347
|
sys.exit(1 if dry_violations else 0)
|
|
1120
1348
|
|
|
1121
1349
|
|
|
1122
|
-
def _setup_dry_orchestrator(path_objs, config_file, verbose):
|
|
1350
|
+
def _setup_dry_orchestrator(path_objs, config_file, verbose, project_root=None):
|
|
1123
1351
|
"""Set up orchestrator for DRY linting."""
|
|
1124
1352
|
from src.orchestrator.core import Orchestrator
|
|
1125
1353
|
from src.utils.project_root import get_project_root
|
|
1126
1354
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1355
|
+
# Use provided project_root or fall back to auto-detection
|
|
1356
|
+
if project_root is None:
|
|
1357
|
+
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
1358
|
+
search_start = first_path if first_path.is_dir() else first_path.parent
|
|
1359
|
+
project_root = get_project_root(search_start)
|
|
1130
1360
|
|
|
1131
1361
|
orchestrator = Orchestrator(project_root=project_root)
|
|
1132
1362
|
|
|
@@ -1213,16 +1443,18 @@ def _run_dry_lint(orchestrator, path_objs, recursive):
|
|
|
1213
1443
|
|
|
1214
1444
|
|
|
1215
1445
|
def _setup_magic_numbers_orchestrator(
|
|
1216
|
-
path_objs: list[Path], config_file: str | None, verbose: bool
|
|
1446
|
+
path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
|
|
1217
1447
|
):
|
|
1218
1448
|
"""Set up orchestrator for magic-numbers command."""
|
|
1219
1449
|
from src.orchestrator.core import Orchestrator
|
|
1220
1450
|
from src.utils.project_root import get_project_root
|
|
1221
1451
|
|
|
1222
|
-
#
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1452
|
+
# Use provided project_root or fall back to auto-detection
|
|
1453
|
+
if project_root is None:
|
|
1454
|
+
# Find actual project root (where .git or .thailint.yaml exists)
|
|
1455
|
+
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
1456
|
+
search_start = first_path if first_path.is_dir() else first_path.parent
|
|
1457
|
+
project_root = get_project_root(search_start)
|
|
1226
1458
|
|
|
1227
1459
|
orchestrator = Orchestrator(project_root=project_root)
|
|
1228
1460
|
|
|
@@ -1289,6 +1521,7 @@ def magic_numbers( # pylint: disable=too-many-arguments,too-many-positional-arg
|
|
|
1289
1521
|
thai-lint magic-numbers --config .thailint.yaml src/
|
|
1290
1522
|
"""
|
|
1291
1523
|
verbose = ctx.obj.get("verbose", False)
|
|
1524
|
+
project_root = _get_project_root_from_context(ctx)
|
|
1292
1525
|
|
|
1293
1526
|
if not paths:
|
|
1294
1527
|
paths = (".",)
|
|
@@ -1296,17 +1529,19 @@ def magic_numbers( # pylint: disable=too-many-arguments,too-many-positional-arg
|
|
|
1296
1529
|
path_objs = [Path(p) for p in paths]
|
|
1297
1530
|
|
|
1298
1531
|
try:
|
|
1299
|
-
_execute_magic_numbers_lint(
|
|
1532
|
+
_execute_magic_numbers_lint(
|
|
1533
|
+
path_objs, config_file, format, recursive, verbose, project_root
|
|
1534
|
+
)
|
|
1300
1535
|
except Exception as e:
|
|
1301
1536
|
_handle_linting_error(e, verbose)
|
|
1302
1537
|
|
|
1303
1538
|
|
|
1304
1539
|
def _execute_magic_numbers_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
1305
|
-
path_objs, config_file, format, recursive, verbose
|
|
1540
|
+
path_objs, config_file, format, recursive, verbose, project_root=None
|
|
1306
1541
|
):
|
|
1307
1542
|
"""Execute magic-numbers lint."""
|
|
1308
1543
|
_validate_paths_exist(path_objs)
|
|
1309
|
-
orchestrator = _setup_magic_numbers_orchestrator(path_objs, config_file, verbose)
|
|
1544
|
+
orchestrator = _setup_magic_numbers_orchestrator(path_objs, config_file, verbose, project_root)
|
|
1310
1545
|
magic_numbers_violations = _run_magic_numbers_lint(orchestrator, path_objs, recursive)
|
|
1311
1546
|
|
|
1312
1547
|
if verbose:
|
|
@@ -62,6 +62,9 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
62
62
|
"""
|
|
63
63
|
super().__init__()
|
|
64
64
|
self._filter_registry = filter_registry or create_default_registry()
|
|
65
|
+
# Performance optimization: Cache parsed AST to avoid re-parsing for each hash window
|
|
66
|
+
self._cached_ast: ast.Module | None = None
|
|
67
|
+
self._cached_content: str | None = None
|
|
65
68
|
|
|
66
69
|
def analyze(self, file_path: Path, content: str, config: DRYConfig) -> list[CodeBlock]:
|
|
67
70
|
"""Analyze Python file for duplicate code blocks, excluding docstrings.
|
|
@@ -74,37 +77,46 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
74
77
|
Returns:
|
|
75
78
|
List of CodeBlock instances with hash values
|
|
76
79
|
"""
|
|
77
|
-
#
|
|
78
|
-
|
|
80
|
+
# Performance optimization: Parse AST once and cache for _is_single_statement_in_source() calls
|
|
81
|
+
self._cached_ast = self._parse_content_safe(content)
|
|
82
|
+
self._cached_content = content
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
try:
|
|
85
|
+
# Get docstring line ranges
|
|
86
|
+
docstring_ranges = self._get_docstring_ranges_from_content(content)
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
# Tokenize with line number tracking
|
|
89
|
+
lines_with_numbers = self._tokenize_with_line_numbers(content, docstring_ranges)
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# Skip blocks that are single logical statements
|
|
89
|
-
# Check the original source code, not the normalized snippet
|
|
90
|
-
if self._is_single_statement_in_source(content, start_line, end_line):
|
|
91
|
-
continue
|
|
91
|
+
# Generate rolling hash windows
|
|
92
|
+
windows = self._rolling_hash_with_tracking(lines_with_numbers, config.min_duplicate_lines)
|
|
92
93
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
94
|
+
blocks = []
|
|
95
|
+
for hash_val, start_line, end_line, snippet in windows:
|
|
96
|
+
# Skip blocks that are single logical statements
|
|
97
|
+
# Check the original source code, not the normalized snippet
|
|
98
|
+
if self._is_single_statement_in_source(content, start_line, end_line):
|
|
99
|
+
continue
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
block = CodeBlock(
|
|
102
|
+
file_path=file_path,
|
|
103
|
+
start_line=start_line,
|
|
104
|
+
end_line=end_line,
|
|
105
|
+
snippet=snippet,
|
|
106
|
+
hash_value=hash_val,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Apply extensible filters (keyword arguments, imports, etc.)
|
|
110
|
+
if self._filter_registry.should_filter_block(block, content):
|
|
111
|
+
continue
|
|
104
112
|
|
|
105
|
-
|
|
113
|
+
blocks.append(block)
|
|
106
114
|
|
|
107
|
-
|
|
115
|
+
return blocks
|
|
116
|
+
finally:
|
|
117
|
+
# Clear cache after analysis to avoid memory leaks
|
|
118
|
+
self._cached_ast = None
|
|
119
|
+
self._cached_content = None
|
|
108
120
|
|
|
109
121
|
def _get_docstring_ranges_from_content(self, content: str) -> set[int]:
|
|
110
122
|
"""Extract line numbers that are part of docstrings.
|
|
@@ -225,10 +237,19 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
225
237
|
return hashes
|
|
226
238
|
|
|
227
239
|
def _is_single_statement_in_source(self, content: str, start_line: int, end_line: int) -> bool:
|
|
228
|
-
"""Check if a line range in the original source is a single logical statement.
|
|
229
|
-
|
|
230
|
-
if
|
|
231
|
-
|
|
240
|
+
"""Check if a line range in the original source is a single logical statement.
|
|
241
|
+
|
|
242
|
+
Performance optimization: Uses cached AST if available (set by analyze() method)
|
|
243
|
+
to avoid re-parsing the entire file for each hash window check.
|
|
244
|
+
"""
|
|
245
|
+
# Use cached AST if available and content matches
|
|
246
|
+
if self._cached_ast is not None and content == self._cached_content:
|
|
247
|
+
tree = self._cached_ast
|
|
248
|
+
else:
|
|
249
|
+
# Fallback: parse content (used by tests or standalone calls)
|
|
250
|
+
tree = self._parse_content_safe(content)
|
|
251
|
+
if tree is None:
|
|
252
|
+
return False
|
|
232
253
|
|
|
233
254
|
return self._check_overlapping_nodes(tree, start_line, end_line)
|
|
234
255
|
|
|
@@ -241,9 +262,19 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
241
262
|
return None
|
|
242
263
|
|
|
243
264
|
def _check_overlapping_nodes(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
|
|
244
|
-
"""Check if any AST node overlaps and matches single-statement pattern.
|
|
265
|
+
"""Check if any AST node overlaps and matches single-statement pattern.
|
|
266
|
+
|
|
267
|
+
Performance optimization: Pre-filter nodes by line range before expensive pattern checks.
|
|
268
|
+
"""
|
|
245
269
|
for node in ast.walk(tree):
|
|
246
|
-
|
|
270
|
+
# Quick line range check to skip nodes that don't overlap
|
|
271
|
+
if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
|
|
272
|
+
continue
|
|
273
|
+
if node.end_lineno < start_line or node.lineno > end_line:
|
|
274
|
+
continue # No overlap, skip expensive pattern matching
|
|
275
|
+
|
|
276
|
+
# Node overlaps - check if it matches single-statement pattern
|
|
277
|
+
if self._is_single_statement_pattern(node, start_line, end_line):
|
|
247
278
|
return True
|
|
248
279
|
return False
|
|
249
280
|
|
src/orchestrator/core.py
CHANGED
|
@@ -101,8 +101,9 @@ class Orchestrator:
|
|
|
101
101
|
self.config_loader = LinterConfigLoader()
|
|
102
102
|
self.ignore_parser = IgnoreDirectiveParser(self.project_root)
|
|
103
103
|
|
|
104
|
-
#
|
|
105
|
-
|
|
104
|
+
# Performance optimization: Defer rule discovery until first file is linted
|
|
105
|
+
# This eliminates ~0.077s overhead for commands that don't need rules (--help, config, etc.)
|
|
106
|
+
self._rules_discovered = False
|
|
106
107
|
|
|
107
108
|
# Use provided config or load from project root
|
|
108
109
|
if config is not None:
|
|
@@ -208,6 +209,12 @@ class Orchestrator:
|
|
|
208
209
|
|
|
209
210
|
return violations
|
|
210
211
|
|
|
212
|
+
def _ensure_rules_discovered(self) -> None:
|
|
213
|
+
"""Ensure rules have been discovered and registered (lazy initialization)."""
|
|
214
|
+
if not self._rules_discovered:
|
|
215
|
+
self.registry.discover_rules("src.linters")
|
|
216
|
+
self._rules_discovered = True
|
|
217
|
+
|
|
211
218
|
def _get_rules_for_file(self, file_path: Path, language: str) -> list[BaseLintRule]:
|
|
212
219
|
"""Get rules applicable to this file.
|
|
213
220
|
|
|
@@ -218,6 +225,9 @@ class Orchestrator:
|
|
|
218
225
|
Returns:
|
|
219
226
|
List of rules to execute against this file.
|
|
220
227
|
"""
|
|
228
|
+
# Lazy initialization: discover rules on first lint operation
|
|
229
|
+
self._ensure_rules_discovered()
|
|
230
|
+
|
|
221
231
|
# For now, return all registered rules
|
|
222
232
|
# Future: filter by language, configuration, etc.
|
|
223
233
|
return self.registry.list_all()
|
src/utils/project_root.py
CHANGED
|
@@ -4,29 +4,52 @@ Purpose: Centralized project root detection for consistent file placement
|
|
|
4
4
|
Scope: Single source of truth for finding project root directory
|
|
5
5
|
|
|
6
6
|
Overview: Uses pyprojroot package to provide reliable project root detection across
|
|
7
|
-
different environments (development, CI/CD, user installations).
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
different environments (development, CI/CD, user installations). Falls back to
|
|
8
|
+
manual detection if pyprojroot is not available (e.g., in test environments).
|
|
9
|
+
Searches for standard project markers like .git, .thailint.yaml, and pyproject.toml.
|
|
10
10
|
|
|
11
|
-
Dependencies: pyprojroot
|
|
11
|
+
Dependencies: pyprojroot (optional, with manual fallback)
|
|
12
12
|
|
|
13
13
|
Exports: is_project_root(), get_project_root()
|
|
14
14
|
|
|
15
15
|
Interfaces: Path-based functions for checking and finding project roots
|
|
16
16
|
|
|
17
|
-
Implementation:
|
|
17
|
+
Implementation: pyprojroot delegation with manual fallback for test environments
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
# Try to import pyprojroot, but don't fail if it's not available
|
|
23
|
+
try:
|
|
24
|
+
from pyprojroot import find_root
|
|
25
|
+
|
|
26
|
+
HAS_PYPROJROOT = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
HAS_PYPROJROOT = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _has_marker(path: Path, marker_name: str, is_dir: bool = False) -> bool:
|
|
32
|
+
"""Check if a directory contains a specific marker.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: Directory path to check
|
|
36
|
+
marker_name: Name of marker file or directory
|
|
37
|
+
is_dir: True if marker is a directory, False if it's a file
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
True if marker exists, False otherwise
|
|
41
|
+
"""
|
|
42
|
+
marker_path = path / marker_name
|
|
43
|
+
if is_dir:
|
|
44
|
+
return marker_path.is_dir()
|
|
45
|
+
return marker_path.is_file()
|
|
23
46
|
|
|
24
47
|
|
|
25
48
|
def is_project_root(path: Path) -> bool:
|
|
26
49
|
"""Check if a directory is a project root.
|
|
27
50
|
|
|
28
|
-
Uses pyprojroot
|
|
29
|
-
|
|
51
|
+
Uses pyprojroot if available, otherwise checks for common project markers
|
|
52
|
+
like .git, .thailint.yaml, or pyproject.toml.
|
|
30
53
|
|
|
31
54
|
Args:
|
|
32
55
|
path: Directory path to check
|
|
@@ -43,6 +66,21 @@ def is_project_root(path: Path) -> bool:
|
|
|
43
66
|
if not path.exists() or not path.is_dir():
|
|
44
67
|
return False
|
|
45
68
|
|
|
69
|
+
if HAS_PYPROJROOT:
|
|
70
|
+
return _check_root_with_pyprojroot(path)
|
|
71
|
+
|
|
72
|
+
return _check_root_with_markers(path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_root_with_pyprojroot(path: Path) -> bool:
|
|
76
|
+
"""Check if path is project root using pyprojroot.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path: Directory path to check
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if path is a project root, False otherwise
|
|
83
|
+
"""
|
|
46
84
|
try:
|
|
47
85
|
# Find root from this path - if it equals this path, it's a root
|
|
48
86
|
found_root = find_root(path)
|
|
@@ -52,6 +90,22 @@ def is_project_root(path: Path) -> bool:
|
|
|
52
90
|
return False
|
|
53
91
|
|
|
54
92
|
|
|
93
|
+
def _check_root_with_markers(path: Path) -> bool:
|
|
94
|
+
"""Check if path contains project root markers.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: Directory path to check
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if path contains .git, .thailint.yaml, or pyproject.toml
|
|
101
|
+
"""
|
|
102
|
+
return (
|
|
103
|
+
_has_marker(path, ".git", is_dir=True)
|
|
104
|
+
or _has_marker(path, ".thailint.yaml", is_dir=False)
|
|
105
|
+
or _has_marker(path, "pyproject.toml", is_dir=False)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
55
109
|
def _try_find_with_criterion(criterion: object, start_path: Path) -> Path | None:
|
|
56
110
|
"""Try to find project root with a specific criterion.
|
|
57
111
|
|
|
@@ -68,14 +122,42 @@ def _try_find_with_criterion(criterion: object, start_path: Path) -> Path | None
|
|
|
68
122
|
return None
|
|
69
123
|
|
|
70
124
|
|
|
125
|
+
def _find_root_manual(start_path: Path) -> Path:
|
|
126
|
+
"""Manually find project root by walking up directory tree.
|
|
127
|
+
|
|
128
|
+
Fallback implementation when pyprojroot is not available.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
start_path: Directory to start searching from
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Path to project root, or start_path if no markers found
|
|
135
|
+
"""
|
|
136
|
+
current = start_path.resolve()
|
|
137
|
+
|
|
138
|
+
# Walk up the directory tree
|
|
139
|
+
for parent in [current] + list(current.parents):
|
|
140
|
+
# Check for project markers
|
|
141
|
+
if (
|
|
142
|
+
_has_marker(parent, ".git", is_dir=True)
|
|
143
|
+
or _has_marker(parent, ".thailint.yaml", is_dir=False)
|
|
144
|
+
or _has_marker(parent, "pyproject.toml", is_dir=False)
|
|
145
|
+
):
|
|
146
|
+
return parent
|
|
147
|
+
|
|
148
|
+
# No markers found, return start path
|
|
149
|
+
return current
|
|
150
|
+
|
|
151
|
+
|
|
71
152
|
def get_project_root(start_path: Path | None = None) -> Path:
|
|
72
153
|
"""Find project root by walking up the directory tree.
|
|
73
154
|
|
|
74
155
|
This is the single source of truth for project root detection.
|
|
75
156
|
All code that needs to find the project root should use this function.
|
|
76
157
|
|
|
77
|
-
Uses pyprojroot
|
|
78
|
-
pyproject.toml, .thailint.yaml, etc)
|
|
158
|
+
Uses pyprojroot if available, otherwise uses manual detection searching for
|
|
159
|
+
standard project markers (.git directory, pyproject.toml, .thailint.yaml, etc)
|
|
160
|
+
starting from start_path and walking upward.
|
|
79
161
|
|
|
80
162
|
Args:
|
|
81
163
|
start_path: Directory to start searching from. If None, uses current working directory.
|
|
@@ -87,13 +169,29 @@ def get_project_root(start_path: Path | None = None) -> Path:
|
|
|
87
169
|
>>> root = get_project_root()
|
|
88
170
|
>>> config_file = root / ".thailint.yaml"
|
|
89
171
|
"""
|
|
90
|
-
from pyprojroot import has_dir, has_file
|
|
91
|
-
|
|
92
172
|
if start_path is None:
|
|
93
173
|
start_path = Path.cwd()
|
|
94
174
|
|
|
95
175
|
current = start_path.resolve()
|
|
96
176
|
|
|
177
|
+
if HAS_PYPROJROOT:
|
|
178
|
+
return _find_root_with_pyprojroot(current)
|
|
179
|
+
|
|
180
|
+
# Manual fallback for test environments
|
|
181
|
+
return _find_root_manual(current)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _find_root_with_pyprojroot(current: Path) -> Path:
|
|
185
|
+
"""Find project root using pyprojroot library.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
current: Current path to start searching from
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Path to project root, or current if no markers found
|
|
192
|
+
"""
|
|
193
|
+
from pyprojroot import has_dir, has_file
|
|
194
|
+
|
|
97
195
|
# Search for project root markers in priority order
|
|
98
196
|
# Try .git first (most reliable), then .thailint.yaml, then pyproject.toml
|
|
99
197
|
for criterion in [has_dir(".git"), has_file(".thailint.yaml"), has_file("pyproject.toml")]:
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: thailint
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: The AI Linter - Enterprise-grade linting and governance for AI-generated code across multiple languages
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Keywords: linter,ai,code-quality,static-analysis,file-placement,governance,multi-language,cli,docker,python
|
|
7
8
|
Author: Steve Jackson
|
|
8
9
|
Requires-Python: >=3.11,<4.0
|
|
@@ -15,6 +16,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
20
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
22
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
@@ -35,7 +37,7 @@ Description-Content-Type: text/markdown
|
|
|
35
37
|
|
|
36
38
|
[](https://opensource.org/licenses/MIT)
|
|
37
39
|
[](https://www.python.org/downloads/)
|
|
38
|
-
[](tests/)
|
|
39
41
|
[](htmlcov/)
|
|
40
42
|
|
|
41
43
|
The AI Linter - Enterprise-ready linting and governance for AI-generated code across multiple languages.
|
|
@@ -201,6 +203,44 @@ docker run --rm -v $(pwd):/data \
|
|
|
201
203
|
washad/thailint:latest nesting /data/src
|
|
202
204
|
```
|
|
203
205
|
|
|
206
|
+
### Docker with Sibling Directories
|
|
207
|
+
|
|
208
|
+
For Docker environments with sibling directories (e.g., separate config and source directories), use `--project-root` or config path inference:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
# Directory structure:
|
|
212
|
+
# /workspace/
|
|
213
|
+
# ├── root/ # Contains .thailint.yaml and .git
|
|
214
|
+
# ├── backend/ # Code to lint
|
|
215
|
+
# └── tools/
|
|
216
|
+
|
|
217
|
+
# Option 1: Explicit project root (recommended)
|
|
218
|
+
docker run --rm -v $(pwd):/data \
|
|
219
|
+
washad/thailint:latest \
|
|
220
|
+
--project-root /data/root \
|
|
221
|
+
magic-numbers /data/backend/
|
|
222
|
+
|
|
223
|
+
# Option 2: Config path inference (automatic)
|
|
224
|
+
docker run --rm -v $(pwd):/data \
|
|
225
|
+
washad/thailint:latest \
|
|
226
|
+
--config /data/root/.thailint.yaml \
|
|
227
|
+
magic-numbers /data/backend/
|
|
228
|
+
|
|
229
|
+
# With ignore patterns resolving from project root
|
|
230
|
+
docker run --rm -v $(pwd):/data \
|
|
231
|
+
washad/thailint:latest \
|
|
232
|
+
--project-root /data/root \
|
|
233
|
+
--config /data/root/.thailint.yaml \
|
|
234
|
+
magic-numbers /data/backend/
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Priority order:**
|
|
238
|
+
1. `--project-root` (highest priority - explicit specification)
|
|
239
|
+
2. Inferred from `--config` path directory
|
|
240
|
+
3. Auto-detection from file location (fallback)
|
|
241
|
+
|
|
242
|
+
See **[Docker Usage](#docker-usage)** section below for more examples.
|
|
243
|
+
|
|
204
244
|
## Configuration
|
|
205
245
|
|
|
206
246
|
Create `.thailint.yaml` in your project root:
|
|
@@ -1033,6 +1073,8 @@ See `just --list` or `just help` for all available commands.
|
|
|
1033
1073
|
|
|
1034
1074
|
## Docker Usage
|
|
1035
1075
|
|
|
1076
|
+
### Basic Docker Commands
|
|
1077
|
+
|
|
1036
1078
|
```bash
|
|
1037
1079
|
# Pull published image
|
|
1038
1080
|
docker pull washad/thailint:latest
|
|
@@ -1061,6 +1103,44 @@ docker run --rm -v $(pwd):/data \
|
|
|
1061
1103
|
washad/thailint:latest file-placement --format json /data
|
|
1062
1104
|
```
|
|
1063
1105
|
|
|
1106
|
+
### Docker with Sibling Directories (Advanced)
|
|
1107
|
+
|
|
1108
|
+
For complex Docker setups with sibling directories, use `--project-root` for explicit control:
|
|
1109
|
+
|
|
1110
|
+
```bash
|
|
1111
|
+
# Scenario: Monorepo with separate config and code directories
|
|
1112
|
+
# Directory structure:
|
|
1113
|
+
# /workspace/
|
|
1114
|
+
# ├── config/ # Contains .thailint.yaml
|
|
1115
|
+
# ├── backend/app/ # Python backend code
|
|
1116
|
+
# ├── frontend/ # TypeScript frontend
|
|
1117
|
+
# └── tools/ # Build tools
|
|
1118
|
+
|
|
1119
|
+
# Explicit project root (recommended for Docker)
|
|
1120
|
+
docker run --rm -v /path/to/workspace:/workspace \
|
|
1121
|
+
washad/thailint:latest \
|
|
1122
|
+
--project-root /workspace/config \
|
|
1123
|
+
magic-numbers /workspace/backend/
|
|
1124
|
+
|
|
1125
|
+
# Config path inference (automatic - no --project-root needed)
|
|
1126
|
+
docker run --rm -v /path/to/workspace:/workspace \
|
|
1127
|
+
washad/thailint:latest \
|
|
1128
|
+
--config /workspace/config/.thailint.yaml \
|
|
1129
|
+
magic-numbers /workspace/backend/
|
|
1130
|
+
|
|
1131
|
+
# Lint multiple sibling directories with shared config
|
|
1132
|
+
docker run --rm -v /path/to/workspace:/workspace \
|
|
1133
|
+
washad/thailint:latest \
|
|
1134
|
+
--project-root /workspace/config \
|
|
1135
|
+
nesting /workspace/backend/ /workspace/frontend/
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
**When to use `--project-root` in Docker:**
|
|
1139
|
+
- **Sibling directory structures** - When config/code aren't nested
|
|
1140
|
+
- **Monorepos** - Multiple projects sharing one config
|
|
1141
|
+
- **CI/CD** - Explicit paths prevent auto-detection issues
|
|
1142
|
+
- **Ignore patterns** - Ensures patterns resolve from correct base directory
|
|
1143
|
+
|
|
1064
1144
|
## Documentation
|
|
1065
1145
|
|
|
1066
1146
|
### Comprehensive Guides
|
|
@@ -2,7 +2,7 @@ src/__init__.py,sha256=f601zncODr2twrUHqTLS5wyOdZqZi9tMjAe2INhRKqU,2175
|
|
|
2
2
|
src/analyzers/__init__.py,sha256=fFloZtjkBGwYbAhKTxS3Qy3yDr2_3i3WSfKTw1mAioo,972
|
|
3
3
|
src/analyzers/typescript_base.py,sha256=4I7fAcMOAY9vY1AXh52QpohgFmguBECwOkvBRP4zCS4,5054
|
|
4
4
|
src/api.py,sha256=pJ5l3qxccKBEY-BkANwzTgLAl1ZFq7OP6hx6LSxbhDw,4664
|
|
5
|
-
src/cli.py,sha256=
|
|
5
|
+
src/cli.py,sha256=VvMTaltsFKtCE4DNu6HqnSgKyZYulq3qALO8uteafI8,48092
|
|
6
6
|
src/config.py,sha256=2ebAjIpAhw4bHbOxViEA5nCjfBlDEIrMR59DBrzcYzM,12460
|
|
7
7
|
src/core/__init__.py,sha256=5FtsDvhMt4SNRx3pbcGURrxn135XRbeRrjSUxiXwkNc,381
|
|
8
8
|
src/core/base.py,sha256=Eklcagi2ktfY4Kytl_ObXov2U49N9OGDpw4cu4PUzGY,7824
|
|
@@ -30,7 +30,7 @@ src/linters/dry/duplicate_storage.py,sha256=3OxE2mtoWGAsNNrB8J2c-4JirLUoqZ9ptydO
|
|
|
30
30
|
src/linters/dry/file_analyzer.py,sha256=ufSQ85ddsGTqGnBHZNTdV_5DGfTpUmJOB58sIdJNV0I,2928
|
|
31
31
|
src/linters/dry/inline_ignore.py,sha256=ASfA-fp_1aPpkakN2e0T6qdTh8S7Jqj89ovxXJLmFlc,4439
|
|
32
32
|
src/linters/dry/linter.py,sha256=XMLwCgGrFX0l0dVUJs1jpsXOfgxeKKDbxOtN5h5Emhk,5835
|
|
33
|
-
src/linters/dry/python_analyzer.py,sha256=
|
|
33
|
+
src/linters/dry/python_analyzer.py,sha256=RoC_OD0UqI0j5HVEwSZWUZVyHDNUtywvxse0HRumLoI,22748
|
|
34
34
|
src/linters/dry/storage_initializer.py,sha256=ykMALFs4uMUrN0_skEwySDl_t5Dm_LGHllF0OxDhiUI,1366
|
|
35
35
|
src/linters/dry/token_hasher.py,sha256=mCFuP0FQFALyKghBgZHcspsoOxgT7C7ZkfspnhFA5U4,3609
|
|
36
36
|
src/linters/dry/typescript_analyzer.py,sha256=n1rsQYp7nuPhgErbG8hWawkywRz-iFGhrGlQXDrIa14,21494
|
|
@@ -71,13 +71,13 @@ src/linters/srp/typescript_analyzer.py,sha256=Wi0P_G1v5AnZYtMN3sNm1iHva84-8Kep2L
|
|
|
71
71
|
src/linters/srp/typescript_metrics_calculator.py,sha256=2VLRux_tf1Cw645wwTuol3Z5A6-mkl4cgyW34myy00Q,2728
|
|
72
72
|
src/linters/srp/violation_builder.py,sha256=jaIjVtRYWUTs1SVJVwd0FxCojo0DxhPzfhyfMKmAroM,3881
|
|
73
73
|
src/orchestrator/__init__.py,sha256=XXLDJq2oaB-TpP2Y97GRnde9EkITGuFCmuLrDfxI9nY,245
|
|
74
|
-
src/orchestrator/core.py,sha256=
|
|
74
|
+
src/orchestrator/core.py,sha256=z0YcwsK18uhlztIPi54ux3mOm8fHMREYJoudsJPhC0Q,8857
|
|
75
75
|
src/orchestrator/language_detector.py,sha256=rHyVMApit80NTTNyDH1ObD1usKD8LjGmH3DwqNAWYGc,2736
|
|
76
76
|
src/templates/thailint_config_template.yaml,sha256=u8WFv2coE4uqfgf_slw7xjo4kGYIowDm1RIgxsKQzrE,4275
|
|
77
77
|
src/utils/__init__.py,sha256=NiBtKeQ09Y3kuUzeN4O1JNfUIYPQDS2AP1l5ODq-Dec,125
|
|
78
|
-
src/utils/project_root.py,sha256=
|
|
79
|
-
thailint-0.4.
|
|
80
|
-
thailint-0.4.
|
|
81
|
-
thailint-0.4.
|
|
82
|
-
thailint-0.4.
|
|
83
|
-
thailint-0.4.
|
|
78
|
+
src/utils/project_root.py,sha256=b3YTEGTa9RPcOeHn1IByMMWyRiUabfVlpnlektL0A0o,6156
|
|
79
|
+
thailint-0.4.4.dist-info/METADATA,sha256=1cmCJ3Myhrt90V0qWw-gfDr6cVP6nRO5fOZmy-TGSVY,36717
|
|
80
|
+
thailint-0.4.4.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
81
|
+
thailint-0.4.4.dist-info/entry_points.txt,sha256=l7DQJgU18sVLDpSaXOXY3lLhnQHQIRrSJZTQjG1cEAk,62
|
|
82
|
+
thailint-0.4.4.dist-info/licenses/LICENSE,sha256=kxh1J0Sb62XvhNJ6MZsVNe8PqNVJ7LHRn_EWa-T3djw,1070
|
|
83
|
+
thailint-0.4.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|