ballpython 2.0.5__py3-none-any.whl → 2.0.7__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 CHANGED
@@ -2,8 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import Any
6
+
5
7
  import pycleaner
6
- from pycleaner import *
8
+ from pycleaner import __all__ as __all__
9
+ from pycleaner import __version__ as __version__
10
+
7
11
 
8
- __version__ = pycleaner.__version__
9
- __all__ = pycleaner.__all__
12
+ def __getattr__(name: str) -> Any:
13
+ return getattr(pycleaner, name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ballpython
3
- Version: 2.0.5
3
+ Version: 2.0.7
4
4
  Summary: The Ultimate Static Python Intelligence, Healing, Type Verification, and Security Suite
5
5
  Author: Developer
6
6
  License-Expression: MIT
@@ -0,0 +1,34 @@
1
+ ballpython/__init__.py,sha256=GSB-LHdou1Un6DS6Zmh8tv_zrwh6HQHO3Py2z8GO39o,304
2
+ ballpython/__main__.py,sha256=aCRr0vnZfKYHDrPHBsYSjX5xTxtFJpTTSPVUSZTBK-I,136
3
+ ballpython/cli.py,sha256=LllCLRAQQ-fRR_lIXwmd2e3Q7DLCIvf-5jHBXsAhJWs,125
4
+ ballpython/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ pycleaner/__init__.py,sha256=VzmbjUip6i0wpKh-s6XBwgBkIeUgI1duGyKDIiv7Pls,3042
6
+ pycleaner/__main__.py,sha256=8QBFrPyxId563F6cipPMNVEBTG8TIZUgxQfp8gk1VSI,135
7
+ pycleaner/baseline.py,sha256=ljED3x3akie0SB3fzhVno_1UHlI2id-9vta0NIGC0mw,3857
8
+ pycleaner/cache.py,sha256=WhCiMkuL6ryWVr3l3XrYLLoI7A3bRF8jNxpOyedNW8M,10761
9
+ pycleaner/cli.py,sha256=ELGV5hzerbER4uSNmioKCRDpMUYWd6HjJnwVgUyjfE0,72953
10
+ pycleaner/complexity_analyzer.py,sha256=j2ktzgR-isWE4xWMe56N3JFLLuYCi-S84BIlRBe0v-I,14975
11
+ pycleaner/config.py,sha256=XhloyaY_9pdwbQuc3QaAPCerLeXz-i1da4xT50h-pjY,8514
12
+ pycleaner/dead_code_detector.py,sha256=4JhZujswvPIcYbF_is3_umTGf6q5Iu6iDWbmRQsDy7s,26984
13
+ pycleaner/dependency_auditor.py,sha256=CsNMboUdB5Ug-xaeajAykMBCpUzCXQDCCbwwVjt5awk,18456
14
+ pycleaner/discovery.py,sha256=dt3OmHg886hy_Z0p80ccMyDkITQxz4biaJY_zB3MU_g,6151
15
+ pycleaner/explanations.py,sha256=2OOU_l3YL5o_Br_6fP6MF8HlY75-1JRmw3eTYT2lu8M,14219
16
+ pycleaner/import_resolver.py,sha256=Wgrch335AQVsnDv5-7F0l4Zm2oQPkNebPEgTRa7ayAg,32855
17
+ pycleaner/linter_formatter.py,sha256=gGl4sLTC5CcuTVHC2QkrkqGaqS_HJ9uUa1TFS5qkmoU,21001
18
+ pycleaner/modernizer.py,sha256=WpOLNYPDxKCAe3fj5tF4iHliR-8nzvjHU79SVZYWepk,16797
19
+ pycleaner/pipeline.py,sha256=ZG7_9PNz0jBadFrZoiI7juLtUJXglGQhLgQhrLJqEac,20612
20
+ pycleaner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ pycleaner/security_scanner.py,sha256=ik485wW_aDbjV2X7HVPQy8uArkj406Jnn_Me78Og_n8,23444
22
+ pycleaner/syntax_healer.py,sha256=RLuUjxMLqgHYzllX_juriJxIgaQv8e0cQIryVvzZeOE,27325
23
+ pycleaner/taint_engine.py,sha256=-bBq38YoQgbO2FHCDxDctZpXWQTqwuS56CEWFNOY000,26870
24
+ pycleaner/test_generator.py,sha256=MM3t0Dx5lyJB5s144IKXq2tXrE8ohKcSvicPR3c9MlU,19680
25
+ pycleaner/type_checker.py,sha256=zkUqi-BNLUzJT0tIECNh7_7GP29Y7_6fMXhVuguwMKg,37881
26
+ pycleaner/typeshed_resolver.py,sha256=N-kWU_CI5DG59lQMkt7wMW9sHYjNCBdfFS42b4y2Kr4,12742
27
+ pycleaner/verifier.py,sha256=IEopcBfPLQXWOJxKV297150lpXBd3uGe7iv6CODJr6k,19305
28
+ pycleaner/frameworks/__init__.py,sha256=SOuSRl39af86eoQx7Qm8rTJGD4jsnEvgyhhQ6cVmRVA,3462
29
+ pycleaner/frameworks/plugins.py,sha256=qunMzS3q7xp1GEGxqkuhrnm-raiSRhYQMzmDxsEhSUg,11452
30
+ ballpython-2.0.7.dist-info/METADATA,sha256=RNLcZfpGvUsfStSio5seuuMfq9p3YGWn86Z0IstIk6o,3466
31
+ ballpython-2.0.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
+ ballpython-2.0.7.dist-info/entry_points.txt,sha256=6ux-5UxoVSmlK5p9M-GDVt24sCdwTQq9wgRDkpMIYU8,81
33
+ ballpython-2.0.7.dist-info/top_level.txt,sha256=u6lmy8RWZ_saxCZAQTL7EijB4sunegOiVyk5fHjpwLk,21
34
+ ballpython-2.0.7.dist-info/RECORD,,
pycleaner/__init__.py CHANGED
@@ -20,6 +20,8 @@ from pycleaner.discovery import (
20
20
 
21
21
  __version__ = "2.0.5"
22
22
  __all__ = [
23
+ "DEFAULT_IGNORED_DIRS",
24
+ "PROTECTED_FILE_PATTERNS",
23
25
  "BaselineFingerprint",
24
26
  "BaselineManager",
25
27
  "CleanPipeline",
@@ -55,7 +57,10 @@ __all__ = [
55
57
  "TypeReport",
56
58
  "TypeshedResolver",
57
59
  "VerificationTier",
60
+ "collect_project_python_files",
58
61
  "get_explanation",
62
+ "is_ignored_directory",
63
+ "is_protected_file",
59
64
  "list_rules",
60
65
  "load_config",
61
66
  ]
pycleaner/baseline.py CHANGED
@@ -54,7 +54,7 @@ class BaselineManager:
54
54
  data = json.loads(self.baseline_path.read_text(encoding="utf-8"))
55
55
  fingerprints = data.get("fingerprints", [])
56
56
  return {
57
- fp.get("hash")
57
+ str(fp["hash"])
58
58
  for fp in fingerprints
59
59
  if isinstance(fp, dict) and fp.get("hash")
60
60
  }
pycleaner/cli.py CHANGED
@@ -40,7 +40,6 @@ from pycleaner.verifier import CounterExample, ProofReceipt, VerificationTier
40
40
 
41
41
  try:
42
42
  from rich.console import Console
43
- from rich.panel import Panel
44
43
  from rich.progress import (
45
44
  BarColumn,
46
45
  Progress,
@@ -714,17 +713,17 @@ def _report_file_modifications(
714
713
  action = "Cleaned" if apply_changes else "Would modify"
715
714
  print_msg(f"[green]{action}:[/green] {py_file.name}")
716
715
  for repair in result.syntax_repairs:
717
- print_msg(f" Syntax: {repair}", style="cyan")
716
+ print_msg(f" - Syntax: {repair}", style="cyan")
718
717
  for mod in result.modernize_transforms:
719
- print_msg(f" Modernize: {mod}", style="green")
718
+ print_msg(f" - Modernize: {mod}", style="green")
720
719
  for dc in result.dead_code_pruned:
721
- print_msg(f" Dead-code: {dc}", style="yellow")
720
+ print_msg(f" - Dead-code: {dc}", style="yellow")
722
721
  for imp in result.resolved_imports:
723
- print_msg(f" Import: {imp}", style="magenta")
722
+ print_msg(f" - Import: {imp}", style="magenta")
724
723
  if result.lint_changed:
725
- print_msg(" Lint: fixed errors and pruned unused imports", style="blue")
724
+ print_msg(" - Lint: fixed errors and pruned unused imports", style="blue")
726
725
  if result.format_changed:
727
- print_msg(" Format: applied PEP 8 formatting", style="blue")
726
+ print_msg(" - Format: applied PEP 8 formatting", style="blue")
728
727
 
729
728
 
730
729
  def _accumulate_result(
@@ -750,11 +749,11 @@ def _accumulate_result(
750
749
  elif result.verification_tier == VerificationTier.TIER_C_REFUSED:
751
750
  if not is_json:
752
751
  print_msg(
753
- f"[bold red]Refused (Tier C):[/bold red] {py_file.name} transformation falsified by differential fuzzing; rolled back!"
752
+ f"[bold red]Refused (Tier C):[/bold red] {py_file.name} - transformation falsified by differential fuzzing; rolled back!"
754
753
  )
755
754
  for ce in result.refused_changes:
756
755
  print_msg(
757
- f" {ce.callable_name} diverged on args={ce.arguments} kwargs={ce.keyword_arguments}"
756
+ f" - {ce.callable_name} diverged on args={ce.arguments} kwargs={ce.keyword_arguments}"
758
757
  )
759
758
  print_msg(
760
759
  f" original={ce.original_result or ce.original_error} vs transformed={ce.transformed_result or ce.transformed_error} (seed {ce.seed})"
@@ -1022,15 +1021,15 @@ def _cmd_fix(
1022
1021
  if not is_json:
1023
1022
  print_msg("\n[bold cyan]Verification Receipts (Trust Ladder):[/bold cyan]")
1024
1023
  print_msg(
1025
- f" [bold green] Proven (Tier A):[/bold green] {total_proven_callables} callable(s) invariant-preserving across {pipeline.proof_iterations} input(s)"
1024
+ f" [bold green]- Proven (Tier A):[/bold green] {total_proven_callables} callable(s) invariant-preserving across {pipeline.proof_iterations} input(s)"
1026
1025
  )
1027
1026
  if state.suggested_count > 0:
1028
1027
  print_msg(
1029
- f" [yellow] Suggested (Tier B):[/yellow] {state.suggested_count} module(s) (not isolated for dynamic fuzzing)"
1028
+ f" [yellow]- Suggested (Tier B):[/yellow] {state.suggested_count} module(s) (not isolated for dynamic fuzzing)"
1030
1029
  )
1031
1030
  if total_refused_callables > 0:
1032
1031
  print_msg(
1033
- f" [bold red] Refused (Tier C):[/bold red] {total_refused_callables} transformation(s) diverged; rolled back"
1032
+ f" [bold red]- Refused (Tier C):[/bold red] {total_refused_callables} transformation(s) diverged; rolled back"
1034
1033
  )
1035
1034
 
1036
1035
  output_report_path = getattr(
@@ -1098,20 +1097,20 @@ def _cmd_fix(
1098
1097
  )
1099
1098
  if tolerated:
1100
1099
  print_msg(
1101
- f" Baseline tolerated: [yellow]{len(tolerated)}[/yellow] existing issue(s)"
1100
+ f" - Baseline tolerated: [yellow]{len(tolerated)}[/yellow] existing issue(s)"
1102
1101
  )
1103
1102
  if new_debt:
1104
1103
  print_msg(
1105
- f" [bold red]Ratchet Violation:[/bold red] {len(new_debt)} new technical debt issue(s) detected!"
1104
+ f" - [bold red]Ratchet Violation:[/bold red] {len(new_debt)} new technical debt issue(s) detected!"
1106
1105
  )
1107
1106
  for nd in new_debt:
1108
1107
  print_msg(
1109
- f" [red] [X] {nd.rule} at {nd.file}:{nd.line} ({nd.symbol})[/red]"
1108
+ f" [red]- [X] {nd.rule} at {nd.file}:{nd.line} ({nd.symbol})[/red]"
1110
1109
  )
1111
1110
  return 1
1112
1111
  else:
1113
1112
  print_msg(
1114
- " [bold green]Ratchet Passed:[/bold green] 0 new technical debt issues introduced."
1113
+ " - [bold green]Ratchet Passed:[/bold green] 0 new technical debt issues introduced."
1115
1114
  )
1116
1115
  elif new_debt:
1117
1116
  return 1
@@ -1223,9 +1222,9 @@ def _cmd_baseline(
1223
1222
  manager = BaselineManager(output_path)
1224
1223
  saved_file = manager.save_baseline(fingerprints, root_dir)
1225
1224
 
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]")
1225
+ print_msg("\n[bold green]Baseline successfully recorded![/bold green]")
1226
+ print_msg(f" - Issues snapshotted: [bold yellow]{len(fingerprints)}[/bold yellow]")
1227
+ print_msg(f" - Output file: [bold]{saved_file}[/bold]")
1229
1228
  print_msg(
1230
1229
  "\n[dim]Ratchet Guarantee: Technical debt in this repository is now locked. Run CI with:[/dim]"
1231
1230
  )
@@ -1501,7 +1500,7 @@ def _render_scan_cli_summary(
1501
1500
  print_msg(f"\n Findings by category: {cat_summary}")
1502
1501
 
1503
1502
  print_msg(
1504
- f"\n Total: {report.count} finding(s) "
1503
+ f"\n Total: {report.count} finding(s) - "
1505
1504
  f"[red]{report.critical_count} critical[/red], [red]{report.high_count} high[/red]"
1506
1505
  )
1507
1506
  return 1 if report.critical_count > 0 else 0
@@ -1604,7 +1603,7 @@ def _render_complexity_cli_output(
1604
1603
  else:
1605
1604
  for f in violations:
1606
1605
  print_msg(
1607
- f" {f.qualified_name} CC:{f.cyclomatic} Cog:{f.cognitive} Ln:{f.lines} Args:{f.args}"
1606
+ f" {f.qualified_name} - CC:{f.cyclomatic} Cog:{f.cognitive} Ln:{f.lines} Args:{f.args}"
1608
1607
  )
1609
1608
  print_msg(f"\n {len(violations)} function(s) exceed threshold(s).")
1610
1609
  return 1
@@ -1661,7 +1660,7 @@ def _render_dead_code_kind_group(
1661
1660
  rel_path = _try_relative(item.filepath, target_base)
1662
1661
  conf_tag = f" [{item.confidence}]" if item.confidence != "high" else ""
1663
1662
  print_msg(
1664
- f" L{item.lineno} {rel_path}: {item.name} {item.reason}{conf_tag}"
1663
+ f" L{item.lineno} {rel_path}: {item.name} - {item.reason}{conf_tag}"
1665
1664
  )
1666
1665
  if len(kind_items) > 20:
1667
1666
  print_msg(f" ... and {len(kind_items) - 20} more")
@@ -1781,7 +1780,7 @@ def _render_types_cli_summary(
1781
1780
  for f in report.findings:
1782
1781
  rel = _try_relative(f.filepath, target_base)
1783
1782
  print_msg(
1784
- f" [{f.severity.upper()}] {rel}:{f.lineno} {f.message} (expected {f.expected_type}, got {f.actual_type})"
1783
+ f" [{f.severity.upper()}] {rel}:{f.lineno} - {f.message} (expected {f.expected_type}, got {f.actual_type})"
1785
1784
  )
1786
1785
 
1787
1786
  print_msg(f"\n Total: {report.count} type finding(s) detected.")
@@ -318,7 +318,7 @@ class _NestingDepthCounter(ast.NodeVisitor):
318
318
  def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
319
319
  self._function_depth += 1
320
320
  if self._function_depth == 1:
321
- # Entry function being scored walk its body normally.
321
+ # Entry function being scored - walk its body normally.
322
322
  self.generic_visit(node)
323
323
  # A nested def starts counting from its own zero depth in its own
324
324
  # report entry; it must not extend the enclosing function's depth.
@@ -346,6 +346,12 @@ class _ProjectScanState:
346
346
  self.exports.update(ref_collector.all_exports)
347
347
  self.decorated.update(ref_collector.decorated_names)
348
348
 
349
+ # In __init__.py files, all module-level non-private definitions represent public package exports
350
+ if py_file.name == "__init__.py":
351
+ for name, kind, lineno, end_lineno, ctx in def_collector.definitions:
352
+ if ctx == "<module>" and not name.startswith("_"):
353
+ self.exports.add(name)
354
+
349
355
  unreachable = _UnreachableCodeDetector(filepath_str, content.splitlines())
350
356
  unreachable.visit(tree)
351
357
  self.unreachable.extend(unreachable.items)
@@ -532,7 +538,9 @@ class DeadCodeDetector:
532
538
  def _is_name_exempt(self, name: str) -> bool:
533
539
  if name in self.PROTECTED_NAMES:
534
540
  return True
535
- exempt_prefixes = ("_", "test_", "Test", "visit_")
541
+ if name == "_" or (name.startswith("__") and name.endswith("__")):
542
+ return True
543
+ exempt_prefixes = ("test_", "Test", "visit_")
536
544
  return name.startswith(exempt_prefixes) or name == "generic_visit"
537
545
 
538
546
  def _should_skip(
@@ -59,7 +59,6 @@ class DependencyAuditor:
59
59
  "Bio": "biopython",
60
60
  "OpenGL": "PyOpenGL",
61
61
  "attr": "attrs",
62
- "google": "protobuf",
63
62
  # High-frequency PyPI packages
64
63
  "nmap": "python-nmap",
65
64
  "dns": "dnspython",
@@ -77,6 +76,52 @@ class DependencyAuditor:
77
76
  "playwright": "playwright",
78
77
  }
79
78
 
79
+ def _find_venv_site_packages(self) -> Path | None:
80
+ """Locate site-packages directory inside project virtual environment."""
81
+ for venv_name in (".venv", "venv", "env"):
82
+ candidate = self.root_dir / venv_name
83
+ if not candidate.is_dir():
84
+ continue
85
+ win_site = candidate / "Lib" / "site-packages"
86
+ if win_site.is_dir():
87
+ return win_site
88
+ lib_dir = candidate / "lib"
89
+ if lib_dir.is_dir():
90
+ for py_dir in lib_dir.glob("python*"):
91
+ site = py_dir / "site-packages"
92
+ if site.is_dir():
93
+ return site
94
+ return None
95
+
96
+ def _load_distributions(self) -> dict[str, list[str]]:
97
+ """Load distribution mappings from project virtual environment or host environment."""
98
+ dists: dict[str, list[str]] = {}
99
+ try:
100
+ dists.update(importlib.metadata.packages_distributions())
101
+ except AttributeError:
102
+ pass
103
+
104
+ site_packages = self._find_venv_site_packages()
105
+ if site_packages and site_packages.is_dir():
106
+ try:
107
+ for dist_info in site_packages.glob("*.dist-info"):
108
+ dist_name = dist_info.name.split("-")[0]
109
+ top_level_file = dist_info / "top_level.txt"
110
+ if top_level_file.is_file():
111
+ try:
112
+ for top_mod in top_level_file.read_text(
113
+ encoding="utf-8", errors="ignore"
114
+ ).splitlines():
115
+ top_mod = top_mod.strip()
116
+ if top_mod:
117
+ dists.setdefault(top_mod, []).insert(0, dist_name)
118
+ except OSError:
119
+ pass
120
+ except OSError:
121
+ pass
122
+
123
+ return dists
124
+
80
125
  def __init__(
81
126
  self, root_dir: str | Path, exclude_patterns: Sequence[str] = ()
82
127
  ) -> None:
@@ -84,10 +129,7 @@ class DependencyAuditor:
84
129
  self.exclude_patterns = tuple(exclude_patterns)
85
130
  self.stdlib_names = set(sys.stdlib_module_names)
86
131
  self._last_optional_packages: set[str] = set()
87
- try:
88
- self.dist_map = importlib.metadata.packages_distributions()
89
- except AttributeError:
90
- self.dist_map = {}
132
+ self.dist_map = self._load_distributions()
91
133
 
92
134
  _KNOWN_DEV_TOOLS: ClassVar[frozenset[str]] = frozenset(
93
135
  {
@@ -127,12 +169,18 @@ class DependencyAuditor:
127
169
  for node in ast.walk(tree):
128
170
  if isinstance(node, ast.Import):
129
171
  for alias in node.names:
130
- root_pkg = alias.name.split(".")[0]
131
- imports.add(root_pkg)
172
+ parts = alias.name.split(".")
173
+ if len(parts) >= 2 and parts[0] in ("google", "azure"):
174
+ imports.add(f"{parts[0]}.{parts[1]}")
175
+ else:
176
+ imports.add(parts[0])
132
177
  elif isinstance(node, ast.ImportFrom):
133
178
  if node.level == 0 and node.module:
134
- root_pkg = node.module.split(".")[0]
135
- imports.add(root_pkg)
179
+ parts = node.module.split(".")
180
+ if len(parts) >= 2 and parts[0] in ("google", "azure"):
181
+ imports.add(f"{parts[0]}.{parts[1]}")
182
+ else:
183
+ imports.add(parts[0])
136
184
  return imports
137
185
 
138
186
  def scan_codebase_imports(
@@ -206,6 +254,13 @@ class DependencyAuditor:
206
254
 
207
255
  def module_to_distribution(self, module_name: str) -> str:
208
256
  """Map a Python import module name to its PyPI distribution package name."""
257
+ if module_name.startswith("google."):
258
+ sub = module_name.split(".")[1]
259
+ return f"google-{sub}"
260
+ if module_name.startswith("azure."):
261
+ sub = module_name.split(".")[1]
262
+ return f"azure-{sub}"
263
+
209
264
  if module_name in self._KNOWN_IMPORT_TO_DIST:
210
265
  return self._KNOWN_IMPORT_TO_DIST[module_name]
211
266
 
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import ast
15
15
  from pathlib import Path
16
- from typing import ClassVar, Protocol, runtime_checkable
16
+ from typing import Protocol, runtime_checkable
17
17
 
18
18
 
19
19
  @runtime_checkable
@@ -361,4 +361,3 @@ class PyTorchPlugin:
361
361
  tree: ast.AST,
362
362
  ) -> bool:
363
363
  return name in self._PROTECTED_METHODS
364
-
@@ -82,7 +82,10 @@ class UndefinedSymbolFinder(ast.NodeVisitor):
82
82
  def _is_defined(self, name: str) -> bool:
83
83
  if name in self.builtin_names:
84
84
  return True
85
- for scope in reversed(self.scopes):
85
+ in_function = any(kind == "function" for kind in self.scope_kinds)
86
+ for scope, kind in zip(reversed(self.scopes), reversed(self.scope_kinds)):
87
+ if in_function and kind == "class":
88
+ continue
86
89
  if name in scope:
87
90
  return True
88
91
  return False
@@ -167,6 +170,13 @@ class UndefinedSymbolFinder(ast.NodeVisitor):
167
170
  finally:
168
171
  self._in_annotation = False
169
172
 
173
+ # Defaults are evaluated in enclosing scope at definition time
174
+ for default in node.args.defaults:
175
+ self.visit(default)
176
+ for kw_default in node.args.kw_defaults:
177
+ if kw_default is not None:
178
+ self.visit(kw_default)
179
+
170
180
  self.scopes.append(set())
171
181
  self.scope_kinds.append("function")
172
182
 
@@ -180,22 +190,16 @@ class UndefinedSymbolFinder(ast.NodeVisitor):
180
190
 
181
191
  visit_AsyncFunctionDef = visit_FunctionDef
182
192
 
183
- def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
184
- if node.value:
185
- self.visit(node.value)
186
- if isinstance(node.target, ast.Name):
187
- self._current_scope().add(node.target.id)
188
- else:
189
- self.visit(node.target)
190
- self._in_annotation = True
191
- try:
192
- self.visit(node.annotation)
193
- finally:
194
- self._in_annotation = False
195
-
196
193
  def visit_Lambda(self, node: ast.Lambda) -> None:
194
+ for default in node.args.defaults:
195
+ self.visit(default)
196
+ for kw_default in node.args.kw_defaults:
197
+ if kw_default is not None:
198
+ self.visit(kw_default)
199
+
197
200
  self.scopes.append(set())
198
201
  self.scope_kinds.append("function")
202
+
199
203
  all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
200
204
  for arg in all_args:
201
205
  self._current_scope().add(arg.arg)
@@ -205,9 +209,23 @@ class UndefinedSymbolFinder(ast.NodeVisitor):
205
209
  self._current_scope().add(node.args.kwarg.arg)
206
210
 
207
211
  self.visit(node.body)
212
+
208
213
  self.scopes.pop()
209
214
  self.scope_kinds.pop()
210
215
 
216
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
217
+ if node.value:
218
+ self.visit(node.value)
219
+ if isinstance(node.target, ast.Name):
220
+ self._current_scope().add(node.target.id)
221
+ else:
222
+ self.visit(node.target)
223
+ self._in_annotation = True
224
+ try:
225
+ self.visit(node.annotation)
226
+ finally:
227
+ self._in_annotation = False
228
+
211
229
  def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
212
230
  # PEP 572: an assignment expression's target binds in the nearest
213
231
  # enclosing scope that is a function or module scope, explicitly
@@ -49,9 +49,10 @@ class _UsageCollector(ast.NodeVisitor):
49
49
 
50
50
  def visit_Constant(self, node: ast.Constant) -> None:
51
51
  if isinstance(node.value, str):
52
- # Extract identifier words from string (e.g. forward references like 'Card' or __all__ = ['Card'])
53
- for ident in re.findall(r"\b[a-zA-Z_]\w*\b", node.value):
54
- self.used_names.add(ident)
52
+ val = node.value.strip()
53
+ # Only treat single valid Python identifiers as used names (e.g. forward references like 'Card' or __all__ = ['Card'])
54
+ if re.fullmatch(r"[a-zA-Z_]\w*", val):
55
+ self.used_names.add(val)
55
56
  self.generic_visit(node)
56
57
 
57
58
 
@@ -134,8 +135,8 @@ class LinterFormatter:
134
135
  proc = subprocess.run(
135
136
  cmd, input=source.encode("utf-8"), capture_output=True, check=False
136
137
  )
137
- output = proc.stdout.decode("utf-8")
138
- stderr = proc.stderr.decode("utf-8").strip()
138
+ output = proc.stdout.decode("utf-8", errors="replace")
139
+ stderr = proc.stderr.decode("utf-8", errors="replace").strip()
139
140
  diag = [stderr] if stderr else []
140
141
  if proc.returncode in (0, 1) and output:
141
142
  return output, output != source, diag
@@ -162,7 +163,7 @@ class LinterFormatter:
162
163
  cmd, input=source.encode("utf-8"), capture_output=True, check=False
163
164
  )
164
165
  if proc.returncode == 0 and proc.stdout:
165
- output = proc.stdout.decode("utf-8")
166
+ output = proc.stdout.decode("utf-8", errors="replace")
166
167
  if output != source:
167
168
  diagnostics.append("Pruned unused imports using autoflake fallback")
168
169
  return output, True
@@ -182,7 +183,7 @@ class LinterFormatter:
182
183
  check=False,
183
184
  )
184
185
  if proc.returncode == 0 and proc.stdout:
185
- res = proc.stdout.decode("utf-8")
186
+ res = proc.stdout.decode("utf-8", errors="replace")
186
187
  return res if res != code else None
187
188
  except OSError:
188
189
  # Fall back to isort Python module or pure-Python import sorter
@@ -264,7 +265,7 @@ class LinterFormatter:
264
265
  cmd, input=source.encode("utf-8"), capture_output=True, check=False
265
266
  )
266
267
  if proc.returncode == 0:
267
- res = proc.stdout.decode("utf-8")
268
+ res = proc.stdout.decode("utf-8", errors="replace")
268
269
  return res, res != source
269
270
  except OSError:
270
271
  # Fall back to Black or pure-Python formatter if ruff CLI execution fails
@@ -281,7 +282,7 @@ class LinterFormatter:
281
282
  check=False,
282
283
  )
283
284
  if proc.returncode == 0 and proc.stdout:
284
- res = proc.stdout.decode("utf-8")
285
+ res = proc.stdout.decode("utf-8", errors="replace")
285
286
  return res, res != source, ["Formatted with black CLI fallback"]
286
287
  except OSError:
287
288
  # Fall back to black module or pure-Python formatter
pycleaner/modernizer.py CHANGED
@@ -40,15 +40,18 @@ class _AnnotationTransformer(ast.NodeTransformer):
40
40
  "Type": "type",
41
41
  }
42
42
 
43
- def __init__(self) -> None:
43
+ def __init__(self, typing_imports: set[str] | None = None) -> None:
44
44
  self.changed = False
45
45
  self.transformations: list[str] = []
46
+ self.typing_imports = typing_imports if typing_imports is not None else set()
46
47
 
47
48
  def visit_Subscript(self, node: ast.Subscript) -> ast.AST:
48
49
  self.generic_visit(node)
49
50
  name: str | None = None
50
51
  if isinstance(node.value, ast.Name):
51
- name = node.value.id
52
+ # Only rewrite bare names if they were imported from typing or if typing imports exist
53
+ if not self.typing_imports or node.value.id in self.typing_imports:
54
+ name = node.value.id
52
55
  elif (
53
56
  isinstance(node.value, ast.Attribute)
54
57
  and isinstance(node.value.value, ast.Name)
@@ -188,12 +191,60 @@ class Modernizer:
188
191
  isinstance(node, ast.Compare)
189
192
  and len(node.ops) == 1
190
193
  and len(node.comparators) == 1
194
+ and node.end_lineno is not None
195
+ and node.end_col_offset is not None
191
196
  ):
192
197
  op = node.ops[0]
193
198
  comparator = node.comparators[0]
194
- if isinstance(comparator, ast.Constant) and (
195
- comparator.value is None or isinstance(comparator.value, bool)
199
+ # Only modernize explicit None comparisons (never bool comparisons, which break vectorized/ORM masks)
200
+ if isinstance(comparator, ast.Constant) and comparator.value is None:
201
+ if isinstance(op, ast.Eq):
202
+ replacement_op = "is"
203
+ transforms.append("Modernized '== None' to 'is None'")
204
+ elif isinstance(op, ast.NotEq):
205
+ replacement_op = "is not"
206
+ transforms.append("Modernized '!= None' to 'is not None'")
207
+ else:
208
+ continue
209
+
210
+ left_unparsed = ast.unparse(node.left)
211
+ new_expr = f"{left_unparsed} {replacement_op} None"
212
+ edits.append(
213
+ (
214
+ node.lineno,
215
+ node.col_offset,
216
+ node.end_lineno,
217
+ node.end_col_offset,
218
+ new_expr,
219
+ )
220
+ )
221
+ elif isinstance(node.left, ast.Constant) and node.left.value is None:
222
+ if isinstance(op, ast.Eq):
223
+ replacement_op = "is"
224
+ transforms.append("Modernized 'None ==' to 'is None'")
225
+ elif isinstance(op, ast.NotEq):
226
+ replacement_op = "is not"
227
+ transforms.append("Modernized 'None !=' to 'is not None'")
228
+ else:
229
+ continue
230
+
231
+ comp_unparsed = ast.unparse(comparator)
232
+ new_expr = f"{comp_unparsed} {replacement_op} None"
233
+ edits.append(
234
+ (
235
+ node.lineno,
236
+ node.col_offset,
237
+ node.end_lineno,
238
+ node.end_col_offset,
239
+ new_expr,
240
+ )
241
+ )
242
+ elif (
243
+ isinstance(comparator, ast.Constant)
244
+ and isinstance(comparator.value, bool)
245
+ and isinstance(node.left, ast.Name)
196
246
  ):
247
+ # Only modernize simple scalar identifier bool comparisons (never subscripts, attributes, or calls)
197
248
  val = comparator.value
198
249
  if isinstance(op, ast.Eq):
199
250
  replacement_op = "is"
@@ -225,6 +276,12 @@ class Modernizer:
225
276
  idx = lineno - 1
226
277
  line = lines[idx]
227
278
  lines[idx] = line[:col_offset] + new_text + line[end_col_offset:]
279
+ elif lineno < end_lineno:
280
+ start_idx = lineno - 1
281
+ end_idx = end_lineno - 1
282
+ prefix = lines[start_idx][:col_offset]
283
+ suffix = lines[end_idx][end_col_offset:]
284
+ lines[start_idx : end_idx + 1] = [prefix + new_text + suffix]
228
285
 
229
286
  return "".join(lines), transforms
230
287
 
@@ -267,14 +324,27 @@ class Modernizer:
267
324
 
268
325
  lines = source.splitlines(keepends=True)
269
326
  edits: list[tuple[int, int, int, int, str]] = []
270
- transformer = _AnnotationTransformer()
327
+
328
+ typing_imports: set[str] = set()
329
+ for node in ast.walk(tree):
330
+ if isinstance(node, ast.ImportFrom) and node.module == "typing":
331
+ for alias in node.names:
332
+ if alias.name == "*":
333
+ typing_imports.update(
334
+ _AnnotationTransformer._PEP_585_MAP.keys()
335
+ )
336
+ typing_imports.update({"Optional", "Union"})
337
+ else:
338
+ typing_imports.add(alias.asname or alias.name)
339
+
340
+ transformer = _AnnotationTransformer(typing_imports=typing_imports)
271
341
 
272
342
  def process_annotation(node: ast.AST | None) -> None:
273
- if node is None:
343
+ if not isinstance(node, ast.expr):
274
344
  return
275
- if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
345
+ if node.end_lineno is None or node.end_col_offset is None:
276
346
  return
277
- sub = _AnnotationTransformer()
347
+ sub = _AnnotationTransformer(typing_imports=typing_imports)
278
348
  new_node = sub.visit(node)
279
349
  if sub.changed:
280
350
  transformer.changed = True
@@ -313,6 +383,12 @@ class Modernizer:
313
383
  idx = lineno - 1
314
384
  line = lines[idx]
315
385
  lines[idx] = line[:col_offset] + new_text + line[end_col_offset:]
386
+ elif lineno < end_lineno:
387
+ start_idx = lineno - 1
388
+ end_idx = end_lineno - 1
389
+ prefix = lines[start_idx][:col_offset]
390
+ suffix = lines[end_idx][end_col_offset:]
391
+ lines[start_idx : end_idx + 1] = [prefix + new_text + suffix]
316
392
 
317
393
  res = "".join(lines)
318
394
 
@@ -245,7 +245,9 @@ class _DangerousCallDetector(ast.NodeVisitor):
245
245
  return False
246
246
 
247
247
  def _add_finding(self, finding: SecurityFinding) -> None:
248
- if not self._is_suppressed(finding.lineno, finding.category, finding.end_lineno):
248
+ if not self._is_suppressed(
249
+ finding.lineno, finding.category, finding.end_lineno
250
+ ):
249
251
  self.findings.append(finding)
250
252
 
251
253
  def visit_Call(self, node: ast.Call) -> None:
@@ -327,7 +327,7 @@ class SyntaxHealer:
327
327
  pointer_indent = " " * max(0, offset - 1)
328
328
 
329
329
  diagnostic_lines = [
330
- f" SyntaxError in {filename}:{lineno}:{offset}",
330
+ f"[!] SyntaxError in {filename}:{lineno}:{offset}",
331
331
  f" --> {filename}:{lineno}:{offset}",
332
332
  " |",
333
333
  f"{lineno:3d} | {error_line}",
@@ -525,6 +525,7 @@ class SyntaxHealer:
525
525
 
526
526
  def _fix_missing_colons(self, code: str) -> tuple[str, int]:
527
527
  """Detect compound statement headers without trailing colons and append them."""
528
+ tokens: list[tokenize.TokenInfo] | None
528
529
  try:
529
530
  tokens = list(tokenize.generate_tokens(io.StringIO(code).readline))
530
531
  except tokenize.TokenError:
@@ -561,8 +562,8 @@ class SyntaxHealer:
561
562
  return None
562
563
 
563
564
  @staticmethod
564
- def _is_standalone_assignment(chars: Sequence[str], i: int, depth: int) -> bool:
565
- if chars[i] != "=" or depth != 0:
565
+ def _is_standalone_assignment(chars: Sequence[str], i: int, in_call: bool) -> bool:
566
+ if chars[i] != "=" or in_call:
566
567
  return False
567
568
  prev_c = chars[i - 1] if i > 0 else ""
568
569
  next_c = chars[i + 1] if i + 1 < len(chars) else ""
@@ -581,7 +582,7 @@ class SyntaxHealer:
581
582
  @classmethod
582
583
  def _replace_condition_equals(cls, condition: str) -> tuple[str, int]:
583
584
  chars = list(condition)
584
- depth = 0
585
+ call_bracket_stack: list[bool] = []
585
586
  in_s = False
586
587
  in_d = False
587
588
  escaped = False
@@ -596,12 +597,24 @@ class SyntaxHealer:
596
597
  elif in_s or in_d or c in ("'", '"'):
597
598
  in_s, in_d = cls._update_quote_flag(c, in_s, in_d)
598
599
  elif c in "([{":
599
- depth += 1
600
+ prev_non_ws = ""
601
+ for j in range(i - 1, -1, -1):
602
+ if not chars[j].isspace():
603
+ prev_non_ws = chars[j]
604
+ break
605
+ is_call_or_subscript = bool(
606
+ prev_non_ws
607
+ and (prev_non_ws.isalnum() or prev_non_ws in ("_", ")", "]"))
608
+ )
609
+ call_bracket_stack.append(is_call_or_subscript)
600
610
  elif c in ")]}":
601
- depth = max(0, depth - 1)
602
- elif cls._is_standalone_assignment(chars, i, depth):
603
- chars[i] = "=="
604
- count += 1
611
+ if call_bracket_stack:
612
+ call_bracket_stack.pop()
613
+ else:
614
+ in_call = any(call_bracket_stack)
615
+ if cls._is_standalone_assignment(chars, i, in_call):
616
+ chars[i] = "=="
617
+ count += 1
605
618
  i += 1
606
619
  return "".join(chars), count
607
620
 
pycleaner/taint_engine.py CHANGED
@@ -323,8 +323,13 @@ class TaintEngine:
323
323
  if not spec:
324
324
  return
325
325
  for arg in child.args:
326
- if isinstance(arg, ast.Name) and arg.id in param_names:
327
- summary.param_to_sink[arg.id] = (
326
+ referenced_params = [
327
+ n.id
328
+ for n in ast.walk(arg)
329
+ if isinstance(n, ast.Name) and n.id in param_names
330
+ ]
331
+ for p in referenced_params:
332
+ summary.param_to_sink[p] = (
328
333
  spec[0],
329
334
  spec[1],
330
335
  getattr(child, "lineno", default_lineno),
@@ -361,10 +366,16 @@ class TaintEngine:
361
366
  """Check if an expression immediately calls or accesses a known untrusted source."""
362
367
  if isinstance(node, ast.Call):
363
368
  call_name = self._resolve_call_name(node.func)
364
- if call_name in self.KNOWN_SOURCES or any(
365
- call_name.startswith(src) for src in self.KNOWN_SOURCES
369
+ if (
370
+ call_name in self.KNOWN_SOURCES
371
+ or any(call_name.startswith(src) for src in self.KNOWN_SOURCES)
372
+ or (
373
+ isinstance(node.func, ast.Attribute)
374
+ and node.func.attr in ("read", "readline", "readlines")
375
+ )
366
376
  ):
367
- return call_name, getattr(node, "lineno", 0)
377
+ source_name = call_name or "file.read"
378
+ return source_name, getattr(node, "lineno", 0)
368
379
  elif isinstance(node, ast.Subscript):
369
380
  # e.g., sys.argv[1], request.args['q']
370
381
  val_name = self._resolve_call_name(node.value)
@@ -54,7 +54,7 @@ class AstMutator(ast.NodeTransformer):
54
54
 
55
55
  def visit_Compare(self, node: ast.Compare) -> ast.AST:
56
56
  self.generic_visit(node)
57
- new_ops = []
57
+ new_ops: list[ast.cmpop] = []
58
58
  for op in node.ops:
59
59
  if isinstance(op, ast.Lt):
60
60
  new_ops.append(ast.GtE())
pycleaner/type_checker.py CHANGED
@@ -24,40 +24,74 @@ from pycleaner.typeshed_resolver import TypeshedResolver
24
24
  class PyType:
25
25
  """Base algebraic type representation."""
26
26
 
27
- def is_assignable_to(self, target: PyType) -> bool:
27
+ def is_assignable_to(
28
+ self,
29
+ target: PyType,
30
+ class_hierarchy: dict[str, list[str]] | None = None,
31
+ ) -> bool:
28
32
  """Check if this type can be assigned to target type."""
29
33
  if isinstance(target, AnyType) or isinstance(self, AnyType) or target == self:
30
34
  return True
35
+ if (
36
+ isinstance(self, CustomClassType)
37
+ and isinstance(target, CustomClassType)
38
+ and class_hierarchy
39
+ and self._is_subclass(self.name, target.name, class_hierarchy)
40
+ ):
41
+ return True
31
42
  if isinstance(target, UnionType):
32
- return self._assignable_to_union(target)
43
+ return self._assignable_to_union(target, class_hierarchy)
33
44
  if isinstance(self, UnionType):
34
- return self._union_assignable_to(target)
45
+ return self._union_assignable_to(target, class_hierarchy)
35
46
  if isinstance(target, CustomClassType) and self._check_custom_target(target):
36
47
  return True
37
48
  if isinstance(self, CustomClassType) and self._check_custom_source(target):
38
49
  return True
39
50
  return self._check_numeric_promotions(target)
40
51
 
41
- def _assignable_to_union(self, target: PyType) -> bool:
52
+ @staticmethod
53
+ def _is_subclass(
54
+ child: str,
55
+ parent: str,
56
+ class_hierarchy: dict[str, list[str]],
57
+ visited: set[str] | None = None,
58
+ ) -> bool:
59
+ if child == parent:
60
+ return True
61
+ if visited is None:
62
+ visited = set()
63
+ if child in visited:
64
+ return False
65
+ visited.add(child)
66
+ for base in class_hierarchy.get(child, []):
67
+ if PyType._is_subclass(base, parent, class_hierarchy, visited):
68
+ return True
69
+ return False
70
+
71
+ def _assignable_to_union(
72
+ self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
73
+ ) -> bool:
42
74
  types = getattr(target, "types", [])
43
75
  for u in types:
44
- if self.is_assignable_to(u):
76
+ if self.is_assignable_to(u, class_hierarchy):
45
77
  return True
46
78
  return False
47
79
 
48
- def _union_assignable_to(self, target: PyType) -> bool:
80
+ def _union_assignable_to(
81
+ self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
82
+ ) -> bool:
49
83
  types = getattr(self, "types", [])
50
84
  non_none = [u for u in types if not isinstance(u, NoneType)]
51
85
  if non_none:
52
86
  all_match = True
53
87
  for u in non_none:
54
- if not u.is_assignable_to(target):
88
+ if not u.is_assignable_to(target, class_hierarchy):
55
89
  all_match = False
56
90
  break
57
91
  if all_match:
58
92
  return True
59
93
  for u in types:
60
- if not u.is_assignable_to(target):
94
+ if not u.is_assignable_to(target, class_hierarchy):
61
95
  return False
62
96
  return True
63
97
 
@@ -183,10 +217,12 @@ class ListType(PyType):
183
217
  def __init__(self, item_type: PyType) -> None:
184
218
  self.item_type = item_type
185
219
 
186
- def is_assignable_to(self, target: PyType) -> bool:
220
+ def is_assignable_to(
221
+ self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
222
+ ) -> bool:
187
223
  if _is_collection_assignable(self.item_type, target):
188
224
  return True
189
- return super().is_assignable_to(target)
225
+ return super().is_assignable_to(target, class_hierarchy)
190
226
 
191
227
  def __eq__(self, other: object) -> bool:
192
228
  return isinstance(other, ListType) and self.item_type == other.item_type
@@ -199,10 +235,12 @@ class SetType(PyType):
199
235
  def __init__(self, item_type: PyType) -> None:
200
236
  self.item_type = item_type
201
237
 
202
- def is_assignable_to(self, target: PyType) -> bool:
238
+ def is_assignable_to(
239
+ self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
240
+ ) -> bool:
203
241
  if _is_collection_assignable(self.item_type, target):
204
242
  return True
205
- return super().is_assignable_to(target)
243
+ return super().is_assignable_to(target, class_hierarchy)
206
244
 
207
245
  def __eq__(self, other: object) -> bool:
208
246
  return isinstance(other, SetType) and self.item_type == other.item_type
@@ -216,20 +254,22 @@ class DictType(PyType):
216
254
  self.key_type = key_type
217
255
  self.value_type = value_type
218
256
 
219
- def is_assignable_to(self, target: PyType) -> bool:
257
+ def is_assignable_to(
258
+ self, target: PyType, class_hierarchy: dict[str, list[str]] | None = None
259
+ ) -> bool:
220
260
  if isinstance(target, AnyType):
221
261
  return True
222
262
  if isinstance(target, DictType):
223
263
  return self.key_type.is_assignable_to(
224
- target.key_type
225
- ) and self.value_type.is_assignable_to(target.value_type)
264
+ target.key_type, class_hierarchy
265
+ ) and self.value_type.is_assignable_to(target.value_type, class_hierarchy)
226
266
  if isinstance(target, CustomClassType) and target.name in (
227
267
  "dict",
228
268
  "Dict",
229
269
  "Mapping",
230
270
  ):
231
271
  return True
232
- return super().is_assignable_to(target)
272
+ return super().is_assignable_to(target, class_hierarchy)
233
273
 
234
274
  def __eq__(self, other: object) -> bool:
235
275
  return (
@@ -418,6 +458,7 @@ class _FileContext:
418
458
  local_functions: dict[str, tuple[dict[str, PyType], PyType]] = field(
419
459
  default_factory=dict
420
460
  )
461
+ class_hierarchy: dict[str, list[str]] = field(default_factory=dict)
421
462
 
422
463
 
423
464
  class TypeChecker:
@@ -440,6 +481,20 @@ class TypeChecker:
440
481
 
441
482
  return self.check_ast(tree, filepath=str(path), source=content)
442
483
 
484
+ @staticmethod
485
+ def _collect_class_hierarchy(tree: ast.Module) -> dict[str, list[str]]:
486
+ hierarchy: dict[str, list[str]] = {}
487
+ for node in ast.walk(tree):
488
+ if isinstance(node, ast.ClassDef):
489
+ bases: list[str] = []
490
+ for b in node.bases:
491
+ if isinstance(b, ast.Name):
492
+ bases.append(b.id)
493
+ elif isinstance(b, ast.Attribute):
494
+ bases.append(b.attr)
495
+ hierarchy[node.name] = bases
496
+ return hierarchy
497
+
443
498
  @staticmethod
444
499
  def _collect_local_signatures(
445
500
  tree: ast.Module,
@@ -473,6 +528,7 @@ class TypeChecker:
473
528
  filepath=filepath,
474
529
  source_lines=source.splitlines() if source else [],
475
530
  local_functions=self._collect_local_signatures(tree),
531
+ class_hierarchy=self._collect_class_hierarchy(tree),
476
532
  )
477
533
 
478
534
  for node in ast.walk(tree):
@@ -548,14 +604,16 @@ class TypeChecker:
548
604
  return []
549
605
 
550
606
  findings: list[TypeFinding] = []
607
+ has_return = False
551
608
  for child in ast.walk(func):
552
609
  if not isinstance(child, ast.Return):
553
610
  continue
554
611
  if self._is_nested_in_other_func(child, func):
555
612
  continue
556
613
 
614
+ has_return = True
557
615
  actual_type = self._infer_expr_type(child.value, scope, ctx.local_functions)
558
- if not actual_type.is_assignable_to(declared_return):
616
+ if not actual_type.is_assignable_to(declared_return, ctx.class_hierarchy):
559
617
  snippet = (
560
618
  ctx.source_lines[child.lineno - 1]
561
619
  if 0 <= child.lineno - 1 < len(ctx.source_lines)
@@ -578,6 +636,43 @@ class TypeChecker:
578
636
  )
579
637
  )
580
638
 
639
+ # Check for missing return paths if declared return is non-None/non-Any
640
+ if (
641
+ has_return
642
+ and not isinstance(declared_return, (AnyType, NoneType))
643
+ and not (
644
+ isinstance(declared_return, UnionType)
645
+ and any(
646
+ isinstance(t, NoneType)
647
+ for t in getattr(declared_return, "types", [])
648
+ )
649
+ )
650
+ ):
651
+ last_stmt = func.body[-1] if func.body else None
652
+ ends_with_terminal = isinstance(last_stmt, (ast.Return, ast.Raise))
653
+ if not ends_with_terminal:
654
+ snippet = (
655
+ ctx.source_lines[func.lineno - 1]
656
+ if 0 <= func.lineno - 1 < len(ctx.source_lines)
657
+ else ""
658
+ )
659
+ findings.append(
660
+ TypeFinding(
661
+ filepath=ctx.filepath,
662
+ lineno=func.lineno,
663
+ column=func.col_offset,
664
+ symbol=func.name,
665
+ expected_type=str(declared_return),
666
+ actual_type="None",
667
+ message=(
668
+ f"Missing return path: function '{func.name}' declared to return "
669
+ f"'{declared_return}' can fall through without returning a value"
670
+ ),
671
+ severity="ERROR",
672
+ code_snippet=snippet.strip(),
673
+ )
674
+ )
675
+
581
676
  return findings
582
677
 
583
678
  def _check_function(
@@ -613,7 +708,7 @@ class TypeChecker:
613
708
 
614
709
  actual_type = self._infer_expr_type(node.value, {}, ctx.local_functions)
615
710
  if isinstance(actual_type, AnyType) or actual_type.is_assignable_to(
616
- declared_type
711
+ declared_type, ctx.class_hierarchy
617
712
  ):
618
713
  return []
619
714
 
@@ -768,7 +863,9 @@ class TypeChecker:
768
863
  continue
769
864
 
770
865
  actual = self._infer_expr_type(arg_expr, scope, ctx.local_functions)
771
- if isinstance(actual, AnyType) or actual.is_assignable_to(expected):
866
+ if isinstance(actual, AnyType) or actual.is_assignable_to(
867
+ expected, ctx.class_hierarchy
868
+ ):
772
869
  continue
773
870
 
774
871
  findings.append(
pycleaner/verifier.py CHANGED
@@ -17,7 +17,9 @@ import enum
17
17
  import inspect
18
18
  import multiprocessing as mp
19
19
  import random
20
+ import sys
20
21
  import time
22
+ from pathlib import Path
21
23
  from typing import Any
22
24
 
23
25
 
@@ -65,25 +67,39 @@ def _worker_differential_fuzz(
65
67
  callable_name: str,
66
68
  inputs: list[tuple[tuple[Any, ...], dict[str, Any]]],
67
69
  conn: Any,
70
+ filepath: str = "<sandbox>",
68
71
  ) -> None:
69
72
  """Child process worker: executes differential equivalence loop across all synthesized inputs."""
70
73
  try:
74
+ file_path_obj = (
75
+ Path(filepath).resolve() if filepath and filepath != "<sandbox>" else None
76
+ )
77
+ package_name = ""
78
+ if file_path_obj and file_path_obj.parent.is_dir():
79
+ parent_dir = file_path_obj.parent
80
+ if (parent_dir / "__init__.py").exists():
81
+ package_name = parent_dir.name
82
+ if str(parent_dir) not in sys.path:
83
+ sys.path.insert(0, str(parent_dir))
84
+ if str(parent_dir.parent) not in sys.path:
85
+ sys.path.insert(0, str(parent_dir.parent))
86
+
71
87
  orig_ns: dict[str, Any] = {
72
88
  "__name__": "__verifier__",
89
+ "__file__": str(file_path_obj) if file_path_obj else "<orig_sandbox>",
90
+ "__package__": package_name,
73
91
  "__builtins__": __builtins__,
74
92
  }
75
93
  trans_ns: dict[str, Any] = {
76
94
  "__name__": "__verifier__",
95
+ "__file__": str(file_path_obj) if file_path_obj else "<trans_sandbox>",
96
+ "__package__": package_name,
77
97
  "__builtins__": __builtins__,
78
98
  }
79
99
 
80
100
  try:
81
- orig_code = compile(
82
- orig_source, "<orig_sandbox>", "exec"
83
- ) # nosec: B102 - intentionally isolated in worker process
84
- exec(
85
- orig_code, orig_ns
86
- ) # nosec: B102 - intentionally isolated in worker process
101
+ orig_code = compile(orig_source, "<orig_sandbox>", "exec") # nosec: B102 - intentionally isolated in worker process
102
+ exec(orig_code, orig_ns) # nosec: B102 - intentionally isolated in worker process
87
103
  except BaseException as e:
88
104
  conn.send(
89
105
  {
@@ -94,12 +110,8 @@ def _worker_differential_fuzz(
94
110
  return
95
111
 
96
112
  try:
97
- trans_code = compile(
98
- trans_source, "<trans_sandbox>", "exec"
99
- ) # nosec: B102 - intentionally isolated in worker process
100
- exec(
101
- trans_code, trans_ns
102
- ) # nosec: B102 - intentionally isolated in worker process
113
+ trans_code = compile(trans_source, "<trans_sandbox>", "exec") # nosec: B102 - intentionally isolated in worker process
114
+ exec(trans_code, trans_ns) # nosec: B102 - intentionally isolated in worker process
103
115
  except BaseException as e:
104
116
  conn.send(
105
117
  {
@@ -109,8 +121,21 @@ def _worker_differential_fuzz(
109
121
  )
110
122
  return
111
123
 
112
- orig_fn = orig_ns.get(callable_name)
113
- trans_fn = trans_ns.get(callable_name)
124
+ def _resolve_callable(ns: dict[str, Any], target_name: str) -> Any:
125
+ if "." in target_name:
126
+ parts = target_name.split(".")
127
+ curr: Any = ns.get(parts[0])
128
+ for p in parts[1:]:
129
+ if curr is None:
130
+ return None
131
+ curr = getattr(curr, p, None)
132
+ if isinstance(curr, (staticmethod, classmethod)):
133
+ return curr.__func__
134
+ return curr
135
+ return ns.get(target_name)
136
+
137
+ orig_fn = _resolve_callable(orig_ns, callable_name)
138
+ trans_fn = _resolve_callable(trans_ns, callable_name)
114
139
 
115
140
  if not callable(orig_fn) or not callable(trans_fn):
116
141
  conn.send(
@@ -212,7 +237,7 @@ class DeterministicInputSynthesizer:
212
237
  )
213
238
  if annotation is str or annotation == "str":
214
239
  return self._rng.choice(
215
- ["", "test", " ", "a" * 100, "\n\t", "null", "123", "utf8_🐍"]
240
+ ["", "test", " ", "a" * 100, "\n\t", "null", "123", "utf8_\U0001f40d"]
216
241
  )
217
242
  if annotation is bool or annotation == "bool":
218
243
  return self._rng.choice([True, False])
@@ -407,6 +432,7 @@ class IsolatedDifferentialVerifier:
407
432
  target_callable,
408
433
  inputs,
409
434
  child_conn,
435
+ filepath,
410
436
  ),
411
437
  )
412
438
  process.daemon = True
@@ -1,34 +0,0 @@
1
- ballpython/__init__.py,sha256=d8ONxgaz9jml_fHPVvg867Vt5jGRhGTqzWPtux3WJOc,206
2
- ballpython/__main__.py,sha256=aCRr0vnZfKYHDrPHBsYSjX5xTxtFJpTTSPVUSZTBK-I,136
3
- ballpython/cli.py,sha256=LllCLRAQQ-fRR_lIXwmd2e3Q7DLCIvf-5jHBXsAhJWs,125
4
- ballpython/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- pycleaner/__init__.py,sha256=G8pQ2M9iH32OK4bKf5Koao554EdGiiHue5rdZ5AS7sM,2894
6
- pycleaner/__main__.py,sha256=8QBFrPyxId563F6cipPMNVEBTG8TIZUgxQfp8gk1VSI,135
7
- pycleaner/baseline.py,sha256=DS32C8_G8khYYNJo42FVv6Cc58lJj7NUmPOzWNfQwlU,3856
8
- pycleaner/cache.py,sha256=WhCiMkuL6ryWVr3l3XrYLLoI7A3bRF8jNxpOyedNW8M,10761
9
- pycleaner/cli.py,sha256=MMK0U57pam-FuvB1UL7jwVk7m9Z_Cosuwuu5FIfrPp0,73029
10
- pycleaner/complexity_analyzer.py,sha256=uGC40cHCcr1tshzfIUw56sodEDySMFR3kGmjo7nq_1c,14977
11
- pycleaner/config.py,sha256=XhloyaY_9pdwbQuc3QaAPCerLeXz-i1da4xT50h-pjY,8514
12
- pycleaner/dead_code_detector.py,sha256=1DwtBSZL1ug1OI9-6jcJyxkhqHmJ0F8ZR19POI2zO3c,26549
13
- pycleaner/dependency_auditor.py,sha256=yQhpN_nfJhEFaBPKVTzndpGas_U2OHyKP_Pifa5YrWA,16070
14
- pycleaner/discovery.py,sha256=dt3OmHg886hy_Z0p80ccMyDkITQxz4biaJY_zB3MU_g,6151
15
- pycleaner/explanations.py,sha256=2OOU_l3YL5o_Br_6fP6MF8HlY75-1JRmw3eTYT2lu8M,14219
16
- pycleaner/import_resolver.py,sha256=JnAysZeYtXlmmM0LEnb9HBdkHnEsNwwp4QlxebRxGNE,32189
17
- pycleaner/linter_formatter.py,sha256=LB5j66OCASU8WG8QgRFpSX7C2c7_UNMBgkJh2hSWxAY,20857
18
- pycleaner/modernizer.py,sha256=L9hxXXfdyTwf8itDO58y6bW5LobGo6dg8q537QdiYbM,12946
19
- pycleaner/pipeline.py,sha256=ZG7_9PNz0jBadFrZoiI7juLtUJXglGQhLgQhrLJqEac,20612
20
- pycleaner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
- pycleaner/security_scanner.py,sha256=cWf4cAGQrgwsGslSQDfZv0XUN3sWvlcOIQaogpmv6C0,23422
22
- pycleaner/syntax_healer.py,sha256=TlIMKIJhaX9GED3e2JNzNMJ5aXmL9eLJMCu7xKSjdv8,26711
23
- pycleaner/taint_engine.py,sha256=96cZdDpSPbo5b13zIZY18f_4GJm_tw0clqCZMBtjXMQ,26481
24
- pycleaner/test_generator.py,sha256=TJsNJSojc6eIsaKZkwMlpnb-MKHuDpNLZCPnMlO-a8M,19663
25
- pycleaner/type_checker.py,sha256=h09QI_8MRHv83s23myjSYFdMTsWoQ900ffBJHtRuhmE,34087
26
- pycleaner/typeshed_resolver.py,sha256=N-kWU_CI5DG59lQMkt7wMW9sHYjNCBdfFS42b4y2Kr4,12742
27
- pycleaner/verifier.py,sha256=pOfSLRNHe4qDUYJG8mxDxjY6Ph8QKBdJZmqBtoJX87w,17937
28
- pycleaner/frameworks/__init__.py,sha256=-W24zqvug8eUkkwqla5oh7rFOqQR-WI4TohQhbXGYoo,3472
29
- pycleaner/frameworks/plugins.py,sha256=sMhP9mZ82sx10OI3opgXIEPXNAWFZRe2I__6yh2Uq_E,11453
30
- ballpython-2.0.5.dist-info/METADATA,sha256=LqjBIVBjanDL5rdXJ7gES5vF4ezSqbG2Y1SKMG6rXx4,3466
31
- ballpython-2.0.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
- ballpython-2.0.5.dist-info/entry_points.txt,sha256=6ux-5UxoVSmlK5p9M-GDVt24sCdwTQq9wgRDkpMIYU8,81
33
- ballpython-2.0.5.dist-info/top_level.txt,sha256=u6lmy8RWZ_saxCZAQTL7EijB4sunegOiVyk5fHjpwLk,21
34
- ballpython-2.0.5.dist-info/RECORD,,