Initial commit
This commit is contained in:
382
hooks/scripts/code-quality-reporter.py
Executable file
382
hooks/scripts/code-quality-reporter.py
Executable file
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CodeQualityReporter:
|
||||
def __init__(self):
|
||||
self.session_file = Path(__file__).parent / ".session-quality.json"
|
||||
self.reports_dir = Path.cwd() / "docs" / "reports"
|
||||
self.ensure_reports_directory()
|
||||
self.load_session()
|
||||
|
||||
def ensure_reports_directory(self):
|
||||
"""Ensure reports directory exists"""
|
||||
try:
|
||||
self.reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
# Silently fail - don't interrupt the workflow
|
||||
pass
|
||||
|
||||
def load_session(self):
|
||||
"""Load or initialize session data"""
|
||||
try:
|
||||
if self.session_file.exists():
|
||||
with open(self.session_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Convert list back to set for filesModified
|
||||
self.session = data
|
||||
if isinstance(data.get("filesModified"), list):
|
||||
self.session["filesModified"] = set(data["filesModified"])
|
||||
else:
|
||||
self.session["filesModified"] = set()
|
||||
else:
|
||||
self.session = self.create_new_session()
|
||||
except Exception:
|
||||
self.session = self.create_new_session()
|
||||
|
||||
def create_new_session(self) -> dict[str, Any]:
|
||||
"""Create a new session"""
|
||||
return {
|
||||
"startTime": datetime.now().isoformat(),
|
||||
"filesModified": set(),
|
||||
"violations": [],
|
||||
"improvements": [],
|
||||
"statistics": {
|
||||
"totalFiles": 0,
|
||||
"totalViolations": 0,
|
||||
"blockedOperations": 0,
|
||||
"autoFixed": 0,
|
||||
},
|
||||
}
|
||||
|
||||
def process_event(self, input_data: dict[str, Any]) -> dict[str, str] | None:
|
||||
"""Process hook event"""
|
||||
event = input_data.get("event")
|
||||
tool_name = input_data.get("tool_name")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
message = input_data.get("message")
|
||||
file_path = tool_input.get("file_path")
|
||||
|
||||
# Security: Validate file path
|
||||
if file_path:
|
||||
try:
|
||||
resolved_path = Path(file_path).resolve()
|
||||
cwd = Path.cwd()
|
||||
# Ensure the path is within the current working directory
|
||||
resolved_path.relative_to(cwd)
|
||||
except (ValueError, OSError):
|
||||
return {"message": "Invalid or unsafe file path detected"}
|
||||
# Track file modifications
|
||||
if file_path and tool_name in ["Write", "Edit", "MultiEdit", "Task"]:
|
||||
self.session["filesModified"].add(file_path)
|
||||
self.session["statistics"]["totalFiles"] += 1
|
||||
|
||||
# Track violations and improvements
|
||||
if message:
|
||||
if "❌" in message:
|
||||
self.session["statistics"]["blockedOperations"] += 1
|
||||
self.record_violation(message, file_path)
|
||||
elif "⚠️" in message:
|
||||
self.session["statistics"]["totalViolations"] += 1
|
||||
self.record_violation(message, file_path)
|
||||
elif "✅" in message and "organized" in message:
|
||||
self.session["statistics"]["autoFixed"] += 1
|
||||
self.record_improvement(message, file_path)
|
||||
|
||||
# Save session data
|
||||
self.save_session()
|
||||
|
||||
# Generate report on Stop event
|
||||
if event == "Stop":
|
||||
return self.generate_report()
|
||||
|
||||
return None
|
||||
|
||||
def record_violation(self, message: str, file_path: str | None):
|
||||
"""Record a violation"""
|
||||
lines = message.split("\n")
|
||||
violations = [
|
||||
line.strip()[2:] # Remove '- '
|
||||
for line in lines
|
||||
if ":" in line and line.strip().startswith("-")
|
||||
]
|
||||
|
||||
for violation in violations:
|
||||
self.session["violations"].append(
|
||||
{
|
||||
"file": file_path or "unknown",
|
||||
"issue": violation,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
def record_improvement(self, message: str, file_path: str | None):
|
||||
"""Record an improvement"""
|
||||
self.session["improvements"].append(
|
||||
{
|
||||
"file": file_path or "unknown",
|
||||
"action": message.split("\n")[0],
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
def save_session(self):
|
||||
"""Save session data"""
|
||||
try:
|
||||
# Convert Set to List for JSON serialization
|
||||
session_data = {
|
||||
**self.session,
|
||||
"filesModified": list(self.session["filesModified"]),
|
||||
}
|
||||
with open(self.session_file, "w", encoding="utf-8") as f:
|
||||
json.dump(session_data, f, indent=2)
|
||||
except Exception:
|
||||
# Silently fail - don't interrupt the workflow
|
||||
pass
|
||||
|
||||
def generate_report(self) -> dict[str, str]:
|
||||
"""Generate quality report"""
|
||||
duration = self.calculate_duration()
|
||||
top_issues = self.get_top_issues()
|
||||
file_stats = self.get_file_statistics()
|
||||
|
||||
report = [
|
||||
"# Code Quality Session Report",
|
||||
"",
|
||||
f"**Duration:** {duration} ",
|
||||
f'**Files Modified:** {len(self.session["filesModified"])} ',
|
||||
f"**Generated:** {datetime.now().isoformat()}",
|
||||
"",
|
||||
"## Statistics",
|
||||
"",
|
||||
f'- **Total Operations:** {self.session["statistics"]["totalFiles"]}',
|
||||
f'- **Violations Found:** {self.session["statistics"]["totalViolations"]}',
|
||||
f'- **Operations Blocked:** {self.session["statistics"]["blockedOperations"]}',
|
||||
f'- **Auto-fixes Applied:** {self.session["statistics"]["autoFixed"]}',
|
||||
"",
|
||||
]
|
||||
|
||||
if top_issues:
|
||||
report.extend(["## Top Issues", ""])
|
||||
for issue in top_issues:
|
||||
report.append(f'- **{issue["type"]}** ({issue["count"]} occurrences)')
|
||||
report.append("")
|
||||
|
||||
if self.session["improvements"]:
|
||||
report.extend(["## Improvements Made", ""])
|
||||
for imp in self.session["improvements"][:5]:
|
||||
report.append(f'- **{Path(imp["file"]).name}:** {imp["action"]}')
|
||||
report.append("")
|
||||
|
||||
if file_stats["mostProblematic"]:
|
||||
report.extend(["## Files Needing Attention", ""])
|
||||
for file in file_stats["mostProblematic"]:
|
||||
report.append(f'- **{file["path"]}** ({file["issues"]} issues)')
|
||||
report.append("")
|
||||
|
||||
report.extend(["## Recommendations", ""])
|
||||
for rec in self.get_recommendations():
|
||||
report.append(f'- {rec.lstrip("- ")}')
|
||||
|
||||
report.extend(
|
||||
[
|
||||
"",
|
||||
"## Reference",
|
||||
"",
|
||||
"For detailed coding standards, see: [docs/architecture/coding-standards.md](../architecture/coding-standards.md)",
|
||||
]
|
||||
)
|
||||
|
||||
# Save report to file with proper naming
|
||||
self.save_report_to_file("\n".join(report))
|
||||
|
||||
# Clean up session file
|
||||
self.cleanup()
|
||||
|
||||
return {"message": "📊 Code quality session report generated"}
|
||||
|
||||
def save_report_to_file(self, report_content: str):
|
||||
"""Save report to file with proper kebab-case naming"""
|
||||
try:
|
||||
timestamp = datetime.now().isoformat()[:19].replace(":", "-")
|
||||
filename = f"code-quality-session-{timestamp}.md"
|
||||
filepath = self.reports_dir / filename
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
|
||||
print(f"📁 Report saved: docs/reports/{filename}", file=sys.stderr)
|
||||
except Exception as error:
|
||||
print(f"⚠️ Failed to save report: {error}", file=sys.stderr)
|
||||
|
||||
def calculate_duration(self) -> str:
|
||||
"""Calculate session duration"""
|
||||
start = datetime.fromisoformat(self.session["startTime"])
|
||||
end = datetime.now()
|
||||
diff = end - start
|
||||
|
||||
hours = int(diff.total_seconds() // 3600)
|
||||
minutes = int((diff.total_seconds() % 3600) // 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}h {minutes}m"
|
||||
return f"{minutes}m"
|
||||
|
||||
def get_top_issues(self) -> list[dict[str, Any]]:
|
||||
"""Get top issues by frequency"""
|
||||
issue_counts = {}
|
||||
|
||||
for violation in self.session["violations"]:
|
||||
issue_type = violation["issue"].split(":")[0]
|
||||
issue_counts[issue_type] = issue_counts.get(issue_type, 0) + 1
|
||||
|
||||
return sorted(
|
||||
[{"type": type_, "count": count} for type_, count in issue_counts.items()],
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)[:5]
|
||||
|
||||
def get_file_statistics(self) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Get file statistics"""
|
||||
file_issues = {}
|
||||
|
||||
for violation in self.session["violations"]:
|
||||
if violation["file"] and violation["file"] != "unknown":
|
||||
file_issues[violation["file"]] = (
|
||||
file_issues.get(violation["file"], 0) + 1
|
||||
)
|
||||
|
||||
most_problematic = sorted(
|
||||
[
|
||||
{"path": Path(path).name, "issues": issues}
|
||||
for path, issues in file_issues.items()
|
||||
],
|
||||
key=lambda x: x["issues"],
|
||||
reverse=True,
|
||||
)[:3]
|
||||
|
||||
return {"mostProblematic": most_problematic}
|
||||
|
||||
def get_recommendations(self) -> list[str]:
|
||||
"""Generate recommendations based on findings"""
|
||||
recommendations = []
|
||||
top_issues = self.get_top_issues()
|
||||
|
||||
# Check for specific issue patterns
|
||||
has_any_type = any("Any Type" in issue["type"] for issue in top_issues)
|
||||
has_var = any("Var" in issue["type"] for issue in top_issues)
|
||||
has_null_safety = any("Null Safety" in issue["type"] for issue in top_issues)
|
||||
|
||||
if has_any_type:
|
||||
recommendations.extend(
|
||||
[
|
||||
' - Replace "any" types with "unknown" or specific types',
|
||||
" - Run: pnpm typecheck to identify type issues",
|
||||
]
|
||||
)
|
||||
|
||||
if has_var:
|
||||
recommendations.extend(
|
||||
[
|
||||
' - Use "const" or "let" instead of "var"',
|
||||
" - Enable no-var ESLint rule for automatic detection",
|
||||
]
|
||||
)
|
||||
|
||||
if has_null_safety:
|
||||
recommendations.extend(
|
||||
[
|
||||
" - Use optional chaining (?.) for nullable values",
|
||||
" - Add null checks before property access",
|
||||
]
|
||||
)
|
||||
|
||||
if self.session["statistics"]["blockedOperations"] > 0:
|
||||
recommendations.extend(
|
||||
[
|
||||
" - Review blocked operations and fix violations",
|
||||
" - Run: pnpm biome:check for comprehensive linting",
|
||||
]
|
||||
)
|
||||
|
||||
if not recommendations:
|
||||
recommendations.extend(
|
||||
[
|
||||
" - Great job! Continue following coding standards",
|
||||
" - Consider running: pnpm code-quality for full validation",
|
||||
]
|
||||
)
|
||||
|
||||
return recommendations
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up session data"""
|
||||
try:
|
||||
if self.session_file.exists():
|
||||
self.session_file.unlink()
|
||||
except Exception:
|
||||
# Silently fail
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
"""Main execution"""
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
|
||||
# Comprehensive logging functionality
|
||||
# Ensure log directory exists
|
||||
log_dir = Path.cwd() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / "code_quality_reporter.json"
|
||||
|
||||
# Read existing log data or initialize empty list
|
||||
if log_path.exists():
|
||||
with open(log_path) as f:
|
||||
try:
|
||||
log_data = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
log_data = []
|
||||
else:
|
||||
log_data = []
|
||||
|
||||
# Add timestamp to the log entry
|
||||
timestamp = datetime.now().strftime("%b %d, %I:%M%p").lower()
|
||||
input_data["timestamp"] = timestamp
|
||||
|
||||
# Process the event and get results
|
||||
reporter = CodeQualityReporter()
|
||||
result = reporter.process_event(input_data)
|
||||
|
||||
# Add processing result to log entry if available
|
||||
if result:
|
||||
input_data["processing_result"] = result
|
||||
|
||||
# Append new data to log
|
||||
log_data.append(input_data)
|
||||
|
||||
# Write back to file with formatting
|
||||
with open(log_path, "w") as f:
|
||||
json.dump(log_data, f, indent=2)
|
||||
|
||||
if result:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
# No output for non-Stop events
|
||||
print(json.dumps({"message": ""}))
|
||||
except Exception as error:
|
||||
print(json.dumps({"message": f"Reporter error: {error}"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user