thailint 0.1.6__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- src/__init__.py +7 -2
- src/analyzers/__init__.py +23 -0
- src/analyzers/typescript_base.py +148 -0
- src/api.py +1 -1
- src/cli.py +524 -141
- src/config.py +6 -31
- src/core/base.py +12 -0
- src/core/cli_utils.py +206 -0
- src/core/config_parser.py +99 -0
- src/core/linter_utils.py +168 -0
- src/core/registry.py +17 -92
- src/core/rule_discovery.py +132 -0
- src/core/violation_builder.py +122 -0
- src/linter_config/ignore.py +112 -40
- src/linter_config/loader.py +3 -13
- src/linters/dry/__init__.py +23 -0
- src/linters/dry/base_token_analyzer.py +76 -0
- src/linters/dry/block_filter.py +262 -0
- src/linters/dry/block_grouper.py +59 -0
- src/linters/dry/cache.py +218 -0
- src/linters/dry/cache_query.py +61 -0
- src/linters/dry/config.py +130 -0
- src/linters/dry/config_loader.py +44 -0
- src/linters/dry/deduplicator.py +120 -0
- src/linters/dry/duplicate_storage.py +126 -0
- src/linters/dry/file_analyzer.py +127 -0
- src/linters/dry/inline_ignore.py +140 -0
- src/linters/dry/linter.py +170 -0
- src/linters/dry/python_analyzer.py +517 -0
- src/linters/dry/storage_initializer.py +51 -0
- src/linters/dry/token_hasher.py +115 -0
- src/linters/dry/typescript_analyzer.py +590 -0
- src/linters/dry/violation_builder.py +74 -0
- src/linters/dry/violation_filter.py +91 -0
- src/linters/dry/violation_generator.py +174 -0
- src/linters/file_placement/config_loader.py +86 -0
- src/linters/file_placement/directory_matcher.py +80 -0
- src/linters/file_placement/linter.py +252 -472
- src/linters/file_placement/path_resolver.py +61 -0
- src/linters/file_placement/pattern_matcher.py +55 -0
- src/linters/file_placement/pattern_validator.py +106 -0
- src/linters/file_placement/rule_checker.py +229 -0
- src/linters/file_placement/violation_factory.py +177 -0
- src/linters/nesting/config.py +13 -3
- src/linters/nesting/linter.py +76 -152
- src/linters/nesting/typescript_analyzer.py +38 -102
- src/linters/nesting/typescript_function_extractor.py +130 -0
- src/linters/nesting/violation_builder.py +139 -0
- src/linters/srp/__init__.py +99 -0
- src/linters/srp/class_analyzer.py +113 -0
- src/linters/srp/config.py +76 -0
- src/linters/srp/heuristics.py +89 -0
- src/linters/srp/linter.py +225 -0
- src/linters/srp/metrics_evaluator.py +47 -0
- src/linters/srp/python_analyzer.py +72 -0
- src/linters/srp/typescript_analyzer.py +75 -0
- src/linters/srp/typescript_metrics_calculator.py +90 -0
- src/linters/srp/violation_builder.py +117 -0
- src/orchestrator/core.py +42 -7
- src/utils/__init__.py +4 -0
- src/utils/project_root.py +84 -0
- {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/METADATA +414 -63
- thailint-0.2.1.dist-info/RECORD +75 -0
- src/.ai/layout.yaml +0 -48
- thailint-0.1.6.dist-info/RECORD +0 -28
- {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/LICENSE +0 -0
- {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/WHEEL +0 -0
- {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/entry_points.txt +0 -0
src/cli.py
CHANGED
|
@@ -11,12 +11,14 @@ Overview: Provides the main CLI application using Click decorators for command d
|
|
|
11
11
|
|
|
12
12
|
Dependencies: click for CLI framework, logging for structured output, pathlib for file paths
|
|
13
13
|
|
|
14
|
-
Exports: cli (main command group), hello command, config command group, file_placement command
|
|
14
|
+
Exports: cli (main command group), hello command, config command group, file_placement command, dry command
|
|
15
15
|
|
|
16
16
|
Interfaces: Click CLI commands, configuration context via Click ctx, logging integration
|
|
17
17
|
|
|
18
18
|
Implementation: Click decorators for commands, context passing for shared state, comprehensive help text
|
|
19
19
|
"""
|
|
20
|
+
# pylint: disable=too-many-lines
|
|
21
|
+
# Justification: CLI modules naturally have many commands and helper functions
|
|
20
22
|
|
|
21
23
|
import logging
|
|
22
24
|
import sys
|
|
@@ -26,11 +28,20 @@ import click
|
|
|
26
28
|
|
|
27
29
|
from src import __version__
|
|
28
30
|
from src.config import ConfigError, load_config, save_config, validate_config
|
|
31
|
+
from src.core.cli_utils import format_violations
|
|
29
32
|
|
|
30
33
|
# Configure module logger
|
|
31
34
|
logger = logging.getLogger(__name__)
|
|
32
35
|
|
|
33
36
|
|
|
37
|
+
# Shared Click option decorators for common CLI options
|
|
38
|
+
def format_option(func):
|
|
39
|
+
"""Add --format option to a command for output format selection."""
|
|
40
|
+
return click.option(
|
|
41
|
+
"--format", "-f", type=click.Choice(["text", "json"]), default="text", help="Output format"
|
|
42
|
+
)(func)
|
|
43
|
+
|
|
44
|
+
|
|
34
45
|
def setup_logging(verbose: bool = False):
|
|
35
46
|
"""
|
|
36
47
|
Configure logging for the CLI application.
|
|
@@ -62,6 +73,10 @@ def cli(ctx, verbose: bool, config: str | None):
|
|
|
62
73
|
|
|
63
74
|
Examples:
|
|
64
75
|
|
|
76
|
+
\b
|
|
77
|
+
# Check for duplicate code (DRY violations)
|
|
78
|
+
thai-lint dry .
|
|
79
|
+
|
|
65
80
|
\b
|
|
66
81
|
# Lint current directory for file placement issues
|
|
67
82
|
thai-lint file-placement .
|
|
@@ -348,16 +363,19 @@ def config_reset(ctx, yes: bool):
|
|
|
348
363
|
|
|
349
364
|
|
|
350
365
|
@cli.command("file-placement")
|
|
351
|
-
@click.argument("
|
|
366
|
+
@click.argument("paths", nargs=-1, type=click.Path())
|
|
352
367
|
@click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
|
|
353
368
|
@click.option("--rules", "-r", help="Inline JSON rules configuration")
|
|
354
|
-
@
|
|
355
|
-
"--format", "-f", type=click.Choice(["text", "json"]), default="text", help="Output format"
|
|
356
|
-
)
|
|
369
|
+
@format_option
|
|
357
370
|
@click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
|
|
358
371
|
@click.pass_context
|
|
359
372
|
def file_placement( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements
|
|
360
|
-
ctx,
|
|
373
|
+
ctx,
|
|
374
|
+
paths: tuple[str, ...],
|
|
375
|
+
config_file: str | None,
|
|
376
|
+
rules: str | None,
|
|
377
|
+
format: str,
|
|
378
|
+
recursive: bool,
|
|
361
379
|
):
|
|
362
380
|
# Justification for Pylint disables:
|
|
363
381
|
# - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 4 options = 6 params
|
|
@@ -369,18 +387,26 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
|
|
|
369
387
|
Checks that files are placed in appropriate directories according to
|
|
370
388
|
configured rules and patterns.
|
|
371
389
|
|
|
372
|
-
|
|
390
|
+
PATHS: Files or directories to lint (defaults to current directory if none provided)
|
|
373
391
|
|
|
374
392
|
Examples:
|
|
375
393
|
|
|
376
394
|
\b
|
|
377
|
-
# Lint current directory
|
|
395
|
+
# Lint current directory (all files recursively)
|
|
378
396
|
thai-lint file-placement
|
|
379
397
|
|
|
380
398
|
\b
|
|
381
399
|
# Lint specific directory
|
|
382
400
|
thai-lint file-placement src/
|
|
383
401
|
|
|
402
|
+
\b
|
|
403
|
+
# Lint single file
|
|
404
|
+
thai-lint file-placement src/app.py
|
|
405
|
+
|
|
406
|
+
\b
|
|
407
|
+
# Lint multiple files
|
|
408
|
+
thai-lint file-placement src/app.py src/utils.py tests/test_app.py
|
|
409
|
+
|
|
384
410
|
\b
|
|
385
411
|
# Use custom config
|
|
386
412
|
thai-lint file-placement --config rules.json .
|
|
@@ -390,25 +416,33 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
|
|
|
390
416
|
thai-lint file-placement --rules '{"allow": [".*\\.py$"]}' .
|
|
391
417
|
"""
|
|
392
418
|
verbose = ctx.obj.get("verbose", False)
|
|
393
|
-
|
|
419
|
+
|
|
420
|
+
if not paths:
|
|
421
|
+
paths = (".",)
|
|
422
|
+
|
|
423
|
+
path_objs = [Path(p) for p in paths]
|
|
394
424
|
|
|
395
425
|
try:
|
|
396
|
-
_execute_file_placement_lint(
|
|
426
|
+
_execute_file_placement_lint(path_objs, config_file, rules, format, recursive, verbose)
|
|
397
427
|
except Exception as e:
|
|
398
428
|
_handle_linting_error(e, verbose)
|
|
399
429
|
|
|
400
430
|
|
|
401
431
|
def _execute_file_placement_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
402
|
-
|
|
432
|
+
path_objs, config_file, rules, format, recursive, verbose
|
|
403
433
|
):
|
|
404
434
|
"""Execute file placement linting."""
|
|
405
|
-
|
|
406
|
-
|
|
435
|
+
_validate_paths_exist(path_objs)
|
|
436
|
+
orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose)
|
|
437
|
+
all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
|
|
438
|
+
|
|
439
|
+
# Filter to only file-placement violations
|
|
440
|
+
violations = [v for v in all_violations if v.rule_id.startswith("file-placement")]
|
|
407
441
|
|
|
408
442
|
if verbose:
|
|
409
443
|
logger.info(f"Found {len(violations)} violation(s)")
|
|
410
444
|
|
|
411
|
-
|
|
445
|
+
format_violations(violations, format)
|
|
412
446
|
sys.exit(1 if violations else 0)
|
|
413
447
|
|
|
414
448
|
|
|
@@ -420,11 +454,55 @@ def _handle_linting_error(error: Exception, verbose: bool) -> None:
|
|
|
420
454
|
sys.exit(2)
|
|
421
455
|
|
|
422
456
|
|
|
423
|
-
def
|
|
457
|
+
def _validate_paths_exist(path_objs: list[Path]) -> None:
|
|
458
|
+
"""Validate that all provided paths exist.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
path_objs: List of Path objects to validate
|
|
462
|
+
|
|
463
|
+
Raises:
|
|
464
|
+
SystemExit: If any path doesn't exist (exit code 2)
|
|
465
|
+
"""
|
|
466
|
+
for path in path_objs:
|
|
467
|
+
if not path.exists():
|
|
468
|
+
click.echo(f"Error: Path does not exist: {path}", err=True)
|
|
469
|
+
click.echo("", err=True)
|
|
470
|
+
click.echo(
|
|
471
|
+
"Hint: When using Docker, ensure paths are inside the mounted volume:", err=True
|
|
472
|
+
)
|
|
473
|
+
click.echo(
|
|
474
|
+
" docker run -v $(pwd):/data thailint <command> /data/your-file.py", err=True
|
|
475
|
+
)
|
|
476
|
+
sys.exit(2)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _find_project_root(start_path: Path) -> Path:
|
|
480
|
+
"""Find project root by looking for .git or pyproject.toml.
|
|
481
|
+
|
|
482
|
+
DEPRECATED: Use src.utils.project_root.get_project_root() instead.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
start_path: Directory to start searching from
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Path to project root, or start_path if no markers found
|
|
489
|
+
"""
|
|
490
|
+
from src.utils.project_root import get_project_root
|
|
491
|
+
|
|
492
|
+
return get_project_root(start_path)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _setup_orchestrator(path_objs, config_file, rules, verbose):
|
|
424
496
|
"""Set up and configure the orchestrator."""
|
|
425
497
|
from src.orchestrator.core import Orchestrator
|
|
498
|
+
from src.utils.project_root import get_project_root
|
|
499
|
+
|
|
500
|
+
# Find actual project root (where .git or pyproject.toml exists)
|
|
501
|
+
# This ensures .artifacts/ is always created at project root, not in subdirectories
|
|
502
|
+
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
503
|
+
search_start = first_path if first_path.is_dir() else first_path.parent
|
|
504
|
+
project_root = get_project_root(search_start)
|
|
426
505
|
|
|
427
|
-
project_root = path_obj if path_obj.is_dir() else path_obj.parent
|
|
428
506
|
orchestrator = Orchestrator(project_root=project_root)
|
|
429
507
|
|
|
430
508
|
if rules:
|
|
@@ -439,7 +517,6 @@ def _apply_inline_rules(orchestrator, rules, verbose):
|
|
|
439
517
|
"""Parse and apply inline JSON rules."""
|
|
440
518
|
rules_config = _parse_json_rules(rules)
|
|
441
519
|
orchestrator.config.update(rules_config)
|
|
442
|
-
_write_layout_config(orchestrator, rules_config, verbose)
|
|
443
520
|
_log_applied_rules(rules_config, verbose)
|
|
444
521
|
|
|
445
522
|
|
|
@@ -454,40 +531,6 @@ def _parse_json_rules(rules: str) -> dict:
|
|
|
454
531
|
sys.exit(2)
|
|
455
532
|
|
|
456
533
|
|
|
457
|
-
def _write_layout_config(orchestrator, rules_config: dict, verbose: bool) -> None:
|
|
458
|
-
"""Write layout config to .ai/layout.yaml if possible."""
|
|
459
|
-
ai_dir = orchestrator.project_root / ".ai"
|
|
460
|
-
layout_file = ai_dir / "layout.yaml"
|
|
461
|
-
|
|
462
|
-
try:
|
|
463
|
-
_write_layout_yaml_file(ai_dir, layout_file, rules_config)
|
|
464
|
-
_log_layout_written(layout_file, verbose)
|
|
465
|
-
except OSError as e:
|
|
466
|
-
_log_layout_error(e, verbose)
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
def _write_layout_yaml_file(ai_dir, layout_file, rules_config):
|
|
470
|
-
"""Write layout YAML file."""
|
|
471
|
-
import yaml
|
|
472
|
-
|
|
473
|
-
ai_dir.mkdir(exist_ok=True)
|
|
474
|
-
layout_config = {"file-placement": rules_config}
|
|
475
|
-
with layout_file.open("w", encoding="utf-8") as f:
|
|
476
|
-
yaml.dump(layout_config, f)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
def _log_layout_written(layout_file, verbose):
|
|
480
|
-
"""Log layout file written."""
|
|
481
|
-
if verbose:
|
|
482
|
-
logger.debug(f"Written layout config to: {layout_file}")
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def _log_layout_error(error, verbose):
|
|
486
|
-
"""Log layout write error."""
|
|
487
|
-
if verbose:
|
|
488
|
-
logger.debug(f"Could not write layout config: {error}")
|
|
489
|
-
|
|
490
|
-
|
|
491
534
|
def _log_applied_rules(rules_config: dict, verbose: bool) -> None:
|
|
492
535
|
"""Log applied rules if verbose."""
|
|
493
536
|
if verbose:
|
|
@@ -504,98 +547,89 @@ def _load_config_file(orchestrator, config_file, verbose):
|
|
|
504
547
|
# Load config into orchestrator
|
|
505
548
|
orchestrator.config = orchestrator.config_loader.load(config_path)
|
|
506
549
|
|
|
507
|
-
|
|
508
|
-
|
|
550
|
+
if verbose:
|
|
551
|
+
logger.debug(f"Loaded config from: {config_file}")
|
|
509
552
|
|
|
510
553
|
|
|
511
|
-
def
|
|
512
|
-
"""
|
|
513
|
-
|
|
514
|
-
|
|
554
|
+
def _execute_linting(orchestrator, path_obj, recursive):
|
|
555
|
+
"""Execute linting on file or directory."""
|
|
556
|
+
if path_obj.is_file():
|
|
557
|
+
return orchestrator.lint_file(path_obj)
|
|
558
|
+
return orchestrator.lint_directory(path_obj, recursive=recursive)
|
|
515
559
|
|
|
516
|
-
try:
|
|
517
|
-
_write_config_yaml(ai_dir, layout_file, orchestrator.config)
|
|
518
|
-
_log_config_loaded(config_file, layout_file, verbose)
|
|
519
|
-
except OSError as e:
|
|
520
|
-
_log_layout_error(e, verbose)
|
|
521
560
|
|
|
561
|
+
def _separate_files_and_dirs(path_objs: list[Path]) -> tuple[list[Path], list[Path]]:
|
|
562
|
+
"""Separate file paths from directory paths.
|
|
522
563
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
import yaml
|
|
564
|
+
Args:
|
|
565
|
+
path_objs: List of Path objects
|
|
526
566
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
567
|
+
Returns:
|
|
568
|
+
Tuple of (files, directories)
|
|
569
|
+
"""
|
|
570
|
+
files = [p for p in path_objs if p.is_file()]
|
|
571
|
+
dirs = [p for p in path_objs if p.is_dir()]
|
|
572
|
+
return files, dirs
|
|
530
573
|
|
|
531
574
|
|
|
532
|
-
def
|
|
533
|
-
"""
|
|
534
|
-
if verbose:
|
|
535
|
-
logger.debug(f"Loaded config from: {config_file}")
|
|
536
|
-
logger.debug(f"Written layout config to: {layout_file}")
|
|
575
|
+
def _lint_files_if_any(orchestrator, files: list[Path]) -> list:
|
|
576
|
+
"""Lint files if list is non-empty.
|
|
537
577
|
|
|
578
|
+
Args:
|
|
579
|
+
orchestrator: Orchestrator instance
|
|
580
|
+
files: List of file paths
|
|
538
581
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
582
|
+
Returns:
|
|
583
|
+
List of violations
|
|
584
|
+
"""
|
|
585
|
+
if files:
|
|
586
|
+
return orchestrator.lint_files(files)
|
|
587
|
+
return []
|
|
544
588
|
|
|
545
589
|
|
|
546
|
-
def
|
|
547
|
-
"""
|
|
548
|
-
if format == "json":
|
|
549
|
-
_output_json(violations)
|
|
550
|
-
else:
|
|
551
|
-
_output_text(violations)
|
|
590
|
+
def _lint_directories(orchestrator, dirs: list[Path], recursive: bool) -> list:
|
|
591
|
+
"""Lint all directories.
|
|
552
592
|
|
|
593
|
+
Args:
|
|
594
|
+
orchestrator: Orchestrator instance
|
|
595
|
+
dirs: List of directory paths
|
|
596
|
+
recursive: Whether to scan recursively
|
|
553
597
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
598
|
+
Returns:
|
|
599
|
+
List of violations from all directories
|
|
600
|
+
"""
|
|
601
|
+
violations = []
|
|
602
|
+
for dir_path in dirs:
|
|
603
|
+
violations.extend(orchestrator.lint_directory(dir_path, recursive=recursive))
|
|
604
|
+
return violations
|
|
557
605
|
|
|
558
|
-
output = {
|
|
559
|
-
"violations": [
|
|
560
|
-
{
|
|
561
|
-
"rule_id": v.rule_id,
|
|
562
|
-
"file_path": str(v.file_path),
|
|
563
|
-
"line": v.line,
|
|
564
|
-
"column": v.column,
|
|
565
|
-
"message": v.message,
|
|
566
|
-
"severity": v.severity.name,
|
|
567
|
-
}
|
|
568
|
-
for v in violations
|
|
569
|
-
],
|
|
570
|
-
"total": len(violations),
|
|
571
|
-
}
|
|
572
|
-
click.echo(json.dumps(output, indent=2))
|
|
573
606
|
|
|
607
|
+
def _execute_linting_on_paths(orchestrator, path_objs: list[Path], recursive: bool) -> list:
|
|
608
|
+
"""Execute linting on list of file/directory paths.
|
|
574
609
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
return
|
|
610
|
+
Args:
|
|
611
|
+
orchestrator: Orchestrator instance
|
|
612
|
+
path_objs: List of Path objects (files or directories)
|
|
613
|
+
recursive: Whether to scan directories recursively
|
|
580
614
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
615
|
+
Returns:
|
|
616
|
+
List of violations from all paths
|
|
617
|
+
"""
|
|
618
|
+
files, dirs = _separate_files_and_dirs(path_objs)
|
|
584
619
|
|
|
620
|
+
violations = []
|
|
621
|
+
violations.extend(_lint_files_if_any(orchestrator, files))
|
|
622
|
+
violations.extend(_lint_directories(orchestrator, dirs, recursive))
|
|
585
623
|
|
|
586
|
-
|
|
587
|
-
"""Print a single violation in text format."""
|
|
588
|
-
location = f"{v.file_path}:{v.line}" if v.line else str(v.file_path)
|
|
589
|
-
if v.column:
|
|
590
|
-
location += f":{v.column}"
|
|
591
|
-
click.echo(f" {location}")
|
|
592
|
-
click.echo(f" [{v.severity.name}] {v.rule_id}: {v.message}")
|
|
593
|
-
click.echo()
|
|
624
|
+
return violations
|
|
594
625
|
|
|
595
626
|
|
|
596
|
-
def _setup_nesting_orchestrator(
|
|
627
|
+
def _setup_nesting_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
|
|
597
628
|
"""Set up orchestrator for nesting command."""
|
|
598
|
-
|
|
629
|
+
# Use first path to determine project root
|
|
630
|
+
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
631
|
+
project_root = first_path if first_path.is_dir() else first_path.parent
|
|
632
|
+
|
|
599
633
|
from src.orchestrator.core import Orchestrator
|
|
600
634
|
|
|
601
635
|
orchestrator = Orchestrator(project_root=project_root)
|
|
@@ -619,45 +653,56 @@ def _apply_nesting_config_override(orchestrator, max_depth: int | None, verbose:
|
|
|
619
653
|
logger.debug(f"Overriding max_nesting_depth to {max_depth}")
|
|
620
654
|
|
|
621
655
|
|
|
622
|
-
def _run_nesting_lint(orchestrator,
|
|
623
|
-
"""Execute nesting lint on
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
else:
|
|
627
|
-
violations = orchestrator.lint_directory(path_obj, recursive=recursive)
|
|
628
|
-
|
|
629
|
-
return [v for v in violations if "nesting" in v.rule_id]
|
|
656
|
+
def _run_nesting_lint(orchestrator, path_objs: list[Path], recursive: bool):
|
|
657
|
+
"""Execute nesting lint on files or directories."""
|
|
658
|
+
all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
|
|
659
|
+
return [v for v in all_violations if "nesting" in v.rule_id]
|
|
630
660
|
|
|
631
661
|
|
|
632
662
|
@cli.command("nesting")
|
|
633
|
-
@click.argument("
|
|
663
|
+
@click.argument("paths", nargs=-1, type=click.Path())
|
|
634
664
|
@click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
|
|
635
|
-
@
|
|
636
|
-
"--format", "-f", type=click.Choice(["text", "json"]), default="text", help="Output format"
|
|
637
|
-
)
|
|
665
|
+
@format_option
|
|
638
666
|
@click.option("--max-depth", type=int, help="Override max nesting depth (default: 4)")
|
|
639
667
|
@click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
|
|
640
668
|
@click.pass_context
|
|
641
669
|
def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
642
|
-
ctx,
|
|
670
|
+
ctx,
|
|
671
|
+
paths: tuple[str, ...],
|
|
672
|
+
config_file: str | None,
|
|
673
|
+
format: str,
|
|
674
|
+
max_depth: int | None,
|
|
675
|
+
recursive: bool,
|
|
643
676
|
):
|
|
644
677
|
"""Check for excessive nesting depth in code.
|
|
645
678
|
|
|
646
679
|
Analyzes Python and TypeScript files for deeply nested code structures
|
|
647
680
|
(if/for/while/try statements) and reports violations.
|
|
648
681
|
|
|
649
|
-
|
|
682
|
+
PATHS: Files or directories to lint (defaults to current directory if none provided)
|
|
650
683
|
|
|
651
684
|
Examples:
|
|
652
685
|
|
|
653
686
|
\b
|
|
654
|
-
# Check current directory
|
|
687
|
+
# Check current directory (all files recursively)
|
|
655
688
|
thai-lint nesting
|
|
656
689
|
|
|
657
690
|
\b
|
|
658
691
|
# Check specific directory
|
|
659
692
|
thai-lint nesting src/
|
|
660
693
|
|
|
694
|
+
\b
|
|
695
|
+
# Check single file
|
|
696
|
+
thai-lint nesting src/app.py
|
|
697
|
+
|
|
698
|
+
\b
|
|
699
|
+
# Check multiple files
|
|
700
|
+
thai-lint nesting src/app.py src/utils.py tests/test_app.py
|
|
701
|
+
|
|
702
|
+
\b
|
|
703
|
+
# Check mix of files and directories
|
|
704
|
+
thai-lint nesting src/app.py tests/
|
|
705
|
+
|
|
661
706
|
\b
|
|
662
707
|
# Use custom max depth
|
|
663
708
|
thai-lint nesting --max-depth 3 src/
|
|
@@ -671,28 +716,366 @@ def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
|
671
716
|
thai-lint nesting --config .thailint.yaml src/
|
|
672
717
|
"""
|
|
673
718
|
verbose = ctx.obj.get("verbose", False)
|
|
674
|
-
|
|
719
|
+
|
|
720
|
+
# Default to current directory if no paths provided
|
|
721
|
+
if not paths:
|
|
722
|
+
paths = (".",)
|
|
723
|
+
|
|
724
|
+
path_objs = [Path(p) for p in paths]
|
|
675
725
|
|
|
676
726
|
try:
|
|
677
|
-
_execute_nesting_lint(
|
|
727
|
+
_execute_nesting_lint(path_objs, config_file, format, max_depth, recursive, verbose)
|
|
678
728
|
except Exception as e:
|
|
679
729
|
_handle_linting_error(e, verbose)
|
|
680
730
|
|
|
681
731
|
|
|
682
732
|
def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
683
|
-
|
|
733
|
+
path_objs, config_file, format, max_depth, recursive, verbose
|
|
684
734
|
):
|
|
685
735
|
"""Execute nesting lint."""
|
|
686
|
-
|
|
736
|
+
_validate_paths_exist(path_objs)
|
|
737
|
+
orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose)
|
|
687
738
|
_apply_nesting_config_override(orchestrator, max_depth, verbose)
|
|
688
|
-
nesting_violations = _run_nesting_lint(orchestrator,
|
|
739
|
+
nesting_violations = _run_nesting_lint(orchestrator, path_objs, recursive)
|
|
689
740
|
|
|
690
741
|
if verbose:
|
|
691
742
|
logger.info(f"Found {len(nesting_violations)} nesting violation(s)")
|
|
692
743
|
|
|
693
|
-
|
|
744
|
+
format_violations(nesting_violations, format)
|
|
694
745
|
sys.exit(1 if nesting_violations else 0)
|
|
695
746
|
|
|
696
747
|
|
|
748
|
+
def _setup_srp_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
|
|
749
|
+
"""Set up orchestrator for SRP command."""
|
|
750
|
+
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
751
|
+
project_root = first_path if first_path.is_dir() else first_path.parent
|
|
752
|
+
|
|
753
|
+
from src.orchestrator.core import Orchestrator
|
|
754
|
+
|
|
755
|
+
orchestrator = Orchestrator(project_root=project_root)
|
|
756
|
+
|
|
757
|
+
if config_file:
|
|
758
|
+
_load_config_file(orchestrator, config_file, verbose)
|
|
759
|
+
|
|
760
|
+
return orchestrator
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _apply_srp_config_override(
|
|
764
|
+
orchestrator, max_methods: int | None, max_loc: int | None, verbose: bool
|
|
765
|
+
):
|
|
766
|
+
"""Apply max_methods and max_loc overrides to orchestrator config."""
|
|
767
|
+
if max_methods is None and max_loc is None:
|
|
768
|
+
return
|
|
769
|
+
|
|
770
|
+
if "srp" not in orchestrator.config:
|
|
771
|
+
orchestrator.config["srp"] = {}
|
|
772
|
+
|
|
773
|
+
_apply_srp_max_methods(orchestrator, max_methods, verbose)
|
|
774
|
+
_apply_srp_max_loc(orchestrator, max_loc, verbose)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _apply_srp_max_methods(orchestrator, max_methods: int | None, verbose: bool):
|
|
778
|
+
"""Apply max_methods override."""
|
|
779
|
+
if max_methods is not None:
|
|
780
|
+
orchestrator.config["srp"]["max_methods"] = max_methods
|
|
781
|
+
if verbose:
|
|
782
|
+
logger.debug(f"Overriding max_methods to {max_methods}")
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _apply_srp_max_loc(orchestrator, max_loc: int | None, verbose: bool):
|
|
786
|
+
"""Apply max_loc override."""
|
|
787
|
+
if max_loc is not None:
|
|
788
|
+
orchestrator.config["srp"]["max_loc"] = max_loc
|
|
789
|
+
if verbose:
|
|
790
|
+
logger.debug(f"Overriding max_loc to {max_loc}")
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _run_srp_lint(orchestrator, path_objs: list[Path], recursive: bool):
|
|
794
|
+
"""Execute SRP lint on files or directories."""
|
|
795
|
+
all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
|
|
796
|
+
return [v for v in all_violations if "srp" in v.rule_id]
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
@cli.command("srp")
|
|
800
|
+
@click.argument("paths", nargs=-1, type=click.Path())
|
|
801
|
+
@click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
|
|
802
|
+
@format_option
|
|
803
|
+
@click.option("--max-methods", type=int, help="Override max methods per class (default: 7)")
|
|
804
|
+
@click.option("--max-loc", type=int, help="Override max lines of code per class (default: 200)")
|
|
805
|
+
@click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
|
|
806
|
+
@click.pass_context
|
|
807
|
+
def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
808
|
+
ctx,
|
|
809
|
+
paths: tuple[str, ...],
|
|
810
|
+
config_file: str | None,
|
|
811
|
+
format: str,
|
|
812
|
+
max_methods: int | None,
|
|
813
|
+
max_loc: int | None,
|
|
814
|
+
recursive: bool,
|
|
815
|
+
):
|
|
816
|
+
"""Check for Single Responsibility Principle violations.
|
|
817
|
+
|
|
818
|
+
Analyzes Python and TypeScript classes for SRP violations using heuristics:
|
|
819
|
+
- Method count exceeding threshold (default: 7)
|
|
820
|
+
- Lines of code exceeding threshold (default: 200)
|
|
821
|
+
- Responsibility keywords in class names (Manager, Handler, Processor, etc.)
|
|
822
|
+
|
|
823
|
+
PATHS: Files or directories to lint (defaults to current directory if none provided)
|
|
824
|
+
|
|
825
|
+
Examples:
|
|
826
|
+
|
|
827
|
+
\b
|
|
828
|
+
# Check current directory (all files recursively)
|
|
829
|
+
thai-lint srp
|
|
830
|
+
|
|
831
|
+
\b
|
|
832
|
+
# Check specific directory
|
|
833
|
+
thai-lint srp src/
|
|
834
|
+
|
|
835
|
+
\b
|
|
836
|
+
# Check single file
|
|
837
|
+
thai-lint srp src/app.py
|
|
838
|
+
|
|
839
|
+
\b
|
|
840
|
+
# Check multiple files
|
|
841
|
+
thai-lint srp src/app.py src/service.py tests/test_app.py
|
|
842
|
+
|
|
843
|
+
\b
|
|
844
|
+
# Use custom thresholds
|
|
845
|
+
thai-lint srp --max-methods 10 --max-loc 300 src/
|
|
846
|
+
|
|
847
|
+
\b
|
|
848
|
+
# Get JSON output
|
|
849
|
+
thai-lint srp --format json .
|
|
850
|
+
|
|
851
|
+
\b
|
|
852
|
+
# Use custom config file
|
|
853
|
+
thai-lint srp --config .thailint.yaml src/
|
|
854
|
+
"""
|
|
855
|
+
verbose = ctx.obj.get("verbose", False)
|
|
856
|
+
|
|
857
|
+
if not paths:
|
|
858
|
+
paths = (".",)
|
|
859
|
+
|
|
860
|
+
path_objs = [Path(p) for p in paths]
|
|
861
|
+
|
|
862
|
+
try:
|
|
863
|
+
_execute_srp_lint(path_objs, config_file, format, max_methods, max_loc, recursive, verbose)
|
|
864
|
+
except Exception as e:
|
|
865
|
+
_handle_linting_error(e, verbose)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
869
|
+
path_objs, config_file, format, max_methods, max_loc, recursive, verbose
|
|
870
|
+
):
|
|
871
|
+
"""Execute SRP lint."""
|
|
872
|
+
_validate_paths_exist(path_objs)
|
|
873
|
+
orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose)
|
|
874
|
+
_apply_srp_config_override(orchestrator, max_methods, max_loc, verbose)
|
|
875
|
+
srp_violations = _run_srp_lint(orchestrator, path_objs, recursive)
|
|
876
|
+
|
|
877
|
+
if verbose:
|
|
878
|
+
logger.info(f"Found {len(srp_violations)} SRP violation(s)")
|
|
879
|
+
|
|
880
|
+
format_violations(srp_violations, format)
|
|
881
|
+
sys.exit(1 if srp_violations else 0)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@cli.command("dry")
|
|
885
|
+
@click.argument("paths", nargs=-1, type=click.Path())
|
|
886
|
+
@click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
|
|
887
|
+
@format_option
|
|
888
|
+
@click.option("--min-lines", type=int, help="Override min duplicate lines threshold")
|
|
889
|
+
@click.option("--no-cache", is_flag=True, help="Disable SQLite cache (force rehash)")
|
|
890
|
+
@click.option("--clear-cache", is_flag=True, help="Clear cache before running")
|
|
891
|
+
@click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
|
|
892
|
+
@click.pass_context
|
|
893
|
+
def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
894
|
+
ctx,
|
|
895
|
+
paths: tuple[str, ...],
|
|
896
|
+
config_file: str | None,
|
|
897
|
+
format: str,
|
|
898
|
+
min_lines: int | None,
|
|
899
|
+
no_cache: bool,
|
|
900
|
+
clear_cache: bool,
|
|
901
|
+
recursive: bool,
|
|
902
|
+
):
|
|
903
|
+
# Justification for Pylint disables:
|
|
904
|
+
# - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 6 options = 8 params
|
|
905
|
+
# All parameters are necessary for flexible DRY linter CLI usage.
|
|
906
|
+
"""
|
|
907
|
+
Check for duplicate code (DRY principle violations).
|
|
908
|
+
|
|
909
|
+
Detects duplicate code blocks across your project using token-based hashing
|
|
910
|
+
with SQLite caching for fast incremental scans.
|
|
911
|
+
|
|
912
|
+
PATHS: Files or directories to lint (defaults to current directory if none provided)
|
|
913
|
+
|
|
914
|
+
Examples:
|
|
915
|
+
|
|
916
|
+
\b
|
|
917
|
+
# Check current directory (all files recursively)
|
|
918
|
+
thai-lint dry
|
|
919
|
+
|
|
920
|
+
\b
|
|
921
|
+
# Check specific directory
|
|
922
|
+
thai-lint dry src/
|
|
923
|
+
|
|
924
|
+
\b
|
|
925
|
+
# Check single file
|
|
926
|
+
thai-lint dry src/app.py
|
|
927
|
+
|
|
928
|
+
\b
|
|
929
|
+
# Check multiple files
|
|
930
|
+
thai-lint dry src/app.py src/service.py tests/test_app.py
|
|
931
|
+
|
|
932
|
+
\b
|
|
933
|
+
# Use custom config file
|
|
934
|
+
thai-lint dry --config .thailint.yaml src/
|
|
935
|
+
|
|
936
|
+
\b
|
|
937
|
+
# Override minimum duplicate lines threshold
|
|
938
|
+
thai-lint dry --min-lines 5 .
|
|
939
|
+
|
|
940
|
+
\b
|
|
941
|
+
# Disable cache (force re-analysis)
|
|
942
|
+
thai-lint dry --no-cache .
|
|
943
|
+
|
|
944
|
+
\b
|
|
945
|
+
# Clear cache before running
|
|
946
|
+
thai-lint dry --clear-cache .
|
|
947
|
+
|
|
948
|
+
\b
|
|
949
|
+
# Get JSON output
|
|
950
|
+
thai-lint dry --format json .
|
|
951
|
+
"""
|
|
952
|
+
verbose = ctx.obj.get("verbose", False)
|
|
953
|
+
|
|
954
|
+
if not paths:
|
|
955
|
+
paths = (".",)
|
|
956
|
+
|
|
957
|
+
path_objs = [Path(p) for p in paths]
|
|
958
|
+
|
|
959
|
+
try:
|
|
960
|
+
_execute_dry_lint(
|
|
961
|
+
path_objs, config_file, format, min_lines, no_cache, clear_cache, recursive, verbose
|
|
962
|
+
)
|
|
963
|
+
except Exception as e:
|
|
964
|
+
_handle_linting_error(e, verbose)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
968
|
+
path_objs, config_file, format, min_lines, no_cache, clear_cache, recursive, verbose
|
|
969
|
+
):
|
|
970
|
+
"""Execute DRY linting."""
|
|
971
|
+
_validate_paths_exist(path_objs)
|
|
972
|
+
orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose)
|
|
973
|
+
_apply_dry_config_override(orchestrator, min_lines, no_cache, verbose)
|
|
974
|
+
|
|
975
|
+
if clear_cache:
|
|
976
|
+
_clear_dry_cache(orchestrator, verbose)
|
|
977
|
+
|
|
978
|
+
dry_violations = _run_dry_lint(orchestrator, path_objs, recursive)
|
|
979
|
+
|
|
980
|
+
if verbose:
|
|
981
|
+
logger.info(f"Found {len(dry_violations)} DRY violation(s)")
|
|
982
|
+
|
|
983
|
+
format_violations(dry_violations, format)
|
|
984
|
+
sys.exit(1 if dry_violations else 0)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _setup_dry_orchestrator(path_objs, config_file, verbose):
|
|
988
|
+
"""Set up orchestrator for DRY linting."""
|
|
989
|
+
from src.orchestrator.core import Orchestrator
|
|
990
|
+
from src.utils.project_root import get_project_root
|
|
991
|
+
|
|
992
|
+
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
993
|
+
search_start = first_path if first_path.is_dir() else first_path.parent
|
|
994
|
+
project_root = get_project_root(search_start)
|
|
995
|
+
|
|
996
|
+
orchestrator = Orchestrator(project_root=project_root)
|
|
997
|
+
|
|
998
|
+
if config_file:
|
|
999
|
+
_load_dry_config_file(orchestrator, config_file, verbose)
|
|
1000
|
+
|
|
1001
|
+
return orchestrator
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _load_dry_config_file(orchestrator, config_file, verbose):
|
|
1005
|
+
"""Load DRY configuration from file."""
|
|
1006
|
+
import yaml
|
|
1007
|
+
|
|
1008
|
+
config_path = Path(config_file)
|
|
1009
|
+
if not config_path.exists():
|
|
1010
|
+
click.echo(f"Error: Config file not found: {config_file}", err=True)
|
|
1011
|
+
sys.exit(2)
|
|
1012
|
+
|
|
1013
|
+
with config_path.open("r", encoding="utf-8") as f:
|
|
1014
|
+
config = yaml.safe_load(f)
|
|
1015
|
+
|
|
1016
|
+
if "dry" in config:
|
|
1017
|
+
orchestrator.config.update({"dry": config["dry"]})
|
|
1018
|
+
|
|
1019
|
+
if verbose:
|
|
1020
|
+
logger.info(f"Loaded DRY config from {config_file}")
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose):
|
|
1024
|
+
"""Apply CLI option overrides to DRY config."""
|
|
1025
|
+
_ensure_dry_config_exists(orchestrator)
|
|
1026
|
+
_apply_min_lines_override(orchestrator, min_lines, verbose)
|
|
1027
|
+
_apply_cache_override(orchestrator, no_cache, verbose)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _ensure_dry_config_exists(orchestrator):
|
|
1031
|
+
"""Ensure dry config section exists."""
|
|
1032
|
+
if "dry" not in orchestrator.config:
|
|
1033
|
+
orchestrator.config["dry"] = {}
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _apply_min_lines_override(orchestrator, min_lines, verbose):
|
|
1037
|
+
"""Apply min_lines override if provided."""
|
|
1038
|
+
if min_lines is None:
|
|
1039
|
+
return
|
|
1040
|
+
|
|
1041
|
+
orchestrator.config["dry"]["min_duplicate_lines"] = min_lines
|
|
1042
|
+
if verbose:
|
|
1043
|
+
logger.info(f"Override: min_duplicate_lines = {min_lines}")
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _apply_cache_override(orchestrator, no_cache, verbose):
|
|
1047
|
+
"""Apply cache override if requested."""
|
|
1048
|
+
if not no_cache:
|
|
1049
|
+
return
|
|
1050
|
+
|
|
1051
|
+
orchestrator.config["dry"]["cache_enabled"] = False
|
|
1052
|
+
if verbose:
|
|
1053
|
+
logger.info("Override: cache_enabled = False")
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _clear_dry_cache(orchestrator, verbose):
|
|
1057
|
+
"""Clear DRY cache before running."""
|
|
1058
|
+
cache_path_str = orchestrator.config.get("dry", {}).get("cache_path", ".thailint-cache/dry.db")
|
|
1059
|
+
cache_path = orchestrator.project_root / cache_path_str
|
|
1060
|
+
|
|
1061
|
+
if cache_path.exists():
|
|
1062
|
+
cache_path.unlink()
|
|
1063
|
+
if verbose:
|
|
1064
|
+
logger.info(f"Cleared cache: {cache_path}")
|
|
1065
|
+
else:
|
|
1066
|
+
if verbose:
|
|
1067
|
+
logger.info("Cache file does not exist, nothing to clear")
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _run_dry_lint(orchestrator, path_objs, recursive):
|
|
1071
|
+
"""Run DRY linting and return violations."""
|
|
1072
|
+
all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
|
|
1073
|
+
|
|
1074
|
+
# Filter to only DRY violations
|
|
1075
|
+
dry_violations = [v for v in all_violations if v.rule_id.startswith("dry.")]
|
|
1076
|
+
|
|
1077
|
+
return dry_violations
|
|
1078
|
+
|
|
1079
|
+
|
|
697
1080
|
if __name__ == "__main__":
|
|
698
1081
|
cli()
|