code_quality_checker.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. #!/usr/bin/env python3
  2. """
  3. Code Quality Checker
  4. Analyzes source code for quality issues, code smells, complexity metrics,
  5. and SOLID principle violations.
  6. Usage:
  7. python code_quality_checker.py /path/to/file.py
  8. python code_quality_checker.py /path/to/directory --recursive
  9. python code_quality_checker.py . --language typescript --json
  10. """
  11. import argparse
  12. import json
  13. import re
  14. import sys
  15. from pathlib import Path
  16. from typing import Dict, List, Optional
  17. # Language-specific file extensions
  18. LANGUAGE_EXTENSIONS = {
  19. "python": [".py"],
  20. "typescript": [".ts", ".tsx"],
  21. "javascript": [".js", ".jsx", ".mjs"],
  22. "go": [".go"],
  23. "swift": [".swift"],
  24. "kotlin": [".kt", ".kts"]
  25. }
  26. # Code smell thresholds
  27. THRESHOLDS = {
  28. "long_function_lines": 50,
  29. "too_many_parameters": 5,
  30. "high_complexity": 10,
  31. "god_class_methods": 20,
  32. "max_imports": 15
  33. }
  34. def get_file_extension(filepath: Path) -> str:
  35. """Get file extension."""
  36. return filepath.suffix.lower()
  37. def detect_language(filepath: Path) -> Optional[str]:
  38. """Detect programming language from file extension."""
  39. ext = get_file_extension(filepath)
  40. for lang, extensions in LANGUAGE_EXTENSIONS.items():
  41. if ext in extensions:
  42. return lang
  43. return None
  44. def read_file_content(filepath: Path) -> str:
  45. """Read file content safely."""
  46. try:
  47. with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
  48. return f.read()
  49. except Exception:
  50. return ""
  51. def calculate_cyclomatic_complexity(content: str) -> int:
  52. """
  53. Estimate cyclomatic complexity based on control flow keywords.
  54. """
  55. complexity = 1 # Base complexity
  56. # Control flow patterns that increase complexity
  57. patterns = [
  58. r"\bif\b",
  59. r"\belif\b",
  60. r"\belse\b",
  61. r"\bfor\b",
  62. r"\bwhile\b",
  63. r"\bcase\b",
  64. r"\bcatch\b",
  65. r"\bexcept\b",
  66. r"\band\b",
  67. r"\bor\b",
  68. r"\|\|",
  69. r"&&"
  70. ]
  71. for pattern in patterns:
  72. matches = re.findall(pattern, content, re.IGNORECASE)
  73. complexity += len(matches)
  74. return complexity
  75. def count_lines(content: str) -> Dict[str, int]:
  76. """Count different types of lines in code."""
  77. lines = content.split("\n")
  78. total = len(lines)
  79. blank = sum(1 for line in lines if not line.strip())
  80. comment = 0
  81. for line in lines:
  82. stripped = line.strip()
  83. if stripped.startswith("#") or stripped.startswith("//"):
  84. comment += 1
  85. elif stripped.startswith("/*") or stripped.startswith("'''") or stripped.startswith('"""'):
  86. comment += 1
  87. code = total - blank - comment
  88. return {
  89. "total": total,
  90. "code": code,
  91. "blank": blank,
  92. "comment": comment
  93. }
  94. def find_functions(content: str, language: str) -> List[Dict]:
  95. """Find function definitions and their metrics."""
  96. functions = []
  97. # Language-specific function patterns
  98. patterns = {
  99. "python": r"def\s+(\w+)\s*\(([^)]*)\)",
  100. "typescript": r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>)",
  101. "javascript": r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>)",
  102. "go": r"func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(([^)]*)\)",
  103. "swift": r"func\s+(\w+)\s*\(([^)]*)\)",
  104. "kotlin": r"fun\s+(\w+)\s*\(([^)]*)\)"
  105. }
  106. pattern = patterns.get(language, patterns["python"])
  107. matches = re.finditer(pattern, content, re.MULTILINE)
  108. for match in matches:
  109. name = next((g for g in match.groups() if g), "anonymous")
  110. params_str = match.group(2) if len(match.groups()) > 1 and match.group(2) else ""
  111. # Count parameters
  112. params = [p.strip() for p in params_str.split(",") if p.strip()]
  113. param_count = len(params)
  114. # Estimate function length
  115. start_pos = match.end()
  116. remaining = content[start_pos:]
  117. next_func = re.search(pattern, remaining)
  118. if next_func:
  119. func_body = remaining[:next_func.start()]
  120. else:
  121. func_body = remaining[:min(2000, len(remaining))]
  122. line_count = len(func_body.split("\n"))
  123. complexity = calculate_cyclomatic_complexity(func_body)
  124. functions.append({
  125. "name": name,
  126. "parameters": param_count,
  127. "lines": line_count,
  128. "complexity": complexity
  129. })
  130. return functions
  131. def find_classes(content: str, language: str) -> List[Dict]:
  132. """Find class definitions and their metrics."""
  133. classes = []
  134. patterns = {
  135. "python": r"class\s+(\w+)",
  136. "typescript": r"class\s+(\w+)",
  137. "javascript": r"class\s+(\w+)",
  138. "go": r"type\s+(\w+)\s+struct",
  139. "swift": r"class\s+(\w+)",
  140. "kotlin": r"class\s+(\w+)"
  141. }
  142. pattern = patterns.get(language, patterns["python"])
  143. matches = re.finditer(pattern, content)
  144. for match in matches:
  145. name = match.group(1)
  146. start_pos = match.end()
  147. remaining = content[start_pos:]
  148. next_class = re.search(pattern, remaining)
  149. if next_class:
  150. class_body = remaining[:next_class.start()]
  151. else:
  152. class_body = remaining
  153. # Count methods
  154. method_patterns = {
  155. "python": r"def\s+\w+\s*\(",
  156. "typescript": r"(?:public|private|protected)?\s*\w+\s*\([^)]*\)\s*[:{]",
  157. "javascript": r"\w+\s*\([^)]*\)\s*\{",
  158. "go": r"func\s+\(",
  159. "swift": r"func\s+\w+",
  160. "kotlin": r"fun\s+\w+"
  161. }
  162. method_pattern = method_patterns.get(language, method_patterns["python"])
  163. methods = len(re.findall(method_pattern, class_body))
  164. classes.append({
  165. "name": name,
  166. "methods": methods,
  167. "lines": len(class_body.split("\n"))
  168. })
  169. return classes
  170. def check_code_smells(content: str, functions: List[Dict], classes: List[Dict]) -> List[Dict]:
  171. """Check for code smells in the content."""
  172. smells = []
  173. # Long functions
  174. for func in functions:
  175. if func["lines"] > THRESHOLDS["long_function_lines"]:
  176. smells.append({
  177. "type": "long_function",
  178. "severity": "medium",
  179. "message": f"Function '{func['name']}' has {func['lines']} lines (max: {THRESHOLDS['long_function_lines']})",
  180. "location": func["name"]
  181. })
  182. # Too many parameters
  183. for func in functions:
  184. if func["parameters"] > THRESHOLDS["too_many_parameters"]:
  185. smells.append({
  186. "type": "too_many_parameters",
  187. "severity": "low",
  188. "message": f"Function '{func['name']}' has {func['parameters']} parameters (max: {THRESHOLDS['too_many_parameters']})",
  189. "location": func["name"]
  190. })
  191. # High complexity
  192. for func in functions:
  193. if func["complexity"] > THRESHOLDS["high_complexity"]:
  194. severity = "high" if func["complexity"] > 20 else "medium"
  195. smells.append({
  196. "type": "high_complexity",
  197. "severity": severity,
  198. "message": f"Function '{func['name']}' has complexity {func['complexity']} (max: {THRESHOLDS['high_complexity']})",
  199. "location": func["name"]
  200. })
  201. # God classes
  202. for cls in classes:
  203. if cls["methods"] > THRESHOLDS["god_class_methods"]:
  204. smells.append({
  205. "type": "god_class",
  206. "severity": "high",
  207. "message": f"Class '{cls['name']}' has {cls['methods']} methods (max: {THRESHOLDS['god_class_methods']})",
  208. "location": cls["name"]
  209. })
  210. # Magic numbers
  211. magic_pattern = r"\b(?<![.\"\'])\d{3,}\b(?!\.\d)"
  212. for i, line in enumerate(content.split("\n"), 1):
  213. if line.strip().startswith(("#", "//", "import", "from")):
  214. continue
  215. matches = re.findall(magic_pattern, line)
  216. for match in matches[:1]: # One per line
  217. smells.append({
  218. "type": "magic_number",
  219. "severity": "low",
  220. "message": f"Magic number {match} should be a named constant",
  221. "location": f"line {i}"
  222. })
  223. # Commented code patterns
  224. commented_code_pattern = r"^\s*[#//]+\s*(if|for|while|def|function|class|const|let|var)\s"
  225. for i, line in enumerate(content.split("\n"), 1):
  226. if re.match(commented_code_pattern, line, re.IGNORECASE):
  227. smells.append({
  228. "type": "commented_code",
  229. "severity": "low",
  230. "message": "Commented-out code should be removed",
  231. "location": f"line {i}"
  232. })
  233. return smells
  234. def check_solid_violations(content: str) -> List[Dict]:
  235. """Check for potential SOLID principle violations."""
  236. violations = []
  237. # OCP: Type checking instead of polymorphism
  238. type_checks = len(re.findall(r"isinstance\(|type\(.*\)\s*==|typeof\s+\w+\s*===", content))
  239. if type_checks > 2:
  240. violations.append({
  241. "principle": "OCP",
  242. "name": "Open/Closed Principle",
  243. "severity": "medium",
  244. "message": f"Found {type_checks} type checks - consider using polymorphism"
  245. })
  246. # LSP/ISP: NotImplementedError
  247. not_impl = len(re.findall(r"raise\s+NotImplementedError|not\s+implemented", content, re.IGNORECASE))
  248. if not_impl:
  249. violations.append({
  250. "principle": "LSP/ISP",
  251. "name": "Liskov/Interface Segregation",
  252. "severity": "low",
  253. "message": f"Found {not_impl} unimplemented methods - may indicate oversized interface"
  254. })
  255. # DIP: Too many direct imports
  256. imports = len(re.findall(r"^(?:import|from)\s+", content, re.MULTILINE))
  257. if imports > THRESHOLDS["max_imports"]:
  258. violations.append({
  259. "principle": "DIP",
  260. "name": "Dependency Inversion Principle",
  261. "severity": "low",
  262. "message": f"File has {imports} imports - consider dependency injection"
  263. })
  264. return violations
  265. def calculate_quality_score(
  266. line_metrics: Dict,
  267. functions: List[Dict],
  268. classes: List[Dict],
  269. smells: List[Dict],
  270. violations: List[Dict]
  271. ) -> int:
  272. """Calculate overall quality score (0-100)."""
  273. score = 100
  274. # Deduct for code smells
  275. for smell in smells:
  276. if smell["severity"] == "high":
  277. score -= 10
  278. elif smell["severity"] == "medium":
  279. score -= 5
  280. elif smell["severity"] == "low":
  281. score -= 2
  282. # Deduct for SOLID violations
  283. for violation in violations:
  284. if violation["severity"] == "high":
  285. score -= 8
  286. elif violation["severity"] == "medium":
  287. score -= 4
  288. elif violation["severity"] == "low":
  289. score -= 2
  290. # Bonus for good comment ratio (10-30%)
  291. if line_metrics["total"] > 0:
  292. comment_ratio = line_metrics["comment"] / line_metrics["total"]
  293. if 0.1 <= comment_ratio <= 0.3:
  294. score += 5
  295. # Bonus for reasonable function sizes
  296. if functions:
  297. avg_lines = sum(f["lines"] for f in functions) / len(functions)
  298. if avg_lines < 30:
  299. score += 5
  300. return max(0, min(100, score))
  301. def get_grade(score: int) -> str:
  302. """Convert score to letter grade."""
  303. if score >= 90:
  304. return "A"
  305. elif score >= 80:
  306. return "B"
  307. elif score >= 70:
  308. return "C"
  309. elif score >= 60:
  310. return "D"
  311. else:
  312. return "F"
  313. def analyze_file(filepath: Path) -> Dict:
  314. """Analyze a single file for code quality."""
  315. language = detect_language(filepath)
  316. if not language:
  317. return {"error": f"Unsupported file type: {filepath.suffix}"}
  318. content = read_file_content(filepath)
  319. if not content:
  320. return {"error": f"Could not read file: {filepath}"}
  321. line_metrics = count_lines(content)
  322. functions = find_functions(content, language)
  323. classes = find_classes(content, language)
  324. smells = check_code_smells(content, functions, classes)
  325. violations = check_solid_violations(content)
  326. score = calculate_quality_score(line_metrics, functions, classes, smells, violations)
  327. return {
  328. "file": str(filepath),
  329. "language": language,
  330. "metrics": {
  331. "lines": line_metrics,
  332. "functions": len(functions),
  333. "classes": len(classes),
  334. "avg_complexity": round(sum(f["complexity"] for f in functions) / max(1, len(functions)), 1)
  335. },
  336. "quality_score": score,
  337. "grade": get_grade(score),
  338. "smells": smells,
  339. "solid_violations": violations,
  340. "function_details": functions[:10],
  341. "class_details": classes[:10]
  342. }
  343. def analyze_directory(
  344. dir_path: Path,
  345. recursive: bool = True,
  346. language: Optional[str] = None
  347. ) -> Dict:
  348. """Analyze all files in a directory."""
  349. results = []
  350. extensions = []
  351. if language:
  352. extensions = LANGUAGE_EXTENSIONS.get(language, [])
  353. else:
  354. for exts in LANGUAGE_EXTENSIONS.values():
  355. extensions.extend(exts)
  356. pattern = "**/*" if recursive else "*"
  357. for ext in extensions:
  358. for filepath in dir_path.glob(f"{pattern}{ext}"):
  359. if "node_modules" in str(filepath) or ".git" in str(filepath):
  360. continue
  361. result = analyze_file(filepath)
  362. if "error" not in result:
  363. results.append(result)
  364. if not results:
  365. return {"error": "No supported files found"}
  366. total_score = sum(r["quality_score"] for r in results)
  367. avg_score = total_score / len(results)
  368. total_smells = sum(len(r["smells"]) for r in results)
  369. total_violations = sum(len(r["solid_violations"]) for r in results)
  370. return {
  371. "directory": str(dir_path),
  372. "files_analyzed": len(results),
  373. "average_score": round(avg_score, 1),
  374. "overall_grade": get_grade(int(avg_score)),
  375. "total_code_smells": total_smells,
  376. "total_solid_violations": total_violations,
  377. "files": sorted(results, key=lambda x: x["quality_score"])
  378. }
  379. def print_report(analysis: Dict) -> None:
  380. """Print human-readable analysis report."""
  381. if "error" in analysis:
  382. print(f"Error: {analysis['error']}")
  383. return
  384. print("=" * 60)
  385. print("CODE QUALITY REPORT")
  386. print("=" * 60)
  387. if "file" in analysis:
  388. print(f"\nFile: {analysis['file']}")
  389. print(f"Language: {analysis['language']}")
  390. print(f"Quality Score: {analysis['quality_score']}/100 ({analysis['grade']})")
  391. metrics = analysis["metrics"]
  392. print(f"\nLines: {metrics['lines']['total']} ({metrics['lines']['code']} code, {metrics['lines']['comment']} comments)")
  393. print(f"Functions: {metrics['functions']}")
  394. print(f"Classes: {metrics['classes']}")
  395. print(f"Avg Complexity: {metrics['avg_complexity']}")
  396. if analysis["smells"]:
  397. print("\n--- CODE SMELLS ---")
  398. for smell in analysis["smells"][:10]:
  399. print(f" [{smell['severity'].upper()}] {smell['message']} ({smell['location']})")
  400. if analysis["solid_violations"]:
  401. print("\n--- SOLID VIOLATIONS ---")
  402. for v in analysis["solid_violations"]:
  403. print(f" [{v['principle']}] {v['message']}")
  404. else:
  405. print(f"\nDirectory: {analysis['directory']}")
  406. print(f"Files Analyzed: {analysis['files_analyzed']}")
  407. print(f"Average Score: {analysis['average_score']}/100 ({analysis['overall_grade']})")
  408. print(f"Total Code Smells: {analysis['total_code_smells']}")
  409. print(f"Total SOLID Violations: {analysis['total_solid_violations']}")
  410. print("\n--- FILES BY QUALITY ---")
  411. for f in analysis["files"][:10]:
  412. print(f" {f['quality_score']:3d}/100 [{f['grade']}] {f['file']}")
  413. print("\n" + "=" * 60)
  414. def main():
  415. parser = argparse.ArgumentParser(
  416. description="Analyze code quality, smells, and SOLID violations"
  417. )
  418. parser.add_argument(
  419. "path",
  420. help="File or directory to analyze"
  421. )
  422. parser.add_argument(
  423. "--recursive", "-r",
  424. action="store_true",
  425. default=True,
  426. help="Recursively analyze directories (default: true)"
  427. )
  428. parser.add_argument(
  429. "--language", "-l",
  430. choices=list(LANGUAGE_EXTENSIONS.keys()),
  431. help="Filter by programming language"
  432. )
  433. parser.add_argument(
  434. "--json",
  435. action="store_true",
  436. help="Output in JSON format"
  437. )
  438. parser.add_argument(
  439. "--output", "-o",
  440. help="Write output to file"
  441. )
  442. args = parser.parse_args()
  443. target = Path(args.path).resolve()
  444. if not target.exists():
  445. print(f"Error: Path does not exist: {target}", file=sys.stderr)
  446. sys.exit(1)
  447. if target.is_file():
  448. analysis = analyze_file(target)
  449. else:
  450. analysis = analyze_directory(target, args.recursive, args.language)
  451. if args.json:
  452. output = json.dumps(analysis, indent=2, default=str)
  453. if args.output:
  454. with open(args.output, "w") as f:
  455. f.write(output)
  456. print(f"Results written to {args.output}")
  457. else:
  458. print(output)
  459. else:
  460. print_report(analysis)
  461. if __name__ == "__main__":
  462. main()