thailint 0.2.0__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 (52) hide show
  1. src/cli.py +646 -36
  2. src/config.py +6 -2
  3. src/core/base.py +90 -5
  4. src/core/config_parser.py +31 -4
  5. src/linters/dry/block_filter.py +5 -2
  6. src/linters/dry/cache.py +46 -92
  7. src/linters/dry/config.py +17 -13
  8. src/linters/dry/duplicate_storage.py +17 -80
  9. src/linters/dry/file_analyzer.py +11 -48
  10. src/linters/dry/linter.py +5 -12
  11. src/linters/dry/python_analyzer.py +188 -37
  12. src/linters/dry/storage_initializer.py +9 -18
  13. src/linters/dry/token_hasher.py +63 -9
  14. src/linters/dry/typescript_analyzer.py +7 -5
  15. src/linters/dry/violation_filter.py +4 -1
  16. src/linters/file_header/__init__.py +24 -0
  17. src/linters/file_header/atemporal_detector.py +87 -0
  18. src/linters/file_header/config.py +66 -0
  19. src/linters/file_header/field_validator.py +69 -0
  20. src/linters/file_header/linter.py +313 -0
  21. src/linters/file_header/python_parser.py +86 -0
  22. src/linters/file_header/violation_builder.py +78 -0
  23. src/linters/file_placement/linter.py +15 -4
  24. src/linters/magic_numbers/__init__.py +48 -0
  25. src/linters/magic_numbers/config.py +82 -0
  26. src/linters/magic_numbers/context_analyzer.py +247 -0
  27. src/linters/magic_numbers/linter.py +516 -0
  28. src/linters/magic_numbers/python_analyzer.py +76 -0
  29. src/linters/magic_numbers/typescript_analyzer.py +218 -0
  30. src/linters/magic_numbers/violation_builder.py +98 -0
  31. src/linters/nesting/__init__.py +6 -2
  32. src/linters/nesting/config.py +6 -3
  33. src/linters/nesting/linter.py +8 -19
  34. src/linters/nesting/typescript_analyzer.py +1 -0
  35. src/linters/print_statements/__init__.py +53 -0
  36. src/linters/print_statements/config.py +83 -0
  37. src/linters/print_statements/linter.py +430 -0
  38. src/linters/print_statements/python_analyzer.py +155 -0
  39. src/linters/print_statements/typescript_analyzer.py +135 -0
  40. src/linters/print_statements/violation_builder.py +98 -0
  41. src/linters/srp/__init__.py +3 -3
  42. src/linters/srp/config.py +12 -6
  43. src/linters/srp/linter.py +33 -24
  44. src/orchestrator/core.py +12 -2
  45. src/templates/thailint_config_template.yaml +158 -0
  46. src/utils/project_root.py +135 -16
  47. {thailint-0.2.0.dist-info → thailint-0.5.0.dist-info}/METADATA +387 -81
  48. thailint-0.5.0.dist-info/RECORD +96 -0
  49. {thailint-0.2.0.dist-info → thailint-0.5.0.dist-info}/WHEEL +1 -1
  50. thailint-0.2.0.dist-info/RECORD +0 -75
  51. {thailint-0.2.0.dist-info → thailint-0.5.0.dist-info}/entry_points.txt +0 -0
  52. {thailint-0.2.0.dist-info → thailint-0.5.0.dist-info/licenses}/LICENSE +0 -0
src/cli.py CHANGED
@@ -59,12 +59,145 @@ def setup_logging(verbose: bool = False):
59
59
  )
60
60
 
61
61
 
