ballpython 2.0.0__py3-none-any.whl → 2.0.2__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 CHANGED
@@ -18,18 +18,29 @@ from dataclasses import dataclass, field
18
18
  from pathlib import Path
19
19
  from typing import Any
20
20
 
21
+ from pycleaner.baseline import BaselineFingerprint, BaselineManager
22
+ from pycleaner.cache import ContentAddressableCache
21
23
  from pycleaner.complexity_analyzer import ComplexityAnalyzer
22
24
  from pycleaner.config import ConfigError, PyCleanerConfig, load_config
23
25
  from pycleaner.dead_code_detector import DeadCodeDetector
24
26
  from pycleaner.dependency_auditor import DependencyAuditor, DependencyAuditReport
27
+ from pycleaner.discovery import (
28
+ DEFAULT_IGNORED_DIRS,
29
+ collect_project_python_files,
30
+ find_project_root,
31
+ is_protected_file,
32
+ )
33
+ from pycleaner.explanations import get_explanation, list_rules
25
34
  from pycleaner.pipeline import CleanPipeline, CleanResult
26
35
  from pycleaner.security_scanner import SecurityScanner
27
36
  from pycleaner.taint_engine import TaintEngine
28
37
  from pycleaner.test_generator import TestGenerator
29
38
  from pycleaner.type_checker import TypeChecker
39
+ from pycleaner.verifier import CounterExample, ProofReceipt, VerificationTier
30
40
 
31
41
  try:
32
42
  from rich.console import Console
