ballpython 2.1.0__tar.gz → 2.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {ballpython-2.1.0 → ballpython-2.1.2}/PKG-INFO +1 -3
  2. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython.egg-info/PKG-INFO +1 -3
  3. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython.egg-info/SOURCES.txt +1 -0
  4. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython.egg-info/requires.txt +0 -4
  5. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/__init__.py +1 -1
  6. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/cli.py +202 -36
  7. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/complexity_analyzer.py +11 -8
  8. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/dead_code_detector.py +156 -43
  9. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/dependency_auditor.py +26 -5
  10. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/discovery.py +17 -0
  11. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/frameworks/__init__.py +8 -1
  12. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/linter_formatter.py +23 -4
  13. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/pipeline.py +99 -11
  14. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/security_scanner.py +149 -14
  15. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/syntax_healer.py +208 -43
  16. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/taint_engine.py +19 -15
  17. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/type_checker.py +128 -25
  18. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/verifier.py +12 -4
  19. {ballpython-2.1.0 → ballpython-2.1.2}/pyproject.toml +1 -3
  20. ballpython-2.1.2/tests/test_c_to_a_overhaul.py +411 -0
  21. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_dead_code_detector.py +22 -0
  22. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_dependency_auditor.py +49 -0
  23. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_syntax_healer.py +1 -0
  24. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_type_checker.py +106 -1
  25. {ballpython-2.1.0 → ballpython-2.1.2}/README.md +0 -0
  26. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython/__init__.py +0 -0
  27. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython/__main__.py +0 -0
  28. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython/cli.py +0 -0
  29. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython/py.typed +0 -0
  30. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython.egg-info/dependency_links.txt +0 -0
  31. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython.egg-info/entry_points.txt +0 -0
  32. {ballpython-2.1.0 → ballpython-2.1.2}/ballpython.egg-info/top_level.txt +0 -0
  33. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/__main__.py +0 -0
  34. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/baseline.py +0 -0
  35. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/cache.py +0 -0
  36. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/config.py +0 -0
  37. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/explanations.py +0 -0
  38. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/frameworks/plugins.py +0 -0
  39. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/import_resolver.py +0 -0
  40. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/modernizer.py +0 -0
  41. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/py.typed +0 -0
  42. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/test_generator.py +0 -0
  43. {ballpython-2.1.0 → ballpython-2.1.2}/pycleaner/typeshed_resolver.py +0 -0
  44. {ballpython-2.1.0 → ballpython-2.1.2}/setup.cfg +0 -0
  45. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_baseline_and_explain.py +0 -0
  46. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_cache.py +0 -0
  47. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_cli.py +0 -0
  48. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_cli_prove.py +0 -0
  49. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_complexity_analyzer.py +0 -0
  50. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_config.py +0 -0
  51. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_dead_code_fixer.py +0 -0
  52. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_discovery.py +0 -0
  53. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_frameworks.py +0 -0
  54. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_import_resolver.py +0 -0
  55. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_linter_formatter.py +0 -0
  56. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_modernizer.py +0 -0
  57. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_mutation_test_generator.py +0 -0
  58. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_pipeline.py +0 -0
  59. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_security_scanner.py +0 -0
  60. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_taint_engine.py +0 -0
  61. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_test_generator.py +0 -0
  62. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_verifier.py +0 -0
  63. {ballpython-2.1.0 → ballpython-2.1.2}/tests/test_verifier_pipeline.py +0 -0
@@ -1,14 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ballpython
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: The Ultimate Static Python Intelligence, Healing, Type Verification, and Security Suite
5
5
  Author: Developer
6
6
  License-Expression: MIT
7
7
  Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: ruff>=0.1.0
10
- Requires-Dist: rich>=13.0.0
11
- Requires-Dist: tomli>=1.1.0; python_version < "3.11"
12
10
  Provides-Extra: dev
13
11
  Requires-Dist: pytest>=8.0.0; extra == "dev"
14
12
  Requires-Dist: black>=24.0.0; extra == "dev"
