thailint 0.1.5__py3-none-any.whl → 0.5.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 (91) 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 +1111 -144
  6. src/config.py +12 -33
  7. src/core/base.py +102 -5
  8. src/core/cli_utils.py +206 -0
  9. src/core/config_parser.py +126 -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 +265 -0
  19. src/linters/dry/block_grouper.py +59 -0
  20. src/linters/dry/cache.py +172 -0
  21. src/linters/dry/cache_query.py +61 -0
  22. src/linters/dry/config.py +134 -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 +63 -0
  26. src/linters/dry/file_analyzer.py +90 -0
  27. src/linters/dry/inline_ignore.py +140 -0
  28. src/linters/dry/linter.py +163 -0
  29. src/linters/dry/python_analyzer.py +668 -0
  30. src/linters/dry/storage_initializer.py +42 -0
  31. src/linters/dry/token_hasher.py +169 -0
  32. src/linters/dry/typescript_analyzer.py +592 -0
  33. src/linters/dry/violation_builder.py +74 -0
  34. src/linters/dry/violation_filter.py +94 -0
  35. src/linters/dry/violation_generator.py +174 -0
  36. src/linters/file_header/__init__.py +24 -0
  37. src/linters/file_header/atemporal_detector.py +87 -0
  38. src/linters/file_header/config.py +66 -0
  39. src/linters/file_header/field_validator.py +69 -0
  40. src/linters/file_header/linter.py +313 -0
  41. src/linters/file_header/python_parser.py +86 -0
  42. src/linters/file_header/violation_builder.py +78 -0
  43. src/linters/file_placement/config_loader.py +86 -0
  44. src/linters/file_placement/directory_matcher.py +80 -0
  45. src/linters/file_placement/linter.py +262 -471
  46. src/linters/file_placement/path_resolver.py +61 -0
  47. src/linters/file_placement/pattern_matcher.py +55 -0
  48. src/linters/file_placement/pattern_validator.py +106 -0
  49. src/linters/file_placement/rule_checker.py +229 -0
  50. src/linters/file_placement/violation_factory.py +177 -0
  51. src/linters/magic_numbers/__init__.py +48 -0
  52. src/linters/magic_numbers/config.py +82 -0
  53. src/linters/magic_numbers/context_analyzer.py +247 -0
  54. src/linters/magic_numbers/linter.py +516 -0
  55. src/linters/magic_numbers/python_analyzer.py +76 -0
  56. src/linters/magic_numbers/typescript_analyzer.py +218 -0
  57. src/linters/magic_numbers/violation_builder.py +98 -0
  58. src/linters/nesting/__init__.py +6 -2
  59. src/linters/nesting/config.py +17 -4
  60. src/linters/nesting/linter.py +81 -168
  61. src/linters/nesting/typescript_analyzer.py +39 -102
  62. src/linters/nesting/typescript_function_extractor.py +130 -0
  63. src/linters/nesting/violation_builder.py +139 -0
  64. src/linters/print_statements/__init__.py +53 -0
  65. src/linters/print_statements/config.py +83 -0
  66. src/linters/print_statements/linter.py +430 -0
  67. src/linters/print_statements/python_analyzer.py +155 -0
  68. src/linters/print_statements/typescript_analyzer.py +135 -0
  69. src/linters/print_statements/violation_builder.py +98 -0
  70. src/linters/srp/__init__.py +99 -0
  71. src/linters/srp/class_analyzer.py +113 -0
  72. src/linters/srp/config.py +82 -0
  73. src/linters/srp/heuristics.py +89 -0
  74. src/linters/srp/linter.py +234 -0
  75. src/linters/srp/metrics_evaluator.py +47 -0
  76. src/linters/srp/python_analyzer.py +72 -0
  77. src/linters/srp/typescript_analyzer.py +75 -0
  78. src/linters/srp/typescript_metrics_calculator.py +90 -0
  79. src/linters/srp/violation_builder.py +117 -0
  80. src/orchestrator/core.py +54 -9
  81. src/templates/thailint_config_template.yaml +158 -0
  82. src/utils/__init__.py +4 -0
  83. src/utils/project_root.py +203 -0
  84. thailint-0.5.0.dist-info/METADATA +1286 -0
  85. thailint-0.5.0.dist-info/RECORD +96 -0
  86. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/WHEEL +1 -1
  87. src/.ai/layout.yaml +0 -48
  88. thailint-0.1.5.dist-info/METADATA +0 -629
  89. thailint-0.1.5.dist-info/RECORD +0 -28
  90. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/entry_points.txt +0 -0
  91. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info/licenses}/LICENSE +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.
@@ -48,12 +59,145 @@ def setup_logging(verbose: bool = False):
48
59
  )
49
60
 
50
61
 
62
+ def _determine_project_root(
63
+ explicit_root: str | None, config_path: str | None, verbose: bool
64
+ ) -> Path:
65
+ """Determine project root with precedence rules.
66
+
67
+ Precedence order:
68
+ 1. Explicit --project-root (highest priority)
69
+ 2. Inferred from --config path directory
70
+ 3. Auto-detection via get_project_root() (fallback)
71
+
72
+ Args:
73
+ explicit_root: Explicitly specified project root path (from --project-root)
74
+ config_path: Config file path (from --config)
75
+ verbose: Whether verbose logging is enabled
76
+
77
+ Returns:
78
+ Path to determined project root
79
+
80
+ Raises:
81
+ SystemExit: If explicit_root doesn't exist or is not a directory
82
+ """
83
+ from src.utils.project_root import get_project_root
84
+
85
+ # Priority 1: Explicit --project-root
86
+ if explicit_root:
87
+ return _resolve_explicit_project_root(explicit_root, verbose)
88
+
89
+ # Priority 2: Infer from --config path
90
+ if config_path:
91
+ return _infer_root_from_config(config_path, verbose)
92
+
93
+ # Priority 3: Auto-detection (fallback)
94
+ return _autodetect_project_root(verbose, get_project_root)
95
+
96
+
97
+ def _resolve_explicit_project_root(explicit_root: str, verbose: bool) -> Path:
98
+ """Resolve and validate explicitly specified project root.
99
+
100
+ Args:
101
+ explicit_root: Explicitly specified project root path
102
+ verbose: Whether verbose logging is enabled
103
+
104
+ Returns:
105
+ Resolved project root path
106
+
107
+ Raises:
108
+ SystemExit: If explicit_root doesn't exist or is not a directory
109
+ """
110
+ root = Path(explicit_root)
111
+ # Check existence before resolving to handle relative paths in test environments
112
+ if not root.exists():
113
+ click.echo(f"Error: Project root does not exist: {explicit_root}", err=True)
114
+ sys.exit(2)
115
+ if not root.is_dir():
116
+ click.echo(f"Error: Project root must be a directory: {explicit_root}", err=True)
117
+ sys.exit(2)
118
+
119
+ # Now resolve after validation
120
+ root = root.resolve()
121
+
122
+ if verbose:
123
+ logger.debug(f"Using explicit project root: {root}")
124
+ return root
125
+
126
+
127
+ def _infer_root_from_config(config_path: str, verbose: bool) -> Path:
128
+ """Infer project root from config file path.
129
+
130
+ Args:
131
+ config_path: Config file path
132
+ verbose: Whether verbose logging is enabled
133
+
134
+ Returns:
135
+ Inferred project root (parent directory of config file)
136
+ """
137
+ config_file = Path(config_path).resolve()
138
+ inferred_root = config_file.parent
139
+
140
+ if verbose:
141
+ logger.debug(f"Inferred project root from config path: {inferred_root}")
142
+ return inferred_root
143
+
144
+
145
+ def _autodetect_project_root(verbose: bool, get_project_root) -> Path:
146
+ """Auto-detect project root using project root detection.
147
+
148
+ Args:
149
+ verbose: Whether verbose logging is enabled
150
+ get_project_root: Function to detect project root
151
+
152
+ Returns:
153
+ Auto-detected project root
154
+ """
155
+ auto_root = get_project_root(None)
156
+ if verbose:
157
+ logger.debug(f"Auto-detected project root: {auto_root}")
158
+ return auto_root
159
+
160
+
161
+ def _get_project_root_from_context(ctx) -> Path:
162
+ """Get or determine project root from Click context.
163
+
164
+ This function defers the actual determination until needed to avoid
165
+ importing pyprojroot in test environments where it may not be available.
166
+
167
+ Args:
168
+ ctx: Click context containing CLI options
169
+
170
+ Returns:
171
+ Path to determined project root
172
+ """
173
+ # Check if already determined and cached
174
+ if "project_root" in ctx.obj:
175
+ return ctx.obj["project_root"]
176
+
177
+ # Determine project root using stored CLI options
178
+ explicit_root = ctx.obj.get("cli_project_root")
179
+ config_path = ctx.obj.get("cli_config_path")
180
+ verbose = ctx.obj.get("verbose", False)
181
+
182
+ project_root = _determine_project_root(explicit_root, config_path, verbose)
183
+
184
+ # Cache for future use
185
+ ctx.obj["project_root"] = project_root
186
+
187
+ return project_root
188
+
189
+
51
190
  @click.group()
