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.
- ballpython/py.typed +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/METADATA +13 -7
- ballpython-2.0.2.dist-info/RECORD +34 -0
- pycleaner/__init__.py +39 -2
- pycleaner/baseline.py +127 -0
- pycleaner/cache.py +282 -0
- pycleaner/cli.py +644 -43
- pycleaner/complexity_analyzer.py +16 -28
- pycleaner/config.py +8 -1
- pycleaner/dead_code_detector.py +275 -38
- pycleaner/dependency_auditor.py +175 -75
- pycleaner/discovery.py +206 -0
- pycleaner/explanations.py +323 -0
- pycleaner/frameworks/__init__.py +115 -0
- pycleaner/frameworks/plugins.py +290 -0
- pycleaner/modernizer.py +349 -0
- pycleaner/pipeline.py +208 -20
- pycleaner/py.typed +0 -0
- pycleaner/security_scanner.py +59 -38
- pycleaner/syntax_healer.py +188 -10
- pycleaner/taint_engine.py +8 -8
- pycleaner/test_generator.py +124 -35
- pycleaner/type_checker.py +29 -33
- pycleaner/verifier.py +495 -0
- ballpython-2.0.0.dist-info/RECORD +0 -24
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/WHEEL +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/top_level.txt +0 -0
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 =
|
|
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()
|
|
345
|
-
|
|
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(
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
660
|
+
root_dir = find_project_root(target_path)
|
|
509
661
|
|
|
510
662
|
try:
|
|
511
|
-
config = load_config(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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/
|
|
1531
|
-
" rev:
|
|
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'
|