ballpython 2.0.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.
pycleaner/cli.py ADDED
@@ -0,0 +1,1548 @@
1
+ """
2
+ Command line interface for pycleaner.
3
+
4
+ Provides subcommands (fix, check, audit, scan, complexity, dead-code, all, watch)
5
+ and backward-compatible flat argument style. Features Rich progress bars, JSON output,
6
+ and granular pass control.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ from collections.abc import Sequence
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from pycleaner.complexity_analyzer import ComplexityAnalyzer
22
+ from pycleaner.config import ConfigError, PyCleanerConfig, load_config
23
+ from pycleaner.dead_code_detector import DeadCodeDetector
24
+ from pycleaner.dependency_auditor import DependencyAuditor, DependencyAuditReport
25
+ from pycleaner.pipeline import CleanPipeline, CleanResult
26
+ from pycleaner.security_scanner import SecurityScanner
27
+ from pycleaner.taint_engine import TaintEngine
28
+ from pycleaner.test_generator import TestGenerator
29
+ from pycleaner.type_checker import TypeChecker
30
+
31
+ try:
32
+ from rich.console import Console
33
+ from rich.progress import (
34
+ BarColumn,
35
+ Progress,
36
+ SpinnerColumn,
37
+ TaskProgressColumn,
38
+ TextColumn,
39
+ )
40
+ from rich.syntax import Syntax
41
+ from rich.table import Table
42
+
43
+ has_rich = True
44
+ except ImportError:
45
+ has_rich = False
46
+
47
+
48
+ SUBCOMMANDS = {
49
+ "fix",
50
+ "check",
51
+ "audit",
52
+ "scan",
53
+ "complexity",
54
+ "dead-code",
55
+ "all",
56
+ "watch",
57
+ "hook",
58
+ "types",
59
+ "taint",
60
+ "test-gen",
61
+ "ultimate",
62
+ }
63
+
64
+ _OPTIONS_WITH_VALUE = {
65
+ "--workers",
66
+ "--severity",
67
+ "--max-cyclomatic",
68
+ "--max-cognitive",
69
+ "--max-lines",
70
+ "--max-args",
71
+ "--interval",
72
+ "--output-dir",
73
+ }
74
+
75
+
76
+ def _has_subcommand(argv: Sequence[str]) -> bool:
77
+ skip_next = False
78
+ for token in argv:
79
+ if skip_next:
80
+ skip_next = False
81
+ continue
82
+ if token in _OPTIONS_WITH_VALUE:
83
+ skip_next = True
84
+ continue
85
+ if token.startswith("-"):
86
+ continue
87
+ return token in SUBCOMMANDS
88
+ return False
89
+
90
+
91
+ def _register_fix_subparsers(subparsers: Any) -> None:
92
+ fix_p = subparsers.add_parser(
93
+ "fix", help="Heal syntax, resolve imports, lint, and format"
94
+ )
95
+ _add_common_args(fix_p)
96
+ _add_fix_args(fix_p)
97
+ fix_p.add_argument("--diff", action="store_true", help="Show unified diffs")
98
+
99
+ check_p = subparsers.add_parser(
100
+ "check", help="Dry-run: report issues without modifying files"
101
+ )
102
+ _add_common_args(check_p)
103
+ _add_fix_args(check_p)
104
+ check_p.add_argument("--diff", action="store_true", help="Show unified diffs")
105
+
106
+ all_p = subparsers.add_parser(
107
+ "all", help="Run everything: fix + audit + scan + complexity + dead-code"
108
+ )
109
+ _add_common_args(all_p)
110
+ _add_fix_args(all_p)
111
+ all_p.add_argument("--diff", action="store_true", help="Show unified diffs")
112
+
113
+ ult_p = subparsers.add_parser(
114
+ "ultimate",
115
+ help="The Ultimate Python Tool: Run healing + imports + lint + types + taint + security + complexity + dead-code",
116
+ )
117
+ _add_common_args(ult_p)
118
+ _add_fix_args(ult_p)
119
+ ult_p.add_argument("--diff", action="store_true", help="Show unified diffs")
120
+
121
+
122
+ def _register_analysis_subparsers(subparsers: Any) -> None:
123
+ audit_p = subparsers.add_parser("audit", help="Audit project dependencies")
124
+ _add_common_args(audit_p)
125
+ audit_p.add_argument(
126
+ "--fix-deps", action="store_true", help="Auto-fix requirements.txt"
127
+ )
128
+ audit_p.add_argument(
129
+ "--prune-deps", action="store_true", help="Remove unused dependencies"
130
+ )
131
+
132
+ scan_p = subparsers.add_parser("scan", help="Security vulnerability scanner")
133
+ _add_common_args(scan_p)
134
+ scan_p.add_argument(
135
+ "--severity",
136
+ default="LOW",
137
+ choices=["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"],
138
+ help="Minimum severity to report",
139
+ )
140
+
141
+ cx_p = subparsers.add_parser("complexity", help="Complexity analysis per function")
142
+ _add_common_args(cx_p)
143
+ cx_p.add_argument(
144
+ "--max-cyclomatic", type=int, default=10, help="Max cyclomatic complexity"
145
+ )
146
+ cx_p.add_argument(
147
+ "--max-cognitive", type=int, default=15, help="Max cognitive complexity"
148
+ )
149
+ cx_p.add_argument("--max-lines", type=int, default=50, help="Max function length")
150
+ cx_p.add_argument("--max-args", type=int, default=5, help="Max argument count")
151
+
152
+ dc_p = subparsers.add_parser(
153
+ "dead-code", help="Detect unused functions, classes, and unreachable code"
154
+ )
155
+ _add_common_args(dc_p)
156
+
157
+ types_p = subparsers.add_parser(
158
+ "types",
159
+ help="Bidirectional type checking and inference with Typeshed stubs",
160
+ )
161
+ _add_common_args(types_p)
162
+
163
+ taint_p = subparsers.add_parser(
164
+ "taint", help="Interprocedural SAST dataflow and taint analysis"
165
+ )
166
+ _add_common_args(taint_p)
167
+
168
+
169
+ def _register_tool_subparsers(subparsers: Any) -> None:
170
+ watch_p = subparsers.add_parser(
171
+ "watch", help="Watch files for changes and auto-fix on save"
172
+ )
173
+ _add_common_args(watch_p)
174
+ watch_p.add_argument(
175
+ "--interval", type=float, default=1.0, help="Polling interval in seconds"
176
+ )
177
+
178
+ hook_p = subparsers.add_parser(
179
+ "hook", help="Output pre-commit hook configuration and instructions"
180
+ )
181
+ hook_p.add_argument("--json", action="store_true", help="Output JSON diagnostics")
182
+
183
+ tg_p = subparsers.add_parser(
184
+ "test-gen", help="Automated behavioral contract test generator"
185
+ )
186
+ _add_common_args(tg_p)
187
+ tg_p.add_argument(
188
+ "--output-dir",
189
+ default=None,
190
+ help="Directory to save generated test suites",
191
+ )
192
+ tg_p.add_argument(
193
+ "--preview",
194
+ action="store_true",
195
+ help="Print generated tests to stdout without saving",
196
+ )
197
+
198
+
199
+ def build_parser(prog: str = "ballpython") -> argparse.ArgumentParser:
200
+ """Construct command-line argument parser with subcommands."""
201
+ from pycleaner import __version__
202
+
203
+ parser = argparse.ArgumentParser(
204
+ prog=prog,
205
+ description="The Ultimate Static Python Intelligence, Healing, and Verification Suite.",
206
+ )
207
+ parser.add_argument(
208
+ "--version", action="version", version=f"ballpython {__version__}"
209
+ )
210
+ parser.add_argument(
211
+ "--config",
212
+ default=None,
213
+ metavar="PATH",
214
+ help="Path to an explicit pycleaner config file (pyproject.toml or a .toml file), "
215
+ "overriding auto-discovery from the target path",
216
+ )
217
+
218
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
219
+ _register_fix_subparsers(subparsers)
220
+ _register_analysis_subparsers(subparsers)
221
+ _register_tool_subparsers(subparsers)
222
+
223
+ return parser
224
+
225
+
226
+ def build_flat_parser(prog: str = "ballpython") -> argparse.ArgumentParser:
227
+ """Construct backward-compatible flat argument parser (no subcommands)."""
228
+ from pycleaner import __version__
229
+
230
+ parser = argparse.ArgumentParser(
231
+ prog=prog,
232
+ description="The Ultimate Static Python Intelligence, Healing, and Verification Suite.",
233
+ )
234
+ parser.add_argument(
235
+ "--version", action="version", version=f"ballpython {__version__}"
236
+ )
237
+ parser.add_argument(
238
+ "--config",
239
+ default=None,
240
+ metavar="PATH",
241
+ help="Path to an explicit pycleaner config file (pyproject.toml or a .toml file), "
242
+ "overriding auto-discovery from the target path",
243
+ )
244
+ _add_common_args(parser)
245
+ _add_fix_args(parser)
246
+ parser.add_argument("--check", action="store_true", help="Dry-run mode")
247
+ parser.add_argument("--diff", action="store_true", help="Show unified diffs")
248
+ parser.add_argument(
249
+ "--fix-deps", action="store_true", help="Auto-fix requirements.txt"
250
+ )
251
+ parser.add_argument(
252
+ "--prune-deps", action="store_true", help="Remove unused dependencies"
253
+ )
254
+ parser.add_argument(
255
+ "--deps-only", action="store_true", help="Only run dependency audit"
256
+ )
257
+ parser.add_argument(
258
+ "--missing-imports-only",
259
+ action="store_true",
260
+ help="Only resolve missing imports",
261
+ )
262
+ parser.add_argument(
263
+ "-a",
264
+ "--all",
265
+ "--fix-all",
266
+ dest="fix_all",
267
+ action="store_true",
268
+ help="Fix everything in one command",
269
+ )
270
+ return parser
271
+
272
+
273
+ def _add_common_args(parser: argparse.ArgumentParser) -> None:
274
+ """Add arguments shared by all subcommands."""
275
+ parser.add_argument(
276
+ "paths", nargs="*", default=["."], help="Files or directories to process"
277
+ )
278
+ parser.add_argument("--json", action="store_true", help="Output JSON diagnostics")
279
+ parser.add_argument(
280
+ "--no-backup",
281
+ action="store_true",
282
+ help="Skip .bak file creation before writing",
283
+ )
284
+ parser.add_argument(
285
+ "--parallel", action="store_true", help="Process files in parallel"
286
+ )
287
+ parser.add_argument(
288
+ "--workers", type=int, default=4, help="Number of parallel workers"
289
+ )
290
+
291
+
292
+ def _add_fix_args(parser: argparse.ArgumentParser) -> None:
293
+ """Add fix-specific arguments."""
294
+ parser.add_argument(
295
+ "--no-syntax-fix", action="store_true", help="Disable syntax healing"
296
+ )
297
+ parser.add_argument(
298
+ "--no-missing-imports", action="store_true", help="Disable import resolution"
299
+ )
300
+ parser.add_argument(
301
+ "--no-lint-fix", action="store_true", help="Disable lint auto-fixing"
302
+ )
303
+ parser.add_argument(
304
+ "--no-format", action="store_true", help="Disable code formatting"
305
+ )
306
+
307
+
308
+ _DEFAULT_IGNORE_DIRS = frozenset(
309
+ {
310
+ ".git",
311
+ ".venv",
312
+ "venv",
313
+ "env",
314
+ "__pycache__",
315
+ "build",
316
+ "dist",
317
+ ".tox",
318
+ ".mypy_cache",
319
+ ".pytest_cache",
320
+ ".ruff_cache",
321
+ "site-packages",
322
+ }
323
+ )
324
+
325
+
326
+ def _collect_dir_python_files(
327
+ dir_path: Path, ignore_dirs: frozenset[str]
328
+ ) -> list[Path]:
329
+ result: list[Path] = []
330
+ for current_root, dirs, filenames in os.walk(dir_path):
331
+ dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith(".")]
332
+ for fname in filenames:
333
+ if fname.endswith(".py"):
334
+ result.append(Path(current_root) / fname)
335
+ return result
336
+
337
+
338
+ def _collect_target_files(
339
+ targets: Sequence[str], ignore_dirs: frozenset[str] = _DEFAULT_IGNORE_DIRS
340
+ ) -> list[Path]:
341
+ files: list[Path] = []
342
+ for target in targets:
343
+ path = Path(target).resolve()
344
+ if path.is_file() and path.suffix == ".py":
345
+ files.append(path)
346
+ elif path.is_dir():
347
+ files.extend(_collect_dir_python_files(path, ignore_dirs))
348
+ return files
349
+
350
+
351
+ def _is_file_excluded(f: Path, root: Path, exclude_patterns: Sequence[str]) -> bool:
352
+ import fnmatch
353
+
354
+ try:
355
+ rel = f.relative_to(root)
356
+ except ValueError:
357
+ rel = f
358
+ rel_parts = rel.parts
359
+ rel_str = str(rel)
360
+
361
+ for pat in exclude_patterns:
362
+ clean_pat = pat.rstrip("/\\")
363
+ if clean_pat in rel_parts:
364
+ return True
365
+ if fnmatch.fnmatch(f.name, pat) or fnmatch.fnmatch(rel_str, pat):
366
+ return True
367
+ return False
368
+
369
+
370
+ def _resolve_project_root(effective_targets: Sequence[str]) -> Path:
371
+ first_target = (
372
+ Path(effective_targets[0]).resolve() if effective_targets else Path.cwd()
373
+ )
374
+ return first_target if first_target.is_dir() else first_target.parent
375
+
376
+
377
+ def discover_python_files(
378
+ targets: list[str],
379
+ config: PyCleanerConfig | None = None,
380
+ root: Path | None = None,
381
+ ) -> list[Path]:
382
+ """Find all relevant Python files from given targets."""
383
+ effective_targets = list(targets)
384
+ if (not targets or targets == ["."]) and config and config.include:
385
+ effective_targets = config.include
386
+
387
+ resolved_root = (
388
+ root if root is not None else _resolve_project_root(effective_targets)
389
+ )
390
+ files = _collect_target_files(effective_targets)
391
+
392
+ if config and config.exclude:
393
+ files = [
394
+ f for f in files if not _is_file_excluded(f, resolved_root, config.exclude)
395
+ ]
396
+
397
+ return sorted(set(files))
398
+
399
+
400
+ def _create_printer(console: Any) -> Any:
401
+ def print_msg(msg: str, style: str = "") -> None:
402
+ if console:
403
+ console.print(msg, style=style)
404
+ else:
405
+ import re
406
+
407
+ cleaned = re.sub(r"\[/?[a-z ]*\]", "", msg)
408
+ print(cleaned)
409
+
410
+ return print_msg
411
+
412
+
413
+ def _resolve_cli_command(args: argparse.Namespace) -> str:
414
+ command = getattr(args, "command", None)
415
+ if command is not None:
416
+ return command
417
+ if getattr(args, "deps_only", False):
418
+ return "audit"
419
+ if getattr(args, "fix_all", False):
420
+ return "all"
421
+ if getattr(args, "check", False):
422
+ return "check"
423
+ return "ultimate"
424
+
425
+
426
+ def _run_all_command(
427
+ args: argparse.Namespace, config: PyCleanerConfig, print_msg: Any, console: Any
428
+ ) -> int:
429
+ backup = config.backup and not getattr(args, "no_backup", False)
430
+ rc = _cmd_fix(
431
+ args,
432
+ config,
433
+ print_msg,
434
+ console,
435
+ _FixOptions(
436
+ apply_changes=True,
437
+ show_diff=getattr(args, "diff", False),
438
+ backup=backup,
439
+ ),
440
+ )
441
+ audit_rc = _cmd_audit(args, config, print_msg)
442
+ scan_rc = _cmd_scan(args, config, print_msg, console)
443
+ _cmd_complexity(args, config, print_msg, console)
444
+ _cmd_dead_code(args, config, print_msg, console)
445
+ return max(rc, audit_rc, scan_rc)
446
+
447
+
448
+ def _route_command(
449
+ command: str,
450
+ args: argparse.Namespace,
451
+ config: PyCleanerConfig,
452
+ print_msg: Any,
453
+ console: Any,
454
+ ) -> int:
455
+ backup = config.backup and not getattr(args, "no_backup", False)
456
+ diff = getattr(args, "diff", False)
457
+
458
+ dispatch_simple = {
459
+ "hook": lambda: _cmd_hook(args, print_msg),
460
+ "audit": lambda: _cmd_audit(args, config, print_msg),
461
+ "scan": lambda: _cmd_scan(args, config, print_msg, console),
462
+ "complexity": lambda: _cmd_complexity(args, config, print_msg, console),
463
+ "dead-code": lambda: _cmd_dead_code(args, config, print_msg, console),
464
+ "types": lambda: _cmd_types(args, config, print_msg, console),
465
+ "taint": lambda: _cmd_taint(args, config, print_msg, console),
466
+ "test-gen": lambda: _cmd_test_gen(args, config, print_msg),
467
+ "watch": lambda: _cmd_watch(args, config, print_msg),
468
+ "ultimate": lambda: _cmd_ultimate(args, config, print_msg, console),
469
+ "all": lambda: _run_all_command(args, config, print_msg, console),
470
+ "check": lambda: _cmd_fix(
471
+ args,
472
+ config,
473
+ print_msg,
474
+ console,
475
+ _FixOptions(apply_changes=False, show_diff=diff, backup=False),
476
+ ),
477
+ }
478
+ handler = dispatch_simple.get(command)
479
+ if handler:
480
+ return handler()
481
+
482
+ return _cmd_fix(
483
+ args,
484
+ config,
485
+ print_msg,
486
+ console,
487
+ _FixOptions(apply_changes=True, show_diff=diff, backup=backup),
488
+ )
489
+
490
+
491
+ def main(argv: Sequence[str] | None = None) -> int:
492
+ """Main CLI entry point."""
493
+ raw_args = list(sys.argv[1:] if argv is None else argv)
494
+
495
+ # If --help / -h is passed without a subcommand, use the subcommand parser to show full help
496
+ if ("-h" in raw_args or "--help" in raw_args) and not _has_subcommand(raw_args):
497
+ parser = build_parser()
498
+ parser.parse_args(raw_args)
499
+ return 0
500
+
501
+ parser = build_parser() if _has_subcommand(raw_args) else build_flat_parser()
502
+ args = parser.parse_args(raw_args)
503
+ console = Console() if has_rich else None
504
+ print_msg = _create_printer(console)
505
+
506
+ targets = getattr(args, "paths", ["."])
507
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
508
+ root_dir = target_path if target_path.is_dir() else target_path.parent
509
+
510
+ try:
511
+ config = load_config(project_root=root_dir)
512
+ except ConfigError as err:
513
+ print_msg(f"[red]Configuration Error:[/red] {err}")
514
+ return 2
515
+
516
+ command = _resolve_cli_command(args)
517
+ return _route_command(command, args, config, print_msg, console)
518
+
519
+
520
+ # ---------------------------------------------------------------------------
521
+ # Command Implementations
522
+ # ---------------------------------------------------------------------------
523
+
524
+
525
+ @dataclass
526
+ class _FixBatchState:
527
+ changed_count: int = 0
528
+ error_count: int = 0
529
+ diagnostics: list[dict[str, str]] = field(default_factory=list)
530
+
531
+
532
+ @dataclass(slots=True)
533
+ class _FixOptions:
534
+ apply_changes: bool = True
535
+ show_diff: bool = False
536
+ backup: bool = False
537
+ in_progress: bool = False
538
+
539
+
540
+ _DEFAULT_FIX_OPTS = _FixOptions()
541
+
542
+
543
+ def _render_fix_diff(diff: str, console: Any) -> None:
544
+ if console and has_rich:
545
+ syntax = Syntax(diff, "diff", theme="monokai", line_numbers=True)
546
+ console.print(syntax)
547
+ else:
548
+ print(diff)
549
+
550
+
551
+ def _report_file_modifications(
552
+ result: CleanResult, py_file: Path, apply_changes: bool, print_msg: Any
553
+ ) -> None:
554
+ action = "Cleaned" if apply_changes else "Would modify"
555
+ print_msg(f"[green]{action}:[/green] {py_file.name}")
556
+ for repair in result.syntax_repairs:
557
+ print_msg(f" • Syntax: {repair}", style="cyan")
558
+ for imp in result.resolved_imports:
559
+ print_msg(f" • Import: {imp}", style="magenta")
560
+ if result.lint_changed:
561
+ print_msg(" • Lint: fixed errors and pruned unused imports", style="blue")
562
+ if result.format_changed:
563
+ print_msg(" • Format: applied PEP 8 formatting", style="blue")
564
+
565
+
566
+ def _accumulate_result(
567
+ result: CleanResult,
568
+ py_file: Path,
569
+ state: _FixBatchState,
570
+ opts: _FixOptions,
571
+ io_ctx: tuple[Any, Any, bool],
572
+ ) -> None:
573
+ print_msg, console, is_json = io_ctx
574
+ if result.diagnostics:
575
+ state.diagnostics.extend(result.diagnostics)
576
+
577
+ if result.error:
578
+ state.error_count += 1
579
+ if not is_json:
580
+ print_msg(f"[red]ERROR in {py_file.name}:[/red] {result.error}")
581
+ return
582
+
583
+ if result.changed:
584
+ state.changed_count += 1
585
+ if not is_json and not opts.in_progress:
586
+ _report_file_modifications(result, py_file, opts.apply_changes, print_msg)
587
+ if opts.show_diff and result.diff:
588
+ _render_fix_diff(result.diff, console)
589
+
590
+
591
+ def _build_fix_pipeline(
592
+ args: argparse.Namespace, config: PyCleanerConfig
593
+ ) -> CleanPipeline:
594
+ if getattr(args, "missing_imports_only", False):
595
+ return CleanPipeline(
596
+ enable_syntax_healing=False,
597
+ enable_import_resolution=True,
598
+ enable_lint_fixing=False,
599
+ enable_formatting=False,
600
+ config=config,
601
+ )
602
+ return CleanPipeline(
603
+ enable_syntax_healing=not getattr(args, "no_syntax_fix", False),
604
+ enable_import_resolution=not getattr(args, "no_missing_imports", False),
605
+ enable_lint_fixing=not getattr(args, "no_lint_fix", False),
606
+ enable_formatting=not getattr(args, "no_format", False),
607
+ config=config,
608
+ )
609
+
610
+
611
+ def _run_progress_fix(
612
+ pipeline: CleanPipeline,
613
+ py_files: list[Path],
614
+ opts: _FixOptions,
615
+ state: _FixBatchState,
616
+ io_ctx: tuple[Any, Any, bool],
617
+ ) -> None:
618
+ prog_opts = _FixOptions(
619
+ apply_changes=opts.apply_changes,
620
+ show_diff=opts.show_diff,
621
+ backup=opts.backup,
622
+ in_progress=True,
623
+ )
624
+ _print_msg, console, _is_json = io_ctx
625
+ with Progress(
626
+ SpinnerColumn(),
627
+ TextColumn("[progress.description]{task.description}"),
628
+ BarColumn(),
629
+ TaskProgressColumn(),
630
+ console=console,
631
+ ) as progress:
632
+ task = progress.add_task("Processing...", total=len(py_files))
633
+ for i, py_file in enumerate(py_files, 1):
634
+ progress.update(task, description=f"[{i}/{len(py_files)}] {py_file.name}")
635
+ result = pipeline.process_file(
636
+ py_file, apply_changes=opts.apply_changes, backup=opts.backup
637
+ )
638
+ _accumulate_result(result, py_file, state, prog_opts, io_ctx)
639
+ progress.advance(task)
640
+
641
+
642
+ def _execute_fix_batch(
643
+ pipeline: CleanPipeline,
644
+ py_files: list[Path],
645
+ opts: _FixOptions,
646
+ io_ctx: tuple[Any, Any, bool],
647
+ parallel_settings: tuple[bool, int],
648
+ ) -> _FixBatchState:
649
+ state = _FixBatchState()
650
+ use_parallel, workers = parallel_settings
651
+ print_msg, console, is_json = io_ctx
652
+
653
+ if use_parallel:
654
+ if not is_json:
655
+ print_msg(f"[dim]Processing with {workers} parallel worker(s)...[/dim]")
656
+ results = pipeline.process_files(
657
+ py_files,
658
+ apply_changes=opts.apply_changes,
659
+ backup=opts.backup,
660
+ max_workers=workers,
661
+ )
662
+ for py_file, result in zip(py_files, results):
663
+ _accumulate_result(result, py_file, state, opts, io_ctx)
664
+ elif has_rich and console and not is_json and len(py_files) > 3:
665
+ _run_progress_fix(pipeline, py_files, opts, state, io_ctx)
666
+ else:
667
+ for py_file in py_files:
668
+ res = pipeline.process_file(
669
+ py_file, apply_changes=opts.apply_changes, backup=opts.backup
670
+ )
671
+ _accumulate_result(res, py_file, state, opts, io_ctx)
672
+
673
+ return state
674
+
675
+
676
+ def _render_audit_terminal_output(
677
+ audit_report: DependencyAuditReport,
678
+ print_msg: Any,
679
+ should_fix: bool,
680
+ ) -> None:
681
+ if not (
682
+ audit_report.missing_packages
683
+ or audit_report.unused_packages
684
+ or audit_report.fixed_requirements
685
+ ):
686
+ return
687
+
688
+ print_msg("\n[bold cyan]Project Dependency Audit:[/bold cyan]")
689
+ if audit_report.missing_packages:
690
+ print_msg(
691
+ f" [red]Missing:[/red] {', '.join(sorted(audit_report.missing_packages))}"
692
+ )
693
+ if not should_fix:
694
+ print_msg(
695
+ " [dim]Tip: Run with -a/--all or --fix-deps to append them[/dim]"
696
+ )
697
+ if audit_report.unused_packages:
698
+ print_msg(
699
+ f" [yellow]Unused:[/yellow] {', '.join(sorted(audit_report.unused_packages))}"
700
+ )
701
+ if audit_report.fixed_requirements:
702
+ print_msg(" [green]requirements.txt synchronized.[/green]")
703
+
704
+
705
+ def _run_fix_audit(
706
+ root_dir: Path,
707
+ apply_changes: bool,
708
+ args: argparse.Namespace,
709
+ print_msg: Any,
710
+ ) -> DependencyAuditReport:
711
+ auditor = DependencyAuditor(root_dir)
712
+ fix_any = getattr(args, "fix_deps", False) or getattr(args, "fix_all", False)
713
+ prune_any = getattr(args, "prune_deps", False) or getattr(args, "fix_all", False)
714
+ should_fix = apply_changes and fix_any
715
+ should_prune = apply_changes and prune_any
716
+ audit_report = auditor.audit(fix=should_fix, prune_unused=should_prune)
717
+ _render_audit_terminal_output(audit_report, print_msg, should_fix)
718
+ return audit_report
719
+
720
+
721
+ def _compute_fix_exit_code(
722
+ state: _FixBatchState,
723
+ audit_report: DependencyAuditReport,
724
+ apply_changes: bool,
725
+ ) -> int:
726
+ if not apply_changes and (
727
+ state.changed_count > 0
728
+ or state.error_count > 0
729
+ or bool(audit_report.missing_packages)
730
+ ):
731
+ return 1
732
+ return 0 if state.error_count == 0 else 1
733
+
734
+
735
+ def _is_parallel_enabled(
736
+ args: argparse.Namespace, config: PyCleanerConfig, file_count: int
737
+ ) -> tuple[bool, int]:
738
+ workers_cfg = getattr(args, "workers", None) or config.max_workers
739
+ use_parallel = (
740
+ (getattr(args, "parallel", False) or config.parallel)
741
+ and workers_cfg > 1
742
+ and file_count > 1
743
+ )
744
+ return use_parallel, workers_cfg
745
+
746
+
747
+ def _resolve_root_dir(paths: Sequence[str] | None) -> Path:
748
+ target = Path(paths[0]).resolve() if paths else Path.cwd()
749
+ return target if target.is_dir() else target.parent
750
+
751
+
752
+ def _report_fix_start(count: int, apply_changes: bool, print_msg: Any) -> None:
753
+ action_label = "Cleaning" if apply_changes else "Checking"
754
+ print_msg(f"[bold blue]{action_label} {count} file(s)...[/bold blue]")
755
+
756
+
757
+ def _cmd_fix(
758
+ args: argparse.Namespace,
759
+ config: PyCleanerConfig,
760
+ print_msg: Any,
761
+ console: Any,
762
+ opts: _FixOptions = _DEFAULT_FIX_OPTS,
763
+ ) -> int:
764
+ """Core fix command: heal + resolve + lint + format."""
765
+ pipeline = _build_fix_pipeline(args, config)
766
+ root_dir = _resolve_root_dir(args.paths)
767
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
768
+ is_json = bool(getattr(args, "json", False))
769
+
770
+ if not py_files:
771
+ if is_json:
772
+ print(json.dumps([]))
773
+ else:
774
+ print_msg("No Python files found matching target paths.", style="yellow")
775
+ return 0
776
+
777
+ if not is_json:
778
+ _report_fix_start(len(py_files), opts.apply_changes, print_msg)
779
+
780
+ parallel_info = _is_parallel_enabled(args, config, len(py_files))
781
+ io_ctx = (print_msg, console, is_json)
782
+ state = _execute_fix_batch(pipeline, py_files, opts, io_ctx, parallel_info)
783
+
784
+ if is_json:
785
+ print(json.dumps(state.diagnostics, indent=2))
786
+ return 0 if state.error_count == 0 else 1
787
+
788
+ audit_report = _run_fix_audit(root_dir, opts.apply_changes, args, print_msg)
789
+ print_msg(
790
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.changed_count} updated, {state.error_count} errors.[/bold]"
791
+ )
792
+ return _compute_fix_exit_code(state, audit_report, opts.apply_changes)
793
+
794
+
795
+ def _render_audit_json(audit_report: DependencyAuditReport) -> int:
796
+ print(
797
+ json.dumps(
798
+ {
799
+ "imported_modules": sorted(audit_report.imported_modules),
800
+ "third_party_modules": sorted(audit_report.third_party_modules),
801
+ "required_packages": sorted(audit_report.required_packages),
802
+ "missing": sorted(audit_report.missing_packages),
803
+ "unused": sorted(audit_report.unused_packages),
804
+ "fixed": audit_report.fixed_requirements,
805
+ },
806
+ indent=2,
807
+ )
808
+ )
809
+ return (
810
+ 1
811
+ if audit_report.missing_packages and not audit_report.fixed_requirements
812
+ else 0
813
+ )
814
+
815
+
816
+ def _render_audit_cli_output(
817
+ audit_report: DependencyAuditReport, print_msg: Any
818
+ ) -> int:
819
+ print_msg("[bold cyan]Dependency Audit:[/bold cyan]")
820
+ if audit_report.missing_packages:
821
+ print_msg(
822
+ f" [red]Missing:[/red] {', '.join(sorted(audit_report.missing_packages))}"
823
+ )
824
+ if audit_report.unused_packages:
825
+ print_msg(
826
+ f" [yellow]Unused:[/yellow] {', '.join(sorted(audit_report.unused_packages))}"
827
+ )
828
+ if not audit_report.missing_packages and not audit_report.unused_packages:
829
+ print_msg(" [green]All dependencies clean.[/green]")
830
+ if audit_report.fixed_requirements:
831
+ print_msg(" [green]requirements.txt synchronized.[/green]")
832
+
833
+ return (
834
+ 1
835
+ if audit_report.missing_packages and not audit_report.fixed_requirements
836
+ else 0
837
+ )
838
+
839
+
840
+ def _cmd_audit(args: argparse.Namespace, config: PyCleanerConfig, print_msg) -> int:
841
+ """Dependency audit command."""
842
+ root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
843
+ if not root_dir.is_dir():
844
+ root_dir = root_dir.parent
845
+
846
+ is_check = getattr(args, "check", False)
847
+ auditor = DependencyAuditor(root_dir)
848
+ audit_report = auditor.audit(
849
+ fix=getattr(args, "fix_deps", False) and not is_check,
850
+ prune_unused=getattr(args, "prune_deps", False) and not is_check,
851
+ )
852
+
853
+ if getattr(args, "json", False):
854
+ return _render_audit_json(audit_report)
855
+ return _render_audit_cli_output(audit_report, print_msg)
856
+
857
+
858
+ def _render_scan_json(report: Any) -> int:
859
+ findings = [
860
+ {
861
+ "file": f.filepath,
862
+ "line": f.lineno,
863
+ "severity": f.severity,
864
+ "category": f.category,
865
+ "message": f.message,
866
+ "suggestion": f.suggestion,
867
+ "code_snippet": f.code_snippet,
868
+ }
869
+ for f in report.findings
870
+ ]
871
+ print(json.dumps(findings, indent=2))
872
+ return 1 if report.critical_count > 0 else 0
873
+
874
+
875
+ def _render_scan_table(report: Any, console: Any, target_base: str) -> None:
876
+ table = Table(title="Security Findings", show_lines=True)
877
+ table.add_column("Severity", style="bold", width=10)
878
+ table.add_column("Category", width=22)
879
+ table.add_column("File:Line", width=35)
880
+ table.add_column("Message", min_width=30)
881
+
882
+ severity_styles = {
883
+ "CRITICAL": "bold red",
884
+ "HIGH": "red",
885
+ "MEDIUM": "yellow",
886
+ "LOW": "cyan",
887
+ "INFO": "dim",
888
+ }
889
+ for f in report.findings:
890
+ rel_path = _try_relative(f.filepath, target_base)
891
+ style = severity_styles.get(f.severity, "")
892
+ sev_str = f"[{style}]{f.severity}[/{style}]" if style else f.severity
893
+ table.add_row(sev_str, f.category, f"{rel_path}:{f.lineno}", f.message)
894
+ console.print(table)
895
+
896
+
897
+ def _render_scan_cli_summary(
898
+ report: Any, print_msg: Any, console: Any, target_base: str
899
+ ) -> int:
900
+ print_msg(f"\n[bold cyan]Security Scan ({report.files_scanned} files):[/bold cyan]")
901
+ if not report.findings:
902
+ print_msg(" [green]No security issues detected.[/green]")
903
+ return 0
904
+
905
+ if has_rich and console:
906
+ _render_scan_table(report, console, target_base)
907
+ else:
908
+ for f in report.findings:
909
+ rel = _try_relative(f.filepath, target_base)
910
+ print_msg(f" [{f.severity}] {f.category} at {rel}:{f.lineno}: {f.message}")
911
+
912
+ categories = sorted({f.category for f in report.findings})
913
+ if categories:
914
+ cat_summary = ", ".join(
915
+ f"{cat} ({len(report.by_category(cat))})" for cat in categories
916
+ )
917
+ print_msg(f"\n Findings by category: {cat_summary}")
918
+
919
+ print_msg(
920
+ f"\n Total: {report.count} finding(s) — "
921
+ f"[red]{report.critical_count} critical[/red], [red]{report.high_count} high[/red]"
922
+ )
923
+ return 1 if report.critical_count > 0 else 0
924
+
925
+
926
+ def _cmd_scan(
927
+ args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
928
+ ) -> int:
929
+ """Security scan command."""
930
+ root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
931
+ if not root_dir.is_dir():
932
+ root_dir = root_dir.parent
933
+
934
+ severity = getattr(args, "severity", config.security_severity_threshold)
935
+ scanner = SecurityScanner(
936
+ severity_threshold=severity,
937
+ ignore_rules=set(config.ignore_security_rules),
938
+ )
939
+ report = scanner.scan_project(root_dir)
940
+
941
+ target_base = args.paths[0] if args.paths else "."
942
+ if getattr(args, "json", False):
943
+ return _render_scan_json(report)
944
+ return _render_scan_cli_summary(report, print_msg, console, target_base)
945
+
946
+
947
+ def _render_complexity_json(violations: Sequence[Any]) -> int:
948
+ data = [
949
+ {
950
+ "file": f.filepath,
951
+ "line": f.lineno,
952
+ "name": f.qualified_name,
953
+ "cyclomatic": f.cyclomatic,
954
+ "cognitive": f.cognitive,
955
+ "lines": f.lines,
956
+ "args": f.args,
957
+ "returns": f.returns,
958
+ "max_nesting": f.max_nesting,
959
+ }
960
+ for f in violations
961
+ ]
962
+ print(json.dumps(data, indent=2))
963
+ return 1 if violations else 0
964
+
965
+
966
+ def _format_cell(val: int, threshold: int) -> str:
967
+ return f"[red]{val}[/red]" if val > threshold else str(val)
968
+
969
+
970
+ def _render_complexity_table(
971
+ violations: Sequence[Any],
972
+ console: Any,
973
+ thresholds: tuple[int, int, int, int],
974
+ target_base: str,
975
+ ) -> None:
976
+ max_cyc, max_cog, max_lines, max_args = thresholds
977
+ table = Table(title="Threshold Violations", show_lines=True)
978
+ table.add_column("Function", min_width=30)
979
+ table.add_column("File:Line", width=35)
980
+ table.add_column("CC", justify="right", width=5)
981
+ table.add_column("Cog", justify="right", width=5)
982
+ table.add_column("Lines", justify="right", width=6)
983
+ table.add_column("Args", justify="right", width=5)
984
+
985
+ for f in violations:
986
+ rel_path = _try_relative(f.filepath, target_base)
987
+ table.add_row(
988
+ f.qualified_name,
989
+ f"{rel_path}:{f.lineno}",
990
+ _format_cell(f.cyclomatic, max_cyc),
991
+ _format_cell(f.cognitive, max_cog),
992
+ _format_cell(f.lines, max_lines),
993
+ _format_cell(f.args, max_args),
994
+ )
995
+ console.print(table)
996
+
997
+
998
+ def _render_complexity_cli_output(
999
+ report: Any,
1000
+ violations: Sequence[Any],
1001
+ thresholds: tuple[int, int, int, int],
1002
+ ui_ctx: tuple[Any, Any, str],
1003
+ ) -> int:
1004
+ print_msg, console, target_base = ui_ctx
1005
+ max_cyc, max_cog, max_lines, max_args = thresholds
1006
+ print_msg(
1007
+ f"\n[bold cyan]Complexity Report ({report.files_scanned} files, {report.count} functions):[/bold cyan]"
1008
+ )
1009
+ print_msg(
1010
+ f" Avg cyclomatic: {report.average_cyclomatic:.1f} | Avg cognitive: {report.average_cognitive:.1f}"
1011
+ )
1012
+ if not violations:
1013
+ print_msg(
1014
+ f" [green]All functions within thresholds (CC<={max_cyc}, Cog<={max_cog}, Ln<={max_lines}, Args<={max_args})[/green]"
1015
+ )
1016
+ return 0
1017
+
1018
+ if has_rich and console:
1019
+ _render_complexity_table(violations, console, thresholds, target_base)
1020
+ else:
1021
+ for f in violations:
1022
+ print_msg(
1023
+ f" {f.qualified_name} — CC:{f.cyclomatic} Cog:{f.cognitive} Ln:{f.lines} Args:{f.args}"
1024
+ )
1025
+ print_msg(f"\n {len(violations)} function(s) exceed threshold(s).")
1026
+ return 1
1027
+
1028
+
1029
+ def _cmd_complexity(
1030
+ args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1031
+ ) -> int:
1032
+ """Complexity analysis command."""
1033
+ root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1034
+ if not root_dir.is_dir():
1035
+ root_dir = root_dir.parent
1036
+
1037
+ analyzer = ComplexityAnalyzer()
1038
+ report = analyzer.analyze_project(root_dir)
1039
+
1040
+ thresholds = (
1041
+ getattr(args, "max_cyclomatic", config.max_cyclomatic_complexity),
1042
+ getattr(args, "max_cognitive", config.max_cognitive_complexity),
1043
+ getattr(args, "max_lines", config.max_function_length),
1044
+ getattr(args, "max_args", config.max_arguments),
1045
+ )
1046
+ violations = report.above_threshold(*thresholds)
1047
+ if getattr(args, "json", False):
1048
+ return _render_complexity_json(violations)
1049
+
1050
+ target_base = args.paths[0] if args.paths else "."
1051
+ return _render_complexity_cli_output(
1052
+ report, violations, thresholds, (print_msg, console, target_base)
1053
+ )
1054
+
1055
+
1056
+ def _render_dead_code_json(items: Sequence[Any]) -> int:
1057
+ data = [
1058
+ {
1059
+ "file": item.filepath,
1060
+ "line": item.lineno,
1061
+ "name": item.name,
1062
+ "kind": item.kind,
1063
+ "reason": item.reason,
1064
+ "confidence": item.confidence,
1065
+ }
1066
+ for item in items
1067
+ ]
1068
+ print(json.dumps(data, indent=2))
1069
+ return 1 if items else 0
1070
+
1071
+
1072
+ def _render_dead_code_kind_group(
1073
+ kind_items: Sequence[Any], label: str, print_msg: Any, target_base: str
1074
+ ) -> None:
1075
+ print_msg(f"\n [bold yellow]{label} ({len(kind_items)}):[/bold yellow]")
1076
+ for item in kind_items[:20]:
1077
+ rel_path = _try_relative(item.filepath, target_base)
1078
+ conf_tag = f" [{item.confidence}]" if item.confidence != "high" else ""
1079
+ print_msg(
1080
+ f" L{item.lineno} {rel_path}: {item.name} — {item.reason}{conf_tag}"
1081
+ )
1082
+ if len(kind_items) > 20:
1083
+ print_msg(f" ... and {len(kind_items) - 20} more")
1084
+
1085
+
1086
+ def _render_dead_code_cli_summary(report: Any, print_msg: Any, target_base: str) -> int:
1087
+ print_msg(
1088
+ f"\n[bold cyan]Dead Code Report ({report.files_scanned} files, {report.total_definitions} definitions):[/bold cyan]"
1089
+ )
1090
+ if not report.items:
1091
+ print_msg(" [green]No dead code detected.[/green]")
1092
+ return 0
1093
+
1094
+ for kind in ("unreachable", "function", "class", "variable", "empty-branch"):
1095
+ kind_items = report.by_kind(kind)
1096
+ if kind_items:
1097
+ label = kind.replace("-", " ").title()
1098
+ _render_dead_code_kind_group(kind_items, label, print_msg, target_base)
1099
+
1100
+ print_msg(f"\n Total: {report.count} dead code item(s) detected.")
1101
+ return 1
1102
+
1103
+
1104
+ def _cmd_dead_code(
1105
+ args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1106
+ ) -> int:
1107
+ """Dead code detection command."""
1108
+ root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1109
+ if not root_dir.is_dir():
1110
+ root_dir = root_dir.parent
1111
+
1112
+ detector = DeadCodeDetector(
1113
+ ignore_decorators=set(config.ignore_decorators),
1114
+ ignore_names=set(config.ignore_names),
1115
+ )
1116
+ report = detector.scan_project(root_dir)
1117
+
1118
+ if getattr(args, "json", False):
1119
+ return _render_dead_code_json(report.items)
1120
+
1121
+ target_base = args.paths[0] if args.paths else "."
1122
+ return _render_dead_code_cli_summary(report, print_msg, target_base)
1123
+
1124
+
1125
+ def _render_types_json(findings: Sequence[Any], has_errors: bool) -> int:
1126
+ data = [
1127
+ {
1128
+ "file": f.filepath,
1129
+ "line": f.lineno,
1130
+ "column": f.column,
1131
+ "severity": f.severity,
1132
+ "category": f.category,
1133
+ "message": f.message,
1134
+ "expected": f.expected_type,
1135
+ "actual": f.actual_type,
1136
+ "code_snippet": f.code_snippet,
1137
+ }
1138
+ for f in findings
1139
+ ]
1140
+ print(json.dumps(data, indent=2))
1141
+ return 1 if has_errors else 0
1142
+
1143
+
1144
+ def _render_types_table(
1145
+ findings: Sequence[Any], console: Any, target_base: str
1146
+ ) -> None:
1147
+ table = Table(title="Type Inconsistencies & Violations", show_lines=True)
1148
+ table.add_column("Severity", style="bold", width=10)
1149
+ table.add_column("File:Line:Col", width=38)
1150
+ table.add_column("Expected", width=15)
1151
+ table.add_column("Actual", width=15)
1152
+ table.add_column("Message", min_width=30)
1153
+
1154
+ for f in findings:
1155
+ rel_path = _try_relative(f.filepath, target_base)
1156
+ style = "bold red" if f.severity == "error" else "yellow"
1157
+ table.add_row(
1158
+ f"[{style}]{f.severity.upper()}[/{style}]",
1159
+ f"{rel_path}:{f.lineno}:{f.column}",
1160
+ f.expected_type or "-",
1161
+ f.actual_type or "-",
1162
+ f.message,
1163
+ )
1164
+ console.print(table)
1165
+
1166
+
1167
+ def _render_types_cli_summary(
1168
+ report: Any, print_msg: Any, console: Any, target_base: str
1169
+ ) -> int:
1170
+ print_msg(
1171
+ f"\n[bold cyan]Type Check Report ({report.files_scanned} files, {report.functions_checked} functions):[/bold cyan]"
1172
+ )
1173
+ if not report.findings:
1174
+ print_msg(
1175
+ " [green]Zero type errors detected across all checked modules.[/green]"
1176
+ )
1177
+ return 0
1178
+
1179
+ if has_rich and console:
1180
+ _render_types_table(report.findings, console, target_base)
1181
+ else:
1182
+ for f in report.findings:
1183
+ rel = _try_relative(f.filepath, target_base)
1184
+ print_msg(
1185
+ f" [{f.severity.upper()}] {rel}:{f.lineno} — {f.message} (expected {f.expected_type}, got {f.actual_type})"
1186
+ )
1187
+
1188
+ print_msg(f"\n Total: {report.count} type finding(s) detected.")
1189
+ return 1 if report.has_errors else 0
1190
+
1191
+
1192
+ def _cmd_types(
1193
+ args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1194
+ ) -> int:
1195
+ """Bidirectional type checking and inference with Typeshed stubs."""
1196
+ root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1197
+ if not root_dir.is_dir():
1198
+ root_dir = root_dir.parent
1199
+
1200
+ checker = TypeChecker(strict=config.strict_types)
1201
+ report = checker.check_project(root_dir)
1202
+
1203
+ target_base = args.paths[0] if args.paths else "."
1204
+ if getattr(args, "json", False):
1205
+ return _render_types_json(report.findings, report.has_errors)
1206
+ return _render_types_cli_summary(report, print_msg, console, target_base)
1207
+
1208
+
1209
+ def _render_taint_json(findings: Sequence[Any], count: int) -> int:
1210
+ data = [
1211
+ {
1212
+ "file": f.filepath,
1213
+ "line": f.lineno,
1214
+ "col": f.col_offset,
1215
+ "severity": f.severity,
1216
+ "sink_type": f.sink_type,
1217
+ "sink": f.sink_call,
1218
+ "source": f.source_desc,
1219
+ "source_line": f.source_lineno,
1220
+ "path": f.propagation_path,
1221
+ "message": f.message,
1222
+ "suggestion": f.suggestion,
1223
+ }
1224
+ for f in findings
1225
+ ]
1226
+ print(json.dumps(data, indent=2))
1227
+ return 1 if count > 0 else 0
1228
+
1229
+
1230
+ def _render_taint_table(
1231
+ findings: Sequence[Any], console: Any, target_base: str
1232
+ ) -> None:
1233
+ table = Table(title="Dataflow Taint Vulnerabilities", show_lines=True)
1234
+ table.add_column("Severity", style="bold", width=10)
1235
+ table.add_column("Vulnerability Type", width=20)
1236
+ table.add_column("File:Line", width=35)
1237
+ table.add_column("Sink Call", width=22)
1238
+ table.add_column("Remediation", min_width=30)
1239
+
1240
+ for f in findings:
1241
+ rel_path = _try_relative(f.filepath, target_base)
1242
+ style = "bold red" if f.severity == "CRITICAL" else "red"
1243
+ table.add_row(
1244
+ f"[{style}]{f.severity}[/{style}]",
1245
+ f.sink_type,
1246
+ f"{rel_path}:{f.lineno}",
1247
+ f.sink_call,
1248
+ f.suggestion,
1249
+ )
1250
+ console.print(table)
1251
+
1252
+
1253
+ def _render_taint_cli_summary(
1254
+ report: Any, print_msg: Any, console: Any, target_base: str
1255
+ ) -> int:
1256
+ print_msg(
1257
+ f"\n[bold cyan]Taint Analysis Report ({report.files_scanned} files, {report.sinks_checked} sinks, {report.sources_detected} sources):[/bold cyan]"
1258
+ )
1259
+ if not report.findings:
1260
+ print_msg(" [green]Zero dataflow taint vulnerabilities detected.[/green]")
1261
+ return 0
1262
+
1263
+ if has_rich and console:
1264
+ _render_taint_table(report.findings, console, target_base)
1265
+ else:
1266
+ print_msg(report.format_summary())
1267
+
1268
+ sink_types = sorted({f.sink_type for f in report.findings})
1269
+ if sink_types:
1270
+ by_sink = ", ".join(
1271
+ f"{st} ({len(report.by_sink_type(st))})" for st in sink_types
1272
+ )
1273
+ print_msg(f"\n Vulnerabilities by sink type: {by_sink}")
1274
+
1275
+ status_tag = (
1276
+ " [bold red](critical vulnerabilities found)[/bold red]"
1277
+ if report.has_critical
1278
+ else ""
1279
+ )
1280
+ print_msg(
1281
+ f"\n Total: {report.count} taint vulnerability finding(s) detected.{status_tag}"
1282
+ )
1283
+ return 1 if report.count > 0 else 0
1284
+
1285
+
1286
+ def _cmd_taint(
1287
+ args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1288
+ ) -> int:
1289
+ """Interprocedural SAST dataflow and taint vulnerability analysis."""
1290
+ root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1291
+ if not root_dir.is_dir():
1292
+ root_dir = root_dir.parent
1293
+
1294
+ engine = TaintEngine()
1295
+ report = engine.scan_path(root_dir)
1296
+
1297
+ target_base = args.paths[0] if args.paths else "."
1298
+ if getattr(args, "json", False):
1299
+ return _render_taint_json(report.findings, report.count)
1300
+ return _render_taint_cli_summary(report, print_msg, console, target_base)
1301
+
1302
+
1303
+ def _cmd_test_gen(args: argparse.Namespace, config: PyCleanerConfig, print_msg) -> int:
1304
+ """Automated behavioral contract test generator."""
1305
+ root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1306
+ output_dir = getattr(args, "output_dir", None)
1307
+ preview = getattr(args, "preview", False)
1308
+
1309
+ generator = TestGenerator()
1310
+ suites = generator.generate_for_project(root_dir, output_dir=output_dir)
1311
+
1312
+ if getattr(args, "json", False):
1313
+ data = [
1314
+ {
1315
+ "module": s.module_name,
1316
+ "target_file": s.target_filepath,
1317
+ "tests_generated": s.test_count,
1318
+ }
1319
+ for s in suites
1320
+ ]
1321
+ print(json.dumps(data, indent=2))
1322
+ return 0
1323
+
1324
+ total_tests = sum(s.test_count for s in suites)
1325
+ print_msg("\n[bold cyan]Behavioral Test Synthesizer:[/bold cyan]")
1326
+ print_msg(
1327
+ f" Generated [bold green]{total_tests}[/bold green] test(s) across {len(suites)} suite(s)."
1328
+ )
1329
+
1330
+ if preview:
1331
+ for s in suites:
1332
+ print_msg(
1333
+ f"\n[bold yellow]--- {s.module_name} (tests: {s.test_count}) ---[/bold yellow]"
1334
+ )
1335
+ print(s.rendered_code)
1336
+
1337
+ if output_dir:
1338
+ print_msg(f" Saved test suites to [bold]{output_dir}[/bold]")
1339
+ elif not preview:
1340
+ print_msg(
1341
+ " [dim]Tip: Pass --output-dir <DIR> to save test suites or --preview to inspect.[/dim]"
1342
+ )
1343
+
1344
+ return 0
1345
+
1346
+
1347
+ def _run_ultimate_phases(
1348
+ args: argparse.Namespace,
1349
+ config: PyCleanerConfig,
1350
+ print_msg: Any,
1351
+ console: Any,
1352
+ ) -> tuple[int, int, int, int, int]:
1353
+ print_msg(
1354
+ "[bold blue]Phase 1: Syntax Healing, Import Resolution, & Canonical Formatting[/bold blue]"
1355
+ )
1356
+ fix_rc = _cmd_fix(
1357
+ args,
1358
+ config,
1359
+ print_msg,
1360
+ console,
1361
+ _FixOptions(
1362
+ apply_changes=True,
1363
+ show_diff=getattr(args, "diff", False),
1364
+ ),
1365
+ )
1366
+ print_msg(
1367
+ "\n[bold blue]Phase 2: Project Dependency Audit & Reconciliation[/bold blue]"
1368
+ )
1369
+ audit_rc = _cmd_audit(args, config, print_msg)
1370
+ print_msg(
1371
+ "\n[bold blue]Phase 3: Bidirectional Type Verification & Typeshed Resolution[/bold blue]"
1372
+ )
1373
+ type_rc = _cmd_types(args, config, print_msg, console)
1374
+ print_msg(
1375
+ "\n[bold blue]Phase 4: Interprocedural SAST Dataflow & Taint Analysis[/bold blue]"
1376
+ )
1377
+ taint_rc = _cmd_taint(args, config, print_msg, console)
1378
+ print_msg("\n[bold blue]Phase 5: AST Security Pattern Scan[/bold blue]")
1379
+ scan_rc = _cmd_scan(args, config, print_msg, console)
1380
+ return fix_rc, audit_rc, type_rc, taint_rc, scan_rc
1381
+
1382
+
1383
+ def _cmd_ultimate(
1384
+ args: argparse.Namespace, config: PyCleanerConfig, print_msg: Any, console: Any
1385
+ ) -> int:
1386
+ """The Ultimate Python Tool flagship runner: executes full multi-layer analysis."""
1387
+ print_msg(
1388
+ "[bold magenta]=== PyCleaner Ultimate: Full Spectrum Analysis & Healing ===[/bold magenta]\n"
1389
+ )
1390
+ fix_rc, audit_rc, type_rc, taint_rc, scan_rc = _run_ultimate_phases(
1391
+ args, config, print_msg, console
1392
+ )
1393
+ print_msg(
1394
+ "\n[bold blue]Phase 6: Structural Complexity & Dead Code Discovery[/bold blue]"
1395
+ )
1396
+ _cmd_complexity(args, config, print_msg, console)
1397
+ _cmd_dead_code(args, config, print_msg, console)
1398
+ print_msg(
1399
+ "\n[bold magenta]=== Ultimate Python Tool: Analysis Complete ===[/bold magenta]"
1400
+ )
1401
+ return max(fix_rc, audit_rc, type_rc, taint_rc, scan_rc)
1402
+
1403
+
1404
+ def _collect_file_mtimes(files: Sequence[Path]) -> dict[Path, float]:
1405
+ mtimes: dict[Path, float] = {}
1406
+ for f in files:
1407
+ try:
1408
+ mtimes[f] = f.stat().st_mtime
1409
+ except OSError:
1410
+ mtimes[f] = 0.0
1411
+ return mtimes
1412
+
1413
+
1414
+ def _describe_watch_changes(result: CleanResult) -> str:
1415
+ changes: list[str] = []
1416
+ if result.syntax_repairs:
1417
+ changes.append(f"{len(result.syntax_repairs)} syntax")
1418
+ if result.resolved_imports:
1419
+ changes.append(f"{len(result.resolved_imports)} import")
1420
+ if result.lint_changed:
1421
+ changes.append("lint")
1422
+ if result.format_changed:
1423
+ changes.append("format")
1424
+ return ", ".join(changes)
1425
+
1426
+
1427
+ def _process_watch_change(
1428
+ py_file: Path,
1429
+ pipeline: CleanPipeline,
1430
+ backup: bool,
1431
+ print_msg: Any,
1432
+ ) -> None:
1433
+ result = pipeline.process_file(py_file, apply_changes=True, backup=backup)
1434
+ if result.changed:
1435
+ detail = _describe_watch_changes(result)
1436
+ print_msg(f" [green]Fixed:[/green] {py_file.name} ({detail})")
1437
+ elif result.error:
1438
+ print_msg(f" [red]Error:[/red] {py_file.name}: {result.error}")
1439
+
1440
+
1441
+ def _scan_watch_changes(
1442
+ current_files: Sequence[Path],
1443
+ mtimes: dict[Path, float],
1444
+ pipeline: CleanPipeline,
1445
+ backup: bool,
1446
+ print_msg: Any,
1447
+ ) -> None:
1448
+ for py_file in current_files:
1449
+ try:
1450
+ current_mtime = py_file.stat().st_mtime
1451
+ except OSError:
1452
+ continue
1453
+ if current_mtime > mtimes.get(py_file, 0.0):
1454
+ mtimes[py_file] = current_mtime
1455
+ _process_watch_change(py_file, pipeline, backup, print_msg)
1456
+
1457
+
1458
+ def _cmd_watch(
1459
+ args: argparse.Namespace, config: PyCleanerConfig, print_msg: Any
1460
+ ) -> int:
1461
+ """Watch mode: monitor files for changes and re-run pipeline on save."""
1462
+ interval = getattr(args, "interval", 1.0)
1463
+ root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1464
+ if not root_dir.is_dir():
1465
+ root_dir = root_dir.parent
1466
+
1467
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1468
+ if not py_files:
1469
+ print_msg("No Python files found to watch.", style="yellow")
1470
+ return 0
1471
+
1472
+ print_msg(
1473
+ f"[bold blue]Watching {len(py_files)} file(s) for changes (poll every {interval}s)...[/bold blue]"
1474
+ )
1475
+ print_msg("[dim]Press Ctrl+C to stop.[/dim]")
1476
+
1477
+ pipeline = CleanPipeline(
1478
+ enable_syntax_healing=not getattr(args, "no_syntax_fix", False),
1479
+ enable_import_resolution=not getattr(args, "no_missing_imports", False),
1480
+ enable_lint_fixing=not getattr(args, "no_lint_fix", False),
1481
+ enable_formatting=not getattr(args, "no_format", False),
1482
+ config=config,
1483
+ )
1484
+ backup = config.backup and not getattr(args, "no_backup", False)
1485
+ mtimes = _collect_file_mtimes(py_files)
1486
+
1487
+ try:
1488
+ while True:
1489
+ time.sleep(interval)
1490
+ current_files = discover_python_files(
1491
+ args.paths, config=config, root=root_dir
1492
+ )
1493
+ _scan_watch_changes(current_files, mtimes, pipeline, backup, print_msg)
1494
+ except KeyboardInterrupt:
1495
+ print_msg("\n[bold]Watch mode stopped.[/bold]")
1496
+ return 0
1497
+
1498
+
1499
+ def _cmd_hook(args: argparse.Namespace, print_msg) -> int:
1500
+ """Output pre-commit hook configuration and setup instructions."""
1501
+ hook_yaml = (
1502
+ "- id: pycleaner\n"
1503
+ " name: pycleaner\n"
1504
+ " description: Static Python code cleanup, analysis, and security suite\n"
1505
+ " entry: pycleaner fix\n"
1506
+ " language: python\n"
1507
+ " types: [python]\n"
1508
+ " require_serial: true\n"
1509
+ )
1510
+ if getattr(args, "json", False):
1511
+ print(
1512
+ json.dumps(
1513
+ {
1514
+ "pre_commit_hook": hook_yaml,
1515
+ "status": "ok",
1516
+ },
1517
+ indent=2,
1518
+ )
1519
+ )
1520
+ return 0
1521
+
1522
+ print_msg("[bold cyan]Pre-commit Hook Integration:[/bold cyan]\n")
1523
+ print_msg(
1524
+ "To integrate pycleaner with pre-commit, add the following to [bold].pre-commit-hooks.yaml[/bold]:\n"
1525
+ )
1526
+ print_msg(hook_yaml)
1527
+ print_msg("Then in your repository's [bold].pre-commit-config.yaml[/bold], add:\n")
1528
+ print_msg(
1529
+ " repos:\n"
1530
+ " - repo: https://github.com/your-org/pycleaner\n"
1531
+ " rev: v2.0.0\n"
1532
+ " hooks:\n"
1533
+ " - id: pycleaner\n"
1534
+ ' args: ["check"] # Use check for non-mutating validation\n'
1535
+ )
1536
+ return 0
1537
+
1538
+
1539
+ def _try_relative(filepath: str, base: str) -> str:
1540
+ """Attempt to make a path relative for display."""
1541
+ try:
1542
+ return str(Path(filepath).relative_to(Path(base).resolve()))
1543
+ except ValueError:
1544
+ return Path(filepath).name
1545
+
1546
+
1547
+ if __name__ == "__main__":
1548
+ sys.exit(main())