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.
Files changed (68) hide show
  1. src/__init__.py +7 -2
  2. src/analyzers/__init__.py +23 -0
  3. src/analyzers/typescript_base.py +148 -0
  4. src/api.py +1 -1
  5. src/cli.py +524 -141
  6. src/config.py +6 -31
  7. src/core/base.py +12 -0
  8. src/core/cli_utils.py +206 -0
  9. src/core/config_parser.py +99 -0
  10. src/core/linter_utils.py +168 -0
  11. src/core/registry.py +17 -92
  12. src/core/rule_discovery.py +132 -0
  13. src/core/violation_builder.py +122 -0
  14. src/linter_config/ignore.py +112 -40
  15. src/linter_config/loader.py +3 -13
  16. src/linters/dry/__init__.py +23 -0
  17. src/linters/dry/base_token_analyzer.py +76 -0
  18. src/linters/dry/block_filter.py +262 -0
  19. src/linters/dry/block_grouper.py +59 -0
  20. src/linters/dry/cache.py +218 -0
  21. src/linters/dry/cache_query.py +61 -0
  22. src/linters/dry/config.py +130 -0
  23. src/linters/dry/config_loader.py +44 -0
  24. src/linters/dry/deduplicator.py +120 -0
  25. src/linters/dry/duplicate_storage.py +126 -0
  26. src/linters/dry/file_analyzer.py +127 -0
  27. src/linters/dry/inline_ignore.py +140 -0
  28. src/linters/dry/linter.py +170 -0
  29. src/linters/dry/python_analyzer.py +517 -0
  30. src/linters/dry/storage_initializer.py +51 -0
  31. src/linters/dry/token_hasher.py +115 -0
  32. src/linters/dry/typescript_analyzer.py +590 -0
  33. src/linters/dry/violation_builder.py +74 -0
  34. src/linters/dry/violation_filter.py +91 -0
  35. src/linters/dry/violation_generator.py +174 -0
  36. src/linters/file_placement/config_loader.py +86 -0
  37. src/linters/file_placement/directory_matcher.py +80 -0
  38. src/linters/file_placement/linter.py +252 -472
  39. src/linters/file_placement/path_resolver.py +61 -0
  40. src/linters/file_placement/pattern_matcher.py +55 -0
  41. src/linters/file_placement/pattern_validator.py +106 -0
  42. src/linters/file_placement/rule_checker.py +229 -0
  43. src/linters/file_placement/violation_factory.py +177 -0
  44. src/linters/nesting/config.py +13 -3
  45. src/linters/nesting/linter.py +76 -152
  46. src/linters/nesting/typescript_analyzer.py +38 -102
  47. src/linters/nesting/typescript_function_extractor.py +130 -0
  48. src/linters/nesting/violation_builder.py +139 -0
  49. src/linters/srp/__init__.py +99 -0
  50. src/linters/srp/class_analyzer.py +113 -0
  51. src/linters/srp/config.py +76 -0
  52. src/linters/srp/heuristics.py +89 -0
  53. src/linters/srp/linter.py +225 -0
  54. src/linters/srp/metrics_evaluator.py +47 -0
  55. src/linters/srp/python_analyzer.py +72 -0
  56. src/linters/srp/typescript_analyzer.py +75 -0
  57. src/linters/srp/typescript_metrics_calculator.py +90 -0
  58. src/linters/srp/violation_builder.py +117 -0
  59. src/orchestrator/core.py +42 -7
  60. src/utils/__init__.py +4 -0
  61. src/utils/project_root.py +84 -0
  62. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/METADATA +414 -63
  63. thailint-0.2.1.dist-info/RECORD +75 -0
  64. src/.ai/layout.yaml +0 -48
  65. thailint-0.1.6.dist-info/RECORD +0 -28
  66. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/LICENSE +0 -0
  67. {thailint-0.1.6.dist-info → thailint-0.2.1.dist-info}/WHEEL +0 -0
  68. {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("path", type=click.Path(exists=True), default=".")
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
- @click.option(
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, path: str, config_file: str | None, rules: str | None, format: str, recursive: bool
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
- PATH: File or directory to lint (defaults to current directory)
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
- path_obj = Path(path)
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(path_obj, config_file, rules, format, recursive, verbose)
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
- path_obj, config_file, rules, format, recursive, verbose
432
+ path_objs, config_file, rules, format, recursive, verbose
403
433
  ):
404
434
  """Execute file placement linting."""
405
- orchestrator = _setup_orchestrator(path_obj, config_file, rules, verbose)
406
- violations = _execute_linting(orchestrator, path_obj, recursive)
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
- _output_violations(violations, format)
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 _setup_orchestrator(path_obj, config_file, rules, verbose):
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
- # Also copy to .ai/layout.yaml for file-placement linter
508
- _write_loaded_config_to_layout(orchestrator, config_file, verbose)
550
+ if verbose:
551
+ logger.debug(f"Loaded config from: {config_file}")
509
552
 
510
553
 
511
- def _write_loaded_config_to_layout(orchestrator, config_file: str, verbose: bool) -> None:
512
- """Write loaded config to .ai/layout.yaml if possible."""
513
- ai_dir = orchestrator.project_root / ".ai"
514
- layout_file = ai_dir / "layout.yaml"
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
- def _write_config_yaml(ai_dir, layout_file, config):
524
- """Write config to YAML file."""
525
- import yaml
564
+ Args:
565
+ path_objs: List of Path objects
526
566
 
527
- ai_dir.mkdir(exist_ok=True)
528
- with layout_file.open("w", encoding="utf-8") as f:
529
- yaml.dump(config, f)
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 _log_config_loaded(config_file, layout_file, verbose):
533
- """Log config loaded and written."""
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
- def _execute_linting(orchestrator, path_obj, recursive):
540
- """Execute linting on file or directory."""
541
- if path_obj.is_file():
542
- return orchestrator.lint_file(path_obj)
543
- return orchestrator.lint_directory(path_obj, recursive=recursive)
582
+ Returns:
583
+ List of violations
584
+ """
585
+ if files:
586
+ return orchestrator.lint_files(files)
587
+ return []
544
588
 
545
589
 
546
- def _output_violations(violations, format):
547
- """Format and output violations."""
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
- def _output_json(violations):
555
- """Output violations in JSON format."""
556
- import json
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
- def _output_text(violations):
576
- """Output violations in text format."""
577
- if not violations:
578
- click.echo("✓ No violations found")
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
- click.echo(f"Found {len(violations)} violation(s):\n")
582
- for v in violations:
583
- _print_violation(v)
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
- def _print_violation(v) -> None:
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(path_obj: Path, config_file: str | None, verbose: bool):
627
+ def _setup_nesting_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
597
628
  """Set up orchestrator for nesting command."""
598
- project_root = path_obj if path_obj.is_dir() else path_obj.parent
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, path_obj: Path, recursive: bool):
623
- """Execute nesting lint on file or directory."""
624
- if path_obj.is_file():
625
- violations = orchestrator.lint_file(path_obj)
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("path", type=click.Path(exists=True), default=".")
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
- @click.option(
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, path: str, config_file: str | None, format: str, max_depth: int | None, recursive: bool
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
- PATH: File or directory to lint (defaults to current directory)
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
- path_obj = Path(path)
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(path_obj, config_file, format, max_depth, recursive, verbose)
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
- path_obj, config_file, format, max_depth, recursive, verbose
733
+ path_objs, config_file, format, max_depth, recursive, verbose
684
734
  ):
685
735
  """Execute nesting lint."""
686
- orchestrator = _setup_nesting_orchestrator(path_obj, config_file, verbose)
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, path_obj, recursive)
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
- _output_violations(nesting_violations, format)
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()