238 lines
7.7 KiB
Python
238 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Static analysis for async/await issues in TypeScript/JavaScript.
|
|
|
|
Catches common mistakes:
|
|
- fetch() without await
|
|
- .json() without await
|
|
- Async function calls without await
|
|
- Floating promises (promise not handled)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple
|
|
|
|
# ============================================================================
|
|
# Async Pattern Detection
|
|
# ============================================================================
|
|
|
|
ASYNC_ISSUES = [
|
|
# fetch without await - but allow .then() chains
|
|
{
|
|
"pattern": r"(?<!await\s)(?<!return\s)fetch\s*\([^)]*\)(?!\s*\.then)(?!\s*\))",
|
|
"severity": "HIGH",
|
|
"message": "fetch() without await or .then()",
|
|
"fix": "Add 'await' before fetch() or use .then().catch()"
|
|
},
|
|
# .json() without await
|
|
{
|
|
"pattern": r"(?<!await\s)\.json\s*\(\s*\)(?!\s*\.then)",
|
|
"severity": "HIGH",
|
|
"message": ".json() without await",
|
|
"fix": "Add 'await' before .json() call"
|
|
},
|
|
# .text() without await
|
|
{
|
|
"pattern": r"(?<!await\s)\.text\s*\(\s*\)(?!\s*\.then)",
|
|
"severity": "MEDIUM",
|
|
"message": ".text() without await",
|
|
"fix": "Add 'await' before .text() call"
|
|
},
|
|
# axios/fetch response access without await
|
|
{
|
|
"pattern": r"(?<!await\s)(axios\.(get|post|put|delete|patch))\s*\([^)]*\)\.data",
|
|
"severity": "HIGH",
|
|
"message": "Accessing .data on unawaited axios call",
|
|
"fix": "Add 'await' or use (await axios.get(...)).data"
|
|
},
|
|
# Promise.all without await
|
|
{
|
|
"pattern": r"(?<!await\s)(?<!return\s)Promise\.(all|allSettled|race|any)\s*\(",
|
|
"severity": "HIGH",
|
|
"message": "Promise.all/race without await",
|
|
"fix": "Add 'await' before Promise.all()"
|
|
},
|
|
# Async function call patterns (common API functions)
|
|
{
|
|
"pattern": r"(?<!await\s)(?<!return\s)(createUser|updateUser|deleteUser|getUser|saveData|loadData|fetchData|submitForm|handleSubmit)\s*\([^)]*\)\s*;",
|
|
"severity": "MEDIUM",
|
|
"message": "Async function call may need await",
|
|
"fix": "Check if this function is async and add 'await' if needed"
|
|
},
|
|
# setState with async value without await
|
|
{
|
|
"pattern": r"set\w+\s*\(\s*(?:await\s+)?fetch\s*\(",
|
|
"severity": "HIGH",
|
|
"message": "Setting state with fetch result - ensure await is used",
|
|
"fix": "Use: const data = await fetch(...); setData(data)"
|
|
},
|
|
# useEffect with async but no await
|
|
{
|
|
"pattern": r"useEffect\s*\(\s*\(\s*\)\s*=>\s*\{[^}]*fetch\s*\([^}]*\}\s*,",
|
|
"severity": "MEDIUM",
|
|
"message": "useEffect with fetch - check async handling",
|
|
"fix": "Create inner async function: useEffect(() => { const load = async () => {...}; load(); }, [])"
|
|
},
|
|
]
|
|
|
|
# Files/patterns to skip
|
|
SKIP_PATTERNS = [
|
|
r"node_modules",
|
|
r"\.next",
|
|
r"dist",
|
|
r"build",
|
|
r"\.test\.",
|
|
r"\.spec\.",
|
|
r"__tests__",
|
|
r"__mocks__",
|
|
]
|
|
|
|
|
|
def should_skip_file(file_path: str) -> bool:
|
|
"""Check if file should be skipped."""
|
|
for pattern in SKIP_PATTERNS:
|
|
if re.search(pattern, file_path):
|
|
return True
|
|
return False
|
|
|
|
|
|
def analyze_file(file_path: Path) -> List[Dict]:
|
|
"""Analyze a single file for async issues."""
|
|
issues = []
|
|
|
|
try:
|
|
content = file_path.read_text(encoding='utf-8')
|
|
except Exception:
|
|
return issues
|
|
|
|
lines = content.split('\n')
|
|
|
|
for line_num, line in enumerate(lines, 1):
|
|
# Skip comments
|
|
stripped = line.strip()
|
|
if stripped.startswith('//') or stripped.startswith('*'):
|
|
continue
|
|
|
|
for rule in ASYNC_ISSUES:
|
|
if re.search(rule["pattern"], line):
|
|
# Additional context check - skip if line has .then or .catch nearby
|
|
context = '\n'.join(lines[max(0, line_num-2):min(len(lines), line_num+2)])
|
|
if '.then(' in context and '.catch(' in context:
|
|
continue
|
|
|
|
issues.append({
|
|
"file": str(file_path),
|
|
"line": line_num,
|
|
"severity": rule["severity"],
|
|
"message": rule["message"],
|
|
"fix": rule["fix"],
|
|
"code": line.strip()[:80]
|
|
})
|
|
|
|
return issues
|
|
|
|
|
|
def analyze_project(root_dir: str = ".") -> List[Dict]:
|
|
"""Analyze all TypeScript/JavaScript files in project."""
|
|
all_issues = []
|
|
|
|
extensions = [".ts", ".tsx", ".js", ".jsx"]
|
|
root = Path(root_dir)
|
|
|
|
for ext in extensions:
|
|
for file_path in root.rglob(f"*{ext}"):
|
|
if should_skip_file(str(file_path)):
|
|
continue
|
|
issues = analyze_file(file_path)
|
|
all_issues.extend(issues)
|
|
|
|
return all_issues
|
|
|
|
|
|
# ============================================================================
|
|
# Output Formatting
|
|
# ============================================================================
|
|
|
|
def format_text(issues: List[Dict]) -> str:
|
|
"""Format issues as readable text."""
|
|
if not issues:
|
|
return "\n✅ No async/await issues found.\n"
|
|
|
|
lines = []
|
|
lines.append("")
|
|
lines.append("╔" + "═" * 70 + "╗")
|
|
lines.append("║" + " ASYNC/AWAIT VERIFICATION".ljust(70) + "║")
|
|
lines.append("╠" + "═" * 70 + "╣")
|
|
|
|
high = [i for i in issues if i["severity"] == "HIGH"]
|
|
medium = [i for i in issues if i["severity"] == "MEDIUM"]
|
|
|
|
lines.append("║" + f" 🔴 High: {len(high)} issues".ljust(70) + "║")
|
|
lines.append("║" + f" 🟡 Medium: {len(medium)} issues".ljust(70) + "║")
|
|
|
|
if high:
|
|
lines.append("╠" + "═" * 70 + "╣")
|
|
lines.append("║" + " 🔴 HIGH SEVERITY".ljust(70) + "║")
|
|
for issue in high:
|
|
loc = f"{issue['file']}:{issue['line']}"
|
|
lines.append("║" + f" {loc}".ljust(70) + "║")
|
|
lines.append("║" + f" ❌ {issue['message']}".ljust(70) + "║")
|
|
lines.append("║" + f" 💡 {issue['fix']}".ljust(70) + "║")
|
|
code = issue['code'][:55]
|
|
lines.append("║" + f" 📝 {code}".ljust(70) + "║")
|
|
|
|
if medium:
|
|
lines.append("╠" + "═" * 70 + "╣")
|
|
lines.append("║" + " 🟡 MEDIUM SEVERITY".ljust(70) + "║")
|
|
for issue in medium[:5]: # Limit display
|
|
loc = f"{issue['file']}:{issue['line']}"
|
|
lines.append("║" + f" {loc}".ljust(70) + "║")
|
|
lines.append("║" + f" ⚠️ {issue['message']}".ljust(70) + "║")
|
|
|
|
lines.append("╚" + "═" * 70 + "╝")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Check for async/await issues")
|
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
parser.add_argument("--path", default=".", help="Project path to analyze")
|
|
parser.add_argument("--strict", action="store_true", help="Fail on any issue")
|
|
|
|
args = parser.parse_args()
|
|
|
|
print("Scanning for async/await issues...")
|
|
issues = analyze_project(args.path)
|
|
|
|
if args.json:
|
|
print(json.dumps({
|
|
"issues": issues,
|
|
"summary": {
|
|
"high": len([i for i in issues if i["severity"] == "HIGH"]),
|
|
"medium": len([i for i in issues if i["severity"] == "MEDIUM"]),
|
|
"total": len(issues)
|
|
}
|
|
}, indent=2))
|
|
else:
|
|
print(format_text(issues))
|
|
|
|
# Exit codes
|
|
high_count = len([i for i in issues if i["severity"] == "HIGH"])
|
|
|
|
if high_count > 0:
|
|
sys.exit(1) # High severity issues found
|
|
elif args.strict and issues:
|
|
sys.exit(1) # Any issues in strict mode
|
|
else:
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|