62
+ def _determine_project_root(
63
+ explicit_root: str | None, config_path: str | None, verbose: bool
64
+ ) -> Path:
65
+ """Determine project root with precedence rules.
66
+
67
+ Precedence order:
68
+ 1. Explicit --project-root (highest priority)
69
+ 2. Inferred from --config path directory
70
+ 3. Auto-detection via get_project_root() (fallback)
71
+
72
+ Args:
73
+ explicit_root: Explicitly specified project root path (from --project-root)
74
+ config_path: Config file path (from --config)
75
+ verbose: Whether verbose logging is enabled
76
+
77
+ Returns:
78
+ Path to determined project root
79
+
80
+ Raises:
81
+ SystemExit: If explicit_root doesn't exist or is not a directory
82
+ """
83
+ from src.utils.project_root import get_project_root
84
+
85
+ # Priority 1: Explicit --project-root
86
+ if explicit_root:
87
+ return _resolve_explicit_project_root(explicit_root, verbose)
88
+
89
+ # Priority 2: Infer from --config path
90
+ if config_path:
91
+ return _infer_root_from_config(config_path, verbose)
92
+
93
+ # Priority 3: Auto-detection (fallback)
94
+ return _autodetect_project_root(verbose, get_project_root)
95
+
96
+
97
+ def _resolve_explicit_project_root(explicit_root: str, verbose: bool) -> Path:
98
+ """Resolve and validate explicitly specified project root.
99
+
100
+ Args:
101
+ explicit_root: Explicitly specified project root path
102
+ verbose: Whether verbose logging is enabled
103
+
104
+ Returns:
105
+ Resolved project root path
106
+
107
+ Raises:
108
+ SystemExit: If explicit_root doesn't exist or is not a directory
109
+ """
110
+ root = Path(explicit_root)
111
+ # Check existence before resolving to handle relative paths in test environments
112
+ if not root.exists():
113
+ click.echo(f"Error: Project root does not exist: {explicit_root}", err=True)
114
+ sys.exit(2)
115
+ if not root.is_dir():
116
+ click.echo(f"Error: Project root must be a directory: {explicit_root}", err=True)
117
+ sys.exit(2)
118
+
119
+ # Now resolve after validation
120
+ root = root.resolve()
121
+
122
+ if verbose:
123
+ logger.debug(f"Using explicit project root: {root}")
124
+ return root
125
+
126
+
127
+ def _infer_root_from_config(config_path: str, verbose: bool) -> Path:
128
+ """Infer project root from config file path.
129
+
130
+ Args:
131
+ config_path: Config file path
132
+ verbose: Whether verbose logging is enabled
133
+
134
+ Returns:
135
+ Inferred project root (parent directory of config file)
136
+ """
137
+ config_file = Path(config_path).resolve()
138
+ inferred_root = config_file.parent
139
+
140
+ if verbose:
141
+ logger.debug(f"Inferred project root from config path: {inferred_root}")
142
+ return inferred_root
143
+
144
+
145
+ def _autodetect_project_root(verbose: bool, get_project_root) -> Path:
146
+ """Auto-detect project root using project root detection.
147
+
148
+ Args:
149
+ verbose: Whether verbose logging is enabled
150
+ get_project_root: Function to detect project root
151
+
152
+ Returns:
153
+ Auto-detected project root
154
+ """
155
+ auto_root = get_project_root(None)
156
+ if verbose:
157
+ logger.debug(f"Auto-detected project root: {auto_root}")
158
+ return auto_root
159
+
160
+
161
+ def _get_project_root_from_context(ctx) -> Path:
162
+ """Get or determine project root from Click context.
163
+
164
+ This function defers the actual determination until needed to avoid
165
+ importing pyprojroot in test environments where it may not be available.
166
+
167
+ Args:
168
+ ctx: Click context containing CLI options
169
+
170
+ Returns:
171
+ Path to determined project root
172
+ """
173
+ # Check if already determined and cached
174
+ if "project_root" in ctx.obj:
175
+ return ctx.obj["project_root"]
176
+
177
+ # Determine project root using stored CLI options
178
+ explicit_root = ctx.obj.get("cli_project_root")
179
+ config_path = ctx.obj.get("cli_config_path")
180
+ verbose = ctx.obj.get("verbose", False)
181
+
182
+ project_root = _determine_project_root(explicit_root, config_path, verbose)
183
+
184
+ # Cache for future use
185
+ ctx.obj["project_root"] = project_root
186
+
187
+ return project_root
188
+
189
+
62
190
  @click.group()
63
191
  @click.version_option(version=__version__)
64
192
  @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
65
193
  @click.option("--config", "-c", type=click.Path(), help="Path to config file")
194
+ @click.option(
195
+ "--project-root",
196
+ type=click.Path(),
197
+ help="Explicitly specify project root directory (overrides auto-detection)",
198
+ )
66
199
  @click.pass_context
