thailint 0.1.5__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +498 -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.5.dist-info → thailint-0.2.0.dist-info}/METADATA +414 -63
  63. thailint-0.2.0.dist-info/RECORD +75 -0
  64. src/.ai/layout.yaml +0 -48
  65. thailint-0.1.5.dist-info/RECORD +0 -28
  66. {thailint-0.1.5.dist-info → thailint-0.2.0.dist-info}/LICENSE +0 -0
  67. {thailint-0.1.5.dist-info → thailint-0.2.0.dist-info}/WHEEL +0 -0
  68. {thailint-0.1.5.dist-info → thailint-0.2.0.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(exists=True))
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,32 @@ 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
+ orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose)
436
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
437
+
438
+ # Filter to only file-placement violations
439
+ violations = [v for v in all_violations if v.rule_id.startswith("file-placement")]
407
440
 
408
441
  if verbose:
409
442
  logger.info(f"Found {len(violations)} violation(s)")
410
443
 
411
- _output_violations(violations, format)
444
+ format_violations(violations, format)
412
445
  sys.exit(1 if violations else 0)
413
446
 
414
447
 
@@ -420,11 +453,33 @@ def _handle_linting_error(error: Exception, verbose: bool) -> None:
420
453
  sys.exit(2)
421
454
 
422
455
 
423
- def _setup_orchestrator(path_obj, config_file, rules, verbose):
456
+ def _find_project_root(start_path: Path) -> Path:
457
+ """Find project root by looking for .git or pyproject.toml.
458
+
459
+ DEPRECATED: Use src.utils.project_root.get_project_root() instead.
460
+
461
+ Args:
462
+ start_path: Directory to start searching from
463
+
464
+ Returns:
465
+ Path to project root, or start_path if no markers found
466
+ """
467
+ from src.utils.project_root import get_project_root
468
+
469
+ return get_project_root(start_path)
470
+
471
+
472
+ def _setup_orchestrator(path_objs, config_file, rules, verbose):
424
473
  """Set up and configure the orchestrator."""
425
474
  from src.orchestrator.core import Orchestrator
475
+ from src.utils.project_root import get_project_root
476
+
477
+ # Find actual project root (where .git or pyproject.toml exists)
478
+ # This ensures .artifacts/ is always created at project root, not in subdirectories
479
+ first_path = path_objs[0] if path_objs else Path.cwd()
480
+ search_start = first_path if first_path.is_dir() else first_path.parent
481
+ project_root = get_project_root(search_start)
426
482
 
427
- project_root = path_obj if path_obj.is_dir() else path_obj.parent
428
483
  orchestrator = Orchestrator(project_root=project_root)
429
484
 
430
485
  if rules:
@@ -439,7 +494,6 @@ def _apply_inline_rules(orchestrator, rules, verbose):
439
494
  """Parse and apply inline JSON rules."""
440
495
  rules_config = _parse_json_rules(rules)
441
496
  orchestrator.config.update(rules_config)
442
- _write_layout_config(orchestrator, rules_config, verbose)
443
497
  _log_applied_rules(rules_config, verbose)
444
498
 
445
499
 
@@ -454,40 +508,6 @@ def _parse_json_rules(rules: str) -> dict:
454
508
  sys.exit(2)
455
509
 
456
510
 
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
511
  def _log_applied_rules(rules_config: dict, verbose: bool) -> None:
492
512
  """Log applied rules if verbose."""
493
513
  if verbose:
@@ -504,98 +524,89 @@ def _load_config_file(orchestrator, config_file, verbose):
504
524
  # Load config into orchestrator
505
525
  orchestrator.config = orchestrator.config_loader.load(config_path)
506
526
 
507
- # Also copy to .ai/layout.yaml for file-placement linter
508
- _write_loaded_config_to_layout(orchestrator, config_file, verbose)
527
+ if verbose:
528
+ logger.debug(f"Loaded config from: {config_file}")
509
529
 
