project-standalo-sonic-cloud/skills/guardrail-orchestrator/scripts/verify_async.py

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()