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.
- ballpython/__init__.py +9 -0
- ballpython/__main__.py +8 -0
- ballpython/cli.py +7 -0
- ballpython-2.0.0.dist-info/METADATA +322 -0
- ballpython-2.0.0.dist-info/RECORD +24 -0
- ballpython-2.0.0.dist-info/WHEEL +5 -0
- ballpython-2.0.0.dist-info/entry_points.txt +3 -0
- ballpython-2.0.0.dist-info/top_level.txt +2 -0
- pycleaner/__init__.py +53 -0
- pycleaner/__main__.py +8 -0
- pycleaner/cli.py +1548 -0
- pycleaner/complexity_analyzer.py +473 -0
- pycleaner/config.py +254 -0
- pycleaner/dead_code_detector.py +515 -0
- pycleaner/dependency_auditor.py +331 -0
- pycleaner/import_resolver.py +832 -0
- pycleaner/linter_formatter.py +590 -0
- pycleaner/pipeline.py +349 -0
- pycleaner/security_scanner.py +563 -0
- pycleaner/syntax_healer.py +577 -0
- pycleaner/taint_engine.py +720 -0
- pycleaner/test_generator.py +444 -0
- pycleaner/type_checker.py +989 -0
- pycleaner/typeshed_resolver.py +395 -0
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())
|