510
530
 
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"
531
+ def _execute_linting(orchestrator, path_obj, recursive):
532
+ """Execute linting on file or directory."""
533
+ if path_obj.is_file():
534
+ return orchestrator.lint_file(path_obj)
535
+ return orchestrator.lint_directory(path_obj, recursive=recursive)
515
536
 
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
537
 
538
+ def _separate_files_and_dirs(path_objs: list[Path]) -> tuple[list[Path], list[Path]]:
539
+ """Separate file paths from directory paths.
522
540
 
523
- def _write_config_yaml(ai_dir, layout_file, config):
524
- """Write config to YAML file."""
525
- import yaml
541
+ Args:
542
+ path_objs: List of Path objects
526
543
 
527
- ai_dir.mkdir(exist_ok=True)
528
- with layout_file.open("w", encoding="utf-8") as f:
529
- yaml.dump(config, f)
544
+ Returns:
545
+ Tuple of (files, directories)
546
+ """
547
+ files = [p for p in path_objs if p.is_file()]
548
+ dirs = [p for p in path_objs if p.is_dir()]
549
+ return files, dirs
530
550
 
531
551
 
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}")
552
+ def _lint_files_if_any(orchestrator, files: list[Path]) -> list:
553
+ """Lint files if list is non-empty.
537
554
 
555
+ Args:
556
+ orchestrator: Orchestrator instance
557
+ files: List of file paths
538
558
 
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)
559
+ Returns:
560
+ List of violations
561
+ """
562
+ if files:
563
+ return orchestrator.lint_files(files)
564
+ return []
544
565
 
545
566
 
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)
567
+ def _lint_directories(orchestrator, dirs: list[Path], recursive: bool) -> list:
568
+ """Lint all directories.
552
569
 
570
+ Args:
571
+ orchestrator: Orchestrator instance
572
+ dirs: List of directory paths
573
+ recursive: Whether to scan recursively
553
574
 
554
- def _output_json(violations):
555
- """Output violations in JSON format."""
556
- import json
575
+ Returns:
576
+ List of violations from all directories
577
+ """
578
+ violations = []
579
+ for dir_path in dirs:
580
+ violations.extend(orchestrator.lint_directory(dir_path, recursive=recursive))
581
+ return violations
557
582
 
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
583
 
584
+ def _execute_linting_on_paths(orchestrator, path_objs: list[Path], recursive: bool) -> list:
585
+ """Execute linting on list of file/directory paths.
574
586
 
575
- def _output_text(violations):
576
- """Output violations in text format."""
577
- if not violations:
578
- click.echo("✓ No violations found")
579
- return
587
+ Args:
588
+ orchestrator: Orchestrator instance
589
+ path_objs: List of Path objects (files or directories)
590
+ recursive: Whether to scan directories recursively
580
591
 
581
- click.echo(f"Found {len(violations)} violation(s):\n")
582
- for v in violations:
583
- _print_violation(v)
592
+ Returns:
593
+ List of violations from all paths
594
+ """
595
+ files, dirs = _separate_files_and_dirs(path_objs)
584
596
 
597
+ violations = []
598
+ violations.extend(_lint_files_if_any(orchestrator, files))
599
+ violations.extend(_lint_directories(orchestrator, dirs, recursive))
585
600
 
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()
601
+ return violations
594
602
 
595
603
 
596
- def _setup_nesting_orchestrator(path_obj: Path, config_file: str | None, verbose: bool):
604
+ def _setup_nesting_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
597
605
  """Set up orchestrator for nesting command."""
598
- project_root = path_obj if path_obj.is_dir() else path_obj.parent
606
+ # Use first path to determine project root
607
+ first_path = path_objs[0] if path_objs else Path.cwd()
608
+ project_root = first_path if first_path.is_dir() else first_path.parent
609
+
599
610
  from src.orchestrator.core import Orchestrator
600
611
 
601
612
  orchestrator = Orchestrator(project_root=project_root)
