apipatch 0.3.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.
Potentially problematic release.
This version of apipatch might be problematic. Click here for more details.
- apipatch/__init__.py +22 -0
- apipatch/auto_detector.py +223 -0
- apipatch/cli.py +101 -0
- apipatch/engine.py +260 -0
- apipatch/proactive_hunter.py +133 -0
- apipatch/providers/__init__.py +17 -0
- apipatch/providers/anthropic_provider.py +39 -0
- apipatch/providers/base.py +124 -0
- apipatch/providers/factory.py +47 -0
- apipatch/providers/gemini_provider.py +63 -0
- apipatch/providers/openai_provider.py +50 -0
- apipatch/validator.py +116 -0
- apipatch-0.3.0.dist-info/METADATA +197 -0
- apipatch-0.3.0.dist-info/RECORD +18 -0
- apipatch-0.3.0.dist-info/WHEEL +5 -0
- apipatch-0.3.0.dist-info/entry_points.txt +2 -0
- apipatch-0.3.0.dist-info/licenses/LICENSE +21 -0
- apipatch-0.3.0.dist-info/top_level.txt +1 -0
apipatch/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ApiPatch - Autonomous AI Agent for API Breaking Changes & Self-Maintaining Codebases
|
|
3
|
+
Detects and fixes deprecated third-party API calls in Python, JavaScript, TypeScript,
|
|
4
|
+
JSX, TSX, and any other language — powered by LLM reasoning.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.3.0"
|
|
8
|
+
__author__ = "Morad Moqbel"
|
|
9
|
+
__license__ = "MIT"
|
|
10
|
+
|
|
11
|
+
from apipatch.engine import ApiPatchEngine
|
|
12
|
+
from apipatch.validator import CodeValidator
|
|
13
|
+
from apipatch.auto_detector import AutoDeprecationDetector
|
|
14
|
+
from apipatch.proactive_hunter import GitHubPRHunter
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ApiPatchEngine",
|
|
18
|
+
"CodeValidator",
|
|
19
|
+
"AutoDeprecationDetector",
|
|
20
|
+
"GitHubPRHunter",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ApiPatch Autonomous Dependency & Import Detector
|
|
3
|
+
Discovers all third-party libraries used in a project (Python, JavaScript, TypeScript)
|
|
4
|
+
without any hardcoded rules — enabling the LLM engine to reason about ANY library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import ast
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import List, Set, Dict, Any, Optional
|
|
12
|
+
from apipatch.providers.factory import ProviderFactory
|
|
13
|
+
|
|
14
|
+
# Python standard library module names (excluded from "third-party" list)
|
|
15
|
+
STANDARD_LIB_MODULES = {
|
|
16
|
+
"abc", "argparse", "array", "ast", "asyncio", "base64", "binascii", "bisect",
|
|
17
|
+
"builtins", "calendar", "cmath", "collections", "concurrent", "contextlib",
|
|
18
|
+
"copy", "csv", "ctypes", "dataclasses", "datetime", "decimal", "difflib",
|
|
19
|
+
"dis", "doctest", "email", "enum", "errno", "faulthandler", "fcntl", "filecmp",
|
|
20
|
+
"fileinput", "fnmatch", "fractions", "functools", "gc", "getopt", "getpass",
|
|
21
|
+
"gettext", "glob", "graphlib", "gzip", "hashlib", "heapq", "hmac", "html",
|
|
22
|
+
"http", "idlelib", "imaplib", "imghdr", "importlib", "inspect", "io",
|
|
23
|
+
"ipaddress", "itertools", "json", "keyword", "linecache", "locale", "logging",
|
|
24
|
+
"lzma", "mailbox", "mailcap", "marshal", "math", "mimetypes", "mmap",
|
|
25
|
+
"modulefinder", "multiprocessing", "netrc", "nntplib", "numbers", "operator",
|
|
26
|
+
"os", "pathlib", "pdb", "pickle", "pipes", "pkgutil", "platform", "plistlib",
|
|
27
|
+
"poplib", "posix", "pprint", "profile", "pstats", "pty", "pwd", "py_compile",
|
|
28
|
+
"pyclbr", "pydoc", "queue", "quopri", "random", "re", "readline", "reprlib",
|
|
29
|
+
"resource", "rlcompleter", "runpy", "sched", "secrets", "select", "selectors",
|
|
30
|
+
"shelve", "shlex", "shutil", "signal", "site", "smtpd", "smtplib", "sndhdr",
|
|
31
|
+
"socket", "socketserver", "spwd", "sqlite3", "ssl", "stat", "statistics",
|
|
32
|
+
"string", "stringprep", "struct", "subprocess", "sunau", "symtable", "sys",
|
|
33
|
+
"sysconfig", "syslog", "tabnanny", "tarfile", "telnetlib", "tempfile", "termios",
|
|
34
|
+
"test", "textwrap", "threading", "time", "timeit", "tkinter", "token",
|
|
35
|
+
"tokenize", "tomllib", "trace", "traceback", "tracemalloc", "tty", "turtle",
|
|
36
|
+
"turtledemo", "types", "typing", "unicodedata", "unittest", "urllib", "uu",
|
|
37
|
+
"uuid", "venv", "warnings", "wave", "weakref", "webbrowser", "winreg", "winsound",
|
|
38
|
+
"wsgiref", "xdrlib", "xml", "xmlrpc", "zipapp", "zipfile", "zipimport", "zlib",
|
|
39
|
+
"zoneinfo"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Node.js built-in modules (excluded from third-party list for JS/TS projects)
|
|
43
|
+
NODE_BUILTIN_MODULES = {
|
|
44
|
+
"fs", "path", "http", "https", "os", "crypto", "stream", "util", "events",
|
|
45
|
+
"child_process", "cluster", "dgram", "dns", "domain", "net", "querystring",
|
|
46
|
+
"readline", "repl", "string_decoder", "timers", "tls", "tty", "url",
|
|
47
|
+
"v8", "vm", "worker_threads", "zlib", "buffer", "assert", "console",
|
|
48
|
+
"module", "process", "inspector", "perf_hooks", "async_hooks"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AutoDeprecationDetector:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
target_dir: str = ".",
|
|
56
|
+
provider_name: Optional[str] = None,
|
|
57
|
+
api_key: Optional[str] = None
|
|
58
|
+
):
|
|
59
|
+
self.target_dir = os.path.abspath(target_dir)
|
|
60
|
+
self.provider = ProviderFactory.get_provider(
|
|
61
|
+
provider_name=provider_name, api_key=api_key
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# ─── Python Import Extraction ─────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def extract_imports_from_file(self, file_path: str) -> Set[str]:
|
|
67
|
+
"""
|
|
68
|
+
Uses Python AST to extract all third-party imported module names
|
|
69
|
+
from a .py or .pyw source file.
|
|
70
|
+
"""
|
|
71
|
+
imports: Set[str] = set()
|
|
72
|
+
if not file_path.endswith((".py", ".pyw")):
|
|
73
|
+
return imports
|
|
74
|
+
try:
|
|
75
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
76
|
+
content = f.read()
|
|
77
|
+
tree = ast.parse(content)
|
|
78
|
+
for node in ast.walk(tree):
|
|
79
|
+
if isinstance(node, ast.Import):
|
|
80
|
+
for alias in node.names:
|
|
81
|
+
top_pkg = alias.name.split('.')[0]
|
|
82
|
+
if top_pkg not in STANDARD_LIB_MODULES:
|
|
83
|
+
imports.add(top_pkg)
|
|
84
|
+
elif isinstance(node, ast.ImportFrom):
|
|
85
|
+
if node.module:
|
|
86
|
+
top_pkg = node.module.split('.')[0]
|
|
87
|
+
if top_pkg not in STANDARD_LIB_MODULES:
|
|
88
|
+
imports.add(top_pkg)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
return imports
|
|
92
|
+
|
|
93
|
+
# ─── JS/TS Import Extraction ──────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def extract_imports_from_js_file(self, file_path: str) -> Set[str]:
|
|
96
|
+
"""
|
|
97
|
+
Uses regex to extract third-party package names from
|
|
98
|
+
JS / TS / JSX / TSX / MJS / CJS source files.
|
|
99
|
+
"""
|
|
100
|
+
imports: Set[str] = set()
|
|
101
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
102
|
+
if ext not in {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}:
|
|
103
|
+
return imports
|
|
104
|
+
try:
|
|
105
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
106
|
+
content = f.read()
|
|
107
|
+
|
|
108
|
+
# ESM: import ... from 'pkg' / import 'pkg'
|
|
109
|
+
esm_patterns = re.findall(
|
|
110
|
+
r"""(?:^|\n)\s*import\s+(?:[^'"]*?from\s+)?['"]([@\w][\w/.-]*)['"]""",
|
|
111
|
+
content
|
|
112
|
+
)
|
|
113
|
+
# CJS: require('pkg')
|
|
114
|
+
cjs_patterns = re.findall(r"""require\s*\(\s*['"]([@\w][\w/.-]*)['"]\s*\)""", content)
|
|
115
|
+
|
|
116
|
+
for raw in esm_patterns + cjs_patterns:
|
|
117
|
+
# Normalise scoped packages: @org/pkg -> @org/pkg
|
|
118
|
+
# Strip sub-paths: lodash/merge -> lodash
|
|
119
|
+
if raw.startswith('@'):
|
|
120
|
+
parts = raw.split('/')
|
|
121
|
+
pkg = '/'.join(parts[:2]) if len(parts) >= 2 else raw
|
|
122
|
+
else:
|
|
123
|
+
pkg = raw.split('/')[0]
|
|
124
|
+
|
|
125
|
+
if pkg and pkg not in NODE_BUILTIN_MODULES and not pkg.startswith('.'):
|
|
126
|
+
imports.add(pkg)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
return imports
|
|
130
|
+
|
|
131
|
+
# ─── Manifest-based Dependency Discovery ─────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def _parse_requirements_txt(self) -> Set[str]:
|
|
134
|
+
deps: Set[str] = set()
|
|
135
|
+
req_path = os.path.join(self.target_dir, "requirements.txt")
|
|
136
|
+
if os.path.exists(req_path):
|
|
137
|
+
with open(req_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
138
|
+
for line in f:
|
|
139
|
+
line = line.strip()
|
|
140
|
+
if line and not line.startswith("#") and not line.startswith("-"):
|
|
141
|
+
pkg = re.split(r"[><=~;!@\[]", line)[0].strip().lower()
|
|
142
|
+
if pkg:
|
|
143
|
+
deps.add(pkg)
|
|
144
|
+
return deps
|
|
145
|
+
|
|
146
|
+
def _parse_package_json(self) -> Set[str]:
|
|
147
|
+
deps: Set[str] = set()
|
|
148
|
+
pkg_json = os.path.join(self.target_dir, "package.json")
|
|
149
|
+
if os.path.exists(pkg_json):
|
|
150
|
+
try:
|
|
151
|
+
with open(pkg_json, "r", encoding="utf-8", errors="ignore") as f:
|
|
152
|
+
data = json.load(f)
|
|
153
|
+
for key in ("dependencies", "devDependencies", "peerDependencies"):
|
|
154
|
+
if key in data and isinstance(data[key], dict):
|
|
155
|
+
deps.update(p.lower() for p in data[key].keys())
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
return deps
|
|
159
|
+
|
|
160
|
+
def _parse_pyproject_toml(self) -> Set[str]:
|
|
161
|
+
deps: Set[str] = set()
|
|
162
|
+
pyproject = os.path.join(self.target_dir, "pyproject.toml")
|
|
163
|
+
if os.path.exists(pyproject):
|
|
164
|
+
try:
|
|
165
|
+
with open(pyproject, "r", encoding="utf-8", errors="ignore") as f:
|
|
166
|
+
content = f.read()
|
|
167
|
+
# Extract quoted package names from dependency lists
|
|
168
|
+
for match in re.findall(r'"([a-zA-Z0-9_\-]+)(?:[><=~;].*)?[">\s]', content):
|
|
169
|
+
m = match.strip().lower()
|
|
170
|
+
if m and m not in {"apipatch", "setuptools", "wheel", "pytest", "python"}:
|
|
171
|
+
deps.add(m)
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
return deps
|
|
175
|
+
|
|
176
|
+
# ─── Main Detection API ───────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def detect_dependencies(self) -> List[str]:
|
|
179
|
+
"""
|
|
180
|
+
Discovers all third-party packages from:
|
|
181
|
+
1. requirements.txt
|
|
182
|
+
2. package.json
|
|
183
|
+
3. pyproject.toml
|
|
184
|
+
4. Recursive AST inspection of Python source files
|
|
185
|
+
5. Regex inspection of JS/TS source files
|
|
186
|
+
"""
|
|
187
|
+
dependencies: Set[str] = set()
|
|
188
|
+
|
|
189
|
+
dependencies.update(self._parse_requirements_txt())
|
|
190
|
+
dependencies.update(self._parse_package_json())
|
|
191
|
+
dependencies.update(self._parse_pyproject_toml())
|
|
192
|
+
|
|
193
|
+
ignore_dirs = {
|
|
194
|
+
".git", "node_modules", "venv", ".venv", "__pycache__",
|
|
195
|
+
".gemini", "dist", "build", ".next", ".nuxt", "out", "coverage"
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for root, dirs, files in os.walk(self.target_dir):
|
|
199
|
+
dirs[:] = [d for d in dirs if d not in ignore_dirs]
|
|
200
|
+
for file in files:
|
|
201
|
+
full_path = os.path.join(root, file)
|
|
202
|
+
ext = os.path.splitext(file)[1].lower()
|
|
203
|
+
if ext in {".py", ".pyw"}:
|
|
204
|
+
dependencies.update(self.extract_imports_from_file(full_path))
|
|
205
|
+
elif ext in {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}:
|
|
206
|
+
dependencies.update(self.extract_imports_from_js_file(full_path))
|
|
207
|
+
|
|
208
|
+
return sorted(dependencies)
|
|
209
|
+
|
|
210
|
+
def run_autonomous_discovery(self) -> Dict[str, Any]:
|
|
211
|
+
"""Runs end-to-end autonomous dependency discovery and prints a summary."""
|
|
212
|
+
deps = self.detect_dependencies()
|
|
213
|
+
print(f"\n[ApiPatch] Detected {len(deps)} third-party package(s) in '{self.target_dir}':")
|
|
214
|
+
for dep in deps:
|
|
215
|
+
print(f" - {dep}")
|
|
216
|
+
print(
|
|
217
|
+
"\nAll detected packages will be passed to the AI engine "
|
|
218
|
+
"for dynamic deprecation analysis when you run `apipatch scan` or `apipatch fix`.\n"
|
|
219
|
+
)
|
|
220
|
+
return {
|
|
221
|
+
"target_directory": self.target_dir,
|
|
222
|
+
"detected_packages": deps,
|
|
223
|
+
}
|
apipatch/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ApiPatch Command Line Interface (CLI)
|
|
3
|
+
Provides modern terminal commands for autonomous API code audits and automated refactoring.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import argparse
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from apipatch import __version__
|
|
10
|
+
from apipatch.engine import ApiPatchEngine, Colors
|
|
11
|
+
from apipatch.auto_detector import AutoDeprecationDetector
|
|
12
|
+
from apipatch.proactive_hunter import GitHubPRHunter
|
|
13
|
+
|
|
14
|
+
# Ensure UTF-8 console output on Windows
|
|
15
|
+
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
16
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="apipatch",
|
|
22
|
+
description=f"{Colors.HEADER}⚡ ApiPatch v{__version__} - Autonomous AI Agent for API Breaking Changes{Colors.ENDC}",
|
|
23
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("-v", "--version", action="version", version=f"apipatch {__version__}")
|
|
26
|
+
|
|
27
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
28
|
+
|
|
29
|
+
# Command: scan
|
|
30
|
+
scan_parser = subparsers.add_parser("scan", help="Scan a codebase for deprecated API signatures")
|
|
31
|
+
scan_parser.add_argument("path", nargs="?", default=".", help="File or directory path to scan (default: current dir)")
|
|
32
|
+
scan_parser.add_argument("--provider", choices=["openai", "anthropic", "gemini"], help="AI provider for dynamic reasoning")
|
|
33
|
+
scan_parser.add_argument("--api-key", help="API key for chosen provider")
|
|
34
|
+
scan_parser.add_argument("--model", help="Specific model name (e.g., gpt-4o, claude-3-7-sonnet)")
|
|
35
|
+
|
|
36
|
+
# Command: fix
|
|
37
|
+
fix_parser = subparsers.add_parser("fix", help="Audit and generate modernized code refactors")
|
|
38
|
+
fix_parser.add_argument("path", nargs="?", default=".", help="File or directory path to refactor")
|
|
39
|
+
fix_parser.add_argument("-w", "--write", action="store_true", help="Apply fixes in-place to files on disk")
|
|
40
|
+
fix_parser.add_argument("--no-backup", action="store_true", help="Disable automatic .bak file creation when writing")
|
|
41
|
+
fix_parser.add_argument("--provider", choices=["openai", "anthropic", "gemini"], help="AI provider for dynamic reasoning")
|
|
42
|
+
fix_parser.add_argument("--api-key", help="API key for chosen provider")
|
|
43
|
+
fix_parser.add_argument("--model", help="Specific model name")
|
|
44
|
+
|
|
45
|
+
# Command: detect
|
|
46
|
+
detect_parser = subparsers.add_parser("detect", help="Auto-discover project dependencies and deprecation rules")
|
|
47
|
+
detect_parser.add_argument("path", nargs="?", default=".", help="Project directory path (default: current dir)")
|
|
48
|
+
|
|
49
|
+
# Command: hunt
|
|
50
|
+
hunt_parser = subparsers.add_parser("hunt", help="Proactively search GitHub for deprecated code and prepare PRs")
|
|
51
|
+
hunt_parser.add_argument("query", nargs="?", default="openai.ChatCompletion.create language:python", help="GitHub Code Search query")
|
|
52
|
+
hunt_parser.add_argument("--max", type=int, default=3, help="Max candidate repositories to inspect")
|
|
53
|
+
hunt_parser.add_argument("--token", help="GitHub Personal Access Token")
|
|
54
|
+
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
|
|
57
|
+
if not args.command:
|
|
58
|
+
# Default behavior if no subcommand passed: interactive help or scan
|
|
59
|
+
parser.print_help()
|
|
60
|
+
sys.exit(0)
|
|
61
|
+
|
|
62
|
+
if args.command == "scan":
|
|
63
|
+
engine = ApiPatchEngine(
|
|
64
|
+
provider_name=args.provider,
|
|
65
|
+
api_key=args.api_key,
|
|
66
|
+
model=args.model,
|
|
67
|
+
create_backup=False
|
|
68
|
+
)
|
|
69
|
+
if os_is_file(args.path):
|
|
70
|
+
engine.process_file(args.path, write_in_place=False)
|
|
71
|
+
else:
|
|
72
|
+
engine.process_directory(args.path, write_in_place=False)
|
|
73
|
+
|
|
74
|
+
elif args.command == "fix":
|
|
75
|
+
engine = ApiPatchEngine(
|
|
76
|
+
provider_name=args.provider,
|
|
77
|
+
api_key=args.api_key,
|
|
78
|
+
model=args.model,
|
|
79
|
+
create_backup=not args.no_backup
|
|
80
|
+
)
|
|
81
|
+
if os_is_file(args.path):
|
|
82
|
+
engine.process_file(args.path, write_in_place=args.write)
|
|
83
|
+
else:
|
|
84
|
+
engine.process_directory(args.path, write_in_place=args.write)
|
|
85
|
+
|
|
86
|
+
elif args.command == "detect":
|
|
87
|
+
detector = AutoDeprecationDetector(target_dir=args.path)
|
|
88
|
+
detector.run_autonomous_discovery()
|
|
89
|
+
|
|
90
|
+
elif args.command == "hunt":
|
|
91
|
+
hunter = GitHubPRHunter(github_token=args.token)
|
|
92
|
+
hunter.hunt_and_preview(query=args.query, max_results=args.max)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def os_is_file(path: str) -> bool:
|
|
96
|
+
import os
|
|
97
|
+
return os.path.isfile(path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|
apipatch/engine.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ApiPatch Core Orchestration Engine
|
|
3
|
+
Pure LLM-powered autonomous agent for detecting and fixing deprecated API calls
|
|
4
|
+
across ANY third-party library in Python, JavaScript, TypeScript, JSX, TSX, and more.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import shutil
|
|
10
|
+
import difflib
|
|
11
|
+
from typing import Dict, Any, List, Optional, Set
|
|
12
|
+
from apipatch.validator import CodeValidator, ValidationResult
|
|
13
|
+
from apipatch.providers.factory import ProviderFactory
|
|
14
|
+
from apipatch.auto_detector import AutoDeprecationDetector
|
|
15
|
+
|
|
16
|
+
# Reconfigure stdout/stderr for safe Unicode / Windows encoding
|
|
17
|
+
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
18
|
+
try:
|
|
19
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
|
|
24
|
+
try:
|
|
25
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Colors:
|
|
31
|
+
HEADER = '\033[95m'
|
|
32
|
+
OKBLUE = '\033[94m'
|
|
33
|
+
OKCYAN = '\033[96m'
|
|
34
|
+
OKGREEN = '\033[92m'
|
|
35
|
+
WARNING = '\033[93m'
|
|
36
|
+
FAIL = '\033[91m'
|
|
37
|
+
ENDC = '\033[0m'
|
|
38
|
+
BOLD = '\033[1m'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def safe_print(text: str):
|
|
42
|
+
"""Safely prints to stdout without dying on encoding mismatches."""
|
|
43
|
+
try:
|
|
44
|
+
print(text)
|
|
45
|
+
except UnicodeEncodeError:
|
|
46
|
+
try:
|
|
47
|
+
print(text.encode("ascii", errors="replace").decode("ascii"))
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ApiPatchEngine:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
provider_name: Optional[str] = None,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
model: Optional[str] = None,
|
|
58
|
+
create_backup: bool = True
|
|
59
|
+
):
|
|
60
|
+
self.provider = ProviderFactory.get_provider(
|
|
61
|
+
provider_name=provider_name,
|
|
62
|
+
api_key=api_key,
|
|
63
|
+
model=model
|
|
64
|
+
)
|
|
65
|
+
self.create_backup = create_backup
|
|
66
|
+
self.detector = AutoDeprecationDetector()
|
|
67
|
+
|
|
68
|
+
def audit_code(
|
|
69
|
+
self,
|
|
70
|
+
file_path: str,
|
|
71
|
+
code: str,
|
|
72
|
+
detected_libraries: Optional[List[str]] = None
|
|
73
|
+
) -> Dict[str, Any]:
|
|
74
|
+
"""
|
|
75
|
+
Fully LLM-driven audit:
|
|
76
|
+
Detects and fixes deprecated/breaking API calls across ANY library,
|
|
77
|
+
in Python, JavaScript, TypeScript, JSX, TSX, etc.
|
|
78
|
+
Falls back gracefully if no provider is configured.
|
|
79
|
+
"""
|
|
80
|
+
file_name = os.path.basename(file_path)
|
|
81
|
+
_, ext = os.path.splitext(file_path)
|
|
82
|
+
|
|
83
|
+
empty_result = {
|
|
84
|
+
"has_breaking_changes": False,
|
|
85
|
+
"detected_issues": [],
|
|
86
|
+
"refactored_code": code
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if not self.provider:
|
|
90
|
+
safe_print(
|
|
91
|
+
f" {Colors.WARNING}[!] No AI provider configured. "
|
|
92
|
+
f"Set GEMINI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY "
|
|
93
|
+
f"to enable full analysis.{Colors.ENDC}"
|
|
94
|
+
)
|
|
95
|
+
return empty_result
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
libs = detected_libraries or list(
|
|
99
|
+
self.detector.extract_imports_from_file(file_path)
|
|
100
|
+
)
|
|
101
|
+
llm_res = self.provider.audit_code(file_name, code, detected_libraries=libs)
|
|
102
|
+
|
|
103
|
+
if llm_res.get("has_breaking_changes") and llm_res.get("refactored_code"):
|
|
104
|
+
refactored = llm_res["refactored_code"]
|
|
105
|
+
val = CodeValidator.validate(code, refactored, file_extension=ext)
|
|
106
|
+
if val.is_valid:
|
|
107
|
+
return llm_res
|
|
108
|
+
else:
|
|
109
|
+
safe_print(
|
|
110
|
+
f" {Colors.WARNING}[!] LLM output failed safety validation "
|
|
111
|
+
f"({val.error_message}). Keeping original.{Colors.ENDC}"
|
|
112
|
+
)
|
|
113
|
+
return empty_result
|
|
114
|
+
|
|
115
|
+
return llm_res if llm_res else empty_result
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
safe_print(f" {Colors.WARNING}[!] Provider error: {e}{Colors.ENDC}")
|
|
119
|
+
return empty_result
|
|
120
|
+
|
|
121
|
+
def generate_diff(self, old_code: str, new_code: str, file_name: str) -> List[str]:
|
|
122
|
+
"""Generates unified diff lines between old and new code."""
|
|
123
|
+
old_lines = old_code.splitlines(keepends=True)
|
|
124
|
+
new_lines = new_code.splitlines(keepends=True)
|
|
125
|
+
return list(difflib.unified_diff(
|
|
126
|
+
old_lines,
|
|
127
|
+
new_lines,
|
|
128
|
+
fromfile=f"a/{file_name}",
|
|
129
|
+
tofile=f"b/{file_name}"
|
|
130
|
+
))
|
|
131
|
+
|
|
132
|
+
def print_diff(self, diff: List[str]):
|
|
133
|
+
"""Prints colorized unified diff to stdout."""
|
|
134
|
+
if not diff:
|
|
135
|
+
safe_print(" (No changes needed)")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
for line in diff:
|
|
139
|
+
if line.startswith('+') and not line.startswith('+++'):
|
|
140
|
+
safe_print(f"{Colors.OKGREEN}{line.rstrip()}{Colors.ENDC}")
|
|
141
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
142
|
+
safe_print(f"{Colors.FAIL}{line.rstrip()}{Colors.ENDC}")
|
|
143
|
+
elif line.startswith('@'):
|
|
144
|
+
safe_print(f"{Colors.OKCYAN}{line.rstrip()}{Colors.ENDC}")
|
|
145
|
+
else:
|
|
146
|
+
safe_print(f" {line.rstrip()}")
|
|
147
|
+
|
|
148
|
+
def process_file(
|
|
149
|
+
self,
|
|
150
|
+
file_path: str,
|
|
151
|
+
write_in_place: bool = False,
|
|
152
|
+
detected_libraries: Optional[List[str]] = None
|
|
153
|
+
) -> Dict[str, Any]:
|
|
154
|
+
"""Audits, refactors, previews diff, and optionally writes fixed code to disk."""
|
|
155
|
+
file_path = os.path.abspath(file_path)
|
|
156
|
+
try:
|
|
157
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
158
|
+
original_code = f.read()
|
|
159
|
+
except Exception as e:
|
|
160
|
+
safe_print(f"{Colors.FAIL}[!] Cannot read {file_path}: {e}{Colors.ENDC}")
|
|
161
|
+
return {"file": file_path, "status": "error", "issues": []}
|
|
162
|
+
|
|
163
|
+
audit = self.audit_code(file_path, original_code, detected_libraries=detected_libraries)
|
|
164
|
+
file_name = os.path.basename(file_path)
|
|
165
|
+
|
|
166
|
+
if not audit["has_breaking_changes"]:
|
|
167
|
+
safe_print(f"{Colors.OKGREEN}[✓] {file_name}: Clean — no breaking changes detected.{Colors.ENDC}")
|
|
168
|
+
return {"file": file_path, "status": "clean", "issues": []}
|
|
169
|
+
|
|
170
|
+
issues = audit["detected_issues"]
|
|
171
|
+
safe_print(f"\n{Colors.FAIL}[!] [{len(issues)} DEPRECATION(S)] {file_path}{Colors.ENDC}")
|
|
172
|
+
for idx, issue in enumerate(issues, 1):
|
|
173
|
+
safe_print(f" {Colors.BOLD}#{idx} [{issue['library']}]:{Colors.ENDC} {issue['description']}")
|
|
174
|
+
safe_print(f" Deprecated : {Colors.FAIL}{issue['deprecated_symbol']}{Colors.ENDC}")
|
|
175
|
+
safe_print(f" Replacement: {Colors.OKGREEN}{issue['replacement_symbol']}{Colors.ENDC}")
|
|
176
|
+
|
|
177
|
+
refactored = audit.get("refactored_code", original_code)
|
|
178
|
+
diff = self.generate_diff(original_code, refactored, file_name)
|
|
179
|
+
|
|
180
|
+
safe_print(f"\n{Colors.OKBLUE}>> Refactored Diff Preview:{Colors.ENDC}")
|
|
181
|
+
self.print_diff(diff)
|
|
182
|
+
|
|
183
|
+
if write_in_place and refactored != original_code:
|
|
184
|
+
if self.create_backup:
|
|
185
|
+
backup_path = f"{file_path}.bak"
|
|
186
|
+
shutil.copy2(file_path, backup_path)
|
|
187
|
+
safe_print(f" {Colors.OKCYAN}[*] Backup saved → {backup_path}{Colors.ENDC}")
|
|
188
|
+
|
|
189
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
190
|
+
f.write(refactored)
|
|
191
|
+
safe_print(f" {Colors.OKGREEN}[✓] Updated {file_name} in place.{Colors.ENDC}")
|
|
192
|
+
|
|
193
|
+
first_lib = issues[0]['library'] if issues else "API"
|
|
194
|
+
safe_print(
|
|
195
|
+
f"\n{Colors.HEADER}[PR Ready]:{Colors.ENDC} "
|
|
196
|
+
f"[ApiPatch] Migrate deprecated {first_lib} calls in {file_name}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
"file": file_path,
|
|
201
|
+
"status": "refactored" if write_in_place else "detected",
|
|
202
|
+
"issues": issues,
|
|
203
|
+
"refactored_code": refactored
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
def process_directory(
|
|
207
|
+
self,
|
|
208
|
+
target_dir: str,
|
|
209
|
+
write_in_place: bool = False
|
|
210
|
+
) -> Dict[str, Any]:
|
|
211
|
+
"""Recursively audits all supported source files in target directory."""
|
|
212
|
+
target_dir = os.path.abspath(target_dir)
|
|
213
|
+
safe_print(f"{Colors.HEADER}{Colors.BOLD}=== ApiPatch: Autonomous AI Codebase Auditor ==={Colors.ENDC}")
|
|
214
|
+
safe_print(f"Target Directory : {Colors.OKCYAN}{target_dir}{Colors.ENDC}")
|
|
215
|
+
provider_name = self.provider.__class__.__name__ if self.provider else "No Provider (Offline)"
|
|
216
|
+
safe_print(f"Active AI Engine : {Colors.OKGREEN}{provider_name}{Colors.ENDC}\n")
|
|
217
|
+
|
|
218
|
+
# Discover project-wide dependencies
|
|
219
|
+
detector = AutoDeprecationDetector(target_dir)
|
|
220
|
+
deps = detector.detect_dependencies()
|
|
221
|
+
if deps:
|
|
222
|
+
listed = ', '.join(deps[:12])
|
|
223
|
+
extra = f'... (+{len(deps) - 12} more)' if len(deps) > 12 else ''
|
|
224
|
+
safe_print(f"[*] Detected {len(deps)} dependencies: {listed}{extra}\n")
|
|
225
|
+
|
|
226
|
+
total_scanned = 0
|
|
227
|
+
affected_files = 0
|
|
228
|
+
results = []
|
|
229
|
+
|
|
230
|
+
ignore_dirs = {
|
|
231
|
+
".git", "node_modules", "venv", ".venv", "__pycache__",
|
|
232
|
+
".gemini", "dist", "build", ".next", ".nuxt", "out", "coverage"
|
|
233
|
+
}
|
|
234
|
+
supported_exts = {".py", ".pyw", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
|
|
235
|
+
|
|
236
|
+
for root, dirs, files in os.walk(target_dir):
|
|
237
|
+
dirs[:] = [d for d in dirs if d not in ignore_dirs]
|
|
238
|
+
for file in files:
|
|
239
|
+
_, ext = os.path.splitext(file)
|
|
240
|
+
if ext in supported_exts:
|
|
241
|
+
total_scanned += 1
|
|
242
|
+
full_path = os.path.join(root, file)
|
|
243
|
+
res = self.process_file(
|
|
244
|
+
full_path,
|
|
245
|
+
write_in_place=write_in_place,
|
|
246
|
+
detected_libraries=deps
|
|
247
|
+
)
|
|
248
|
+
if res["status"] in {"detected", "refactored"}:
|
|
249
|
+
affected_files += 1
|
|
250
|
+
results.append(res)
|
|
251
|
+
|
|
252
|
+
safe_print(
|
|
253
|
+
f"\n[Audit Summary]: {total_scanned} file(s) inspected, "
|
|
254
|
+
f"{affected_files} file(s) with deprecated APIs found."
|
|
255
|
+
)
|
|
256
|
+
return {
|
|
257
|
+
"total_scanned": total_scanned,
|
|
258
|
+
"affected_files": affected_files,
|
|
259
|
+
"results": results
|
|
260
|
+
}
|