43
+ from rich.panel import Panel
33
44
  from rich.progress import (
34
45
  BarColumn,
35
46
  Progress,
@@ -48,6 +59,7 @@ except ImportError:
48
59
  SUBCOMMANDS = {
49
60
  "fix",
50
61
  "check",
62
+ "prove",
51
63
  "audit",
52
64
  "scan",
53
65
  "complexity",
@@ -59,9 +71,14 @@ SUBCOMMANDS = {
59
71
  "taint",
60
72
  "test-gen",
61
73
  "ultimate",
74
+ "baseline",
75
+ "explain",
76
+ "cache",
77
+ "help",
62
78
  }
63
79
 
64
80
  _OPTIONS_WITH_VALUE = {
81
+ "--config",
65
82
  "--workers",
66
83
  "--severity",
67
84
  "--max-cyclomatic",
@@ -70,6 +87,12 @@ _OPTIONS_WITH_VALUE = {
70
87
  "--max-args",
71
88
  "--interval",
72
89
  "--output-dir",
90
+ "--output-json",
91
+ "--proof-iterations",
92
+ "--proof-seed",
93
+ "--baseline",
94
+ "--cache-db",
95
+ "--output",
73
96
  }
74
97
 
75
98
 
@@ -118,6 +141,19 @@ def _register_fix_subparsers(subparsers: Any) -> None:
118
141
  _add_fix_args(ult_p)
119
142
  ult_p.add_argument("--diff", action="store_true", help="Show unified diffs")
120
143
 
144
+ prove_p = subparsers.add_parser(
145
+ "prove",
146
+ help="Verify transformations using differential execution fuzzing (Tier A/B/C)",
147
+ )
148
+ _add_common_args(prove_p)
149
+ _add_fix_args(prove_p)
150
+ prove_p.add_argument("--diff", action="store_true", help="Show unified diffs")
151
+ prove_p.add_argument(
152
+ "--apply",
153
+ action="store_true",
154
+ help="Apply proven (Tier A) changes to disk",
155
+ )
156
+
121
157
 
122
158
  def _register_analysis_subparsers(subparsers: Any) -> None:
123
159
  audit_p = subparsers.add_parser("audit", help="Audit project dependencies")
@@ -153,6 +189,11 @@ def _register_analysis_subparsers(subparsers: Any) -> None:
153
189
  "dead-code", help="Detect unused functions, classes, and unreachable code"
154
190
  )
155
191
  _add_common_args(dc_p)
192
+ dc_p.add_argument(
193
+ "--fix",
194
+ action="store_true",
195
+ help="Auto-prune unreachable code, redundant pass statements, and dead branches",
196
+ )
156
197
 
157
198
  types_p = subparsers.add_parser(
158
199
  "types",
@@ -195,6 +236,65 @@ def _register_tool_subparsers(subparsers: Any) -> None:
195
236
  help="Print generated tests to stdout without saving",
196
237
  )
197
238
 
239
+ baseline_p = subparsers.add_parser(
240
+ "baseline",
241
+ help="Generate or update technical debt baseline for ratchet enforcement",
242
+ )
243
+ _add_common_args(baseline_p)
244
+ baseline_p.add_argument(
245
+ "--output",
246
+ default=".pycleaner/baseline.json",
247
+ help="Path to save baseline JSON (default: .pycleaner/baseline.json)",
248
+ )
249
+
250
+ explain_p = subparsers.add_parser(
251
+ "explain",
252
+ help="Explain diagnostic codes, security rules, and verification tiers",
253
+ )
254
+ explain_p.add_argument(
255
+ "code",
256
+ nargs="?",
257
+ default=None,
258
+ help="Diagnostic rule code or topic to explain (e.g. DC001, SEC001, PROVE001, or omit to list all)",
259
+ )
260
+
261
+ help_p = subparsers.add_parser(
262
+ "help",
263
+ help="Show help for ballpython or a specific command",
264
+ )
265
+ help_p.add_argument(
266
+ "command_name",
267
+ nargs="?",
268
+ default=None,
269
+ metavar="COMMAND",
270
+ help="Subcommand to show help for (e.g. fix, prove, check)",
271
+ )
272
+
273
+ cache_p = subparsers.add_parser(
274
+ "cache",
275
+ help="Inspect, query statistics, or invalidate content-addressable cache",
276
+ )
277
+ cache_p.add_argument(
278
+ "--stats",
279
+ action="store_true",
280
+ help="Display cache utilization and database storage statistics",
281
+ )
282
+ cache_p.add_argument(
283
+ "--clear",
284
+ action="store_true",
285
+ help="Wipe all entries from the local cache database",
286
+ )
287
+ cache_p.add_argument(
288
+ "--db",
289
+ default=None,
290
+ help="Custom path to cache SQLite database file (default: .pycleaner/cache.db)",
291
+ )
292
+ cache_p.add_argument(
293
+ "--json",
294
+ action="store_true",
295
+ help="Output statistics in JSON format",
296
+ )
297
+
198
298
 
199
299
  def build_parser(prog: str = "ballpython") -> argparse.ArgumentParser:
200
300
  """Construct command-line argument parser with subcommands."""
@@ -294,6 +394,14 @@ def _add_fix_args(parser: argparse.ArgumentParser) -> None:
294
394
  parser.add_argument(
295
395
  "--no-syntax-fix", action="store_true", help="Disable syntax healing"
296
396
  )
397
+ parser.add_argument(
398
+ "--no-modernize",
399
+ action="store_true",
400
+ help="Disable PEP 585/604 code modernization",
401
+ )
402
+ parser.add_argument(
403
+ "--no-dead-code", action="store_true", help="Disable dead code pruning"
404
+ )
297
405
  parser.add_argument(
298
406
  "--no-missing-imports", action="store_true", help="Disable import resolution"
299
407
  )
@@ -303,24 +411,47 @@ def _add_fix_args(parser: argparse.ArgumentParser) -> None:
303
411
  parser.add_argument(
304
412
  "--no-format", action="store_true", help="Disable code formatting"
305
413
  )
414
+ parser.add_argument(
415
+ "--prove",
416
+ action="store_true",
417
+ help="Prove transformations preserve behavior using differential execution fuzzing",
418
+ )
419
+ parser.add_argument(
420
+ "--proof-iterations",
421
+ type=int,
422
+ default=50,
423
+ help="Number of differential fuzzing test cases per callable (default: 50)",
424
+ )
425
+ parser.add_argument(
426
+ "--proof-seed",
427
+ type=int,
428
+ default=None,
429
+ help="Deterministic random seed for differential proof engine",
430
+ )
431
+ parser.add_argument(
432
+ "--output-json",
433
+ default=".pycleaner/verification-report.json",
434
+ help="Path to save verification receipt JSON",
435
+ )
436
+ parser.add_argument(
437
+ "--cache",
438
+ action="store_true",
439
+ help="Enable content-addressable incremental caching (.pycleaner/cache.db)",
440
+ )
441
+ parser.add_argument(
442
+ "--cache-db",
443
+ default=".pycleaner/cache.db",
444
+ help="Path to SQLite cache database (default: .pycleaner/cache.db)",
445
+ )
446
+ parser.add_argument(
447
+ "--baseline",
448
+ default=None,
449
+ metavar="PATH",
450
+ help="Path to baseline JSON for ratchet enforcement (e.g. .pycleaner/baseline.json)",
451
+ )
306
452
 
307
453
 
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
- )
454
+ _DEFAULT_IGNORE_DIRS = DEFAULT_IGNORED_DIRS
324
455
 
325
456
 
326
457
  def _collect_dir_python_files(
@@ -341,10 +472,11 @@ def _collect_target_files(
341
472
  files: list[Path] = []
342
473
  for target in targets:
343
474
  path = Path(target).resolve()
344
- if path.is_file() and path.suffix == ".py":
345
- files.append(path)
475
+ if path.is_file():
476
+ if path.suffix == ".py" and not is_protected_file(path):
477
+ files.append(path)
346
478
  elif path.is_dir():
347
- files.extend(_collect_dir_python_files(path, ignore_dirs))
479
+ files.extend(collect_project_python_files(path))
348
480
  return files
349
481
 
350
482
 
@@ -387,12 +519,27 @@ def discover_python_files(
387
519
  resolved_root = (
388
520
  root if root is not None else _resolve_project_root(effective_targets)
389
521
  )
390
- files = _collect_target_files(effective_targets)
391
522
 
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
- ]
523
+ explicit_files: list[Path] = []
524
+ dir_targets: list[str] = []
525
+ for target in effective_targets:
526
+ p = Path(target).resolve()
527
+ if p.is_file():
528
+ if p.suffix == ".py" and not is_protected_file(p):
529
+ explicit_files.append(p)
530
+ elif p.is_dir():
531
+ dir_targets.append(str(p))
532
+
533
+ files = list(explicit_files)
534
+ if dir_targets:
535
+ dir_files = _collect_target_files(dir_targets)
536
+ if config and config.exclude:
537
+ dir_files = [
538
+ f
539
+ for f in dir_files
540
+ if not _is_file_excluded(f, resolved_root, config.exclude)
541
+ ]
542
+ files.extend(dir_files)
396
543
 
397
544
  return sorted(set(files))
398
545
 
@@ -456,6 +603,7 @@ def _route_command(
456
603
  diff = getattr(args, "diff", False)
457
604
 
458
605
  dispatch_simple = {
606
+ "help": lambda: _cmd_help(args, print_msg),
459
607
  "hook": lambda: _cmd_hook(args, print_msg),
460
608
  "audit": lambda: _cmd_audit(args, config, print_msg),
461
609
  "scan": lambda: _cmd_scan(args, config, print_msg, console),
@@ -467,6 +615,10 @@ def _route_command(
467
615
  "watch": lambda: _cmd_watch(args, config, print_msg),
468
616
  "ultimate": lambda: _cmd_ultimate(args, config, print_msg, console),
469
617
  "all": lambda: _run_all_command(args, config, print_msg, console),
618
+ "prove": lambda: _cmd_prove(args, config, print_msg, console),
619
+ "baseline": lambda: _cmd_baseline(args, config, print_msg, console),
620
+ "explain": lambda: _cmd_explain(args, print_msg, console),
621
+ "cache": lambda: _cmd_cache(args, config, print_msg, console),
470
622
  "check": lambda: _cmd_fix(
471
623
  args,
472
624
  config,
@@ -505,10 +657,13 @@ def main(argv: Sequence[str] | None = None) -> int:
505
657
 
506
658
  targets = getattr(args, "paths", ["."])
507
659
  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
660
+ root_dir = find_project_root(target_path)
509
661
 
510
662
  try:
511
- config = load_config(project_root=root_dir)
663
+ config = load_config(
664
+ project_root=root_dir,
665
+ explicit_config_file=getattr(args, "config", None),
666
+ )
512
667
  except ConfigError as err:
513
668
  print_msg(f"[red]Configuration Error:[/red] {err}")
514
669
  return 2
@@ -527,6 +682,11 @@ class _FixBatchState:
527
682
  changed_count: int = 0
528
683
  error_count: int = 0
529
684
  diagnostics: list[dict[str, str]] = field(default_factory=list)
685
+ proven_count: int = 0
686
+ suggested_count: int = 0
687
+ refused_count: int = 0
688
+ proof_receipts: list[ProofReceipt] = field(default_factory=list)
689
+ counterexamples: list[CounterExample] = field(default_factory=list)
530
690
 
531
691
 
532
692
  @dataclass(slots=True)
@@ -555,6 +715,10 @@ def _report_file_modifications(
555
715
  print_msg(f"[green]{action}:[/green] {py_file.name}")
556
716
  for repair in result.syntax_repairs:
557
717
  print_msg(f" • Syntax: {repair}", style="cyan")
718
+ for mod in result.modernize_transforms:
719
+ print_msg(f" • Modernize: {mod}", style="green")
720
+ for dc in result.dead_code_pruned:
721
+ print_msg(f" • Dead-code: {dc}", style="yellow")
558
722
  for imp in result.resolved_imports:
559
723
  print_msg(f" • Import: {imp}", style="magenta")
560
724
  if result.lint_changed:
@@ -574,6 +738,28 @@ def _accumulate_result(
574
738
  if result.diagnostics:
575
739
  state.diagnostics.extend(result.diagnostics)
576
740
 
741
+ if result.proof_receipts:
742
+ state.proof_receipts.extend(result.proof_receipts)
743
+ if result.refused_changes:
744
+ state.counterexamples.extend(result.refused_changes)
745
+ state.refused_count += len(result.refused_changes)
746
+ if result.verification_tier == VerificationTier.TIER_A_PROVEN:
747
+ state.proven_count += 1
748
+ elif result.verification_tier == VerificationTier.TIER_B_SUGGESTED:
749
+ state.suggested_count += 1
750
+ elif result.verification_tier == VerificationTier.TIER_C_REFUSED:
751
+ if not is_json:
752
+ print_msg(
753
+ f"[bold red]Refused (Tier C):[/bold red] {py_file.name} — transformation falsified by differential fuzzing; rolled back!"
754
+ )
755
+ for ce in result.refused_changes:
756
+ print_msg(
757
+ f" • {ce.callable_name} diverged on args={ce.arguments} kwargs={ce.keyword_arguments}"
758
+ )
759
+ print_msg(
760
+ f" original={ce.original_result or ce.original_error} vs transformed={ce.transformed_result or ce.transformed_error} (seed {ce.seed})"
761
+ )
762
+
577
763
  if result.error:
578
764
  state.error_count += 1
579
765
  if not is_json:
@@ -591,20 +777,41 @@ def _accumulate_result(
591
777
  def _build_fix_pipeline(
592
778
  args: argparse.Namespace, config: PyCleanerConfig
593
779
  ) -> CleanPipeline:
780
+ verify_proofs = (
781
+ getattr(args, "prove", False) or getattr(args, "command", None) == "prove"
782
+ )
783
+ proof_iterations = getattr(args, "proof_iterations", 50)
784
+ proof_seed = getattr(args, "proof_seed", None)
785
+ enable_cache = getattr(args, "cache", False)
786
+ cache_db_path = getattr(args, "cache_db", ".pycleaner/cache.db")
594
787
  if getattr(args, "missing_imports_only", False):
595
788
  return CleanPipeline(
596
789
  enable_syntax_healing=False,
790
+ enable_modernizer=False,
791
+ enable_dead_code_pruning=False,
597
792
  enable_import_resolution=True,
598
793
  enable_lint_fixing=False,
599
794
  enable_formatting=False,
600
795
  config=config,
796
+ verify_proofs=verify_proofs,
797
+ proof_iterations=proof_iterations,
798
+ proof_seed=proof_seed,
799
+ enable_cache=enable_cache,
800
+ cache_db_path=cache_db_path,
601
801
  )
602
802
  return CleanPipeline(
603
803
  enable_syntax_healing=not getattr(args, "no_syntax_fix", False),
804
+ enable_modernizer=not getattr(args, "no_modernize", False),
805
+ enable_dead_code_pruning=not getattr(args, "no_dead_code", False),
604
806
  enable_import_resolution=not getattr(args, "no_missing_imports", False),
605
807
  enable_lint_fixing=not getattr(args, "no_lint_fix", False),
606
808
  enable_formatting=not getattr(args, "no_format", False),
607
809
  config=config,
810
+ verify_proofs=verify_proofs,
811
+ proof_iterations=proof_iterations,
812
+ proof_seed=proof_seed,
813
+ enable_cache=enable_cache,
814
+ cache_db_path=cache_db_path,
608
815
  )
609
816
 
610
817
 
@@ -707,13 +914,18 @@ def _run_fix_audit(
707
914
  apply_changes: bool,
708
915
  args: argparse.Namespace,
709
916
  print_msg: Any,
917
+ config: PyCleanerConfig | None = None,
918
+ target_files: Sequence[Path] | None = None,
710
919
  ) -> DependencyAuditReport:
711
- auditor = DependencyAuditor(root_dir)
920
+ exclude_patterns = config.exclude if config else ()
921
+ auditor = DependencyAuditor(root_dir, exclude_patterns=exclude_patterns)
712
922
  fix_any = getattr(args, "fix_deps", False) or getattr(args, "fix_all", False)
713
923
  prune_any = getattr(args, "prune_deps", False) or getattr(args, "fix_all", False)
714
924
  should_fix = apply_changes and fix_any
715
925
  should_prune = apply_changes and prune_any
716
- audit_report = auditor.audit(fix=should_fix, prune_unused=should_prune)
926
+ audit_report = auditor.audit(
927
+ fix=should_fix, prune_unused=should_prune, target_files=target_files
928
+ )
717
929
  _render_audit_terminal_output(audit_report, print_msg, should_fix)
718
930
  return audit_report
719
931
 
@@ -722,7 +934,12 @@ def _compute_fix_exit_code(
722
934
  state: _FixBatchState,
723
935
  audit_report: DependencyAuditReport,
724
936
  apply_changes: bool,
937
+ is_prove_cmd: bool = False,
725
938
  ) -> int:
939
+ if state.refused_count > 0:
940
+ return 1
941
+ if is_prove_cmd:
942
+ return 0 if state.error_count == 0 else 1
726
943
  if not apply_changes and (
727
944
  state.changed_count > 0
728
945
  or state.error_count > 0
@@ -746,7 +963,7 @@ def _is_parallel_enabled(
746
963
 
747
964
  def _resolve_root_dir(paths: Sequence[str] | None) -> Path:
748
965
  target = Path(paths[0]).resolve() if paths else Path.cwd()
749
- return target if target.is_dir() else target.parent
966
+ return find_project_root(target)
750
967
 
751
968
 
752
969
  def _report_fix_start(count: int, apply_changes: bool, print_msg: Any) -> None:
@@ -785,11 +1002,380 @@ def _cmd_fix(
785
1002
  print(json.dumps(state.diagnostics, indent=2))
786
1003
  return 0 if state.error_count == 0 else 1
787
1004
 
788
- audit_report = _run_fix_audit(root_dir, opts.apply_changes, args, print_msg)
1005
+ audit_report = _run_fix_audit(
1006
+ root_dir,
1007
+ opts.apply_changes,
1008
+ args,
1009
+ print_msg,
1010
+ config=config,
1011
+ target_files=py_files,
1012
+ )
789
1013
  print_msg(
790
1014
  f"\n[bold]Summary: {len(py_files)} inspected, {state.changed_count} updated, {state.error_count} errors.[/bold]"
791
1015
  )
792
- return _compute_fix_exit_code(state, audit_report, opts.apply_changes)
1016
+
1017
+ if pipeline.verify_proofs:
1018
+ total_proven_callables = sum(
1019
+ 1 for r in state.proof_receipts if r.tier == VerificationTier.TIER_A_PROVEN
1020
+ )
1021
+ total_refused_callables = len(state.counterexamples)
1022
+ if not is_json:
1023
+ print_msg("\n[bold cyan]Verification Receipts (Trust Ladder):[/bold cyan]")
1024
+ print_msg(
1025
+ f" [bold green]• Proven (Tier A):[/bold green] {total_proven_callables} callable(s) invariant-preserving across {pipeline.proof_iterations} input(s)"
1026
+ )
1027
+ if state.suggested_count > 0:
1028
+ print_msg(
1029
+ f" [yellow]• Suggested (Tier B):[/yellow] {state.suggested_count} module(s) (not isolated for dynamic fuzzing)"
1030
+ )
1031
+ if total_refused_callables > 0:
1032
+ print_msg(
1033
+ f" [bold red]• Refused (Tier C):[/bold red] {total_refused_callables} transformation(s) diverged; rolled back"
1034
+ )
1035
+
1036
+ output_report_path = getattr(
1037
+ args, "output_json", ".pycleaner/verification-report.json"
1038
+ )
1039
+ try:
1040
+ out_p = Path(output_report_path)
1041
+ out_p.parent.mkdir(parents=True, exist_ok=True)
1042
+ report_data = {
1043
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1044
+ "total_inspected": len(py_files),
1045
+ "proven_tier_a_callables": total_proven_callables,
1046
+ "suggested_tier_b_files": state.suggested_count,
1047
+ "refused_tier_c_callables": total_refused_callables,
1048
+ "proof_receipts": [
1049
+ {
1050
+ "file": r.filepath,
1051
+ "callable": r.callable_name,
1052
+ "tier": r.tier.value,
1053
+ "iterations": r.iterations_run,
1054
+ "seed": r.seed,
1055
+ "duration_ms": r.duration_ms,
1056
+ "reason": r.reason,
1057
+ }
1058
+ for r in state.proof_receipts
1059
+ ],
1060
+ "counterexamples": [
1061
+ {
1062
+ "callable": ce.callable_name,
1063
+ "arguments": [repr(a) for a in ce.arguments],
1064
+ "kwargs": {k: repr(v) for k, v in ce.keyword_arguments.items()},
1065
+ "original_result": ce.original_result,
1066
+ "original_error": ce.original_error,
1067
+ "transformed_result": ce.transformed_result,
1068
+ "transformed_error": ce.transformed_error,
1069
+ "seed": ce.seed,
1070
+ }
1071
+ for ce in state.counterexamples
1072
+ ],
1073
+ }
1074
+ out_p.write_text(json.dumps(report_data, indent=2), encoding="utf-8")
1075
+ if not is_json:
1076
+ print_msg(f" [dim]Report saved to {output_report_path}[/dim]")
1077
+ except OSError as e:
1078
+ if not is_json:
1079
+ print_msg(f" [red]Failed to write verification report:[/red] {e}")
1080
+
1081
+ if getattr(args, "baseline", None):
1082
+ baseline_mgr = BaselineManager(args.baseline)
1083
+ current_issues: list[BaselineFingerprint] = []
1084
+ for diag in state.diagnostics:
1085
+ current_issues.append(
1086
+ BaselineManager.create_fingerprint(
1087
+ rule=diag.get("rule", "LINT001"),
1088
+ file_path=diag.get("file", "<unknown>"),
1089
+ line=int(diag.get("line", 1)),
1090
+ symbol=diag.get("message", "diagnostic violation"),
1091
+ root_dir=root_dir,
1092
+ )
1093
+ )
1094
+ tolerated, new_debt = baseline_mgr.filter_new_issues(current_issues)
1095
+ if not is_json:
1096
+ print_msg(
1097
+ f"\n[bold cyan]Baseline Ratchet Enforcement ({args.baseline}):[/bold cyan]"
1098
+ )
1099
+ if tolerated:
1100
+ print_msg(
1101
+ f" • Baseline tolerated: [yellow]{len(tolerated)}[/yellow] existing issue(s)"
1102
+ )
1103
+ if new_debt:
1104
+ print_msg(
1105
+ f" • [bold red]Ratchet Violation:[/bold red] {len(new_debt)} new technical debt issue(s) detected!"
1106
+ )
1107
+ for nd in new_debt:
1108
+ print_msg(
1109
+ f" [red]• [X] {nd.rule} at {nd.file}:{nd.line} ({nd.symbol})[/red]"
1110
+ )
1111
+ return 1
1112
+ else:
1113
+ print_msg(
1114
+ " • [bold green]Ratchet Passed:[/bold green] 0 new technical debt issues introduced."
1115
+ )
1116
+ elif new_debt:
1117
+ return 1
1118
+
1119
+ is_prove = getattr(args, "command", None) == "prove"
1120
+ return _compute_fix_exit_code(
1121
+ state, audit_report, opts.apply_changes, is_prove_cmd=is_prove
1122
+ )
1123
+
1124
+
1125
+ def _cmd_prove(
1126
+ args: argparse.Namespace,
1127
+ config: PyCleanerConfig,
1128
+ print_msg: Any,
1129
+ console: Any,
1130
+ ) -> int:
1131
+ """The Proof-Carrying Differential Equivalence Runner."""
1132
+ args.prove = True
1133
+ diff = getattr(args, "diff", False)
1134
+ apply_changes = getattr(args, "apply", False)
1135
+
1136
+ print_msg(
1137
+ "[bold cyan]=== PyCleaner Prove: Differential Equivalence Verification ===[/bold cyan]\n"
1138
+ )
1139
+
1140
+ return _cmd_fix(
1141
+ args,
1142
+ config,
1143
+ print_msg,
1144
+ console,
1145
+ _FixOptions(
1146
+ apply_changes=apply_changes,
1147
+ show_diff=diff,
1148
+ backup=config.backup and not getattr(args, "no_backup", False),
1149
+ ),
1150
+ )
1151
+
1152
+
1153
+ def _cmd_baseline(
1154
+ args: argparse.Namespace,
1155
+ config: PyCleanerConfig,
1156
+ print_msg: Any,
1157
+ console: Any,
1158
+ ) -> int:
1159
+ """Generate or update technical debt baseline for ratchet enforcement."""
1160
+ output_path = Path(getattr(args, "output", ".pycleaner/baseline.json"))
1161
+ root_dir = _resolve_root_dir(getattr(args, "paths", None))
1162
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1163
+
1164
+ print_msg(
1165
+ "[bold cyan]=== Generating PyCleaner Technical Debt Baseline ===[/bold cyan]"
1166
+ )
1167
+ print_msg(f"Inspecting {len(py_files)} file(s) across {root_dir.name}...")
1168
+
1169
+ fingerprints: list[BaselineFingerprint] = []
1170
+
1171
+ detector = DeadCodeDetector()
1172
+ dead_code_report = detector.scan_project(root_dir)
1173
+ for item in dead_code_report.items:
1174
+ if item.kind == "unused-import":
1175
+ rule = "DC002"
1176
+ elif item.kind in ("function", "class"):
1177
+ rule = "DC003"
1178
+ else:
1179
+ rule = "DC001"
1180
+ fingerprints.append(
1181
+ BaselineManager.create_fingerprint(
1182
+ rule=rule,
1183
+ file_path=item.filepath,
1184
+ line=item.lineno,
1185
+ symbol=item.name,
1186
+ root_dir=root_dir,
1187
+ )
1188
+ )
1189
+
1190
+ scanner = SecurityScanner()
1191
+ sec_report = scanner.scan_project(root_dir)
1192
+ for finding in sec_report.findings:
1193
+ fingerprints.append(
1194
+ BaselineManager.create_fingerprint(
1195
+ rule=finding.category,
1196
+ file_path=finding.filepath,
1197
+ line=finding.lineno,
1198
+ symbol=finding.message,
1199
+ root_dir=root_dir,
1200
+ )
1201
+ )
1202
+
1203
+ analyzer = ComplexityAnalyzer()
1204
+ comp_report = analyzer.analyze_project(root_dir)
1205
+ thresholds = (
1206
+ getattr(args, "max_cyclomatic", config.max_cyclomatic_complexity),
1207
+ getattr(args, "max_cognitive", config.max_cognitive_complexity),
1208
+ getattr(args, "max_lines", config.max_function_length),
1209
+ getattr(args, "max_args", config.max_arguments),
1210
+ )
1211
+ violations = comp_report.above_threshold(*thresholds)
1212
+ for v in violations:
1213
+ fingerprints.append(
1214
+ BaselineManager.create_fingerprint(
1215
+ rule="CMP001",
1216
+ file_path=v.filepath,
1217
+ line=v.lineno,
1218
+ symbol=f"{v.qualified_name} (CC={v.cyclomatic})",
1219
+ root_dir=root_dir,
1220
+ )
1221
+ )
1222
+
1223
+ manager = BaselineManager(output_path)
1224
+ saved_file = manager.save_baseline(fingerprints, root_dir)
1225
+
1226
+ print_msg(f"\n[bold green]Baseline successfully recorded![/bold green]")
1227
+ print_msg(f" • Issues snapshotted: [bold yellow]{len(fingerprints)}[/bold yellow]")
1228
+ print_msg(f" • Output file: [bold]{saved_file}[/bold]")
1229
+ print_msg(
1230
+ "\n[dim]Ratchet Guarantee: Technical debt in this repository is now locked. Run CI with:[/dim]"
1231
+ )
1232
+ print_msg(f" [cyan]pycleaner check --baseline {output_path}[/cyan]\n")
1233
+
1234
+ return 0
1235
+
1236
+
1237
+ def _cmd_explain(
1238
+ args: argparse.Namespace,
1239
+ print_msg: Any,
1240
+ console: Any,
1241
+ ) -> int:
1242
+ """Explain diagnostic codes, security rules, and verification tiers."""
1243
+ code = getattr(args, "code", None)
1244
+
1245
+ if not code:
1246
+ rules = list_rules()
1247
+ print_msg(
1248
+ "[bold cyan]PyCleaner Diagnostic & Verification Rules Catalog[/bold cyan]\n"
1249
+ )
1250
+ if console and has_rich:
1251
+ table = Table(show_header=True, header_style="bold magenta")
1252
+ table.add_column("Code", style="cyan", width=10)
1253
+ table.add_column("Category", style="yellow", width=25)
1254
+ table.add_column("Severity", width=12)
1255
+ table.add_column("Title", style="white")
1256
+ for r in rules:
1257
+ sev_color = (
1258
+ "red"
1259
+ if r.severity in ("Critical", "High")
1260
+ else ("yellow" if r.severity == "Medium" else "green")
1261
+ )
1262
+ table.add_row(
1263
+ r.code,
1264
+ r.category,
1265
+ f"[{sev_color}]{r.severity}[/{sev_color}]",
1266
+ r.title,
1267
+ )
1268
+ console.print(table)
1269
+ else:
1270
+ for r in rules:
1271
+ print(f"{r.code:8} [{r.severity:8}] {r.title} ({r.category})")
1272
+ print_msg(
1273
+ "\n[dim]Run 'pycleaner explain <CODE>' for full details and remediation examples.[/dim]"
1274
+ )
1275
+ return 0
1276
+
1277
+ rule = get_explanation(code)
1278
+ if rule is None:
1279
+ print_msg(f"[bold red]Unknown rule code or topic:[/bold red] '{code}'")
1280
+ print_msg(
1281
+ "[dim]Run 'pycleaner explain' without arguments to list all available rules.[/dim]"
1282
+ )
1283
+ return 1
1284
+
1285
+ sev_color = (
1286
+ "red"
1287
+ if rule.severity in ("Critical", "High")
1288
+ else ("yellow" if rule.severity == "Medium" else "green")
1289
+ )
1290
+ print_msg(
1291
+ f"\n[bold cyan]PyCleaner Rule Guide: {rule.code} - {rule.title}[/bold cyan]"
1292
+ )
1293
+ print_msg(
1294
+ f" [bold]Category:[/bold] {rule.category} | [bold]Severity:[/bold] [{sev_color}]{rule.severity}[/{sev_color}]\n"
1295
+ )
1296
+ print_msg(f"[bold]Description:[/bold]\n{rule.description}\n")
1297
+
1298
+ if rule.vulnerable_example:
1299
+ print_msg("[bold red][X] Flawed / Baseline Example:[/bold red]")
1300
+ if console and has_rich:
1301
+ console.print(
1302
+ Syntax(
1303
+ rule.vulnerable_example,
1304
+ "python",
1305
+ theme="monokai",
1306
+ line_numbers=False,
1307
+ )
1308
+ )
1309
+ else:
1310
+ print(rule.vulnerable_example)
1311
+
1312
+ if rule.remediated_example:
1313
+ print_msg("\n[bold green][+] Verified / Remediated Example:[/bold green]")
1314
+ if console and has_rich:
1315
+ console.print(
1316
+ Syntax(
1317
+ rule.remediated_example,
1318
+ "python",
1319
+ theme="monokai",
1320
+ line_numbers=False,
1321
+ )
1322
+ )
1323
+ else:
1324
+ print(rule.remediated_example)
1325
+
1326
+ print_msg(
1327
+ f"\n[bold]Remediation Details & Proof Invariants:[/bold]\n{rule.remediation_details}\n"
1328
+ )
1329
+ return 0
1330
+
1331
+
1332
+ def _cmd_help(args: argparse.Namespace, print_msg: Any) -> int:
1333
+ cmd_name = getattr(args, "command_name", None)
1334
+ parser = build_parser()
1335
+ if not cmd_name:
1336
+ parser.print_help()
1337
+ return 0
1338
+
1339
+ for action in parser._actions:
1340
+ if isinstance(action, argparse._SubParsersAction):
1341
+ if cmd_name in action.choices:
1342
+ action.choices[cmd_name].print_help()
1343
+ return 0
1344
+ print_msg(
1345
+ f"Unknown command: '{cmd_name}'. Run 'ballpython --help' to view available commands.",
1346
+ style="yellow",
1347
+ )
1348
+ return 1
1349
+
1350
+
1351
+ def _cmd_cache(
1352
+ args: argparse.Namespace,
1353
+ config: PyCleanerConfig,
1354
+ print_msg: Any,
1355
+ console: Any,
1356
+ ) -> int:
1357
+ """Manage and inspect the content-addressable verification and AST cache."""
1358
+ db_path = getattr(args, "db", None) or getattr(
1359
+ config, "cache_db_path", ".pycleaner/cache.db"
1360
+ )
1361
+ cache = ContentAddressableCache(db_path=db_path)
1362
+
1363
+ if getattr(args, "clear", False):
1364
+ cache.clear()
1365
+ print_msg("[green]Content-addressable cache cleared successfully.[/green]")
1366
+ return 0
1367
+
1368
+ stats = cache.get_stats()
1369
+ if getattr(args, "json", False):
1370
+ print(json.dumps(stats, indent=2))
1371
+ return 0
1372
+
1373
+ print_msg("[bold cyan]=== Content-Addressable Cache Statistics ===[/bold cyan]")
1374
+ print_msg(f" Database Path: {cache.db_path}")
1375
+ print_msg(f" Cached File Entries: {stats['file_cache_entries']}")
1376
+ print_msg(f" Provenance Records: {stats['provenance_entries']}")
1377
+ print_msg(f" Database File Size: {stats['db_size_bytes']:,} bytes")
1378
+ return 0
793
1379
 
794
1380
 
795
1381
  def _render_audit_json(audit_report: DependencyAuditReport) -> int:
@@ -839,12 +1425,10 @@ def _render_audit_cli_output(
839
1425
 
840
1426
  def _cmd_audit(args: argparse.Namespace, config: PyCleanerConfig, print_msg) -> int:
841
1427
  """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
1428
+ root_dir = find_project_root(args.paths[0] if args.paths else None)
845
1429
 
846
1430
  is_check = getattr(args, "check", False)
847
- auditor = DependencyAuditor(root_dir)
1431
+ auditor = DependencyAuditor(root_dir, exclude_patterns=config.exclude)
848
1432
  audit_report = auditor.audit(
849
1433
  fix=getattr(args, "fix_deps", False) and not is_check,
850
1434
  prune_unused=getattr(args, "prune_deps", False) and not is_check,
@@ -936,7 +1520,7 @@ def _cmd_scan(
936
1520
  severity_threshold=severity,
937
1521
  ignore_rules=set(config.ignore_security_rules),
938
1522
  )
939
- report = scanner.scan_project(root_dir)
1523
+ report = scanner.scan_project(root_dir, exclude_patterns=config.exclude)
940
1524
 
941
1525
  target_base = args.paths[0] if args.paths else "."
942
1526
  if getattr(args, "json", False):
@@ -1035,7 +1619,7 @@ def _cmd_complexity(
1035
1619
  root_dir = root_dir.parent
1036
1620
 
1037
1621
  analyzer = ComplexityAnalyzer()
1038
- report = analyzer.analyze_project(root_dir)
1622
+ report = analyzer.analyze_project(root_dir, exclude_patterns=config.exclude)
1039
1623
 
1040
1624
  thresholds = (
1041
1625
  getattr(args, "max_cyclomatic", config.max_cyclomatic_complexity),
@@ -1113,7 +1697,22 @@ def _cmd_dead_code(
1113
1697
  ignore_decorators=set(config.ignore_decorators),
1114
1698
  ignore_names=set(config.ignore_names),
1115
1699
  )
1116
- report = detector.scan_project(root_dir)
1700
+ if getattr(args, "fix", False):
1701
+ print_msg(f"[bold green]Pruning dead code across {root_dir}...[/bold green]")
1702
+ fix_results = detector.fix_project(root_dir, exclude_patterns=config.exclude)
1703
+ total_pruned = sum(len(res.pruned_items) for res in fix_results.values())
1704
+ print_msg(
1705
+ f"[green]Successfully fixed {len(fix_results)} file(s), pruned {total_pruned} dead code item(s).[/green]"
1706
+ )
1707
+ for p, res in fix_results.items():
1708
+ try:
1709
+ rel_p = p.relative_to(root_dir)
1710
+ except ValueError:
1711
+ rel_p = p
1712
+ print_msg(f" [cyan]{rel_p}[/cyan]: {len(res.pruned_items)} pruned")
1713
+ return 0
1714
+
1715
+ report = detector.scan_project(root_dir, exclude_patterns=config.exclude)
1117
1716
 
1118
1717
  if getattr(args, "json", False):
1119
1718
  return _render_dead_code_json(report.items)
@@ -1198,7 +1797,7 @@ def _cmd_types(
1198
1797
  root_dir = root_dir.parent
1199
1798
 
1200
1799
  checker = TypeChecker(strict=config.strict_types)
1201
- report = checker.check_project(root_dir)
1800
+ report = checker.check_project(root_dir, exclude_patterns=config.exclude)
1202
1801
 
1203
1802
  target_base = args.paths[0] if args.paths else "."
1204
1803
  if getattr(args, "json", False):
@@ -1292,7 +1891,7 @@ def _cmd_taint(
1292
1891
  root_dir = root_dir.parent
1293
1892
 
1294
1893
  engine = TaintEngine()
1295
- report = engine.scan_path(root_dir)
1894
+ report = engine.scan_path(root_dir, exclude_patterns=config.exclude)
1296
1895
 
1297
1896
  target_base = args.paths[0] if args.paths else "."
1298
1897
  if getattr(args, "json", False):
@@ -1498,6 +2097,8 @@ def _cmd_watch(
1498
2097
 
1499
2098
  def _cmd_hook(args: argparse.Namespace, print_msg) -> int:
1500
2099
  """Output pre-commit hook configuration and setup instructions."""
2100
+ from pycleaner import __version__
2101
+
1501
2102
  hook_yaml = (
1502
2103
  "- id: pycleaner\n"
1503
2104
  " name: pycleaner\n"
@@ -1523,12 +2124,12 @@ def _cmd_hook(args: argparse.Namespace, print_msg) -> int:
1523
2124
  print_msg(
1524
2125
  "To integrate pycleaner with pre-commit, add the following to [bold].pre-commit-hooks.yaml[/bold]:\n"
1525
2126
  )
1526
- print_msg(hook_yaml)
2127
+ print_msg(hook_yaml.replace("[", "\\["))
1527
2128
  print_msg("Then in your repository's [bold].pre-commit-config.yaml[/bold], add:\n")
1528
2129
  print_msg(
1529
2130
  " repos:\n"
1530
- " - repo: https://github.com/your-org/pycleaner\n"
1531
- " rev: v2.0.0\n"
2131
+ " - repo: https://github.com/moonrox420/ball-python\n"
2132
+ f" rev: v{__version__}\n"
1532
2133
  " hooks:\n"
1533
2134
  " - id: pycleaner\n"
1534
2135
  ' args: ["check"] # Use check for non-mutating validation\n'