@@ -619,45 +630,56 @@ def _apply_nesting_config_override(orchestrator, max_depth: int | None, verbose:
619
630
  logger.debug(f"Overriding max_nesting_depth to {max_depth}")
620
631
 
621
632
 
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]
633
+ def _run_nesting_lint(orchestrator, path_objs: list[Path], recursive: bool):
634
+ """Execute nesting lint on files or directories."""
635
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
636
+ return [v for v in all_violations if "nesting" in v.rule_id]
630
637
 
631
638
 
632
639
  @cli.command("nesting")
633
- @click.argument("path", type=click.Path(exists=True), default=".")
640
+ @click.argument("paths", nargs=-1, type=click.Path(exists=True))
634
641
  @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
- )
642
+ @format_option
638
643
  @click.option("--max-depth", type=int, help="Override max nesting depth (default: 4)")
639
644
  @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
640
645
  @click.pass_context
641
646
  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
647
+ ctx,
648
+ paths: tuple[str, ...],
649
+ config_file: str | None,
650
+ format: str,
651
+ max_depth: int | None,
652
+ recursive: bool,
643
653
  ):
644
654
  """Check for excessive nesting depth in code.
645
655
 
646
656
  Analyzes Python and TypeScript files for deeply nested code structures
647
657
  (if/for/while/try statements) and reports violations.
648
658
 
649
- PATH: File or directory to lint (defaults to current directory)
659
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
650
660
 
651
661
  Examples:
652
662
 
653
663
  \b
654
- # Check current directory
664
+ # Check current directory (all files recursively)
655
665
  thai-lint nesting
656
666
 
657
667
  \b
658
668
  # Check specific directory
659
669
  thai-lint nesting src/
660
670
 
671
+ \b
672
+ # Check single file
673
+ thai-lint nesting src/app.py
674
+
675
+ \b
676
+ # Check multiple files
677
+ thai-lint nesting src/app.py src/utils.py tests/test_app.py
678
+
679
+ \b
680
+ # Check mix of files and directories
681
+ thai-lint nesting src/app.py tests/
682
+
661
683
  \b
662
684
  # Use custom max depth
663
685
  thai-lint nesting --max-depth 3 src/
@@ -671,28 +693,363 @@ def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
671
693
  thai-lint nesting --config .thailint.yaml src/
672
694
  """
673
695
  verbose = ctx.obj.get("verbose", False)
674
- path_obj = Path(path)
696
+
697
+ # Default to current directory if no paths provided
698
+ if not paths:
699
+ paths = (".",)
700
+
701
+ path_objs = [Path(p) for p in paths]
675
702
 
676
703
  try:
677
- _execute_nesting_lint(path_obj, config_file, format, max_depth, recursive, verbose)
704
+ _execute_nesting_lint(path_objs, config_file, format, max_depth, recursive, verbose)
678
705
  except Exception as e:
679
706
  _handle_linting_error(e, verbose)
680
707
 
681
708
 
682
709
  def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
683
- path_obj, config_file, format, max_depth, recursive, verbose
710
+ path_objs, config_file, format, max_depth, recursive, verbose
684
711
  ):
685
712
  """Execute nesting lint."""
686
- orchestrator = _setup_nesting_orchestrator(path_obj, config_file, verbose)
713
+ orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose)
687
714
  _apply_nesting_config_override(orchestrator, max_depth, verbose)
688
- nesting_violations = _run_nesting_lint(orchestrator, path_obj, recursive)
715
+ nesting_violations = _run_nesting_lint(orchestrator, path_objs, recursive)
689
716
 
690
717
  if verbose:
691
718
  logger.info(f"Found {len(nesting_violations)} nesting violation(s)")
692
719
 
693
- _output_violations(nesting_violations, format)
720
+ format_violations(nesting_violations, format)
694
721
  sys.exit(1 if nesting_violations else 0)
695
722
 
696
723
 
724
+ def _setup_srp_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
725
+ """Set up orchestrator for SRP command."""
726
+ first_path = path_objs[0] if path_objs else Path.cwd()
727
+ project_root = first_path if first_path.is_dir() else first_path.parent
728
+
729
+ from src.orchestrator.core import Orchestrator
730
+
731
+ orchestrator = Orchestrator(project_root=project_root)
732
+
733
+ if config_file:
734
+ _load_config_file(orchestrator, config_file, verbose)
735
+
736
+ return orchestrator
737
+
738
+
739
+ def _apply_srp_config_override(
740
+ orchestrator, max_methods: int | None, max_loc: int | None, verbose: bool
741
+ ):
742
+ """Apply max_methods and max_loc overrides to orchestrator config."""
743
+ if max_methods is None and max_loc is None:
744
+ return
745
+
746
+ if "srp" not in orchestrator.config:
747
+ orchestrator.config["srp"] = {}
748
+
749
+ _apply_srp_max_methods(orchestrator, max_methods, verbose)
750
+ _apply_srp_max_loc(orchestrator, max_loc, verbose)
751
+
752
+
753
+ def _apply_srp_max_methods(orchestrator, max_methods: int | None, verbose: bool):
754
+ """Apply max_methods override."""
755
+ if max_methods is not None:
756
+ orchestrator.config["srp"]["max_methods"] = max_methods
757
+ if verbose:
758
+ logger.debug(f"Overriding max_methods to {max_methods}")
759
+
760
+
761
+ def _apply_srp_max_loc(orchestrator, max_loc: int | None, verbose: bool):
762
+ """Apply max_loc override."""
763
+ if max_loc is not None:
764
+ orchestrator.config["srp"]["max_loc"] = max_loc
765
+ if verbose:
766
+ logger.debug(f"Overriding max_loc to {max_loc}")
767
+
768
+
769
+ def _run_srp_lint(orchestrator, path_objs: list[Path], recursive: bool):
770
+ """Execute SRP lint on files or directories."""
771
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
772
+ return [v for v in all_violations if "srp" in v.rule_id]
773
+
774
+
775
+ @cli.command("srp")
776
+ @click.argument("paths", nargs=-1, type=click.Path(exists=True))
777
+ @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
778
+ @format_option
779
+ @click.option("--max-methods", type=int, help="Override max methods per class (default: 7)")
780
+ @click.option("--max-loc", type=int, help="Override max lines of code per class (default: 200)")
781
+ @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
782
+ @click.pass_context
783
+ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
784
+ ctx,
785
+ paths: tuple[str, ...],
786
+ config_file: str | None,
787
+ format: str,
788
+ max_methods: int | None,
789
+ max_loc: int | None,
790
+ recursive: bool,
791
+ ):
792
+ """Check for Single Responsibility Principle violations.
793
+
794
+ Analyzes Python and TypeScript classes for SRP violations using heuristics:
795
+ - Method count exceeding threshold (default: 7)
796
+ - Lines of code exceeding threshold (default: 200)
797
+ - Responsibility keywords in class names (Manager, Handler, Processor, etc.)
798
+
799
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
800
+
801
+ Examples:
802
+
803
+ \b
804
+ # Check current directory (all files recursively)
805
+ thai-lint srp
806
+
807
+ \b
808
+ # Check specific directory
809
+ thai-lint srp src/
810
+
811
+ \b
812
+ # Check single file
813
+ thai-lint srp src/app.py
814
+
815
+ \b
816
+ # Check multiple files
817
+ thai-lint srp src/app.py src/service.py tests/test_app.py
818
+
819
+ \b
820
+ # Use custom thresholds
821
+ thai-lint srp --max-methods 10 --max-loc 300 src/
822
+
823
+ \b
824
+ # Get JSON output
825
+ thai-lint srp --format json .
826
+
827
+ \b
828
+ # Use custom config file
829
+ thai-lint srp --config .thailint.yaml src/
830
+ """
831
+ verbose = ctx.obj.get("verbose", False)
832
+
833
+ if not paths:
834
+ paths = (".",)
835
+
836
+ path_objs = [Path(p) for p in paths]
837
+
838
+ try:
839
+ _execute_srp_lint(path_objs, config_file, format, max_methods, max_loc, recursive, verbose)
840
+ except Exception as e:
841
+ _handle_linting_error(e, verbose)
842
+
843
+
844
+ def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
845
+ path_objs, config_file, format, max_methods, max_loc, recursive, verbose
846
+ ):
847
+ """Execute SRP lint."""
848
+ orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose)
849
+ _apply_srp_config_override(orchestrator, max_methods, max_loc, verbose)
850
+ srp_violations = _run_srp_lint(orchestrator, path_objs, recursive)
851
+
852
+ if verbose:
853
+ logger.info(f"Found {len(srp_violations)} SRP violation(s)")
854
+
855
+ format_violations(srp_violations, format)
856
+ sys.exit(1 if srp_violations else 0)
857
+
858
+
859
+ @cli.command("dry")
860
+ @click.argument("paths", nargs=-1, type=click.Path(exists=True))
861
+ @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
862
+ @format_option
863
+ @click.option("--min-lines", type=int, help="Override min duplicate lines threshold")
864
+ @click.option("--no-cache", is_flag=True, help="Disable SQLite cache (force rehash)")
865
+ @click.option("--clear-cache", is_flag=True, help="Clear cache before running")
866
+ @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
867
+ @click.pass_context
868
+ def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
869
+ ctx,
870
+ paths: tuple[str, ...],
871
+ config_file: str | None,
872
+ format: str,
873
+ min_lines: int | None,
874
+ no_cache: bool,
875
+ clear_cache: bool,
876
+ recursive: bool,
877
+ ):
878
+ # Justification for Pylint disables:
879
+ # - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 6 options = 8 params
880
+ # All parameters are necessary for flexible DRY linter CLI usage.
881
+ """
882
+ Check for duplicate code (DRY principle violations).
883
+
884
+ Detects duplicate code blocks across your project using token-based hashing
885
+ with SQLite caching for fast incremental scans.
886
+
887
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
888
+
889
+ Examples:
890
+
891
+ \b
892
+ # Check current directory (all files recursively)
893
+ thai-lint dry
894
+
895
+ \b
896
+ # Check specific directory
897
+ thai-lint dry src/
898
+
899
+ \b
900
+ # Check single file
901
+ thai-lint dry src/app.py
902
+
903
+ \b
904
+ # Check multiple files
905
+ thai-lint dry src/app.py src/service.py tests/test_app.py
906
+
907
+ \b
908
+ # Use custom config file
909
+ thai-lint dry --config .thailint.yaml src/
910
+
911
+ \b
912
+ # Override minimum duplicate lines threshold
913
+ thai-lint dry --min-lines 5 .
914
+
915
+ \b
916
+ # Disable cache (force re-analysis)
917
+ thai-lint dry --no-cache .
918
+
919
+ \b
920
+ # Clear cache before running
921
+ thai-lint dry --clear-cache .
922
+
923
+ \b
924
+ # Get JSON output
925
+ thai-lint dry --format json .
926
+ """
927
+ verbose = ctx.obj.get("verbose", False)
928
+
929
+ if not paths:
930
+ paths = (".",)
931
+
932
+ path_objs = [Path(p) for p in paths]
933
+
934
+ try:
935
+ _execute_dry_lint(
936
+ path_objs, config_file, format, min_lines, no_cache, clear_cache, recursive, verbose
937
+ )
938
+ except Exception as e:
939
+ _handle_linting_error(e, verbose)
940
+
941
+
942
+ def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
943
+ path_objs, config_file, format, min_lines, no_cache, clear_cache, recursive, verbose
944
+ ):
945
+ """Execute DRY linting."""
946
+ orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose)
947
+ _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose)
948
+
949
+ if clear_cache:
950
+ _clear_dry_cache(orchestrator, verbose)
951
+
952
+ dry_violations = _run_dry_lint(orchestrator, path_objs, recursive)
953
+
954
+ if verbose:
955
+ logger.info(f"Found {len(dry_violations)} DRY violation(s)")
956
+
957
+ format_violations(dry_violations, format)
958
+ sys.exit(1 if dry_violations else 0)
959
+
960
+
961
+ def _setup_dry_orchestrator(path_objs, config_file, verbose):
962
+ """Set up orchestrator for DRY linting."""
963
+ from src.orchestrator.core import Orchestrator
964
+ from src.utils.project_root import get_project_root
965
+
966
+ first_path = path_objs[0] if path_objs else Path.cwd()
967
+ search_start = first_path if first_path.is_dir() else first_path.parent
968
+ project_root = get_project_root(search_start)
969
+
970
+ orchestrator = Orchestrator(project_root=project_root)
971
+
972
+ if config_file:
973
+ _load_dry_config_file(orchestrator, config_file, verbose)
974
+
975
+ return orchestrator
976
+
977
+
978
+ def _load_dry_config_file(orchestrator, config_file, verbose):
979
+ """Load DRY configuration from file."""
980
+ import yaml
981
+
982
+ config_path = Path(config_file)
983
+ if not config_path.exists():
984
+ click.echo(f"Error: Config file not found: {config_file}", err=True)
985
+ sys.exit(2)
986
+
987
+ with config_path.open("r", encoding="utf-8") as f:
988
+ config = yaml.safe_load(f)
989
+
990
+ if "dry" in config:
991
+ orchestrator.config.update({"dry": config["dry"]})
992
+
993
+ if verbose:
994
+ logger.info(f"Loaded DRY config from {config_file}")
995
+
996
+
997
+ def _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose):
998
+ """Apply CLI option overrides to DRY config."""
999
+ _ensure_dry_config_exists(orchestrator)
1000
+ _apply_min_lines_override(orchestrator, min_lines, verbose)
1001
+ _apply_cache_override(orchestrator, no_cache, verbose)
1002
+
1003
+
1004
+ def _ensure_dry_config_exists(orchestrator):
1005
+ """Ensure dry config section exists."""
1006
+ if "dry" not in orchestrator.config:
1007
+ orchestrator.config["dry"] = {}
1008
+
1009
+
1010
+ def _apply_min_lines_override(orchestrator, min_lines, verbose):
1011
+ """Apply min_lines override if provided."""
1012
+ if min_lines is None:
1013
+ return
1014
+
1015
+ orchestrator.config["dry"]["min_duplicate_lines"] = min_lines
1016
+ if verbose:
1017
+ logger.info(f"Override: min_duplicate_lines = {min_lines}")
1018
+
1019
+
1020
+ def _apply_cache_override(orchestrator, no_cache, verbose):
1021
+ """Apply cache override if requested."""
1022
+ if not no_cache:
1023
+ return
1024
+
1025
+ orchestrator.config["dry"]["cache_enabled"] = False
1026
+ if verbose:
1027
+ logger.info("Override: cache_enabled = False")
1028
+
1029
+
1030
+ def _clear_dry_cache(orchestrator, verbose):
1031
+ """Clear DRY cache before running."""
1032
+ cache_path_str = orchestrator.config.get("dry", {}).get("cache_path", ".thailint-cache/dry.db")
1033
+ cache_path = orchestrator.project_root / cache_path_str
1034
+
1035
+ if cache_path.exists():
1036
+ cache_path.unlink()
1037
+ if verbose:
1038
+ logger.info(f"Cleared cache: {cache_path}")
1039
+ else:
1040
+ if verbose:
1041
+ logger.info("Cache file does not exist, nothing to clear")
1042
+
1043
+
1044
+ def _run_dry_lint(orchestrator, path_objs, recursive):
1045
+ """Run DRY linting and return violations."""
1046
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1047
+
1048
+ # Filter to only DRY violations
1049
+ dry_violations = [v for v in all_violations if v.rule_id.startswith("dry.")]
1050
+
1051
+ return dry_violations
1052
+
1053
+
697
1054
  if __name__ == "__main__":
698
1055
  cli()