@@ -1,14 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ballpython
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: The Ultimate Static Python Intelligence, Healing, Type Verification, and Security Suite
5
5
  Author: Developer
6
6
  License-Expression: MIT
7
7
  Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: ruff>=0.1.0
10
- Requires-Dist: rich>=13.0.0
11
- Requires-Dist: tomli>=1.1.0; python_version < "3.11"
12
10
  Provides-Extra: dev
13
11
  Requires-Dist: pytest>=8.0.0; extra == "dev"
14
12
  Requires-Dist: black>=24.0.0; extra == "dev"
@@ -36,6 +36,7 @@ pycleaner/verifier.py
36
36
  pycleaner/frameworks/__init__.py
37
37
  pycleaner/frameworks/plugins.py
38
38
  tests/test_baseline_and_explain.py
39
+ tests/test_c_to_a_overhaul.py
39
40
  tests/test_cache.py
40
41
  tests/test_cli.py
41
42
  tests/test_cli_prove.py
@@ -1,8 +1,4 @@
1
1
  ruff>=0.1.0
2
- rich>=13.0.0
3
-
4
- [:python_version < "3.11"]
5
- tomli>=1.1.0
6
2
 
7
3
  [dev]
8
4
  pytest>=8.0.0
@@ -18,7 +18,7 @@ from pycleaner.discovery import (
18
18
  is_protected_file,
19
19
  )
20
20
 