67
- def cli(ctx, verbose: bool, config: str | None):
200
+ def cli(ctx, verbose: bool, config: str | None, project_root: str | None):
68
201
  """
69
202
  thai-lint - AI code linter and governance tool
70
203
 
@@ -85,6 +218,10 @@ def cli(ctx, verbose: bool, config: str | None):
85
218
  # Lint with custom config
86
219
  thai-lint file-placement --config .thailint.yaml src/
87
220
 
221
+ \b
222
+ # Specify project root explicitly (useful in Docker)
223
+ thai-lint --project-root /workspace/root magic-numbers backend/
224
+
88
225
  \b
89
226
  # Get JSON output
90
227
  thai-lint file-placement --format json .
@@ -99,6 +236,11 @@ def cli(ctx, verbose: bool, config: str | None):
99
236
  # Setup logging
100
237
  setup_logging(verbose)
101
238
 
239
+ # Store CLI options for later project root determination
240
+ # (deferred to avoid pyprojroot import issues in test environments)
241
+ ctx.obj["cli_project_root"] = project_root
242
+ ctx.obj["cli_config_path"] = config
243
+
102
244
  # Load configuration
103
245
  try:
104
246
  if config:
@@ -362,8 +504,143 @@ def config_reset(ctx, yes: bool):
362
504
  sys.exit(1)
363
505
 
364
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
+
365
642
  @cli.command("file-placement")
366
- @click.argument("paths", nargs=-1, type=click.Path(exists=True))
643
+ @click.argument("paths", nargs=-1, type=click.Path())
367
644
  @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
368
645
  @click.option("--rules", "-r", help="Inline JSON rules configuration")
369
646
  @format_option
@@ -416,6 +693,7 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
416
693
  thai-lint file-placement --rules '{"allow": [".*\\.py$"]}' .
417
694
  """
418
695
  verbose = ctx.obj.get("verbose", False)
696
+ project_root = _get_project_root_from_context(ctx)
419
697
 
420
698
  if not paths:
421
699
  paths = (".",)
@@ -423,16 +701,19 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
423
701
  path_objs = [Path(p) for p in paths]
424
702
 
425
703
  try:
426
- _execute_file_placement_lint(path_objs, config_file, rules, format, recursive, verbose)
704
+ _execute_file_placement_lint(
705
+ path_objs, config_file, rules, format, recursive, verbose, project_root
706
+ )
427
707
  except Exception as e:
428
708
  _handle_linting_error(e, verbose)
429
709
 
430
710
 
431
711
  def _execute_file_placement_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
432
- path_objs, config_file, rules, format, recursive, verbose
712
+ path_objs, config_file, rules, format, recursive, verbose, project_root=None
433
713
  ):
434
714
  """Execute file placement linting."""
435
- orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose)
715
+ _validate_paths_exist(path_objs)
716
+ orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose, project_root)
436
717
  all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
437
718
 
438
719
  # Filter to only file-placement violations
@@ -453,6 +734,28 @@ def _handle_linting_error(error: Exception, verbose: bool) -> None:
453
734
  sys.exit(2)
454
735
 
455
736
 
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
+
456
759
  def _find_project_root(start_path: Path) -> Path:
457
760
  """Find project root by looking for .git or pyproject.toml.
458
761
 
@@ -469,26 +772,55 @@ def _find_project_root(start_path: Path) -> Path:
469
772
  return get_project_root(start_path)
470
773
 
471
774
 
472
- def _setup_orchestrator(path_objs, config_file, rules, verbose):
775
+ def _setup_orchestrator(path_objs, config_file, rules, verbose, project_root=None):
473
776
  """Set up and configure the orchestrator."""
474
777
  from src.orchestrator.core import Orchestrator
475
778
  from src.utils.project_root import get_project_root
476
779
 
780
+ # Use provided project_root or fall back to auto-detection
781
+ project_root = _get_or_detect_project_root(path_objs, project_root, get_project_root)
782
+
783
+ orchestrator = Orchestrator(project_root=project_root)
784
+ _apply_orchestrator_config(orchestrator, config_file, rules, verbose)
785
+
786
+ return orchestrator
787
+
788
+
789
+ def _get_or_detect_project_root(path_objs, project_root, get_project_root):
790
+ """Get provided project root or auto-detect from paths.
791
+
792
+ Args:
793
+ path_objs: List of path objects
794
+ project_root: Optionally provided project root
795
+ get_project_root: Function to detect project root
796
+
797
+ Returns:
798
+ Project root path
799
+ """
800
+ if project_root is not None:
801
+ return project_root
802
+
477
803
  # Find actual project root (where .git or pyproject.toml exists)
478
804
  # This ensures .artifacts/ is always created at project root, not in subdirectories
479
805
  first_path = path_objs[0] if path_objs else Path.cwd()
480
806
  search_start = first_path if first_path.is_dir() else first_path.parent
481
- project_root = get_project_root(search_start)
807
+ return get_project_root(search_start)
482
808
 
483
- orchestrator = Orchestrator(project_root=project_root)
484
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
+ """
485
819
  if rules:
486
820
  _apply_inline_rules(orchestrator, rules, verbose)
487
821
  elif config_file:
488
822
  _load_config_file(orchestrator, config_file, verbose)
489
823
 
490
- return orchestrator
491
-
492
824
 
493
825
  def _apply_inline_rules(orchestrator, rules, verbose):
494
826
  """Parse and apply inline JSON rules."""
@@ -601,13 +933,18 @@ def _execute_linting_on_paths(orchestrator, path_objs: list[Path], recursive: bo
601
933
  return violations
602
934
 
603
935
 
604
- def _setup_nesting_orchestrator(path_objs: list[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
+ ):
605
939
  """Set up orchestrator for nesting command."""
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
-
610
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)
611
948
 
612
949
  orchestrator = Orchestrator(project_root=project_root)
613
950
 
@@ -622,14 +959,34 @@ def _apply_nesting_config_override(orchestrator, max_depth: int | None, verbose:
622
959
  if max_depth is None:
623
960
  return
624
961
 
962
+ # Ensure nesting config exists
625
963
  if "nesting" not in orchestrator.config:
626
964
  orchestrator.config["nesting"] = {}
627
- 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)
628
973
 
629
974
  if verbose:
630
975
  logger.debug(f"Overriding max_nesting_depth to {max_depth}")
631
976
 
632
977
 
978
+ def _override_language_specific_nesting(nesting_config: dict, max_depth: int):
979
+ """Override language-specific nesting depth configs.
980
+
981
+ Args:
982
+ nesting_config: Nesting configuration dictionary
983
+ max_depth: Maximum nesting depth to set
984
+ """
985
+ for lang in ["python", "typescript", "javascript"]:
986
+ if lang in nesting_config:
987
+ nesting_config[lang]["max_nesting_depth"] = max_depth
988
+
989
+
633
990
  def _run_nesting_lint(orchestrator, path_objs: list[Path], recursive: bool):
634
991
  """Execute nesting lint on files or directories."""
635
992
  all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
@@ -637,7 +994,7 @@ def _run_nesting_lint(orchestrator, path_objs: list[Path], recursive: bool):
637
994
 
638
995
 
639
996
  @cli.command("nesting")
640
- @click.argument("paths", nargs=-1, type=click.Path(exists=True))
997
+ @click.argument("paths", nargs=-1, type=click.Path())
641
998
  @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
642
999
  @format_option
643
1000
  @click.option("--max-depth", type=int, help="Override max nesting depth (default: 4)")
@@ -693,6 +1050,7 @@ def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
693
1050
  thai-lint nesting --config .thailint.yaml src/
694
1051
  """
695
1052
  verbose = ctx.obj.get("verbose", False)
1053
+ project_root = _get_project_root_from_context(ctx)
696
1054
 
697
1055
  # Default to current directory if no paths provided
698
1056
  if not paths:
@@ -701,16 +1059,19 @@ def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
701
1059
  path_objs = [Path(p) for p in paths]
702
1060
 
703
1061
  try:
704
- _execute_nesting_lint(path_objs, config_file, format, max_depth, recursive, verbose)
1062
+ _execute_nesting_lint(
1063
+ path_objs, config_file, format, max_depth, recursive, verbose, project_root
1064
+ )
705
1065
  except Exception as e:
706
1066
  _handle_linting_error(e, verbose)
707
1067
 
708
1068
 
709
1069
  def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
710
- path_objs, config_file, format, max_depth, recursive, verbose
1070
+ path_objs, config_file, format, max_depth, recursive, verbose, project_root=None
711
1071
  ):
712
1072
  """Execute nesting lint."""
713
- orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose)
1073
+ _validate_paths_exist(path_objs)
1074
+ orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose, project_root)
714
1075
  _apply_nesting_config_override(orchestrator, max_depth, verbose)
715
1076
  nesting_violations = _run_nesting_lint(orchestrator, path_objs, recursive)
716
1077
 
@@ -721,12 +1082,18 @@ def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positi
721
1082
  sys.exit(1 if nesting_violations else 0)
722
1083
 
723
1084
 
724
- def _setup_srp_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
1085
+ def _setup_srp_orchestrator(
1086
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1087
+ ):
725
1088
  """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
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)
730
1097
 
731
1098
  orchestrator = Orchestrator(project_root=project_root)
732
1099
 
@@ -773,7 +1140,7 @@ def _run_srp_lint(orchestrator, path_objs: list[Path], recursive: bool):
773
1140
 
774
1141
 
775
1142
  @cli.command("srp")
776
- @click.argument("paths", nargs=-1, type=click.Path(exists=True))
1143
+ @click.argument("paths", nargs=-1, type=click.Path())
777
1144
  @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
778
1145
  @format_option
779
1146
  @click.option("--max-methods", type=int, help="Override max methods per class (default: 7)")
@@ -829,6 +1196,7 @@ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
829
1196
  thai-lint srp --config .thailint.yaml src/
830
1197
  """
831
1198
  verbose = ctx.obj.get("verbose", False)
1199
+ project_root = _get_project_root_from_context(ctx)
832
1200
 
833
1201
  if not paths:
834
1202
  paths = (".",)
@@ -836,16 +1204,19 @@ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
836
1204
  path_objs = [Path(p) for p in paths]
837
1205
 
838
1206
  try:
839
- _execute_srp_lint(path_objs, config_file, format, max_methods, max_loc, recursive, verbose)
1207
+ _execute_srp_lint(
1208
+ path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root
1209
+ )
840
1210
  except Exception as e:
841
1211
  _handle_linting_error(e, verbose)
842
1212
 
843
1213
 
844
1214
  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
1215
+ path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root=None
846
1216
  ):
847
1217
  """Execute SRP lint."""
848
- orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose)
1218
+ _validate_paths_exist(path_objs)
1219
+ orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose, project_root)
849
1220
  _apply_srp_config_override(orchestrator, max_methods, max_loc, verbose)
850
1221
  srp_violations = _run_srp_lint(orchestrator, path_objs, recursive)
851
1222
 
@@ -857,7 +1228,7 @@ def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional
857
1228
 
858
1229
 
859
1230
  @cli.command("dry")
860
- @click.argument("paths", nargs=-1, type=click.Path(exists=True))
1231
+ @click.argument("paths", nargs=-1, type=click.Path())
861
1232
  @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
862
1233
  @format_option
863
1234
  @click.option("--min-lines", type=int, help="Override min duplicate lines threshold")
@@ -925,6 +1296,7 @@ def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
925
1296
  thai-lint dry --format json .
926
1297
  """
927
1298
  verbose = ctx.obj.get("verbose", False)
1299
+ project_root = _get_project_root_from_context(ctx)
928
1300
 
929
1301
  if not paths:
930
1302
  paths = (".",)
@@ -933,17 +1305,34 @@ def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
933
1305
 
934
1306
  try:
935
1307
  _execute_dry_lint(
936
- path_objs, config_file, format, min_lines, no_cache, clear_cache, recursive, verbose
1308
+ path_objs,
1309
+ config_file,
1310
+ format,
1311
+ min_lines,
1312
+ no_cache,
1313
+ clear_cache,
1314
+ recursive,
1315
+ verbose,
1316
+ project_root,
937
1317
  )
938
1318
  except Exception as e:
939
1319
  _handle_linting_error(e, verbose)
940
1320
 
941
1321
 
942
1322
  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
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,
944
1332
  ):
945
1333
  """Execute DRY linting."""
946
- orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose)
1334
+ _validate_paths_exist(path_objs)
1335
+ orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose, project_root)
947
1336
  _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose)
948
1337
 
949
1338
  if clear_cache:
@@ -958,14 +1347,16 @@ def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional
958
1347
  sys.exit(1 if dry_violations else 0)
959
1348
 
960
1349
 
961
- def _setup_dry_orchestrator(path_objs, config_file, verbose):
1350
+ def _setup_dry_orchestrator(path_objs, config_file, verbose, project_root=None):
962
1351
  """Set up orchestrator for DRY linting."""
963
1352
  from src.orchestrator.core import Orchestrator
964
1353
  from src.utils.project_root import get_project_root
965
1354
 
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)
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)
969
1360
 
970
1361
  orchestrator = Orchestrator(project_root=project_root)
971
1362
 
@@ -1051,5 +1442,224 @@ def _run_dry_lint(orchestrator, path_objs, recursive):
1051
1442
  return dry_violations
1052
1443
 
1053
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
+
1054
1664
  if __name__ == "__main__":
1055
1665
  cli()