52
191
  @click.version_option(version=__version__)
53
192
  @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
54
193
  @click.option("--config", "-c", type=click.Path(), help="Path to config file")
194
+ @click.option(
195
+ "--project-root",
196
+ type=click.Path(),
197
+ help="Explicitly specify project root directory (overrides auto-detection)",
198
+ )
55
199
  @click.pass_context
56
- def cli(ctx, verbose: bool, config: str | None):
200
+ def cli(ctx, verbose: bool, config: str | None, project_root: str | None):
57
201
  """
58
202
  thai-lint - AI code linter and governance tool
59
203
 
@@ -62,6 +206,10 @@ def cli(ctx, verbose: bool, config: str | None):
62
206
 
63
207
  Examples:
64
208
 
209
+ \b
210
+ # Check for duplicate code (DRY violations)
211
+ thai-lint dry .
212
+
65
213
  \b
66
214
  # Lint current directory for file placement issues
67
215
  thai-lint file-placement .
@@ -70,6 +218,10 @@ def cli(ctx, verbose: bool, config: str | None):
70
218
  # Lint with custom config
71
219
  thai-lint file-placement --config .thailint.yaml src/
72
220
 
221
+ \b
222
+ # Specify project root explicitly (useful in Docker)
223
+ thai-lint --project-root /workspace/root magic-numbers backend/
224
+
73
225
  \b
74
226
  # Get JSON output
75
227
  thai-lint file-placement --format json .
@@ -84,6 +236,11 @@ def cli(ctx, verbose: bool, config: str | None):
84
236
  # Setup logging
85
237
  setup_logging(verbose)
86
238
 
239
+ # Store CLI options for later project root determination
240
+ # (deferred to avoid pyprojroot import issues in test environments)
241
+ ctx.obj["cli_project_root"] = project_root
242
+ ctx.obj["cli_config_path"] = config
243
+
87
244
  # Load configuration
88
245
  try:
89
246
  if config:
@@ -347,17 +504,155 @@ def config_reset(ctx, yes: bool):
347
504
  sys.exit(1)
348
505
 
349
506
 
507
+ @cli.command("init-config")
508
+ @click.option(
509
+ "--preset",
510
+ "-p",
511
+ type=click.Choice(["strict", "standard", "lenient"]),
512
+ default="standard",
513
+ help="Configuration preset",
514
+ )
515
+ @click.option("--non-interactive", is_flag=True, help="Skip interactive prompts (for AI agents)")
516
+ @click.option("--force", is_flag=True, help="Overwrite existing .thailint.yaml file")
517
+ @click.option(
518
+ "--output", "-o", type=click.Path(), default=".thailint.yaml", help="Output file path"
519
+ )
520
+ def init_config(preset: str, non_interactive: bool, force: bool, output: str):
521
+ """
522
+ Generate a .thailint.yaml configuration file with preset values.
523
+
524
+ Creates a richly-commented configuration file with sensible defaults
525
+ and optional customizations for different strictness levels.
526
+
527
+ For AI agents, use --non-interactive mode:
528
+ thailint init-config --non-interactive --preset lenient
529
+
530
+ Presets:
531
+ strict: Minimal allowed numbers (only -1, 0, 1)
532
+ standard: Balanced defaults (includes 2, 3, 4, 5, 10, 100, 1000)
533
+ lenient: Includes time conversions (adds 60, 3600)
534
+
535
+ Examples:
536
+
537
+ \\b
538
+ # Interactive mode (default, for humans)
539
+ thailint init-config
540
+
541
+ \\b
542
+ # Non-interactive mode (for AI agents)
543
+ thailint init-config --non-interactive
544
+
545
+ \\b
546
+ # Generate with lenient preset
547
+ thailint init-config --preset lenient
548
+
549
+ \\b
550
+ # Overwrite existing config
551
+ thailint init-config --force
552
+
553
+ \\b
554
+ # Custom output path
555
+ thailint init-config --output my-config.yaml
556
+ """
557
+ output_path = Path(output)
558
+
559
+ # Check if file exists (unless --force)
560
+ if output_path.exists() and not force:
561
+ click.echo(f"Error: {output} already exists", err=True)
562
+ click.echo("", err=True)
563
+ click.echo("Use --force to overwrite:", err=True)
564
+ click.echo(" thailint init-config --force", err=True)
565
+ sys.exit(1)
566
+
567
+ # Interactive mode: Ask user for preferences
568
+ if not non_interactive:
569
+ click.echo("thai-lint Configuration Generator")
570
+ click.echo("=" * 50)
571
+ click.echo("")
572
+ click.echo("This will create a .thailint.yaml configuration file.")
573
+ click.echo("For non-interactive mode (AI agents), use:")
574
+ click.echo(" thailint init-config --non-interactive")
575
+ click.echo("")
576
+
577
+ # Ask for preset
578
+ click.echo("Available presets:")
579
+ click.echo(" strict: Only -1, 0, 1 allowed (strictest)")
580
+ click.echo(" standard: -1, 0, 1, 2, 3, 4, 5, 10, 100, 1000 (balanced)")
581
+ click.echo(" lenient: Includes time conversions 60, 3600 (most permissive)")
582
+ click.echo("")
583
+
584
+ preset = click.prompt(
585
+ "Choose preset", type=click.Choice(["strict", "standard", "lenient"]), default=preset
586
+ )
587
+
588
+ # Generate config based on preset
589
+ config_content = _generate_config_content(preset)
590
+
591
+ # Write config file
592
+ try:
593
+ output_path.write_text(config_content, encoding="utf-8")
594
+ click.echo("")
595
+ click.echo(f"✓ Created {output}")
596
+ click.echo(f"✓ Preset: {preset}")
597
+ click.echo("")
598
+ click.echo("Next steps:")
599
+ click.echo(f" 1. Review and customize {output}")
600
+ click.echo(" 2. Run: thailint magic-numbers .")
601
+ click.echo(" 3. See docs: https://github.com/your-org/thai-lint")
602
+ except OSError as e:
603
+ click.echo(f"Error writing config file: {e}", err=True)
604
+ sys.exit(1)
605
+
606
+
607
+ def _generate_config_content(preset: str) -> str:
608
+ """Generate config file content based on preset."""
609
+ # Preset configurations
610
+ presets = {
611
+ "strict": {
612
+ "allowed_numbers": "[-1, 0, 1]",
613
+ "max_small_integer": "3",
614
+ "description": "Strict (only universal values)",
615
+ },
616
+ "standard": {
617
+ "allowed_numbers": "[-1, 0, 1, 2, 3, 4, 5, 10, 100, 1000]",
618
+ "max_small_integer": "10",
619
+ "description": "Standard (balanced defaults)",
620
+ },
621
+ "lenient": {
622
+ "allowed_numbers": "[-1, 0, 1, 2, 3, 4, 5, 10, 60, 100, 1000, 3600]",
623
+ "max_small_integer": "10",
624
+ "description": "Lenient (includes time conversions)",
625
+ },
626
+ }
627
+
628
+ config = presets[preset]
629
+
630
+ # Read template
631
+ template_path = Path(__file__).parent / "templates" / "thailint_config_template.yaml"
632
+ template = template_path.read_text(encoding="utf-8")
633
+
634
+ # Replace placeholders
635
+ content = template.replace("{{PRESET}}", config["description"])
636
+ content = content.replace("{{ALLOWED_NUMBERS}}", config["allowed_numbers"])
637
+ content = content.replace("{{MAX_SMALL_INTEGER}}", config["max_small_integer"])
638
+
639
+ return content
640
+
641
+
350
642
  @cli.command("file-placement")
351
- @click.argument("path", type=click.Path(exists=True), default=".")
643
+ @click.argument("paths", nargs=-1, type=click.Path())
352
644
  @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
353
645
  @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
- )
646
+ @format_option
357
647
  @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
358
648
  @click.pass_context
359
649
  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
650
+ ctx,
651
+ paths: tuple[str, ...],
652
+ config_file: str | None,
653
+ rules: str | None,
654
+ format: str,
655
+ recursive: bool,
361
656
  ):
362
657
  # Justification for Pylint disables:
363
658
  # - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 4 options = 6 params
@@ -369,18 +664,26 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
369
664
  Checks that files are placed in appropriate directories according to
370
665
  configured rules and patterns.
371
666
 
372
- PATH: File or directory to lint (defaults to current directory)
667
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
373
668
 
374
669
  Examples:
375
670
 
376
671
  \b
377
- # Lint current directory
672
+ # Lint current directory (all files recursively)
378
673
  thai-lint file-placement
379
674
 
380
675
  \b
381
676
  # Lint specific directory
382
677
  thai-lint file-placement src/
383
678
 
679
+ \b
680
+ # Lint single file
681
+ thai-lint file-placement src/app.py
682
+
683
+ \b
684
+ # Lint multiple files
685
+ thai-lint file-placement src/app.py src/utils.py tests/test_app.py
686
+
384
687
  \b
385
688
  # Use custom config
386
689
  thai-lint file-placement --config rules.json .
@@ -390,25 +693,36 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
390
693
  thai-lint file-placement --rules '{"allow": [".*\\.py$"]}' .
391
694
  """
392
695
  verbose = ctx.obj.get("verbose", False)
393
- path_obj = Path(path)
696
+ project_root = _get_project_root_from_context(ctx)
697
+
698
+ if not paths:
699
+ paths = (".",)
700
+
701
+ path_objs = [Path(p) for p in paths]
394
702
 
395
703
  try:
396
- _execute_file_placement_lint(path_obj, config_file, rules, format, recursive, verbose)
704
+ _execute_file_placement_lint(
705
+ path_objs, config_file, rules, format, recursive, verbose, project_root
706
+ )
397
707
  except Exception as e:
398
708
  _handle_linting_error(e, verbose)
399
709
 
400
710
 
401
711
  def _execute_file_placement_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
402
- path_obj, config_file, rules, format, recursive, verbose
712
+ path_objs, config_file, rules, format, recursive, verbose, project_root=None
403
713
  ):
404
714
  """Execute file placement linting."""
405
- orchestrator = _setup_orchestrator(path_obj, config_file, rules, verbose)
406
- violations = _execute_linting(orchestrator, path_obj, recursive)
715
+ _validate_paths_exist(path_objs)
716
+ orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose, project_root)
717
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
718
+
719
+ # Filter to only file-placement violations
720
+ violations = [v for v in all_violations if v.rule_id.startswith("file-placement")]
407
721
 
408
722
  if verbose:
409
723
  logger.info(f"Found {len(violations)} violation(s)")
410
724
 
411
- _output_violations(violations, format)
725
+ format_violations(violations, format)
412
726
  sys.exit(1 if violations else 0)
413
727
 
414
728
 
@@ -420,26 +734,98 @@ def _handle_linting_error(error: Exception, verbose: bool) -> None:
420
734
  sys.exit(2)
421
735
 
422
736
 
423
- def _setup_orchestrator(path_obj, config_file, rules, verbose):
737
+ def _validate_paths_exist(path_objs: list[Path]) -> None:
738
+ """Validate that all provided paths exist.
739
+
740
+ Args:
741
+ path_objs: List of Path objects to validate
742
+
743
+ Raises:
744
+ SystemExit: If any path doesn't exist (exit code 2)
745
+ """
746
+ for path in path_objs:
747
+ if not path.exists():
748
+ click.echo(f"Error: Path does not exist: {path}", err=True)
749
+ click.echo("", err=True)
750
+ click.echo(
751
+ "Hint: When using Docker, ensure paths are inside the mounted volume:", err=True
752
+ )
753
+ click.echo(
754
+ " docker run -v $(pwd):/data thailint <command> /data/your-file.py", err=True
755
+ )
756
+ sys.exit(2)
757
+
758
+
759
+ def _find_project_root(start_path: Path) -> Path:
760
+ """Find project root by looking for .git or pyproject.toml.
761
+
762
+ DEPRECATED: Use src.utils.project_root.get_project_root() instead.
763
+
764
+ Args:
765
+ start_path: Directory to start searching from
766
+
767
+ Returns:
768
+ Path to project root, or start_path if no markers found
769
+ """
770
+ from src.utils.project_root import get_project_root
771
+
772
+ return get_project_root(start_path)
773
+
774
+
775
+ def _setup_orchestrator(path_objs, config_file, rules, verbose, project_root=None):
424
776
  """Set up and configure the orchestrator."""
425
777
  from src.orchestrator.core import Orchestrator
778
+ from src.utils.project_root import get_project_root
779
+
780
+ # Use provided project_root or fall back to auto-detection
781
+ project_root = _get_or_detect_project_root(path_objs, project_root, get_project_root)
426
782
 
427
- project_root = path_obj if path_obj.is_dir() else path_obj.parent
428
783
  orchestrator = Orchestrator(project_root=project_root)
784
+ _apply_orchestrator_config(orchestrator, config_file, rules, verbose)
785
+
786
+ return orchestrator
787
+
429
788
 
789
+ def _get_or_detect_project_root(path_objs, project_root, get_project_root):
790
+ """Get provided project root or auto-detect from paths.
791
+
792
+ Args:
793
+ path_objs: List of path objects
794
+ project_root: Optionally provided project root
795
+ get_project_root: Function to detect project root
796
+
797
+ Returns:
798
+ Project root path
799
+ """
800
+ if project_root is not None:
801
+ return project_root
802
+
803
+ # Find actual project root (where .git or pyproject.toml exists)
804
+ # This ensures .artifacts/ is always created at project root, not in subdirectories
805
+ first_path = path_objs[0] if path_objs else Path.cwd()
806
+ search_start = first_path if first_path.is_dir() else first_path.parent
807
+ return get_project_root(search_start)
808
+
809
+
810
+ def _apply_orchestrator_config(orchestrator, config_file, rules, verbose):
811
+ """Apply configuration to orchestrator.
812
+
813
+ Args:
814
+ orchestrator: Orchestrator instance
815
+ config_file: Path to config file (optional)
816
+ rules: Inline JSON rules (optional)
817
+ verbose: Whether verbose logging is enabled
818
+ """
430
819
  if rules:
431
820
  _apply_inline_rules(orchestrator, rules, verbose)
432
821
  elif config_file:
433
822
  _load_config_file(orchestrator, config_file, verbose)
434
823
 
435
- return orchestrator
436
-
437
824
 
438
825
  def _apply_inline_rules(orchestrator, rules, verbose):
439
826
  """Parse and apply inline JSON rules."""
440
827
  rules_config = _parse_json_rules(rules)
441
828
  orchestrator.config.update(rules_config)
442
- _write_layout_config(orchestrator, rules_config, verbose)
443
829
  _log_applied_rules(rules_config, verbose)
444
830
 
445
831
 
@@ -454,40 +840,6 @@ def _parse_json_rules(rules: str) -> dict:
454
840
  sys.exit(2)
455
841
 
456
842
 
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
843
  def _log_applied_rules(rules_config: dict, verbose: bool) -> None:
492
844
  """Log applied rules if verbose."""
493
845
  if verbose:
@@ -504,99 +856,95 @@ def _load_config_file(orchestrator, config_file, verbose):
504
856
  # Load config into orchestrator
505
857
  orchestrator.config = orchestrator.config_loader.load(config_path)
506
858
 
507
- # Also copy to .ai/layout.yaml for file-placement linter
508
- _write_loaded_config_to_layout(orchestrator, config_file, verbose)
859
+ if verbose:
860
+ logger.debug(f"Loaded config from: {config_file}")
509
861
 
510
862
 
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"
863
+ def _execute_linting(orchestrator, path_obj, recursive):
864
+ """Execute linting on file or directory."""
865
+ if path_obj.is_file():
866
+ return orchestrator.lint_file(path_obj)
867
+ return orchestrator.lint_directory(path_obj, recursive=recursive)
515
868
 
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
869
 
870
+ def _separate_files_and_dirs(path_objs: list[Path]) -> tuple[list[Path], list[Path]]:
871
+ """Separate file paths from directory paths.
522
872
 
523
- def _write_config_yaml(ai_dir, layout_file, config):
524
- """Write config to YAML file."""
525
- import yaml
873
+ Args:
874
+ path_objs: List of Path objects
526
875
 
527
- ai_dir.mkdir(exist_ok=True)
528
- with layout_file.open("w", encoding="utf-8") as f:
529
- yaml.dump(config, f)
876
+ Returns:
877
+ Tuple of (files, directories)
878
+ """
879
+ files = [p for p in path_objs if p.is_file()]
880
+ dirs = [p for p in path_objs if p.is_dir()]
881
+ return files, dirs
530
882
 
531
883
 
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}")
884
+ def _lint_files_if_any(orchestrator, files: list[Path]) -> list:
885
+ """Lint files if list is non-empty.
537
886
 
887
+ Args:
888
+ orchestrator: Orchestrator instance
889
+ files: List of file paths
538
890
 
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)
891
+ Returns:
892
+ List of violations
893
+ """
894
+ if files:
895
+ return orchestrator.lint_files(files)
896
+ return []
544
897
 
545
898
 
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)
899
+ def _lint_directories(orchestrator, dirs: list[Path], recursive: bool) -> list:
900
+ """Lint all directories.
552
901
 
902
+ Args:
903
+ orchestrator: Orchestrator instance
904
+ dirs: List of directory paths
905
+ recursive: Whether to scan recursively
553
906
 
554
- def _output_json(violations):
555
- """Output violations in JSON format."""
556
- import json
907
+ Returns:
908
+ List of violations from all directories
909
+ """
910
+ violations = []
911
+ for dir_path in dirs:
912
+ violations.extend(orchestrator.lint_directory(dir_path, recursive=recursive))
913
+ return violations
557
914
 
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
915
 
916
+ def _execute_linting_on_paths(orchestrator, path_objs: list[Path], recursive: bool) -> list:
917
+ """Execute linting on list of file/directory paths.
574
918
 
575
- def _output_text(violations):
576
- """Output violations in text format."""
577
- if not violations:
578
- click.echo("✓ No violations found")
579
- return
919
+ Args:
920
+ orchestrator: Orchestrator instance
921
+ path_objs: List of Path objects (files or directories)
922
+ recursive: Whether to scan directories recursively
580
923
 
581
- click.echo(f"Found {len(violations)} violation(s):\n")
582
- for v in violations:
583
- _print_violation(v)
924
+ Returns:
925
+ List of violations from all paths
926
+ """
927
+ files, dirs = _separate_files_and_dirs(path_objs)
584
928
 
929
+ violations = []
930
+ violations.extend(_lint_files_if_any(orchestrator, files))
931
+ violations.extend(_lint_directories(orchestrator, dirs, recursive))
585
932
 
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()
933
+ return violations
594
934
 
595
935
 
596
- def _setup_nesting_orchestrator(path_obj: Path, config_file: str | None, verbose: bool):
936
+ def _setup_nesting_orchestrator(
937
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
938
+ ):
597
939
  """Set up orchestrator for nesting command."""
598
- project_root = path_obj if path_obj.is_dir() else path_obj.parent
599
940
  from src.orchestrator.core import Orchestrator
941
+ from src.utils.project_root import get_project_root
942
+
943
+ # Use provided project_root or fall back to auto-detection
944
+ if project_root is None:
945
+ first_path = path_objs[0] if path_objs else Path.cwd()
946
+ search_start = first_path if first_path.is_dir() else first_path.parent
947
+ project_root = get_project_root(search_start)
600
948
 
601
949
  orchestrator = Orchestrator(project_root=project_root)
602
950
 
@@ -611,53 +959,84 @@ def _apply_nesting_config_override(orchestrator, max_depth: int | None, verbose:
611
959
  if max_depth is None:
612
960
  return
613
961
 
962
+ # Ensure nesting config exists
614
963
  if "nesting" not in orchestrator.config:
615
964
  orchestrator.config["nesting"] = {}
616
- orchestrator.config["nesting"]["max_nesting_depth"] = max_depth
965
+
966
+ nesting_config = orchestrator.config["nesting"]
967
+
968
+ # Set top-level max_nesting_depth
969
+ nesting_config["max_nesting_depth"] = max_depth
970
+
971
+ # Override language-specific configs to ensure CLI option takes precedence
972
+ _override_language_specific_nesting(nesting_config, max_depth)
617
973
 
618
974
  if verbose:
619
975
  logger.debug(f"Overriding max_nesting_depth to {max_depth}")
620
976
 
621
977
 
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)
978
+ def _override_language_specific_nesting(nesting_config: dict, max_depth: int):
979
+ """Override language-specific nesting depth configs.
980
+
981
+ Args:
982
+ nesting_config: Nesting configuration dictionary
983
+ max_depth: Maximum nesting depth to set
984
+ """
985
+ for lang in ["python", "typescript", "javascript"]:
986
+ if lang in nesting_config:
987
+ nesting_config[lang]["max_nesting_depth"] = max_depth
628
988
 
629
- return [v for v in violations if "nesting" in v.rule_id]
989
+
990
+ def _run_nesting_lint(orchestrator, path_objs: list[Path], recursive: bool):
991
+ """Execute nesting lint on files or directories."""
992
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
993
+ return [v for v in all_violations if "nesting" in v.rule_id]
630
994
 
631
995
 
632
996
  @cli.command("nesting")
633
- @click.argument("path", type=click.Path(exists=True), default=".")
997
+ @click.argument("paths", nargs=-1, type=click.Path())
634
998
  @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
- )
999
+ @format_option
638
1000
  @click.option("--max-depth", type=int, help="Override max nesting depth (default: 4)")
639
1001
  @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
640
1002
  @click.pass_context
641
1003
  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
1004
+ ctx,
1005
+ paths: tuple[str, ...],
1006
+ config_file: str | None,
1007
+ format: str,
1008
+ max_depth: int | None,
1009
+ recursive: bool,
643
1010
  ):
644
1011
  """Check for excessive nesting depth in code.
645
1012
 
646
1013
  Analyzes Python and TypeScript files for deeply nested code structures
647
1014
  (if/for/while/try statements) and reports violations.
648
1015
 
649
- PATH: File or directory to lint (defaults to current directory)
1016
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
650
1017
 
651
1018
  Examples:
652
1019
 
653
1020
  \b
654
- # Check current directory
1021
+ # Check current directory (all files recursively)
655
1022
  thai-lint nesting
656
1023
 
657
1024
  \b
658
1025
  # Check specific directory
659
1026
  thai-lint nesting src/
660
1027
 
1028
+ \b
1029
+ # Check single file
1030
+ thai-lint nesting src/app.py
1031
+
1032
+ \b
1033
+ # Check multiple files
1034
+ thai-lint nesting src/app.py src/utils.py tests/test_app.py
1035
+
1036
+ \b
1037
+ # Check mix of files and directories
1038
+ thai-lint nesting src/app.py tests/
1039
+
661
1040
  \b
662
1041
  # Use custom max depth
663
1042
  thai-lint nesting --max-depth 3 src/
@@ -671,28 +1050,616 @@ def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
671
1050
  thai-lint nesting --config .thailint.yaml src/
672
1051
  """
673
1052
  verbose = ctx.obj.get("verbose", False)
674
- path_obj = Path(path)
1053
+ project_root = _get_project_root_from_context(ctx)
1054
+
1055
+ # Default to current directory if no paths provided
1056
+ if not paths:
1057
+ paths = (".",)
1058
+
1059
+ path_objs = [Path(p) for p in paths]
675
1060
 
676
1061
  try:
677
- _execute_nesting_lint(path_obj, config_file, format, max_depth, recursive, verbose)
1062
+ _execute_nesting_lint(
1063
+ path_objs, config_file, format, max_depth, recursive, verbose, project_root
1064
+ )
678
1065
  except Exception as e:
679
1066
  _handle_linting_error(e, verbose)
680
1067
 
681
1068
 
682
1069
  def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
683
- path_obj, config_file, format, max_depth, recursive, verbose
1070
+ path_objs, config_file, format, max_depth, recursive, verbose, project_root=None
684
1071
  ):
685
1072
  """Execute nesting lint."""
686
- orchestrator = _setup_nesting_orchestrator(path_obj, config_file, verbose)
1073
+ _validate_paths_exist(path_objs)
1074
+ orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose, project_root)
687
1075
  _apply_nesting_config_override(orchestrator, max_depth, verbose)
688
- nesting_violations = _run_nesting_lint(orchestrator, path_obj, recursive)
1076
+ nesting_violations = _run_nesting_lint(orchestrator, path_objs, recursive)
689
1077
 
690
1078
  if verbose:
691
1079
  logger.info(f"Found {len(nesting_violations)} nesting violation(s)")
692
1080
 
693
- _output_violations(nesting_violations, format)
1081
+ format_violations(nesting_violations, format)
694
1082
  sys.exit(1 if nesting_violations else 0)
695
1083
 
696
1084
 