21
- __version__ = "2.0.5"
21
+ __version__ = "2.1.1"
22
22
  __all__ = [
23
23
  "DEFAULT_IGNORED_DIRS",
24
24
  "PROTECTED_FILE_PATTERNS",
@@ -555,7 +555,12 @@ def _create_printer(console: Any) -> Any:
555
555
  else:
556
556
  import re
557
557
 
558
- cleaned = re.sub(r"\[/?[a-z ]*\]", "", msg)
558
+ cleaned = re.sub(
559
+ r"(?<!\\)\[/?(?:bold|dim|italic|underline|blink|reverse|strike|black|red|green|yellow|blue|magenta|cyan|white)(?: [a-z]+)*\]",
560
+ "",
561
+ msg,
562
+ )
563
+ cleaned = cleaned.replace("\\[", "[").replace("\\]", "]")
559
564
  print(cleaned)
560
565
 
561
566
  return print_msg
@@ -684,8 +689,13 @@ def main(argv: Sequence[str] | None = None) -> int:
684
689
  @dataclass
685
690
  class _FixBatchState:
686
691
  changed_count: int = 0
692
+ would_change_count: int = 0
693
+ actually_written_count: int = 0
694
+ partially_updated_count: int = 0
695
+ partially_would_change_count: int = 0
687
696
  error_count: int = 0
688
697
  diagnostics: list[dict[str, str]] = field(default_factory=list)
698
+ security_findings: list[Any] = field(default_factory=list)
689
699
  proven_count: int = 0
690
700
  suggested_count: int = 0
691
701
  refused_count: int = 0
@@ -729,6 +739,12 @@ def _report_file_modifications(
729
739
  print_msg(" - Lint: fixed errors and pruned unused imports", style="blue")
730
740
  if result.format_changed:
731
741
  print_msg(" - Format: applied PEP 8 formatting", style="blue")
742
+ for sf in getattr(result, "security_findings", []):
743
+ if getattr(sf, "severity", None) in ("CRITICAL", "HIGH"):
744
+ print_msg(
745
+ f" - Unresolved Security [{sf.severity}]: {sf.message} (line {sf.lineno})",
746
+ style="bold red",
747
+ )
732
748
 
733
749
 
734
750
  def _accumulate_result(
@@ -741,6 +757,8 @@ def _accumulate_result(
741
757
  print_msg, console, is_json = io_ctx
742
758
  if result.diagnostics:
743
759
  state.diagnostics.extend(result.diagnostics)
760
+ if getattr(result, "security_findings", None):
761
+ state.security_findings.extend(result.security_findings)
744
762
 
745
763
  if result.proof_receipts:
746
764
  state.proof_receipts.extend(result.proof_receipts)
@@ -766,12 +784,63 @@ def _accumulate_result(
766
784
 
767
785
  if result.error:
768
786
  state.error_count += 1
787
+ is_partial = (result.changed or bool(result.syntax_repairs)) and (
788
+ result.verification_tier != VerificationTier.TIER_C_REFUSED
789
+ )
790
+ if is_partial:
791
+ state.changed_count += 1
792
+ if opts.apply_changes:
793
+ state.partially_updated_count += 1
794
+ else:
795
+ state.partially_would_change_count += 1
796
+
797
+ if not is_json and not opts.in_progress:
798
+ action = (
799
+ "Partially cleaned"
800
+ if opts.apply_changes
801
+ else "Would partially modify"
802
+ )
803
+ print_msg(f"[yellow]{action}:[/yellow] {py_file.name}")
804
+ for repair in result.syntax_repairs:
805
+ print_msg(f" - Syntax: {repair}", style="cyan")
806
+ for mod in result.modernize_transforms:
807
+ print_msg(f" - Modernize: {mod}", style="green")
808
+ for dc in result.dead_code_pruned:
809
+ print_msg(f" - Dead-code: {dc}", style="yellow")
810
+ for imp in result.resolved_imports:
811
+ print_msg(f" - Import: {imp}", style="magenta")
812
+ if result.lint_changed:
813
+ print_msg(
814
+ " - Lint: fixed errors and pruned unused imports", style="blue"
815
+ )
816
+ if result.format_changed:
817
+ print_msg(" - Format: applied PEP 8 formatting", style="blue")
818
+ for sf in getattr(result, "security_findings", []):
819
+ if getattr(sf, "severity", None) in ("CRITICAL", "HIGH"):
820
+ print_msg(
821
+ f" - Unresolved Security [{sf.severity}]: {sf.message} (line {sf.lineno})",
822
+ style="bold red",
823
+ )
824
+ if opts.show_diff and result.diff:
825
+ _render_fix_diff(result.diff, console)
826
+ else:
827
+ if not is_json and not opts.in_progress:
828
+ for sf in getattr(result, "security_findings", []):
829
+ if getattr(sf, "severity", None) in ("CRITICAL", "HIGH"):
830
+ print_msg(
831
+ f" - Unresolved Security [{sf.severity}]: {sf.message} (line {sf.lineno})",
832
+ style="bold red",
833
+ )
834
+
769
835
  if not is_json:
770
836
  print_msg(f"[red]ERROR in {py_file.name}:[/red] {result.error}")
771
837
  return
772
838
 
773
839
  if result.changed:
774
840
  state.changed_count += 1
841
+ state.would_change_count += 1
842
+ if opts.apply_changes and not result.error:
843
+ state.actually_written_count += 1
775
844
  if not is_json and not opts.in_progress:
776
845
  _report_file_modifications(result, py_file, opts.apply_changes, print_msg)
777
846
  if opts.show_diff and result.diff:
@@ -832,7 +901,9 @@ def _run_progress_fix(
832
901
  backup=opts.backup,
833
902
  in_progress=True,
834
903
  )
835
- _print_msg, console, _is_json = io_ctx
904
+ print_msg, console, is_json = io_ctx
905
+ modified_items: list[tuple[CleanResult, Path]] = []
906
+
836
907
  with Progress(
837
908
  SpinnerColumn(),
838
909
  TextColumn("[progress.description]{task.description}"),
@@ -847,8 +918,18 @@ def _run_progress_fix(
847
918
  py_file, apply_changes=opts.apply_changes, backup=opts.backup
848
919
  )
849
920
  _accumulate_result(result, py_file, state, prog_opts, io_ctx)
921
+ if result.changed or result.error or result.syntax_repairs:
922
+ modified_items.append((result, py_file))
850
923
  progress.advance(task)
851
924
 
925
+ if not is_json and modified_items:
926
+ print_msg("")
927
+ for res, py_file in modified_items:
928
+ if not res.error and res.changed:
929
+ _report_file_modifications(res, py_file, opts.apply_changes, print_msg)
930
+ if opts.show_diff and res.diff:
931
+ _render_fix_diff(res.diff, console)
932
+
852
933
 
853
934
  def _execute_fix_batch(
854
935
  pipeline: CleanPipeline,
@@ -952,8 +1033,14 @@ def _compute_fix_exit_code(
952
1033
  return 1
953
1034
  if is_prove_cmd:
954
1035
  return 0 if state.error_count == 0 else 1
1036
+ has_unresolved_security = any(
1037
+ getattr(sf, "severity", None) in ("CRITICAL", "HIGH")
1038
+ for sf in state.security_findings
1039
+ )
1040
+ if has_unresolved_security:
1041
+ return 1
955
1042
  if not apply_changes and (
956
- state.changed_count > 0
1043
+ state.would_change_count > 0
957
1044
  or state.error_count > 0
958
1045
  or bool(audit_report.missing_packages)
959
1046
  ):
@@ -1012,7 +1099,11 @@ def _cmd_fix(
1012
1099
 
1013
1100
  if is_json:
1014
1101
  print(json.dumps(state.diagnostics, indent=2))
1015
- return 0 if state.error_count == 0 else 1
1102
+ has_sec_issues = any(
1103
+ getattr(sf, "severity", None) in ("CRITICAL", "HIGH")
1104
+ for sf in state.security_findings
1105
+ )
1106
+ return 1 if (state.error_count > 0 or has_sec_issues) else 0
1016
1107
 
1017
1108
  audit_report = _run_fix_audit(
1018
1109
  root_dir,
@@ -1027,10 +1118,64 @@ def _cmd_fix(
1027
1118
  or getattr(audit_report, "fixed_pyproject", False)
1028
1119
  ):
1029
1120
  state.changed_count += 1
1121
+ state.would_change_count += 1
1122
+ state.actually_written_count += 1
1123
+
1124
+ if not opts.apply_changes:
1125
+ if state.partially_would_change_count > 0:
1126
+ err_label = (
1127
+ "1 remaining error"
1128
+ if state.error_count == 1
1129
+ else f"{state.error_count} remaining errors"
1130
+ )
1131
+ if state.would_change_count > 0:
1132
+ print_msg(
1133
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.would_change_count} would change, {state.partially_would_change_count} would partially change, 0 written, {err_label}.[/bold]"
1134
+ )
1135
+ else:
1136
+ print_msg(
1137
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.partially_would_change_count} would partially change, 0 written, {err_label}.[/bold]"
1138
+ )
1139
+ else:
1140
+ print_msg(
1141
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.would_change_count} would change, 0 written, {state.error_count} errors.[/bold]"
1142
+ )
1143
+ else:
1144
+ if state.partially_updated_count > 0:
1145
+ err_label = (
1146
+ "1 remaining error"
1147
+ if state.error_count == 1
1148
+ else f"{state.error_count} remaining errors"
1149
+ )
1150
+ if state.actually_written_count > 0:
1151
+ print_msg(
1152
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.actually_written_count} updated, {state.partially_updated_count} partially updated, {err_label}.[/bold]"
1153
+ )
1154
+ else:
1155
+ print_msg(
1156
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.partially_updated_count} partially updated, {err_label}.[/bold]"
1157
+ )
1158
+ else:
1159
+ print_msg(
1160
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.actually_written_count} updated, {state.error_count} errors.[/bold]"
1161
+ )
1030
1162
 
1031
- print_msg(
1032
- f"\n[bold]Summary: {len(py_files)} inspected, {state.changed_count} updated, {state.error_count} errors.[/bold]"
1033
- )
1163
+ critical_or_high_findings = [
1164
+ sf
1165
+ for sf in state.security_findings
1166
+ if getattr(sf, "severity", None) in ("CRITICAL", "HIGH")
1167
+ ]
1168
+ if critical_or_high_findings and not is_json:
1169
+ print_msg(
1170
+ f"\n[bold red]Unresolved Security Findings ({len(critical_or_high_findings)} issue(s)):[/bold red]"
1171
+ )
1172
+ for sf in critical_or_high_findings:
1173
+ f_path = getattr(sf, "filepath", getattr(sf, "file", "<unknown>"))
1174
+ print_msg(
1175
+ f" [bold red]- [{sf.severity}] {f_path}:{sf.lineno}: {sf.message}[/bold red]"
1176
+ )
1177
+ if getattr(sf, "suggestion", None):
1178
+ print_msg(f" [dim]Suggestion: {sf.suggestion}[/dim]")
1034
1179
 
1035
1180
  if pipeline.verify_proofs:
1036
1181
  total_proven_callables = sum(
@@ -1187,7 +1332,7 @@ def _cmd_baseline(
1187
1332
  fingerprints: list[BaselineFingerprint] = []
1188
1333
 
1189
1334
  detector = DeadCodeDetector()
1190
- dead_code_report = detector.scan_project(root_dir)
1335
+ dead_code_report = detector.scan_files(py_files)
1191
1336
  for item in dead_code_report.items:
1192
1337
  if item.kind == "unused-import":
1193
1338
  rule = "DC002"
@@ -1206,7 +1351,7 @@ def _cmd_baseline(
1206
1351
  )
1207
1352
 
1208
1353
  scanner = SecurityScanner()
1209
- sec_report = scanner.scan_project(root_dir)
1354
+ sec_report = scanner.scan_files(py_files)
1210
1355
  for finding in sec_report.findings:
1211
1356
  fingerprints.append(
1212
1357
  BaselineManager.create_fingerprint(
@@ -1219,7 +1364,7 @@ def _cmd_baseline(
1219
1364
  )
1220
1365
 
1221
1366
  analyzer = ComplexityAnalyzer()
1222
- comp_report = analyzer.analyze_project(root_dir)
1367
+ comp_report = analyzer.analyze_files(py_files)
1223
1368
  thresholds = (
1224
1369
  getattr(args, "max_cyclomatic", config.max_cyclomatic_complexity),
1225
1370
  getattr(args, "max_cognitive", config.max_cognitive_complexity),
@@ -1373,9 +1518,10 @@ def _cmd_cache(
1373
1518
  console: Any,
1374
1519
  ) -> int:
1375
1520
  """Manage and inspect the content-addressable verification and AST cache."""
1376
- db_path = getattr(args, "db", None) or getattr(
1521
+ raw_db_path = getattr(args, "db", None) or getattr(
1377
1522
  config, "cache_db_path", ".pycleaner/cache.db"
1378
1523
  )
1524
+ db_path: str = str(raw_db_path) if raw_db_path else ".pycleaner/cache.db"
1379
1525
  cache = ContentAddressableCache(db_path=db_path)
1380
1526
 
1381
1527
  if getattr(args, "clear", False):
@@ -1445,7 +1591,18 @@ def _render_audit_cli_output(
1445
1591
 
1446
1592
  def _cmd_audit(args: argparse.Namespace, config: PyCleanerConfig, print_msg) -> int:
1447
1593
  """Dependency audit command."""
1448
- root_dir = find_project_root(args.paths[0] if args.paths else None)
1594
+ targets = getattr(args, "paths", ["."])
1595
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1596
+ root_dir = find_project_root(target_path)
1597
+ explicit_files = (
1598
+ [
1599
+ Path(p).resolve()
1600
+ for p in targets
1601
+ if Path(p).is_file() and Path(p).suffix == ".py"
1602
+ ]
1603
+ if targets
1604
+ else None
1605
+ )
1449
1606
 
1450
1607
  is_check = getattr(args, "check", False)
1451
1608
  fix_flag = getattr(args, "fix", False) or getattr(args, "fix_deps", False)
@@ -1454,6 +1611,7 @@ def _cmd_audit(args: argparse.Namespace, config: PyCleanerConfig, print_msg) ->
1454
1611
  audit_report = auditor.audit(
1455
1612
  fix=fix_flag and not is_check,
1456
1613
  prune_unused=prune_flag and not is_check,
1614
+ target_files=explicit_files or None,
1457
1615
  )
1458
1616
 
1459
1617
  if getattr(args, "json", False):
@@ -1475,7 +1633,7 @@ def _render_scan_json(report: Any) -> int:
1475
1633
  for f in report.findings
1476
1634
  ]
1477
1635
  print(json.dumps(findings, indent=2))
1478
- return 1 if report.critical_count > 0 else 0
1636
+ return 1 if (report.critical_count > 0 or report.high_count > 0) else 0
1479
1637
 
1480
1638
 
1481
1639
  def _render_scan_table(report: Any, console: Any, target_base: str) -> None:
@@ -1526,23 +1684,24 @@ def _render_scan_cli_summary(
1526
1684
  f"\n Total: {report.count} finding(s) - "
1527
1685
  f"[red]{report.critical_count} critical[/red], [red]{report.high_count} high[/red]"
1528
1686
  )
1529
- return 1 if report.critical_count > 0 else 0
1687
+ return 1 if (report.critical_count > 0 or report.high_count > 0) else 0
1530
1688
 
1531
1689
 
1532
1690
  def _cmd_scan(
1533
1691
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1534
1692
  ) -> int:
1535
1693
  """Security scan command."""
1536
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1537
- if not root_dir.is_dir():
1538
- root_dir = root_dir.parent
1694
+ targets = getattr(args, "paths", ["."])
1695
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1696
+ root_dir = find_project_root(target_path)
1697
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1539
1698
 
1540
1699
  severity = getattr(args, "severity", config.security_severity_threshold)
1541
1700
  scanner = SecurityScanner(
1542
1701
  severity_threshold=severity,
1543
1702
  ignore_rules=set(config.ignore_security_rules),
1544
1703
  )
1545
- report = scanner.scan_project(root_dir, exclude_patterns=config.exclude)
1704
+ report = scanner.scan_files(py_files)
1546
1705
 
1547
1706
  target_base = args.paths[0] if args.paths else "."
1548
1707
  if getattr(args, "json", False):
@@ -1636,12 +1795,13 @@ def _cmd_complexity(
1636
1795
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1637
1796
  ) -> int:
1638
1797
  """Complexity analysis command."""
1639
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1640
- if not root_dir.is_dir():
1641
- root_dir = root_dir.parent
1798
+ targets = getattr(args, "paths", ["."])
1799
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1800
+ root_dir = find_project_root(target_path)
1801
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1642
1802
 
1643
1803
  analyzer = ComplexityAnalyzer()
1644
- report = analyzer.analyze_project(root_dir, exclude_patterns=config.exclude)
1804
+ report = analyzer.analyze_files(py_files)
1645
1805
 
1646
1806
  thresholds = (
1647
1807
  getattr(args, "max_cyclomatic", config.max_cyclomatic_complexity),
@@ -1711,17 +1871,20 @@ def _cmd_dead_code(
1711
1871
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1712
1872
  ) -> int:
1713
1873
  """Dead code detection command."""
1714
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1715
- if not root_dir.is_dir():
1716
- root_dir = root_dir.parent
1874
+ targets = getattr(args, "paths", ["."])
1875
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1876
+ root_dir = find_project_root(target_path)
1877
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1717
1878
 
1718
1879
  detector = DeadCodeDetector(
1719
1880
  ignore_decorators=set(config.ignore_decorators),
1720
1881
  ignore_names=set(config.ignore_names),
1721
1882
  )
1722
1883
  if getattr(args, "fix", False):
1723
- print_msg(f"[bold green]Pruning dead code across {root_dir}...[/bold green]")
1724
- fix_results = detector.fix_project(root_dir, exclude_patterns=config.exclude)
1884
+ print_msg(
1885
+ f"[bold green]Pruning dead code across {len(py_files)} file(s)...[/bold green]"
1886
+ )
1887
+ fix_results = detector.fix_files(py_files)
1725
1888
  total_pruned = sum(len(res.pruned_items) for res in fix_results.values())
1726
1889
  print_msg(
1727
1890
  f"[green]Successfully fixed {len(fix_results)} file(s), pruned {total_pruned} dead code item(s).[/green]"
@@ -1734,7 +1897,7 @@ def _cmd_dead_code(
1734
1897
  print_msg(f" [cyan]{rel_p}[/cyan]: {len(res.pruned_items)} pruned")
1735
1898
  return 0
1736
1899
 
1737
- report = detector.scan_project(root_dir, exclude_patterns=config.exclude)
1900
+ report = detector.scan_files(py_files)
1738
1901
 
1739
1902
  if getattr(args, "json", False):
1740
1903
  return _render_dead_code_json(report.items)
@@ -1814,12 +1977,13 @@ def _cmd_types(
1814
1977
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1815
1978
  ) -> int:
1816
1979
  """Bidirectional type checking and inference with Typeshed stubs."""
1817
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1818
- if not root_dir.is_dir():
1819
- root_dir = root_dir.parent
1980
+ targets = getattr(args, "paths", ["."])
1981
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1982
+ root_dir = find_project_root(target_path)
1983
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1820
1984
 
1821
1985
  checker = TypeChecker(strict=config.strict_types)
1822
- report = checker.check_project(root_dir, exclude_patterns=config.exclude)
1986
+ report = checker.check_files(py_files)
1823
1987
 
1824
1988
  target_base = args.paths[0] if args.paths else "."
1825
1989
  if getattr(args, "json", False):
@@ -1908,12 +2072,13 @@ def _cmd_taint(
1908
2072
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1909
2073
  ) -> int:
1910
2074
  """Interprocedural SAST dataflow and taint vulnerability analysis."""
1911
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1912
- if not root_dir.is_dir():
1913
- root_dir = root_dir.parent
2075
+ targets = getattr(args, "paths", ["."])
2076
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
2077
+ root_dir = find_project_root(target_path)
2078
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1914
2079
 
1915
2080
  engine = TaintEngine()
1916
- report = engine.scan_path(root_dir, exclude_patterns=config.exclude)
2081
+ report = engine.scan_files(py_files)
1917
2082
 
1918
2083
  target_base = args.paths[0] if args.paths else "."
1919
2084
  if getattr(args, "json", False):
@@ -2118,6 +2283,7 @@ def _cmd_watch(
2118
2283
  except KeyboardInterrupt:
2119
2284
  print_msg("\n[bold]Watch mode stopped.[/bold]")
2120
2285
  return 0
2286
+ return 0
2121
2287
 
2122
2288
 
2123
2289
  def _cmd_hook(args: argparse.Namespace, print_msg) -> int:
@@ -354,17 +354,12 @@ class ComplexityAnalyzer:
354
354
  except OSError:
355
355
  return None
356
356
 
357
- def analyze_project(
358
- self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
359
- ) -> ComplexityReport:
360
- """Analyze complexity across all Python files in a project."""
361
- root = Path(root_dir).resolve()
357
+ def analyze_files(self, target_files: Sequence[Path]) -> ComplexityReport:
358
+ """Analyze complexity across a specific sequence of Python files without walking directories."""
362
359
  all_functions: list[ComplexityMetrics] = []
363
360
  files_scanned = 0
364
361
 
365
- for fpath in collect_project_python_files(
366
- root, exclude_patterns=exclude_patterns
367
- ):
362
+ for fpath in target_files:
368
363
  try:
369
364
  content = fpath.read_text(encoding="utf-8", errors="replace")
370
365
  metrics = self.analyze_source(content, filename=str(fpath)).functions
@@ -376,6 +371,14 @@ class ComplexityAnalyzer:
376
371
  all_functions.sort(key=lambda f: f.cyclomatic, reverse=True)
377
372
  return ComplexityReport(functions=all_functions, files_scanned=files_scanned)
378
373
 
374
+ def analyze_project(
375
+ self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
376
+ ) -> ComplexityReport:
377
+ """Analyze complexity across all Python files in a project."""
378
+ root = Path(root_dir).resolve()
379
+ py_files = collect_project_python_files(root, exclude_patterns=exclude_patterns)
380
+ return self.analyze_files(py_files)
381
+
379
382
  @staticmethod
380
383
  def _count_function_args(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
381
384
  all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs