thailint 0.10.0__py3-none-any.whl → 0.11.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 (62) hide show
  1. src/__init__.py +1 -0
  2. src/cli/__init__.py +27 -0
  3. src/cli/__main__.py +22 -0
  4. src/cli/config.py +478 -0
  5. src/cli/linters/__init__.py +58 -0
  6. src/cli/linters/code_patterns.py +372 -0
  7. src/cli/linters/code_smells.py +343 -0
  8. src/cli/linters/documentation.py +155 -0
  9. src/cli/linters/shared.py +89 -0
  10. src/cli/linters/structure.py +313 -0
  11. src/cli/linters/structure_quality.py +316 -0
  12. src/cli/main.py +120 -0
  13. src/cli/utils.py +375 -0
  14. src/cli_main.py +34 -0
  15. src/core/types.py +13 -0
  16. src/core/violation_utils.py +69 -0
  17. src/linter_config/ignore.py +32 -16
  18. src/linters/collection_pipeline/linter.py +2 -2
  19. src/linters/dry/block_filter.py +97 -1
  20. src/linters/dry/cache.py +94 -6
  21. src/linters/dry/config.py +47 -10
  22. src/linters/dry/constant.py +92 -0
  23. src/linters/dry/constant_matcher.py +214 -0
  24. src/linters/dry/constant_violation_builder.py +98 -0
  25. src/linters/dry/linter.py +89 -48
  26. src/linters/dry/python_analyzer.py +12 -415
  27. src/linters/dry/python_constant_extractor.py +101 -0
  28. src/linters/dry/single_statement_detector.py +415 -0
  29. src/linters/dry/token_hasher.py +5 -5
  30. src/linters/dry/typescript_analyzer.py +5 -354
  31. src/linters/dry/typescript_constant_extractor.py +134 -0
  32. src/linters/dry/typescript_statement_detector.py +255 -0
  33. src/linters/dry/typescript_value_extractor.py +66 -0
  34. src/linters/file_header/linter.py +2 -2
  35. src/linters/file_placement/linter.py +2 -2
  36. src/linters/file_placement/pattern_matcher.py +19 -5
  37. src/linters/magic_numbers/linter.py +8 -67
  38. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  39. src/linters/nesting/linter.py +12 -9
  40. src/linters/print_statements/linter.py +7 -24
  41. src/linters/srp/class_analyzer.py +9 -9
  42. src/linters/srp/heuristics.py +2 -2
  43. src/linters/srp/linter.py +2 -2
  44. src/linters/stateless_class/linter.py +2 -2
  45. src/linters/stringly_typed/__init__.py +23 -0
  46. src/linters/stringly_typed/config.py +165 -0
  47. src/linters/stringly_typed/python/__init__.py +29 -0
  48. src/linters/stringly_typed/python/analyzer.py +198 -0
  49. src/linters/stringly_typed/python/condition_extractor.py +131 -0
  50. src/linters/stringly_typed/python/conditional_detector.py +176 -0
  51. src/linters/stringly_typed/python/constants.py +21 -0
  52. src/linters/stringly_typed/python/match_analyzer.py +88 -0
  53. src/linters/stringly_typed/python/validation_detector.py +186 -0
  54. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  55. src/orchestrator/core.py +241 -12
  56. {thailint-0.10.0.dist-info → thailint-0.11.0.dist-info}/METADATA +2 -2
  57. {thailint-0.10.0.dist-info → thailint-0.11.0.dist-info}/RECORD +60 -28
  58. thailint-0.11.0.dist-info/entry_points.txt +4 -0
  59. src/cli.py +0 -2141
  60. thailint-0.10.0.dist-info/entry_points.txt +0 -4
  61. {thailint-0.10.0.dist-info → thailint-0.11.0.dist-info}/WHEEL +0 -0
  62. {thailint-0.10.0.dist-info → thailint-0.11.0.dist-info}/licenses/LICENSE +0 -0
src/cli.py DELETED
@@ -1,2141 +0,0 @@
1
- """
2
- Purpose: Main CLI entrypoint with Click framework for command-line interface
3
-
4
- Scope: CLI command definitions, option parsing, and command execution coordination
5
-
6
- Overview: Provides the main CLI application using Click decorators for command definition, option
7
- parsing, and help text generation. Includes example commands (hello, config management) that
8
- demonstrate best practices for CLI design including error handling, logging configuration,
9
- context management, and user-friendly output. Serves as the entry point for the installed
10
- CLI tool and coordinates between user input and application logic.
11
-
12
- Dependencies: click for CLI framework, logging for structured output, pathlib for file paths
13
-
14
- Exports: cli (main command group), hello command, config command group, linter commands
15
-
16
- Interfaces: Click CLI commands, configuration context via Click ctx, logging integration
17
-
18
- Implementation: Click decorators for commands, context passing for shared state, comprehensive help text
19
- """
20
- # pylint: disable=too-many-lines
21
- # Justification: CLI modules naturally have many commands and helper functions
22
-
23
- import logging
24
- import sys
25
- from pathlib import Path
26
-
27
- import click
28
-
29
- from src import __version__
30
- from src.config import ConfigError, load_config, save_config, validate_config
31
- from src.core.cli_utils import format_violations
32
-
33
- # Configure module logger
34
- logger = logging.getLogger(__name__)
35
-
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",
42
- "-f",
43
- type=click.Choice(["text", "json", "sarif"]),
44
- default="text",
45
- help="Output format",
46
- )(func)
47
-
48
-
49
- def setup_logging(verbose: bool = False):
50
- """
51
- Configure logging for the CLI application.
52
-
53
- Args:
54
- verbose: Enable DEBUG level logging if True, INFO otherwise.
55
- """
56
- level = logging.DEBUG if verbose else logging.INFO
57
-
58
- logging.basicConfig(
59
- level=level,
60
- format="%(asctime)s | %(levelname)-8s | %(message)s",
61
- datefmt="%Y-%m-%d %H:%M:%S",
62
- stream=sys.stdout,
63
- )
64
-
65
-
66
- def _determine_project_root(
67
- explicit_root: str | None, config_path: str | None, verbose: bool
68
- ) -> Path:
69
- """Determine project root with precedence rules.
70
-
71
- Precedence order:
72
- 1. Explicit --project-root (highest priority)
73
- 2. Inferred from --config path directory
74
- 3. Auto-detection via get_project_root() (fallback)
75
-
76
- Args:
77
- explicit_root: Explicitly specified project root path (from --project-root)
78
- config_path: Config file path (from --config)
79
- verbose: Whether verbose logging is enabled
80
-
81
- Returns:
82
- Path to determined project root
83
-
84
- Raises:
85
- SystemExit: If explicit_root doesn't exist or is not a directory
86
- """
87
- from src.utils.project_root import get_project_root
88
-
89
- # Priority 1: Explicit --project-root
90
- if explicit_root:
91
- return _resolve_explicit_project_root(explicit_root, verbose)
92
-
93
- # Priority 2: Infer from --config path
94
- if config_path:
95
- return _infer_root_from_config(config_path, verbose)
96
-
97
- # Priority 3: Auto-detection (fallback)
98
- return _autodetect_project_root(verbose, get_project_root)
99
-
100
-
101
- def _resolve_explicit_project_root(explicit_root: str, verbose: bool) -> Path:
102
- """Resolve and validate explicitly specified project root.
103
-
104
- Args:
105
- explicit_root: Explicitly specified project root path
106
- verbose: Whether verbose logging is enabled
107
-
108
- Returns:
109
- Resolved project root path
110
-
111
- Raises:
112
- SystemExit: If explicit_root doesn't exist or is not a directory
113
- """
114
- root = Path(explicit_root)
115
- # Check existence before resolving to handle relative paths in test environments
116
- if not root.exists():
117
- click.echo(f"Error: Project root does not exist: {explicit_root}", err=True)
118
- sys.exit(2)
119
- if not root.is_dir():
120
- click.echo(f"Error: Project root must be a directory: {explicit_root}", err=True)
121
- sys.exit(2)
122
-
123
- # Now resolve after validation
124
- root = root.resolve()
125
-
126
- if verbose:
127
- logger.debug(f"Using explicit project root: {root}")
128
- return root
129
-
130
-
131
- def _infer_root_from_config(config_path: str, verbose: bool) -> Path:
132
- """Infer project root from config file path.
133
-
134
- Args:
135
- config_path: Config file path
136
- verbose: Whether verbose logging is enabled
137
-
138
- Returns:
139
- Inferred project root (parent directory of config file)
140
- """
141
- config_file = Path(config_path).resolve()
142
- inferred_root = config_file.parent
143
-
144
- if verbose:
145
- logger.debug(f"Inferred project root from config path: {inferred_root}")
146
- return inferred_root
147
-
148
-
149
- def _autodetect_project_root(verbose: bool, get_project_root) -> Path:
150
- """Auto-detect project root using project root detection.
151
-
152
- Args:
153
- verbose: Whether verbose logging is enabled
154
- get_project_root: Function to detect project root
155
-
156
- Returns:
157
- Auto-detected project root
158
- """
159
- auto_root = get_project_root(None)
160
- if verbose:
161
- logger.debug(f"Auto-detected project root: {auto_root}")
162
- return auto_root
163
-
164
-
165
- def _get_project_root_from_context(ctx) -> Path:
166
- """Get or determine project root from Click context.
167
-
168
- This function defers the actual determination until needed to avoid
169
- importing pyprojroot in test environments where it may not be available.
170
-
171
- Args:
172
- ctx: Click context containing CLI options
173
-
174
- Returns:
175
- Path to determined project root
176
- """
177
- # Check if already determined and cached
178
- if "project_root" in ctx.obj:
179
- return ctx.obj["project_root"]
180
-
181
- # Determine project root using stored CLI options
182
- explicit_root = ctx.obj.get("cli_project_root")
183
- config_path = ctx.obj.get("cli_config_path")
184
- verbose = ctx.obj.get("verbose", False)
185
-
186
- project_root = _determine_project_root(explicit_root, config_path, verbose)
187
-
188
- # Cache for future use
189
- ctx.obj["project_root"] = project_root
190
-
191
- return project_root
192
-
193
-
194
- @click.group()
195
- @click.version_option(version=__version__)
196
- @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
197
- @click.option("--config", "-c", type=click.Path(), help="Path to config file")
198
- @click.option(
199
- "--project-root",
200
- type=click.Path(),
201
- help="Explicitly specify project root directory (overrides auto-detection)",
202
- )
203
- @click.pass_context
204
- def cli(ctx, verbose: bool, config: str | None, project_root: str | None):
205
- """
206
- thai-lint - AI code linter and governance tool
207
-
208
- Lint and governance for AI-generated code across multiple languages.
209
- Identifies common mistakes, anti-patterns, and security issues.
210
-
211
- Examples:
212
-
213
- \b
214
- # Check for duplicate code (DRY violations)
215
- thai-lint dry .
216
-
217
- \b
218
- # Lint current directory for file placement issues
219
- thai-lint file-placement .
220
-
221
- \b
222
- # Lint with custom config
223
- thai-lint file-placement --config .thailint.yaml src/
224
-
225
- \b
226
- # Specify project root explicitly (useful in Docker)
227
- thai-lint --project-root /workspace/root magic-numbers backend/
228
-
229
- \b
230
- # Get JSON output
231
- thai-lint file-placement --format json .
232
-
233
- \b
234
- # Show help
235
- thai-lint --help
236
- """
237
- # Ensure context object exists
238
- ctx.ensure_object(dict)
239
-
240
- # Setup logging
241
- setup_logging(verbose)
242
-
243
- # Store CLI options for later project root determination
244
- # (deferred to avoid pyprojroot import issues in test environments)
245
- ctx.obj["cli_project_root"] = project_root
246
- ctx.obj["cli_config_path"] = config
247
-
248
- # Load configuration
249
- try:
250
- if config:
251
- ctx.obj["config"] = load_config(Path(config))
252
- ctx.obj["config_path"] = Path(config)
253
- else:
254
- ctx.obj["config"] = load_config()
255
- ctx.obj["config_path"] = None
256
-
257
- logger.debug("Configuration loaded successfully")
258
- except ConfigError as e:
259
- click.echo(f"Error loading configuration: {e}", err=True)
260
- sys.exit(2)
261
-
262
- ctx.obj["verbose"] = verbose
263
-
264
-
265
- @cli.command()
266
- @click.option("--name", "-n", default="World", help="Name to greet")
267
- @click.option("--uppercase", "-u", is_flag=True, help="Convert greeting to uppercase")
268
- @click.pass_context
269
- def hello(ctx, name: str, uppercase: bool):
270
- """
271
- Print a greeting message.
272
-
273
- This is a simple example command demonstrating CLI basics.
274
-
275
- Examples:
276
-
277
- \b
278
- # Basic greeting
279
- thai-lint hello
280
-
281
- \b
282
- # Custom name
283
- thai-lint hello --name Alice
284
-
285
- \b
286
- # Uppercase output
287
- thai-lint hello --name Bob --uppercase
288
- """
289
- config = ctx.obj["config"]
290
- verbose = ctx.obj.get("verbose", False)
291
-
292
- # Get greeting from config or use default
293
- greeting_template = config.get("greeting", "Hello")
294
-
295
- # Build greeting message
296
- message = f"{greeting_template}, {name}!"
297
-
298
- if uppercase:
299
- message = message.upper()
300
-
301
- # Output greeting
302
- click.echo(message)
303
-
304
- if verbose:
305
- logger.info(f"Greeted {name} with template '{greeting_template}'")
306
-
307
-
308
- @cli.group()
309
- def config():
310
- """Configuration management commands."""
311
- pass
312
-
313
-
314
- @config.command("show")
315
- @click.option(
316
- "--format",
317
- "-f",
318
- type=click.Choice(["text", "json", "yaml"]),
319
- default="text",
320
- help="Output format",
321
- )
322
- @click.pass_context
323
- def config_show(ctx, format: str):
324
- """
325
- Display current configuration.
326
-
327
- Shows all configuration values in the specified format.
328
-
329
- Examples:
330
-
331
- \b
332
- # Show as text
333
- thai-lint config show
334
-
335
- \b
336
- # Show as JSON
337
- thai-lint config show --format json
338
-
339
- \b
340
- # Show as YAML
341
- thai-lint config show --format yaml
342
- """
343
- cfg = ctx.obj["config"]
344
-
345
- formatters = {
346
- "json": _format_config_json,
347
- "yaml": _format_config_yaml,
348
- "text": _format_config_text,
349
- }
350
- formatters[format](cfg)
351
-
352
-
353
- def _format_config_json(cfg: dict) -> None:
354
- """Format configuration as JSON."""
355
- import json
356
-
357
- click.echo(json.dumps(cfg, indent=2))
358
-
359
-
360
- def _format_config_yaml(cfg: dict) -> None:
361
- """Format configuration as YAML."""
362
- import yaml
363
-
364
- click.echo(yaml.dump(cfg, default_flow_style=False, sort_keys=False))
365
-
366
-
367
- def _format_config_text(cfg: dict) -> None:
368
- """Format configuration as text."""
369
- click.echo("Current Configuration:")
370
- click.echo("-" * 40)
371
- for key, value in cfg.items():
372
- click.echo(f"{key:20} : {value}")
373
-
374
-
375
- @config.command("get")
376
- @click.argument("key")
377
- @click.pass_context
378
- def config_get(ctx, key: str):
379
- """
380
- Get specific configuration value.
381
-
382
- KEY: Configuration key to retrieve
383
-
384
- Examples:
385
-
386
- \b
387
- # Get log level
388
- thai-lint config get log_level
389
-
390
- \b
391
- # Get greeting template
392
- thai-lint config get greeting
393
- """
394
- cfg = ctx.obj["config"]
395
-
396
- if key not in cfg:
397
- click.echo(f"Configuration key not found: {key}", err=True)
398
- sys.exit(1)
399
-
400
- click.echo(cfg[key])
401
-
402
-
403
- def _convert_value_type(value: str):
404
- """Convert string value to appropriate type."""
405
- if value.lower() in ["true", "false"]:
406
- return value.lower() == "true"
407
- if value.isdigit():
408
- return int(value)
409
- if value.replace(".", "", 1).isdigit() and value.count(".") == 1:
410
- return float(value)
411
- return value
412
-
413
-
414
- def _validate_and_report_errors(cfg: dict):
415
- """Validate configuration and report errors."""
416
- is_valid, errors = validate_config(cfg)
417
- if not is_valid:
418
- click.echo("Invalid configuration:", err=True)
419
- for error in errors:
420
- click.echo(f" - {error}", err=True)
421
- sys.exit(1)
422
-
423
-
424
- def _save_and_report_success(cfg: dict, key: str, value, config_path, verbose: bool):
425
- """Save configuration and report success."""
426
- save_config(cfg, config_path)
427
- click.echo(f"✓ Set {key} = {value}")
428
- if verbose:
429
- logger.info(f"Configuration updated: {key}={value}")
430
-
431
-
432
- @config.command("set")
433
- @click.argument("key")
434
- @click.argument("value")
435
- @click.pass_context
436
- def config_set(ctx, key: str, value: str):
437
- """
438
- Set configuration value.
439
-
440
- KEY: Configuration key to set
441
-
442
- VALUE: New value for the key
443
-
444
- Examples:
445
-
446
- \b
447
- # Set log level
448
- thai-lint config set log_level DEBUG
449
-
450
- \b
451
- # Set greeting template
452
- thai-lint config set greeting "Hi"
453
-
454
- \b
455
- # Set numeric value
456
- thai-lint config set max_retries 5
457
- """
458
- cfg = ctx.obj["config"]
459
- converted_value = _convert_value_type(value)
460
- cfg[key] = converted_value
461
-
462
- try:
463
- _validate_and_report_errors(cfg)
464
- except Exception as e:
465
- click.echo(f"Validation error: {e}", err=True)
466
- sys.exit(1)
467
-
468
- try:
469
- config_path = ctx.obj.get("config_path")
470
- verbose = ctx.obj.get("verbose", False)
471
- _save_and_report_success(cfg, key, converted_value, config_path, verbose)
472
- except ConfigError as e:
473
- click.echo(f"Error saving configuration: {e}", err=True)
474
- sys.exit(1)
475
-
476
-
477
- @config.command("reset")
478
- @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
479
- @click.pass_context
480
- def config_reset(ctx, yes: bool):
481
- """
482
- Reset configuration to defaults.
483
-
484
- Examples:
485
-
486
- \b
487
- # Reset with confirmation
488
- thai-lint config reset
489
-
490
- \b
491
- # Reset without confirmation
492
- thai-lint config reset --yes
493
- """
494
- if not yes:
495
- click.confirm("Reset configuration to defaults?", abort=True)
496
-
497
- from src.config import DEFAULT_CONFIG
498
-
499
- try:
500
- config_path = ctx.obj.get("config_path")
501
- save_config(DEFAULT_CONFIG.copy(), config_path)
502
- click.echo("✓ Configuration reset to defaults")
503
-
504
- if ctx.obj.get("verbose"):
505
- logger.info("Configuration reset to defaults")
506
- except ConfigError as e:
507
- click.echo(f"Error resetting configuration: {e}", err=True)
508
- sys.exit(1)
509
-
510
-
511
- @cli.command("init-config")
512
- @click.option(
513
- "--preset",
514
- "-p",
515
- type=click.Choice(["strict", "standard", "lenient"]),
516
- default="standard",
517
- help="Configuration preset",
518
- )
519
- @click.option("--non-interactive", is_flag=True, help="Skip interactive prompts (for AI agents)")
520
- @click.option("--force", is_flag=True, help="Overwrite existing .thailint.yaml file")
521
- @click.option(
522
- "--output", "-o", type=click.Path(), default=".thailint.yaml", help="Output file path"
523
- )
524
- def init_config(preset: str, non_interactive: bool, force: bool, output: str):
525
- """
526
- Generate a .thailint.yaml configuration file with preset values.
527
-
528
- Creates a richly-commented configuration file with sensible defaults
529
- and optional customizations for different strictness levels.
530
-
531
- For AI agents, use --non-interactive mode:
532
- thailint init-config --non-interactive --preset lenient
533
-
534
- Presets:
535
- strict: Minimal allowed numbers (only -1, 0, 1)
536
- standard: Balanced defaults (includes 2, 3, 4, 5, 10, 100, 1000)
537
- lenient: Includes time conversions (adds 60, 3600)
538
-
539
- Examples:
540
-
541
- \\b
542
- # Interactive mode (default, for humans)
543
- thailint init-config
544
-
545
- \\b
546
- # Non-interactive mode (for AI agents)
547
- thailint init-config --non-interactive
548
-
549
- \\b
550
- # Generate with lenient preset
551
- thailint init-config --preset lenient
552
-
553
- \\b
554
- # Overwrite existing config
555
- thailint init-config --force
556
-
557
- \\b
558
- # Custom output path
559
- thailint init-config --output my-config.yaml
560
- """
561
- output_path = Path(output)
562
-
563
- # Check if file exists (unless --force)
564
- if output_path.exists() and not force:
565
- click.echo(f"Error: {output} already exists", err=True)
566
- click.echo("", err=True)
567
- click.echo("Use --force to overwrite:", err=True)
568
- click.echo(" thailint init-config --force", err=True)
569
- sys.exit(1)
570
-
571
- # Interactive mode: Ask user for preferences
572
- if not non_interactive:
573
- click.echo("thai-lint Configuration Generator")
574
- click.echo("=" * 50)
575
- click.echo("")
576
- click.echo("This will create a .thailint.yaml configuration file.")
577
- click.echo("For non-interactive mode (AI agents), use:")
578
- click.echo(" thailint init-config --non-interactive")
579
- click.echo("")
580
-
581
- # Ask for preset
582
- click.echo("Available presets:")
583
- click.echo(" strict: Only -1, 0, 1 allowed (strictest)")
584
- click.echo(" standard: -1, 0, 1, 2, 3, 4, 5, 10, 100, 1000 (balanced)")
585
- click.echo(" lenient: Includes time conversions 60, 3600 (most permissive)")
586
- click.echo("")
587
-
588
- preset = click.prompt(
589
- "Choose preset", type=click.Choice(["strict", "standard", "lenient"]), default=preset
590
- )
591
-
592
- # Generate config based on preset
593
- config_content = _generate_config_content(preset)
594
-
595
- # Write config file
596
- try:
597
- output_path.write_text(config_content, encoding="utf-8")
598
- click.echo("")
599
- click.echo(f"✓ Created {output}")
600
- click.echo(f"✓ Preset: {preset}")
601
- click.echo("")
602
- click.echo("Next steps:")
603
- click.echo(f" 1. Review and customize {output}")
604
- click.echo(" 2. Run: thailint magic-numbers .")
605
- click.echo(" 3. See docs: https://github.com/your-org/thai-lint")
606
- except OSError as e:
607
- click.echo(f"Error writing config file: {e}", err=True)
608
- sys.exit(1)
609
-
610
-
611
- def _generate_config_content(preset: str) -> str:
612
- """Generate config file content based on preset."""
613
- # Preset configurations
614
- presets = {
615
- "strict": {
616
- "allowed_numbers": "[-1, 0, 1]",
617
- "max_small_integer": "3",
618
- "description": "Strict (only universal values)",
619
- },
620
- "standard": {
621
- "allowed_numbers": "[-1, 0, 1, 2, 3, 4, 5, 10, 100, 1000]",
622
- "max_small_integer": "10",
623
- "description": "Standard (balanced defaults)",
624
- },
625
- "lenient": {
626
- "allowed_numbers": "[-1, 0, 1, 2, 3, 4, 5, 10, 60, 100, 1000, 3600]",
627
- "max_small_integer": "10",
628
- "description": "Lenient (includes time conversions)",
629
- },
630
- }
631
-
632
- config = presets[preset]
633
-
634
- # Read template
635
- template_path = Path(__file__).parent / "templates" / "thailint_config_template.yaml"
636
- template = template_path.read_text(encoding="utf-8")
637
-
638
- # Replace placeholders
639
- content = template.replace("{{PRESET}}", config["description"])
640
- content = content.replace("{{ALLOWED_NUMBERS}}", config["allowed_numbers"])
641
- content = content.replace("{{MAX_SMALL_INTEGER}}", config["max_small_integer"])
642
-
643
- return content
644
-
645
-
646
- @cli.command("file-placement")
647
- @click.argument("paths", nargs=-1, type=click.Path())
648
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
649
- @click.option("--rules", "-r", help="Inline JSON rules configuration")
650
- @format_option
651
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
652
- @click.pass_context
653
- def file_placement( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements
654
- ctx,
655
- paths: tuple[str, ...],
656
- config_file: str | None,
657
- rules: str | None,
658
- format: str,
659
- recursive: bool,
660
- ):
661
- # Justification for Pylint disables:
662
- # - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 4 options = 6 params
663
- # - too-many-locals/statements: Complex CLI logic for config, linting, and output formatting
664
- # All parameters and logic are necessary for flexible CLI usage.
665
- """
666
- Lint files for proper file placement.
667
-
668
- Checks that files are placed in appropriate directories according to
669
- configured rules and patterns.
670
-
671
- PATHS: Files or directories to lint (defaults to current directory if none provided)
672
-
673
- Examples:
674
-
675
- \b
676
- # Lint current directory (all files recursively)
677
- thai-lint file-placement
678
-
679
- \b
680
- # Lint specific directory
681
- thai-lint file-placement src/
682
-
683
- \b
684
- # Lint single file
685
- thai-lint file-placement src/app.py
686
-
687
- \b
688
- # Lint multiple files
689
- thai-lint file-placement src/app.py src/utils.py tests/test_app.py
690
-
691
- \b
692
- # Use custom config
693
- thai-lint file-placement --config rules.json .
694
-
695
- \b
696
- # Inline JSON rules
697
- thai-lint file-placement --rules '{"allow": [".*\\.py$"]}' .
698
- """
699
- verbose = ctx.obj.get("verbose", False)
700
- project_root = _get_project_root_from_context(ctx)
701
-
702
- if not paths:
703
- paths = (".",)
704
-
705
- path_objs = [Path(p) for p in paths]
706
-
707
- try:
708
- _execute_file_placement_lint(
709
- path_objs, config_file, rules, format, recursive, verbose, project_root
710
- )
711
- except Exception as e:
712
- _handle_linting_error(e, verbose)
713
-
714
-
715
- def _execute_file_placement_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
716
- path_objs, config_file, rules, format, recursive, verbose, project_root=None
717
- ):
718
- """Execute file placement linting."""
719
- _validate_paths_exist(path_objs)
720
- orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose, project_root)
721
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
722
-
723
- # Filter to only file-placement violations
724
- violations = [v for v in all_violations if v.rule_id.startswith("file-placement")]
725
-
726
- if verbose:
727
- logger.info(f"Found {len(violations)} violation(s)")
728
-
729
- format_violations(violations, format)
730
- sys.exit(1 if violations else 0)
731
-
732
-
733
- def _handle_linting_error(error: Exception, verbose: bool) -> None:
734
- """Handle linting errors."""
735
- click.echo(f"Error during linting: {error}", err=True)
736
- if verbose:
737
- logger.exception("Linting failed with exception")
738
- sys.exit(2)
739
-
740
-
741
- def _validate_paths_exist(path_objs: list[Path]) -> None:
742
- """Validate that all provided paths exist.
743
-
744
- Args:
745
- path_objs: List of Path objects to validate
746
-
747
- Raises:
748
- SystemExit: If any path doesn't exist (exit code 2)
749
- """
750
- for path in path_objs:
751
- if not path.exists():
752
- click.echo(f"Error: Path does not exist: {path}", err=True)
753
- click.echo("", err=True)
754
- click.echo(
755
- "Hint: When using Docker, ensure paths are inside the mounted volume:", err=True
756
- )
757
- click.echo(
758
- " docker run -v $(pwd):/data thailint <command> /data/your-file.py", err=True
759
- )
760
- sys.exit(2)
761
-
762
-
763
- def _find_project_root(start_path: Path) -> Path:
764
- """Find project root by looking for .git or pyproject.toml.
765
-
766
- DEPRECATED: Use src.utils.project_root.get_project_root() instead.
767
-
768
- Args:
769
- start_path: Directory to start searching from
770
-
771
- Returns:
772
- Path to project root, or start_path if no markers found
773
- """
774
- from src.utils.project_root import get_project_root
775
-
776
- return get_project_root(start_path)
777
-
778
-
779
- def _setup_orchestrator(path_objs, config_file, rules, verbose, project_root=None):
780
- """Set up and configure the orchestrator."""
781
- from src.orchestrator.core import Orchestrator
782
- from src.utils.project_root import get_project_root
783
-
784
- # Use provided project_root or fall back to auto-detection
785
- project_root = _get_or_detect_project_root(path_objs, project_root, get_project_root)
786
-
787
- orchestrator = Orchestrator(project_root=project_root)
788
- _apply_orchestrator_config(orchestrator, config_file, rules, verbose)
789
-
790
- return orchestrator
791
-
792
-
793
- def _get_or_detect_project_root(path_objs, project_root, get_project_root):
794
- """Get provided project root or auto-detect from paths.
795
-
796
- Args:
797
- path_objs: List of path objects
798
- project_root: Optionally provided project root
799
- get_project_root: Function to detect project root
800
-
801
- Returns:
802
- Project root path
803
- """
804
- if project_root is not None:
805
- return project_root
806
-
807
- # Find actual project root (where .git or pyproject.toml exists)
808
- # This ensures .artifacts/ is always created at project root, not in subdirectories
809
- first_path = path_objs[0] if path_objs else Path.cwd()
810
- search_start = first_path if first_path.is_dir() else first_path.parent
811
- return get_project_root(search_start)
812
-
813
-
814
- def _apply_orchestrator_config(orchestrator, config_file, rules, verbose):
815
- """Apply configuration to orchestrator.
816
-
817
- Args:
818
- orchestrator: Orchestrator instance
819
- config_file: Path to config file (optional)
820
- rules: Inline JSON rules (optional)
821
- verbose: Whether verbose logging is enabled
822
- """
823
- if rules:
824
- _apply_inline_rules(orchestrator, rules, verbose)
825
- elif config_file:
826
- _load_config_file(orchestrator, config_file, verbose)
827
-
828
-
829
- def _apply_inline_rules(orchestrator, rules, verbose):
830
- """Parse and apply inline JSON rules."""
831
- rules_config = _parse_json_rules(rules)
832
- orchestrator.config.update(rules_config)
833
- _log_applied_rules(rules_config, verbose)
834
-
835
-
836
- def _parse_json_rules(rules: str) -> dict:
837
- """Parse JSON rules string, exit on error."""
838
- import json
839
-
840
- try:
841
- return json.loads(rules)
842
- except json.JSONDecodeError as e:
843
- click.echo(f"Error: Invalid JSON in --rules: {e}", err=True)
844
- sys.exit(2)
845
-
846
-
847
- def _log_applied_rules(rules_config: dict, verbose: bool) -> None:
848
- """Log applied rules if verbose."""
849
- if verbose:
850
- logger.debug(f"Applied inline rules: {rules_config}")
851
-
852
-
853
- def _load_config_file(orchestrator, config_file, verbose):
854
- """Load configuration from external file."""
855
- config_path = Path(config_file)
856
- if not config_path.exists():
857
- click.echo(f"Error: Config file not found: {config_file}", err=True)
858
- sys.exit(2)
859
-
860
- # Load config into orchestrator
861
- orchestrator.config = orchestrator.config_loader.load(config_path)
862
-
863
- if verbose:
864
- logger.debug(f"Loaded config from: {config_file}")
865
-
866
-
867
- def _execute_linting(orchestrator, path_obj, recursive):
868
- """Execute linting on file or directory."""
869
- if path_obj.is_file():
870
- return orchestrator.lint_file(path_obj)
871
- return orchestrator.lint_directory(path_obj, recursive=recursive)
872
-
873
-
874
- def _separate_files_and_dirs(path_objs: list[Path]) -> tuple[list[Path], list[Path]]:
875
- """Separate file paths from directory paths.
876
-
877
- Args:
878
- path_objs: List of Path objects
879
-
880
- Returns:
881
- Tuple of (files, directories)
882
- """
883
- files = [p for p in path_objs if p.is_file()]
884
- dirs = [p for p in path_objs if p.is_dir()]
885
- return files, dirs
886
-
887
-
888
- def _lint_files_if_any(orchestrator, files: list[Path]) -> list:
889
- """Lint files if list is non-empty.
890
-
891
- Args:
892
- orchestrator: Orchestrator instance
893
- files: List of file paths
894
-
895
- Returns:
896
- List of violations
897
- """
898
- if files:
899
- return orchestrator.lint_files(files)
900
- return []
901
-
902
-
903
- def _lint_directories(orchestrator, dirs: list[Path], recursive: bool) -> list:
904
- """Lint all directories.
905
-
906
- Args:
907
- orchestrator: Orchestrator instance
908
- dirs: List of directory paths
909
- recursive: Whether to scan recursively
910
-
911
- Returns:
912
- List of violations from all directories
913
- """
914
- violations = []
915
- for dir_path in dirs:
916
- violations.extend(orchestrator.lint_directory(dir_path, recursive=recursive))
917
- return violations
918
-
919
-
920
- def _execute_linting_on_paths(orchestrator, path_objs: list[Path], recursive: bool) -> list:
921
- """Execute linting on list of file/directory paths.
922
-
923
- Args:
924
- orchestrator: Orchestrator instance
925
- path_objs: List of Path objects (files or directories)
926
- recursive: Whether to scan directories recursively
927
-
928
- Returns:
929
- List of violations from all paths
930
- """
931
- files, dirs = _separate_files_and_dirs(path_objs)
932
-
933
- violations = []
934
- violations.extend(_lint_files_if_any(orchestrator, files))
935
- violations.extend(_lint_directories(orchestrator, dirs, recursive))
936
-
937
- return violations
938
-
939
-
940
- def _setup_nesting_orchestrator(
941
- path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
942
- ):
943
- """Set up orchestrator for nesting command."""
944
- from src.orchestrator.core import Orchestrator
945
- from src.utils.project_root import get_project_root
946
-
947
- # Use provided project_root or fall back to auto-detection
948
- if project_root is None:
949
- first_path = path_objs[0] if path_objs else Path.cwd()
950
- search_start = first_path if first_path.is_dir() else first_path.parent
951
- project_root = get_project_root(search_start)
952
-
953
- orchestrator = Orchestrator(project_root=project_root)
954
-
955
- if config_file:
956
- _load_config_file(orchestrator, config_file, verbose)
957
-
958
- return orchestrator
959
-
960
-
961
- def _apply_nesting_config_override(orchestrator, max_depth: int | None, verbose: bool):
962
- """Apply max_depth override to orchestrator config."""
963
- if max_depth is None:
964
- return
965
-
966
- # Ensure nesting config exists
967
- if "nesting" not in orchestrator.config:
968
- orchestrator.config["nesting"] = {}
969
-
970
- nesting_config = orchestrator.config["nesting"]
971
-
972
- # Set top-level max_nesting_depth
973
- nesting_config["max_nesting_depth"] = max_depth
974
-
975
- # Override language-specific configs to ensure CLI option takes precedence
976
- _override_language_specific_nesting(nesting_config, max_depth)
977
-
978
- if verbose:
979
- logger.debug(f"Overriding max_nesting_depth to {max_depth}")
980
-
981
-
982
- def _override_language_specific_nesting(nesting_config: dict, max_depth: int):
983
- """Override language-specific nesting depth configs.
984
-
985
- Args:
986
- nesting_config: Nesting configuration dictionary
987
- max_depth: Maximum nesting depth to set
988
- """
989
- for lang in ["python", "typescript", "javascript"]:
990
- if lang in nesting_config:
991
- nesting_config[lang]["max_nesting_depth"] = max_depth
992
-
993
-
994
- def _run_nesting_lint(orchestrator, path_objs: list[Path], recursive: bool):
995
- """Execute nesting lint on files or directories."""
996
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
997
- return [v for v in all_violations if "nesting" in v.rule_id]
998
-
999
-
1000
- @cli.command("nesting")
1001
- @click.argument("paths", nargs=-1, type=click.Path())
1002
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1003
- @format_option
1004
- @click.option("--max-depth", type=int, help="Override max nesting depth (default: 4)")
1005
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1006
- @click.pass_context
1007
- def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
1008
- ctx,
1009
- paths: tuple[str, ...],
1010
- config_file: str | None,
1011
- format: str,
1012
- max_depth: int | None,
1013
- recursive: bool,
1014
- ):
1015
- """Check for excessive nesting depth in code.
1016
-
1017
- Analyzes Python and TypeScript files for deeply nested code structures
1018
- (if/for/while/try statements) and reports violations.
1019
-
1020
- PATHS: Files or directories to lint (defaults to current directory if none provided)
1021
-
1022
- Examples:
1023
-
1024
- \b
1025
- # Check current directory (all files recursively)
1026
- thai-lint nesting
1027
-
1028
- \b
1029
- # Check specific directory
1030
- thai-lint nesting src/
1031
-
1032
- \b
1033
- # Check single file
1034
- thai-lint nesting src/app.py
1035
-
1036
- \b
1037
- # Check multiple files
1038
- thai-lint nesting src/app.py src/utils.py tests/test_app.py
1039
-
1040
- \b
1041
- # Check mix of files and directories
1042
- thai-lint nesting src/app.py tests/
1043
-
1044
- \b
1045
- # Use custom max depth
1046
- thai-lint nesting --max-depth 3 src/
1047
-
1048
- \b
1049
- # Get JSON output
1050
- thai-lint nesting --format json .
1051
-
1052
- \b
1053
- # Use custom config file
1054
- thai-lint nesting --config .thailint.yaml src/
1055
- """
1056
- verbose = ctx.obj.get("verbose", False)
1057
- project_root = _get_project_root_from_context(ctx)
1058
-
1059
- # Default to current directory if no paths provided
1060
- if not paths:
1061
- paths = (".",)
1062
-
1063
- path_objs = [Path(p) for p in paths]
1064
-
1065
- try:
1066
- _execute_nesting_lint(
1067
- path_objs, config_file, format, max_depth, recursive, verbose, project_root
1068
- )
1069
- except Exception as e:
1070
- _handle_linting_error(e, verbose)
1071
-
1072
-
1073
- def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1074
- path_objs, config_file, format, max_depth, recursive, verbose, project_root=None
1075
- ):
1076
- """Execute nesting lint."""
1077
- _validate_paths_exist(path_objs)
1078
- orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose, project_root)
1079
- _apply_nesting_config_override(orchestrator, max_depth, verbose)
1080
- nesting_violations = _run_nesting_lint(orchestrator, path_objs, recursive)
1081
-
1082
- if verbose:
1083
- logger.info(f"Found {len(nesting_violations)} nesting violation(s)")
1084
-
1085
- format_violations(nesting_violations, format)
1086
- sys.exit(1 if nesting_violations else 0)
1087
-
1088
-
1089
- def _setup_srp_orchestrator(
1090
- path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1091
- ):
1092
- """Set up orchestrator for SRP command."""
1093
- from src.orchestrator.core import Orchestrator
1094
- from src.utils.project_root import get_project_root
1095
-
1096
- # Use provided project_root or fall back to auto-detection
1097
- if project_root is None:
1098
- first_path = path_objs[0] if path_objs else Path.cwd()
1099
- search_start = first_path if first_path.is_dir() else first_path.parent
1100
- project_root = get_project_root(search_start)
1101
-
1102
- orchestrator = Orchestrator(project_root=project_root)
1103
-
1104
- if config_file:
1105
- _load_config_file(orchestrator, config_file, verbose)
1106
-
1107
- return orchestrator
1108
-
1109
-
1110
- def _apply_srp_config_override(
1111
- orchestrator, max_methods: int | None, max_loc: int | None, verbose: bool
1112
- ):
1113
- """Apply max_methods and max_loc overrides to orchestrator config."""
1114
- if max_methods is None and max_loc is None:
1115
- return
1116
-
1117
- if "srp" not in orchestrator.config:
1118
- orchestrator.config["srp"] = {}
1119
-
1120
- _apply_srp_max_methods(orchestrator, max_methods, verbose)
1121
- _apply_srp_max_loc(orchestrator, max_loc, verbose)
1122
-
1123
-
1124
- def _apply_srp_max_methods(orchestrator, max_methods: int | None, verbose: bool):
1125
- """Apply max_methods override."""
1126
- if max_methods is not None:
1127
- orchestrator.config["srp"]["max_methods"] = max_methods
1128
- if verbose:
1129
- logger.debug(f"Overriding max_methods to {max_methods}")
1130
-
1131
-
1132
- def _apply_srp_max_loc(orchestrator, max_loc: int | None, verbose: bool):
1133
- """Apply max_loc override."""
1134
- if max_loc is not None:
1135
- orchestrator.config["srp"]["max_loc"] = max_loc
1136
- if verbose:
1137
- logger.debug(f"Overriding max_loc to {max_loc}")
1138
-
1139
-
1140
- def _run_srp_lint(orchestrator, path_objs: list[Path], recursive: bool):
1141
- """Execute SRP lint on files or directories."""
1142
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1143
- return [v for v in all_violations if "srp" in v.rule_id]
1144
-
1145
-
1146
- @cli.command("srp")
1147
- @click.argument("paths", nargs=-1, type=click.Path())
1148
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1149
- @format_option
1150
- @click.option("--max-methods", type=int, help="Override max methods per class (default: 7)")
1151
- @click.option("--max-loc", type=int, help="Override max lines of code per class (default: 200)")
1152
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1153
- @click.pass_context
1154
- def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
1155
- ctx,
1156
- paths: tuple[str, ...],
1157
- config_file: str | None,
1158
- format: str,
1159
- max_methods: int | None,
1160
- max_loc: int | None,
1161
- recursive: bool,
1162
- ):
1163
- """Check for Single Responsibility Principle violations.
1164
-
1165
- Analyzes Python and TypeScript classes for SRP violations using heuristics:
1166
- - Method count exceeding threshold (default: 7)
1167
- - Lines of code exceeding threshold (default: 200)
1168
- - Responsibility keywords in class names (Manager, Handler, Processor, etc.)
1169
-
1170
- PATHS: Files or directories to lint (defaults to current directory if none provided)
1171
-
1172
- Examples:
1173
-
1174
- \b
1175
- # Check current directory (all files recursively)
1176
- thai-lint srp
1177
-
1178
- \b
1179
- # Check specific directory
1180
- thai-lint srp src/
1181
-
1182
- \b
1183
- # Check single file
1184
- thai-lint srp src/app.py
1185
-
1186
- \b
1187
- # Check multiple files
1188
- thai-lint srp src/app.py src/service.py tests/test_app.py
1189
-
1190
- \b
1191
- # Use custom thresholds
1192
- thai-lint srp --max-methods 10 --max-loc 300 src/
1193
-
1194
- \b
1195
- # Get JSON output
1196
- thai-lint srp --format json .
1197
-
1198
- \b
1199
- # Use custom config file
1200
- thai-lint srp --config .thailint.yaml src/
1201
- """
1202
- verbose = ctx.obj.get("verbose", False)
1203
- project_root = _get_project_root_from_context(ctx)
1204
-
1205
- if not paths:
1206
- paths = (".",)
1207
-
1208
- path_objs = [Path(p) for p in paths]
1209
-
1210
- try:
1211
- _execute_srp_lint(
1212
- path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root
1213
- )
1214
- except Exception as e:
1215
- _handle_linting_error(e, verbose)
1216
-
1217
-
1218
- def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1219
- path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root=None
1220
- ):
1221
- """Execute SRP lint."""
1222
- _validate_paths_exist(path_objs)
1223
- orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose, project_root)
1224
- _apply_srp_config_override(orchestrator, max_methods, max_loc, verbose)
1225
- srp_violations = _run_srp_lint(orchestrator, path_objs, recursive)
1226
-
1227
- if verbose:
1228
- logger.info(f"Found {len(srp_violations)} SRP violation(s)")
1229
-
1230
- format_violations(srp_violations, format)
1231
- sys.exit(1 if srp_violations else 0)
1232
-
1233
-
1234
- @cli.command("dry")
1235
- @click.argument("paths", nargs=-1, type=click.Path())
1236
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1237
- @format_option
1238
- @click.option("--min-lines", type=int, help="Override min duplicate lines threshold")
1239
- @click.option("--no-cache", is_flag=True, help="Disable SQLite cache (force rehash)")
1240
- @click.option("--clear-cache", is_flag=True, help="Clear cache before running")
1241
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1242
- @click.pass_context
1243
- def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
1244
- ctx,
1245
- paths: tuple[str, ...],
1246
- config_file: str | None,
1247
- format: str,
1248
- min_lines: int | None,
1249
- no_cache: bool,
1250
- clear_cache: bool,
1251
- recursive: bool,
1252
- ):
1253
- # Justification for Pylint disables:
1254
- # - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 6 options = 8 params
1255
- # All parameters are necessary for flexible DRY linter CLI usage.
1256
- """
1257
- Check for duplicate code (DRY principle violations).
1258
-
1259
- Detects duplicate code blocks across your project using token-based hashing
1260
- with SQLite caching for fast incremental scans.
1261
-
1262
- PATHS: Files or directories to lint (defaults to current directory if none provided)
1263
-
1264
- Examples:
1265
-
1266
- \b
1267
- # Check current directory (all files recursively)
1268
- thai-lint dry
1269
-
1270
- \b
1271
- # Check specific directory
1272
- thai-lint dry src/
1273
-
1274
- \b
1275
- # Check single file
1276
- thai-lint dry src/app.py
1277
-
1278
- \b
1279
- # Check multiple files
1280
- thai-lint dry src/app.py src/service.py tests/test_app.py
1281
-
1282
- \b
1283
- # Use custom config file
1284
- thai-lint dry --config .thailint.yaml src/
1285
-
1286
- \b
1287
- # Override minimum duplicate lines threshold
1288
- thai-lint dry --min-lines 5 .
1289
-
1290
- \b
1291
- # Disable cache (force re-analysis)
1292
- thai-lint dry --no-cache .
1293
-
1294
- \b
1295
- # Clear cache before running
1296
- thai-lint dry --clear-cache .
1297
-
1298
- \b
1299
- # Get JSON output
1300
- thai-lint dry --format json .
1301
- """
1302
- verbose = ctx.obj.get("verbose", False)
1303
- project_root = _get_project_root_from_context(ctx)
1304
-
1305
- if not paths:
1306
- paths = (".",)
1307
-
1308
- path_objs = [Path(p) for p in paths]
1309
-
1310
- try:
1311
- _execute_dry_lint(
1312
- path_objs,
1313
- config_file,
1314
- format,
1315
- min_lines,
1316
- no_cache,
1317
- clear_cache,
1318
- recursive,
1319
- verbose,
1320
- project_root,
1321
- )
1322
- except Exception as e:
1323
- _handle_linting_error(e, verbose)
1324
-
1325
-
1326
- def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1327
- path_objs,
1328
- config_file,
1329
- format,
1330
- min_lines,
1331
- no_cache,
1332
- clear_cache,
1333
- recursive,
1334
- verbose,
1335
- project_root=None,
1336
- ):
1337
- """Execute DRY linting."""
1338
- _validate_paths_exist(path_objs)
1339
- orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose, project_root)
1340
- _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose)
1341
-
1342
- if clear_cache:
1343
- _clear_dry_cache(orchestrator, verbose)
1344
-
1345
- dry_violations = _run_dry_lint(orchestrator, path_objs, recursive)
1346
-
1347
- if verbose:
1348
- logger.info(f"Found {len(dry_violations)} DRY violation(s)")
1349
-
1350
- format_violations(dry_violations, format)
1351
- sys.exit(1 if dry_violations else 0)
1352
-
1353
-
1354
- def _setup_dry_orchestrator(path_objs, config_file, verbose, project_root=None):
1355
- """Set up orchestrator for DRY linting."""
1356
- from src.orchestrator.core import Orchestrator
1357
- from src.utils.project_root import get_project_root
1358
-
1359
- # Use provided project_root or fall back to auto-detection
1360
- if project_root is None:
1361
- first_path = path_objs[0] if path_objs else Path.cwd()
1362
- search_start = first_path if first_path.is_dir() else first_path.parent
1363
- project_root = get_project_root(search_start)
1364
-
1365
- orchestrator = Orchestrator(project_root=project_root)
1366
-
1367
- if config_file:
1368
- _load_dry_config_file(orchestrator, config_file, verbose)
1369
-
1370
- return orchestrator
1371
-
1372
-
1373
- def _load_dry_config_file(orchestrator, config_file, verbose):
1374
- """Load DRY configuration from file."""
1375
- import yaml
1376
-
1377
- config_path = Path(config_file)
1378
- if not config_path.exists():
1379
- click.echo(f"Error: Config file not found: {config_file}", err=True)
1380
- sys.exit(2)
1381
-
1382
- with config_path.open("r", encoding="utf-8") as f:
1383
- config = yaml.safe_load(f)
1384
-
1385
- if "dry" in config:
1386
- orchestrator.config.update({"dry": config["dry"]})
1387
-
1388
- if verbose:
1389
- logger.info(f"Loaded DRY config from {config_file}")
1390
-
1391
-
1392
- def _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose):
1393
- """Apply CLI option overrides to DRY config."""
1394
- _ensure_dry_config_exists(orchestrator)
1395
- _apply_min_lines_override(orchestrator, min_lines, verbose)
1396
- _apply_cache_override(orchestrator, no_cache, verbose)
1397
-
1398
-
1399
- def _ensure_dry_config_exists(orchestrator):
1400
- """Ensure dry config section exists."""
1401
- if "dry" not in orchestrator.config:
1402
- orchestrator.config["dry"] = {}
1403
-
1404
-
1405
- def _apply_min_lines_override(orchestrator, min_lines, verbose):
1406
- """Apply min_lines override if provided."""
1407
- if min_lines is None:
1408
- return
1409
-
1410
- orchestrator.config["dry"]["min_duplicate_lines"] = min_lines
1411
- if verbose:
1412
- logger.info(f"Override: min_duplicate_lines = {min_lines}")
1413
-
1414
-
1415
- def _apply_cache_override(orchestrator, no_cache, verbose):
1416
- """Apply cache override if requested."""
1417
- if not no_cache:
1418
- return
1419
-
1420
- orchestrator.config["dry"]["cache_enabled"] = False
1421
- if verbose:
1422
- logger.info("Override: cache_enabled = False")
1423
-
1424
-
1425
- def _clear_dry_cache(orchestrator, verbose):
1426
- """Clear DRY cache before running."""
1427
- cache_path_str = orchestrator.config.get("dry", {}).get("cache_path", ".thailint-cache/dry.db")
1428
- cache_path = orchestrator.project_root / cache_path_str
1429
-
1430
- if cache_path.exists():
1431
- cache_path.unlink()
1432
- if verbose:
1433
- logger.info(f"Cleared cache: {cache_path}")
1434
- else:
1435
- if verbose:
1436
- logger.info("Cache file does not exist, nothing to clear")
1437
-
1438
-
1439
- def _run_dry_lint(orchestrator, path_objs, recursive):
1440
- """Run DRY linting and return violations."""
1441
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1442
-
1443
- # Filter to only DRY violations
1444
- dry_violations = [v for v in all_violations if v.rule_id.startswith("dry.")]
1445
-
1446
- return dry_violations
1447
-
1448
-
1449
- def _setup_magic_numbers_orchestrator(
1450
- path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1451
- ):
1452
- """Set up orchestrator for magic-numbers command."""
1453
- from src.orchestrator.core import Orchestrator
1454
- from src.utils.project_root import get_project_root
1455
-
1456
- # Use provided project_root or fall back to auto-detection
1457
- if project_root is None:
1458
- # Find actual project root (where .git or .thailint.yaml exists)
1459
- first_path = path_objs[0] if path_objs else Path.cwd()
1460
- search_start = first_path if first_path.is_dir() else first_path.parent
1461
- project_root = get_project_root(search_start)
1462
-
1463
- orchestrator = Orchestrator(project_root=project_root)
1464
-
1465
- if config_file:
1466
- _load_config_file(orchestrator, config_file, verbose)
1467
-
1468
- return orchestrator
1469
-
1470
-
1471
- def _run_magic_numbers_lint(orchestrator, path_objs: list[Path], recursive: bool):
1472
- """Execute magic-numbers lint on files or directories."""
1473
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1474
- return [v for v in all_violations if "magic-number" in v.rule_id]
1475
-
1476
-
1477
- @cli.command("magic-numbers")
1478
- @click.argument("paths", nargs=-1, type=click.Path())
1479
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1480
- @format_option
1481
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1482
- @click.pass_context
1483
- def magic_numbers( # pylint: disable=too-many-arguments,too-many-positional-arguments
1484
- ctx,
1485
- paths: tuple[str, ...],
1486
- config_file: str | None,
1487
- format: str,
1488
- recursive: bool,
1489
- ):
1490
- """Check for magic numbers in code.
1491
-
1492
- Detects unnamed numeric literals in Python and TypeScript/JavaScript code
1493
- that should be extracted as named constants for better readability.
1494
-
1495
- PATHS: Files or directories to lint (defaults to current directory if none provided)
1496
-
1497
- Examples:
1498
-
1499
- \b
1500
- # Check current directory (all files recursively)
1501
- thai-lint magic-numbers
1502
-
1503
- \b
1504
- # Check specific directory
1505
- thai-lint magic-numbers src/
1506
-
1507
- \b
1508
- # Check single file
1509
- thai-lint magic-numbers src/app.py
1510
-
1511
- \b
1512
- # Check multiple files
1513
- thai-lint magic-numbers src/app.py src/utils.py tests/test_app.py
1514
-
1515
- \b
1516
- # Check mix of files and directories
1517
- thai-lint magic-numbers src/app.py tests/
1518
-
1519
- \b
1520
- # Get JSON output
1521
- thai-lint magic-numbers --format json .
1522
-
1523
- \b
1524
- # Use custom config file
1525
- thai-lint magic-numbers --config .thailint.yaml src/
1526
- """
1527
- verbose = ctx.obj.get("verbose", False)
1528
- project_root = _get_project_root_from_context(ctx)
1529
-
1530
- if not paths:
1531
- paths = (".",)
1532
-
1533
- path_objs = [Path(p) for p in paths]
1534
-
1535
- try:
1536
- _execute_magic_numbers_lint(
1537
- path_objs, config_file, format, recursive, verbose, project_root
1538
- )
1539
- except Exception as e:
1540
- _handle_linting_error(e, verbose)
1541
-
1542
-
1543
- def _execute_magic_numbers_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1544
- path_objs, config_file, format, recursive, verbose, project_root=None
1545
- ):
1546
- """Execute magic-numbers lint."""
1547
- _validate_paths_exist(path_objs)
1548
- orchestrator = _setup_magic_numbers_orchestrator(path_objs, config_file, verbose, project_root)
1549
- magic_numbers_violations = _run_magic_numbers_lint(orchestrator, path_objs, recursive)
1550
-
1551
- if verbose:
1552
- logger.info(f"Found {len(magic_numbers_violations)} magic number violation(s)")
1553
-
1554
- format_violations(magic_numbers_violations, format)
1555
- sys.exit(1 if magic_numbers_violations else 0)
1556
-
1557
-
1558
- # =============================================================================
1559
- # Print Statements Linter Command
1560
- # =============================================================================
1561
-
1562
-
1563
- def _setup_print_statements_orchestrator(
1564
- path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1565
- ):
1566
- """Set up orchestrator for print-statements command."""
1567
- from src.orchestrator.core import Orchestrator
1568
- from src.utils.project_root import get_project_root
1569
-
1570
- if project_root is None:
1571
- first_path = path_objs[0] if path_objs else Path.cwd()
1572
- search_start = first_path if first_path.is_dir() else first_path.parent
1573
- project_root = get_project_root(search_start)
1574
-
1575
- orchestrator = Orchestrator(project_root=project_root)
1576
-
1577
- if config_file:
1578
- _load_config_file(orchestrator, config_file, verbose)
1579
-
1580
- return orchestrator
1581
-
1582
-
1583
- def _run_print_statements_lint(orchestrator, path_objs: list[Path], recursive: bool):
1584
- """Execute print-statements lint on files or directories."""
1585
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1586
- return [v for v in all_violations if "print-statement" in v.rule_id]
1587
-
1588
-
1589
- @cli.command("print-statements")
1590
- @click.argument("paths", nargs=-1, type=click.Path())
1591
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1592
- @format_option
1593
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1594
- @click.pass_context
1595
- def print_statements( # pylint: disable=too-many-arguments,too-many-positional-arguments
1596
- ctx,
1597
- paths: tuple[str, ...],
1598
- config_file: str | None,
1599
- format: str,
1600
- recursive: bool,
1601
- ):
1602
- """Check for print/console statements in code.
1603
-
1604
- Detects print() calls in Python and console.log/warn/error/debug/info calls
1605
- in TypeScript/JavaScript that should be replaced with proper logging.
1606
-
1607
- PATHS: Files or directories to lint (defaults to current directory if none provided)
1608
-
1609
- Examples:
1610
-
1611
- \b
1612
- # Check current directory (all files recursively)
1613
- thai-lint print-statements
1614
-
1615
- \b
1616
- # Check specific directory
1617
- thai-lint print-statements src/
1618
-
1619
- \b
1620
- # Check single file
1621
- thai-lint print-statements src/app.py
1622
-
1623
- \b
1624
- # Check multiple files
1625
- thai-lint print-statements src/app.py src/utils.ts tests/test_app.py
1626
-
1627
- \b
1628
- # Get JSON output
1629
- thai-lint print-statements --format json .
1630
-
1631
- \b
1632
- # Use custom config file
1633
- thai-lint print-statements --config .thailint.yaml src/
1634
- """
1635
- verbose = ctx.obj.get("verbose", False)
1636
- project_root = _get_project_root_from_context(ctx)
1637
-
1638
- if not paths:
1639
- paths = (".",)
1640
-
1641
- path_objs = [Path(p) for p in paths]
1642
-
1643
- try:
1644
- _execute_print_statements_lint(
1645
- path_objs, config_file, format, recursive, verbose, project_root
1646
- )
1647
- except Exception as e:
1648
- _handle_linting_error(e, verbose)
1649
-
1650
-
1651
- def _execute_print_statements_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1652
- path_objs, config_file, format, recursive, verbose, project_root=None
1653
- ):
1654
- """Execute print-statements lint."""
1655
- _validate_paths_exist(path_objs)
1656
- orchestrator = _setup_print_statements_orchestrator(
1657
- path_objs, config_file, verbose, project_root
1658
- )
1659
- print_statements_violations = _run_print_statements_lint(orchestrator, path_objs, recursive)
1660
-
1661
- if verbose:
1662
- logger.info(f"Found {len(print_statements_violations)} print statement violation(s)")
1663
-
1664
- format_violations(print_statements_violations, format)
1665
- sys.exit(1 if print_statements_violations else 0)
1666
-
1667
-
1668
- # File Header Command Helper Functions
1669
-
1670
-
1671
- def _setup_file_header_orchestrator(
1672
- path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1673
- ):
1674
- """Set up orchestrator for file-header command."""
1675
- from src.orchestrator.core import Orchestrator
1676
- from src.utils.project_root import get_project_root
1677
-
1678
- # Use provided project_root or fall back to auto-detection
1679
- if project_root is None:
1680
- first_path = path_objs[0] if path_objs else Path.cwd()
1681
- search_start = first_path if first_path.is_dir() else first_path.parent
1682
- project_root = get_project_root(search_start)
1683
-
1684
- orchestrator = Orchestrator(project_root=project_root)
1685
-
1686
- if config_file:
1687
- _load_config_file(orchestrator, config_file, verbose)
1688
-
1689
- return orchestrator
1690
-
1691
-
1692
- def _run_file_header_lint(orchestrator, path_objs: list[Path], recursive: bool):
1693
- """Execute file-header lint on files or directories."""
1694
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1695
- return [v for v in all_violations if "file-header" in v.rule_id]
1696
-
1697
-
1698
- @cli.command("file-header")
1699
- @click.argument("paths", nargs=-1, type=click.Path())
1700
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1701
- @format_option
1702
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1703
- @click.pass_context
1704
- def file_header(
1705
- ctx,
1706
- paths: tuple[str, ...],
1707
- config_file: str | None,
1708
- format: str,
1709
- recursive: bool,
1710
- ):
1711
- """Check file headers for mandatory fields and atemporal language.
1712
-
1713
- Validates that source files have proper documentation headers containing
1714
- required fields (Purpose, Scope, Overview, etc.) and don't use temporal
1715
- language (dates, "currently", "now", etc.).
1716
-
1717
- Supports Python, TypeScript, JavaScript, Bash, Markdown, and CSS files.
1718
-
1719
- PATHS: Files or directories to lint (defaults to current directory if none provided)
1720
-
1721
- Examples:
1722
-
1723
- \b
1724
- # Check current directory (all files recursively)
1725
- thai-lint file-header
1726
-
1727
- \b
1728
- # Check specific directory
1729
- thai-lint file-header src/
1730
-
1731
- \b
1732
- # Check single file
1733
- thai-lint file-header src/cli.py
1734
-
1735
- \b
1736
- # Check multiple files
1737
- thai-lint file-header src/cli.py src/api.py tests/
1738
-
1739
- \b
1740
- # Get JSON output
1741
- thai-lint file-header --format json .
1742
-
1743
- \b
1744
- # Get SARIF output for CI/CD integration
1745
- thai-lint file-header --format sarif src/
1746
-
1747
- \b
1748
- # Use custom config file
1749
- thai-lint file-header --config .thailint.yaml src/
1750
- """
1751
- verbose = ctx.obj.get("verbose", False)
1752
- project_root = _get_project_root_from_context(ctx)
1753
-
1754
- # Default to current directory if no paths provided
1755
- if not paths:
1756
- paths = (".",)
1757
-
1758
- path_objs = [Path(p) for p in paths]
1759
-
1760
- try:
1761
- _execute_file_header_lint(path_objs, config_file, format, recursive, verbose, project_root)
1762
- except Exception as e:
1763
- _handle_linting_error(e, verbose)
1764
-
1765
-
1766
- def _execute_file_header_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1767
- path_objs, config_file, format, recursive, verbose, project_root=None
1768
- ):
1769
- """Execute file-header lint."""
1770
- _validate_paths_exist(path_objs)
1771
- orchestrator = _setup_file_header_orchestrator(path_objs, config_file, verbose, project_root)
1772
- file_header_violations = _run_file_header_lint(orchestrator, path_objs, recursive)
1773
-
1774
- if verbose:
1775
- logger.info(f"Found {len(file_header_violations)} file header violation(s)")
1776
-
1777
- format_violations(file_header_violations, format)
1778
- sys.exit(1 if file_header_violations else 0)
1779
-
1780
-
1781
- # =============================================================================
1782
- # Method Property Linter Command
1783
- # =============================================================================
1784
-
1785
-
1786
- def _setup_method_property_orchestrator(
1787
- path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1788
- ):
1789
- """Set up orchestrator for method-property command."""
1790
- from src.orchestrator.core import Orchestrator
1791
- from src.utils.project_root import get_project_root
1792
-
1793
- if project_root is None:
1794
- first_path = path_objs[0] if path_objs else Path.cwd()
1795
- search_start = first_path if first_path.is_dir() else first_path.parent
1796
- project_root = get_project_root(search_start)
1797
-
1798
- orchestrator = Orchestrator(project_root=project_root)
1799
-
1800
- if config_file:
1801
- _load_config_file(orchestrator, config_file, verbose)
1802
-
1803
- return orchestrator
1804
-
1805
-
1806
- def _run_method_property_lint(orchestrator, path_objs: list[Path], recursive: bool):
1807
- """Execute method-property lint on files or directories."""
1808
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1809
- return [v for v in all_violations if "method-property" in v.rule_id]
1810
-
1811
-
1812
- @cli.command("method-property")
1813
- @click.argument("paths", nargs=-1, type=click.Path())
1814
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1815
- @format_option
1816
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1817
- @click.pass_context
1818
- def method_property(
1819
- ctx,
1820
- paths: tuple[str, ...],
1821
- config_file: str | None,
1822
- format: str,
1823
- recursive: bool,
1824
- ):
1825
- """Check for methods that should be @property decorators.
1826
-
1827
- Detects Python methods that could be converted to properties following
1828
- Pythonic conventions:
1829
- - Methods returning only self._attribute or self.attribute
1830
- - get_* prefixed methods (Java-style getters)
1831
- - Simple computed values with no side effects
1832
-
1833
- PATHS: Files or directories to lint (defaults to current directory if none provided)
1834
-
1835
- Examples:
1836
-
1837
- \b
1838
- # Check current directory (all files recursively)
1839
- thai-lint method-property
1840
-
1841
- \b
1842
- # Check specific directory
1843
- thai-lint method-property src/
1844
-
1845
- \b
1846
- # Check single file
1847
- thai-lint method-property src/models.py
1848
-
1849
- \b
1850
- # Check multiple files
1851
- thai-lint method-property src/models.py src/services.py
1852
-
1853
- \b
1854
- # Get JSON output
1855
- thai-lint method-property --format json .
1856
-
1857
- \b
1858
- # Get SARIF output for CI/CD integration
1859
- thai-lint method-property --format sarif src/
1860
-
1861
- \b
1862
- # Use custom config file
1863
- thai-lint method-property --config .thailint.yaml src/
1864
- """
1865
- verbose = ctx.obj.get("verbose", False)
1866
- project_root = _get_project_root_from_context(ctx)
1867
-
1868
- if not paths:
1869
- paths = (".",)
1870
-
1871
- path_objs = [Path(p) for p in paths]
1872
-
1873
- try:
1874
- _execute_method_property_lint(
1875
- path_objs, config_file, format, recursive, verbose, project_root
1876
- )
1877
- except Exception as e:
1878
- _handle_linting_error(e, verbose)
1879
-
1880
-
1881
- def _execute_method_property_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1882
- path_objs, config_file, format, recursive, verbose, project_root=None
1883
- ):
1884
- """Execute method-property lint."""
1885
- _validate_paths_exist(path_objs)
1886
- orchestrator = _setup_method_property_orchestrator(
1887
- path_objs, config_file, verbose, project_root
1888
- )
1889
- method_property_violations = _run_method_property_lint(orchestrator, path_objs, recursive)
1890
-
1891
- if verbose:
1892
- logger.info(f"Found {len(method_property_violations)} method-property violation(s)")
1893
-
1894
- format_violations(method_property_violations, format)
1895
- sys.exit(1 if method_property_violations else 0)
1896
-
1897
-
1898
- # =============================================================================
1899
- # Stateless Class Linter Command
1900
- # =============================================================================
1901
-
1902
-
1903
- def _setup_stateless_class_orchestrator(
1904
- path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1905
- ):
1906
- """Set up orchestrator for stateless-class command."""
1907
- from src.orchestrator.core import Orchestrator
1908
- from src.utils.project_root import get_project_root
1909
-
1910
- if project_root is None:
1911
- first_path = path_objs[0] if path_objs else Path.cwd()
1912
- search_start = first_path if first_path.is_dir() else first_path.parent
1913
- project_root = get_project_root(search_start)
1914
-
1915
- orchestrator = Orchestrator(project_root=project_root)
1916
-
1917
- if config_file:
1918
- _load_config_file(orchestrator, config_file, verbose)
1919
-
1920
- return orchestrator
1921
-
1922
-
1923
- def _run_stateless_class_lint(orchestrator, path_objs: list[Path], recursive: bool):
1924
- """Execute stateless-class lint on files or directories."""
1925
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
1926
- return [v for v in all_violations if "stateless-class" in v.rule_id]
1927
-
1928
-
1929
- @cli.command("stateless-class")
1930
- @click.argument("paths", nargs=-1, type=click.Path())
1931
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
1932
- @format_option
1933
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
1934
- @click.pass_context
1935
- def stateless_class(
1936
- ctx,
1937
- paths: tuple[str, ...],
1938
- config_file: str | None,
1939
- format: str,
1940
- recursive: bool,
1941
- ):
1942
- """Check for stateless classes that should be module functions.
1943
-
1944
- Detects Python classes that have no constructor (__init__), no instance
1945
- state, and 2+ methods - indicating they should be refactored to module-level
1946
- functions instead of using a class as a namespace.
1947
-
1948
- PATHS: Files or directories to lint (defaults to current directory if none provided)
1949
-
1950
- Examples:
1951
-
1952
- \b
1953
- # Check current directory (all files recursively)
1954
- thai-lint stateless-class
1955
-
1956
- \b
1957
- # Check specific directory
1958
- thai-lint stateless-class src/
1959
-
1960
- \b
1961
- # Check single file
1962
- thai-lint stateless-class src/utils.py
1963
-
1964
- \b
1965
- # Check multiple files
1966
- thai-lint stateless-class src/utils.py src/helpers.py
1967
-
1968
- \b
1969
- # Get JSON output
1970
- thai-lint stateless-class --format json .
1971
-
1972
- \b
1973
- # Get SARIF output for CI/CD integration
1974
- thai-lint stateless-class --format sarif src/
1975
-
1976
- \b
1977
- # Use custom config file
1978
- thai-lint stateless-class --config .thailint.yaml src/
1979
- """
1980
- verbose = ctx.obj.get("verbose", False)
1981
- project_root = _get_project_root_from_context(ctx)
1982
-
1983
- if not paths:
1984
- paths = (".",)
1985
-
1986
- path_objs = [Path(p) for p in paths]
1987
-
1988
- try:
1989
- _execute_stateless_class_lint(
1990
- path_objs, config_file, format, recursive, verbose, project_root
1991
- )
1992
- except Exception as e:
1993
- _handle_linting_error(e, verbose)
1994
-
1995
-
1996
- def _execute_stateless_class_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1997
- path_objs, config_file, format, recursive, verbose, project_root=None
1998
- ):
1999
- """Execute stateless-class lint."""
2000
- _validate_paths_exist(path_objs)
2001
- orchestrator = _setup_stateless_class_orchestrator(
2002
- path_objs, config_file, verbose, project_root
2003
- )
2004
- stateless_class_violations = _run_stateless_class_lint(orchestrator, path_objs, recursive)
2005
-
2006
- if verbose:
2007
- logger.info(f"Found {len(stateless_class_violations)} stateless-class violation(s)")
2008
-
2009
- format_violations(stateless_class_violations, format)
2010
- sys.exit(1 if stateless_class_violations else 0)
2011
-
2012
-
2013
- # Collection Pipeline command helper functions
2014
-
2015
-
2016
- def _setup_pipeline_orchestrator(
2017
- path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
2018
- ):
2019
- """Set up orchestrator for pipeline command."""
2020
- from src.orchestrator.core import Orchestrator
2021
- from src.utils.project_root import get_project_root
2022
-
2023
- # Use provided project_root or fall back to auto-detection
2024
- if project_root is None:
2025
- first_path = path_objs[0] if path_objs else Path.cwd()
2026
- search_start = first_path if first_path.is_dir() else first_path.parent
2027
- project_root = get_project_root(search_start)
2028
-
2029
- orchestrator = Orchestrator(project_root=project_root)
2030
-
2031
- if config_file:
2032
- _load_config_file(orchestrator, config_file, verbose)
2033
-
2034
- return orchestrator
2035
-
2036
-
2037
- def _apply_pipeline_config_override(orchestrator, min_continues: int | None, verbose: bool):
2038
- """Apply min_continues override to orchestrator config."""
2039
- if min_continues is None:
2040
- return
2041
-
2042
- if "collection_pipeline" not in orchestrator.config:
2043
- orchestrator.config["collection_pipeline"] = {}
2044
-
2045
- orchestrator.config["collection_pipeline"]["min_continues"] = min_continues
2046
- if verbose:
2047
- logger.debug(f"Overriding min_continues to {min_continues}")
2048
-
2049
-
2050
- def _run_pipeline_lint(orchestrator, path_objs: list[Path], recursive: bool):
2051
- """Execute collection-pipeline lint on files or directories."""
2052
- all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
2053
- return [v for v in all_violations if "collection-pipeline" in v.rule_id]
2054
-
2055
-
2056
- @cli.command("pipeline")
2057
- @click.argument("paths", nargs=-1, type=click.Path())
2058
- @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
2059
- @format_option
2060
- @click.option("--min-continues", type=int, help="Override min continue guards to flag (default: 1)")
2061
- @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
2062
- @click.pass_context
2063
- def pipeline( # pylint: disable=too-many-arguments,too-many-positional-arguments
2064
- ctx,
2065
- paths: tuple[str, ...],
2066
- config_file: str | None,
2067
- format: str,
2068
- min_continues: int | None,
2069
- recursive: bool,
2070
- ):
2071
- """Check for collection pipeline anti-patterns in code.
2072
-
2073
- Detects for loops with embedded if/continue filtering patterns that could
2074
- be refactored to use collection pipelines (generator expressions, filter()).
2075
-
2076
- PATHS: Files or directories to lint (defaults to current directory if none provided)
2077
-
2078
- Examples:
2079
-
2080
- \b
2081
- # Check current directory (all Python files recursively)
2082
- thai-lint pipeline
2083
-
2084
- \b
2085
- # Check specific directory
2086
- thai-lint pipeline src/
2087
-
2088
- \b
2089
- # Check single file
2090
- thai-lint pipeline src/app.py
2091
-
2092
- \b
2093
- # Only flag loops with 2+ continue guards
2094
- thai-lint pipeline --min-continues 2 src/
2095
-
2096
- \b
2097
- # Get JSON output
2098
- thai-lint pipeline --format json .
2099
-
2100
- \b
2101
- # Get SARIF output for CI/CD integration
2102
- thai-lint pipeline --format sarif src/
2103
-
2104
- \b
2105
- # Use custom config file
2106
- thai-lint pipeline --config .thailint.yaml src/
2107
- """
2108
- verbose = ctx.obj.get("verbose", False)
2109
- project_root = _get_project_root_from_context(ctx)
2110
-
2111
- if not paths:
2112
- paths = (".",)
2113
-
2114
- path_objs = [Path(p) for p in paths]
2115
-
2116
- try:
2117
- _execute_pipeline_lint(
2118
- path_objs, config_file, format, min_continues, recursive, verbose, project_root
2119
- )
2120
- except Exception as e:
2121
- _handle_linting_error(e, verbose)
2122
-
2123
-
2124
- def _execute_pipeline_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
2125
- path_objs, config_file, format, min_continues, recursive, verbose, project_root=None
2126
- ):
2127
- """Execute collection-pipeline lint."""
2128
- _validate_paths_exist(path_objs)
2129
- orchestrator = _setup_pipeline_orchestrator(path_objs, config_file, verbose, project_root)
2130
- _apply_pipeline_config_override(orchestrator, min_continues, verbose)
2131
- pipeline_violations = _run_pipeline_lint(orchestrator, path_objs, recursive)
2132
-
2133
- if verbose:
2134
- logger.info(f"Found {len(pipeline_violations)} collection-pipeline violation(s)")
2135
-
2136
- format_violations(pipeline_violations, format)
2137
- sys.exit(1 if pipeline_violations else 0)
2138
-
2139
-
2140
- if __name__ == "__main__":
2141
- cli()