1085
+ def _setup_srp_orchestrator(
1086
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1087
+ ):
1088
+ """Set up orchestrator for SRP command."""
1089
+ from src.orchestrator.core import Orchestrator
1090
+ from src.utils.project_root import get_project_root
1091
+
1092
+ # Use provided project_root or fall back to auto-detection
1093
+ if project_root is None:
1094
+ first_path = path_objs[0] if path_objs else Path.cwd()
1095
+ search_start = first_path if first_path.is_dir() else first_path.parent
1096
+ project_root = get_project_root(search_start)
1097
+
1098
+ orchestrator = Orchestrator(project_root=project_root)
1099
+
1100
+ if config_file:
1101
+ _load_config_file(orchestrator, config_file, verbose)
1102
+
1103
+ return orchestrator
1104
+
1105
+
1106
+ def _apply_srp_config_override(
1107
+ orchestrator, max_methods: int | None, max_loc: int | None, verbose: bool
1108
+ ):
1109
+ """Apply max_methods and max_loc overrides to orchestrator config."""
1110
+ if max_methods is None and max_loc is None:
1111
+ return
1112
+
1113
+ if "srp" not in orchestrator.config:
1114
+ orchestrator.config["srp"] = {}
1115
+
1116
+ _apply_srp_max_methods(orchestrator, max_methods, verbose)
1117
+ _apply_srp_max_loc(orchestrator, max_loc, verbose)
1118
+
1119
+
1120
+ def _apply_srp_max_methods(orchestrator, max_methods: int | None, verbose: bool):
1121
+ """Apply max_methods override."""
1122
+ if max_methods is not None:
1123
+ orchestrator.config["srp"]["max_methods"] = max_methods
1124
+ if verbose:
1125
+ logger.debug(f"Overriding max_methods to {max_methods}")
1126
+
1127
+
1128
+ def _apply_srp_max_loc(orchestrator, max_loc: int | None, verbose: bool):
1129
+ """Apply max_loc override."""
1130
+ if max_loc is not None:
1131
+ orchestrator.config["srp"]["max_loc"] = max_loc
1132
+ if verbose:
1133
+ logger.debug(f"Overriding max_loc to {max_loc}")
1134
+
1135
+
1136
+ def _run_srp_lint(orchestrator, path_objs: list[Path], recursive: bool):
1137
+ """Execute SRP lint on files or directories."""
1138
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1139
+ return [v for v in all_violations if "srp" in v.rule_id]
1140
+
1141
+
1142
+ @cli.command("srp")
1143
+ @click.argument("paths", nargs=-1, type=click.Path())
1144
+ @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1145
+ @format_option
1146
+ @click.option("--max-methods", type=int, help="Override max methods per class (default: 7)")
1147
+ @click.option("--max-loc", type=int, help="Override max lines of code per class (default: 200)")
1148
+ @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1149
+ @click.pass_context
1150
+ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
1151
+ ctx,
1152
+ paths: tuple[str, ...],
1153
+ config_file: str | None,
1154
+ format: str,
1155
+ max_methods: int | None,
1156
+ max_loc: int | None,
1157
+ recursive: bool,
1158
+ ):
1159
+ """Check for Single Responsibility Principle violations.
1160
+
1161
+ Analyzes Python and TypeScript classes for SRP violations using heuristics:
1162
+ - Method count exceeding threshold (default: 7)
1163
+ - Lines of code exceeding threshold (default: 200)
1164
+ - Responsibility keywords in class names (Manager, Handler, Processor, etc.)
1165
+
1166
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
1167
+
1168
+ Examples:
1169
+
1170
+ \b
1171
+ # Check current directory (all files recursively)
1172
+ thai-lint srp
1173
+
1174
+ \b
1175
+ # Check specific directory
1176
+ thai-lint srp src/
1177
+
1178
+ \b
1179
+ # Check single file
1180
+ thai-lint srp src/app.py
1181
+
1182
+ \b
1183
+ # Check multiple files
1184
+ thai-lint srp src/app.py src/service.py tests/test_app.py
1185
+
1186
+ \b
1187
+ # Use custom thresholds
1188
+ thai-lint srp --max-methods 10 --max-loc 300 src/
1189
+
1190
+ \b
1191
+ # Get JSON output
1192
+ thai-lint srp --format json .
1193
+
1194
+ \b
1195
+ # Use custom config file
1196
+ thai-lint srp --config .thailint.yaml src/
1197
+ """
1198
+ verbose = ctx.obj.get("verbose", False)
1199
+ project_root = _get_project_root_from_context(ctx)
1200
+
1201
+ if not paths:
1202
+ paths = (".",)
1203
+
1204
+ path_objs = [Path(p) for p in paths]
1205
+
1206
+ try:
1207
+ _execute_srp_lint(
1208
+ path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root
1209
+ )
1210
+ except Exception as e:
1211
+ _handle_linting_error(e, verbose)
1212
+
1213
+
1214
+ def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1215
+ path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root=None
1216
+ ):
1217
+ """Execute SRP lint."""
1218
+ _validate_paths_exist(path_objs)
1219
+ orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose, project_root)
1220
+ _apply_srp_config_override(orchestrator, max_methods, max_loc, verbose)
1221
+ srp_violations = _run_srp_lint(orchestrator, path_objs, recursive)
1222
+
1223
+ if verbose:
1224
+ logger.info(f"Found {len(srp_violations)} SRP violation(s)")
1225
+
1226
+ format_violations(srp_violations, format)
1227
+ sys.exit(1 if srp_violations else 0)
1228
+
1229
+
1230
+ @cli.command("dry")
1231
+ @click.argument("paths", nargs=-1, type=click.Path())
1232
+ @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1233
+ @format_option
1234
+ @click.option("--min-lines", type=int, help="Override min duplicate lines threshold")
1235
+ @click.option("--no-cache", is_flag=True, help="Disable SQLite cache (force rehash)")
1236
+ @click.option("--clear-cache", is_flag=True, help="Clear cache before running")
1237
+ @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1238
+ @click.pass_context
1239
+ def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
1240
+ ctx,
1241
+ paths: tuple[str, ...],
1242
+ config_file: str | None,
1243
+ format: str,
1244
+ min_lines: int | None,
1245
+ no_cache: bool,
1246
+ clear_cache: bool,
1247
+ recursive: bool,
1248
+ ):
1249
+ # Justification for Pylint disables:
1250
+ # - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 6 options = 8 params
1251
+ # All parameters are necessary for flexible DRY linter CLI usage.
1252
+ """
1253
+ Check for duplicate code (DRY principle violations).
1254
+
1255
+ Detects duplicate code blocks across your project using token-based hashing
1256
+ with SQLite caching for fast incremental scans.
1257
+
1258
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
1259
+
1260
+ Examples:
1261
+
1262
+ \b
1263
+ # Check current directory (all files recursively)
1264
+ thai-lint dry
1265
+
1266
+ \b
1267
+ # Check specific directory
1268
+ thai-lint dry src/
1269
+
1270
+ \b
1271
+ # Check single file
1272
+ thai-lint dry src/app.py
1273
+
1274
+ \b
1275
+ # Check multiple files
1276
+ thai-lint dry src/app.py src/service.py tests/test_app.py
1277
+
1278
+ \b
1279
+ # Use custom config file
1280
+ thai-lint dry --config .thailint.yaml src/
1281
+
1282
+ \b
1283
+ # Override minimum duplicate lines threshold
1284
+ thai-lint dry --min-lines 5 .
1285
+
1286
+ \b
1287
+ # Disable cache (force re-analysis)
1288
+ thai-lint dry --no-cache .
1289
+
1290
+ \b
1291
+ # Clear cache before running
1292
+ thai-lint dry --clear-cache .
1293
+
1294
+ \b
1295
+ # Get JSON output
1296
+ thai-lint dry --format json .
1297
+ """
1298
+ verbose = ctx.obj.get("verbose", False)
1299
+ project_root = _get_project_root_from_context(ctx)
1300
+
1301
+ if not paths:
1302
+ paths = (".",)
1303
+
1304
+ path_objs = [Path(p) for p in paths]
1305
+
1306
+ try:
1307
+ _execute_dry_lint(
1308
+ path_objs,
1309
+ config_file,
1310
+ format,
1311
+ min_lines,
1312
+ no_cache,
1313
+ clear_cache,
1314
+ recursive,
1315
+ verbose,
1316
+ project_root,
1317
+ )
1318
+ except Exception as e:
1319
+ _handle_linting_error(e, verbose)
1320
+
1321
+
1322
+ def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1323
+ path_objs,
1324
+ config_file,
1325
+ format,
1326
+ min_lines,
1327
+ no_cache,
1328
+ clear_cache,
1329
+ recursive,
1330
+ verbose,
1331
+ project_root=None,
1332
+ ):
1333
+ """Execute DRY linting."""
1334
+ _validate_paths_exist(path_objs)
1335
+ orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose, project_root)
1336
+ _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose)
1337
+
1338
+ if clear_cache:
1339
+ _clear_dry_cache(orchestrator, verbose)
1340
+
1341
+ dry_violations = _run_dry_lint(orchestrator, path_objs, recursive)
1342
+
1343
+ if verbose:
1344
+ logger.info(f"Found {len(dry_violations)} DRY violation(s)")
1345
+
1346
+ format_violations(dry_violations, format)
1347
+ sys.exit(1 if dry_violations else 0)
1348
+
1349
+
1350
+ def _setup_dry_orchestrator(path_objs, config_file, verbose, project_root=None):
1351
+ """Set up orchestrator for DRY linting."""
1352
+ from src.orchestrator.core import Orchestrator
1353
+ from src.utils.project_root import get_project_root
1354
+
1355
+ # Use provided project_root or fall back to auto-detection
1356
+ if project_root is None:
1357
+ first_path = path_objs[0] if path_objs else Path.cwd()
1358
+ search_start = first_path if first_path.is_dir() else first_path.parent
1359
+ project_root = get_project_root(search_start)
1360
+
1361
+ orchestrator = Orchestrator(project_root=project_root)
1362
+
1363
+ if config_file:
1364
+ _load_dry_config_file(orchestrator, config_file, verbose)
1365
+
1366
+ return orchestrator
1367
+
1368
+
1369
+ def _load_dry_config_file(orchestrator, config_file, verbose):
1370
+ """Load DRY configuration from file."""
1371
+ import yaml
1372
+
1373
+ config_path = Path(config_file)
1374
+ if not config_path.exists():
1375
+ click.echo(f"Error: Config file not found: {config_file}", err=True)
1376
+ sys.exit(2)
1377
+
1378
+ with config_path.open("r", encoding="utf-8") as f:
1379
+ config = yaml.safe_load(f)
1380
+
1381
+ if "dry" in config:
1382
+ orchestrator.config.update({"dry": config["dry"]})
1383
+
1384
+ if verbose:
1385
+ logger.info(f"Loaded DRY config from {config_file}")
1386
+
1387
+
1388
+ def _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose):
1389
+ """Apply CLI option overrides to DRY config."""
1390
+ _ensure_dry_config_exists(orchestrator)
1391
+ _apply_min_lines_override(orchestrator, min_lines, verbose)
1392
+ _apply_cache_override(orchestrator, no_cache, verbose)
1393
+
1394
+
1395
+ def _ensure_dry_config_exists(orchestrator):
1396
+ """Ensure dry config section exists."""
1397
+ if "dry" not in orchestrator.config:
1398
+ orchestrator.config["dry"] = {}
1399
+
1400
+
1401
+ def _apply_min_lines_override(orchestrator, min_lines, verbose):
1402
+ """Apply min_lines override if provided."""
1403
+ if min_lines is None:
1404
+ return
1405
+
1406
+ orchestrator.config["dry"]["min_duplicate_lines"] = min_lines
1407
+ if verbose:
1408
+ logger.info(f"Override: min_duplicate_lines = {min_lines}")
1409
+
1410
+
1411
+ def _apply_cache_override(orchestrator, no_cache, verbose):
1412
+ """Apply cache override if requested."""
1413
+ if not no_cache:
1414
+ return
1415
+
1416
+ orchestrator.config["dry"]["cache_enabled"] = False
1417
+ if verbose:
1418
+ logger.info("Override: cache_enabled = False")
1419
+
1420
+
1421
+ def _clear_dry_cache(orchestrator, verbose):
1422
+ """Clear DRY cache before running."""
1423
+ cache_path_str = orchestrator.config.get("dry", {}).get("cache_path", ".thailint-cache/dry.db")
1424
+ cache_path = orchestrator.project_root / cache_path_str
1425
+
1426
+ if cache_path.exists():
1427
+ cache_path.unlink()
1428
+ if verbose:
1429
+ logger.info(f"Cleared cache: {cache_path}")
1430
+ else:
1431
+ if verbose:
1432
+ logger.info("Cache file does not exist, nothing to clear")
1433
+
1434
+
1435
+ def _run_dry_lint(orchestrator, path_objs, recursive):
1436
+ """Run DRY linting and return violations."""
1437
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1438
+
1439
+ # Filter to only DRY violations
1440
+ dry_violations = [v for v in all_violations if v.rule_id.startswith("dry.")]
1441
+
1442
+ return dry_violations
1443
+
1444
+
1445
+ def _setup_magic_numbers_orchestrator(
1446
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1447
+ ):
1448
+ """Set up orchestrator for magic-numbers command."""
1449
+ from src.orchestrator.core import Orchestrator
1450
+ from src.utils.project_root import get_project_root
1451
+
1452
+ # Use provided project_root or fall back to auto-detection
1453
+ if project_root is None:
1454
+ # Find actual project root (where .git or .thailint.yaml exists)
1455
+ first_path = path_objs[0] if path_objs else Path.cwd()
1456
+ search_start = first_path if first_path.is_dir() else first_path.parent
1457
+ project_root = get_project_root(search_start)
1458
+
1459
+ orchestrator = Orchestrator(project_root=project_root)
1460
+
1461
+ if config_file:
1462
+ _load_config_file(orchestrator, config_file, verbose)
1463
+
1464
+ return orchestrator
1465
+
1466
+
1467
+ def _run_magic_numbers_lint(orchestrator, path_objs: list[Path], recursive: bool):
1468
+ """Execute magic-numbers lint on files or directories."""
1469
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1470
+ return [v for v in all_violations if "magic-number" in v.rule_id]
1471
+
1472
+
1473
+ @cli.command("magic-numbers")
1474
+ @click.argument("paths", nargs=-1, type=click.Path())
1475
+ @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1476
+ @format_option
1477
+ @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1478
+ @click.pass_context
1479
+ def magic_numbers( # pylint: disable=too-many-arguments,too-many-positional-arguments
1480
+ ctx,
1481
+ paths: tuple[str, ...],
1482
+ config_file: str | None,
1483
+ format: str,
1484
+ recursive: bool,
1485
+ ):
1486
+ """Check for magic numbers in code.
1487
+
1488
+ Detects unnamed numeric literals in Python and TypeScript/JavaScript code
1489
+ that should be extracted as named constants for better readability.
1490
+
1491
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
1492
+
1493
+ Examples:
1494
+
1495
+ \b
1496
+ # Check current directory (all files recursively)
1497
+ thai-lint magic-numbers
1498
+
1499
+ \b
1500
+ # Check specific directory
1501
+ thai-lint magic-numbers src/
1502
+
1503
+ \b
1504
+ # Check single file
1505
+ thai-lint magic-numbers src/app.py
1506
+
1507
+ \b
1508
+ # Check multiple files
1509
+ thai-lint magic-numbers src/app.py src/utils.py tests/test_app.py
1510
+
1511
+ \b
1512
+ # Check mix of files and directories
1513
+ thai-lint magic-numbers src/app.py tests/
1514
+
1515
+ \b
1516
+ # Get JSON output
1517
+ thai-lint magic-numbers --format json .
1518
+
1519
+ \b
1520
+ # Use custom config file
1521
+ thai-lint magic-numbers --config .thailint.yaml src/
1522
+ """
1523
+ verbose = ctx.obj.get("verbose", False)
1524
+ project_root = _get_project_root_from_context(ctx)
1525
+
1526
+ if not paths:
1527
+ paths = (".",)
1528
+
1529
+ path_objs = [Path(p) for p in paths]
1530
+
1531
+ try:
1532
+ _execute_magic_numbers_lint(
1533
+ path_objs, config_file, format, recursive, verbose, project_root
1534
+ )
1535
+ except Exception as e:
1536
+ _handle_linting_error(e, verbose)
1537
+
1538
+
1539
+ def _execute_magic_numbers_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1540
+ path_objs, config_file, format, recursive, verbose, project_root=None
1541
+ ):
1542
+ """Execute magic-numbers lint."""
1543
+ _validate_paths_exist(path_objs)
1544
+ orchestrator = _setup_magic_numbers_orchestrator(path_objs, config_file, verbose, project_root)
1545
+ magic_numbers_violations = _run_magic_numbers_lint(orchestrator, path_objs, recursive)
1546
+
1547
+ if verbose:
1548
+ logger.info(f"Found {len(magic_numbers_violations)} magic number violation(s)")
1549
+
1550
+ format_violations(magic_numbers_violations, format)
1551
+ sys.exit(1 if magic_numbers_violations else 0)
1552
+
1553
+
1554
+ # =============================================================================
1555
+ # Print Statements Linter Command
1556
+ # =============================================================================
1557
+
1558
+
1559
+ def _setup_print_statements_orchestrator(
1560
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1561
+ ):
1562
+ """Set up orchestrator for print-statements command."""
1563
+ from src.orchestrator.core import Orchestrator
1564
+ from src.utils.project_root import get_project_root
1565
+
1566
+ if project_root is None:
1567
+ first_path = path_objs[0] if path_objs else Path.cwd()
1568
+ search_start = first_path if first_path.is_dir() else first_path.parent
1569
+ project_root = get_project_root(search_start)
1570
+
1571
+ orchestrator = Orchestrator(project_root=project_root)
1572
+
1573
+ if config_file:
1574
+ _load_config_file(orchestrator, config_file, verbose)
1575
+
1576
+ return orchestrator
1577
+
1578
+
1579
+ def _run_print_statements_lint(orchestrator, path_objs: list[Path], recursive: bool):
1580
+ """Execute print-statements lint on files or directories."""
1581
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1582
+ return [v for v in all_violations if "print-statement" in v.rule_id]
1583
+
1584
+
1585
+ @cli.command("print-statements")
1586
+ @click.argument("paths", nargs=-1, type=click.Path())
1587
+ @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1588
+ @format_option
1589
+ @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1590
+ @click.pass_context
1591
+ def print_statements( # pylint: disable=too-many-arguments,too-many-positional-arguments
1592
+ ctx,
1593
+ paths: tuple[str, ...],
1594
+ config_file: str | None,
1595
+ format: str,
1596
+ recursive: bool,
1597
+ ):
1598
+ """Check for print/console statements in code.
1599
+
1600
+ Detects print() calls in Python and console.log/warn/error/debug/info calls
1601
+ in TypeScript/JavaScript that should be replaced with proper logging.
1602
+
1603
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
1604
+
1605
+ Examples:
1606
+
1607
+ \b
1608
+ # Check current directory (all files recursively)
1609
+ thai-lint print-statements
1610
+
1611
+ \b
1612
+ # Check specific directory
1613
+ thai-lint print-statements src/
1614
+
1615
+ \b
1616
+ # Check single file
1617
+ thai-lint print-statements src/app.py
1618
+
1619
+ \b
1620
+ # Check multiple files
1621
+ thai-lint print-statements src/app.py src/utils.ts tests/test_app.py
1622
+
1623
+ \b
1624
+ # Get JSON output
1625
+ thai-lint print-statements --format json .
1626
+
1627
+ \b
1628
+ # Use custom config file
1629
+ thai-lint print-statements --config .thailint.yaml src/
1630
+ """
1631
+ verbose = ctx.obj.get("verbose", False)
1632
+ project_root = _get_project_root_from_context(ctx)
1633
+
1634
+ if not paths:
1635
+ paths = (".",)
1636
+
1637
+ path_objs = [Path(p) for p in paths]
1638
+
1639
+ try:
1640
+ _execute_print_statements_lint(
1641
+ path_objs, config_file, format, recursive, verbose, project_root
1642
+ )
1643
+ except Exception as e:
1644
+ _handle_linting_error(e, verbose)
1645
+
1646
+
1647
+ def _execute_print_statements_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1648
+ path_objs, config_file, format, recursive, verbose, project_root=None
1649
+ ):
1650
+ """Execute print-statements lint."""
1651
+ _validate_paths_exist(path_objs)
1652
+ orchestrator = _setup_print_statements_orchestrator(
1653
+ path_objs, config_file, verbose, project_root
1654
+ )
1655
+ print_statements_violations = _run_print_statements_lint(orchestrator, path_objs, recursive)
1656
+
1657
+ if verbose:
1658
+ logger.info(f"Found {len(print_statements_violations)} print statement violation(s)")
1659
+
1660
+ format_violations(print_statements_violations, format)
1661
+ sys.exit(1 if print_statements_violations else 0)
1662
+
1663
+
697
1664
  if __name__ == "__main__":
698
1665
  cli()