#!/usr/bin/env python3 """ Code Quality Checker Analyzes source code for quality issues, code smells, complexity metrics, and SOLID principle violations. Usage: python code_quality_checker.py /path/to/file.py python code_quality_checker.py /path/to/directory --recursive python code_quality_checker.py . --language typescript --json """ import argparse import json import re import sys from pathlib import Path from typing import Dict, List, Optional # Language-specific file extensions. # `c` is declared before `cpp` so plain `.h` resolves to C, matching the # dispatch table in SKILL.md. C++ headers use `.hpp` / `.hh` / `.hxx`. LANGUAGE_EXTENSIONS = { "python": [".py"], "typescript": [".ts", ".tsx"], "javascript": [".js", ".jsx", ".mjs"], "go": [".go"], "swift": [".swift"], "kotlin": [".kt", ".kts"], "csharp": [".cs", ".csx", ".razor", ".cshtml"], "java": [".java"], "c": [".c", ".h"], "cpp": [".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"], "rust": [".rs"], "ruby": [".rb", ".rake", ".gemspec", ".ru"], "php": [".php", ".phtml"], "dart": [".dart"], } # Code smell thresholds THRESHOLDS = { "long_function_lines": 50, "too_many_parameters": 5, "high_complexity": 10, "god_class_methods": 20, "max_imports": 15 } def get_file_extension(filepath: Path) -> str: """Get file extension.""" return filepath.suffix.lower() def detect_language(filepath: Path) -> Optional[str]: """Detect programming language from file extension.""" ext = get_file_extension(filepath) for lang, extensions in LANGUAGE_EXTENSIONS.items(): if ext in extensions: return lang return None def read_file_content(filepath: Path) -> str: """Read file content safely.""" try: with open(filepath, "r", encoding="utf-8", errors="ignore") as f: return f.read() except Exception: return "" def calculate_cyclomatic_complexity(content: str) -> int: """ Estimate cyclomatic complexity based on control flow keywords. """ complexity = 1 # Base complexity # Control flow patterns that increase complexity patterns = [ r"\bif\b", r"\belif\b", r"\belse\b", r"\bfor\b", r"\bwhile\b", r"\bcase\b", r"\bcatch\b", r"\bexcept\b", r"\band\b", r"\bor\b", r"\|\|", r"&&" ] for pattern in patterns: matches = re.findall(pattern, content, re.IGNORECASE) complexity += len(matches) return complexity def count_lines(content: str) -> Dict[str, int]: """Count different types of lines in code.""" lines = content.split("\n") total = len(lines) blank = sum(1 for line in lines if not line.strip()) comment = 0 for line in lines: stripped = line.strip() if stripped.startswith("#") or stripped.startswith("//"): comment += 1 elif stripped.startswith("/*") or stripped.startswith("'''") or stripped.startswith('"""'): comment += 1 code = total - blank - comment return { "total": total, "code": code, "blank": blank, "comment": comment } def find_functions(content: str, language: str) -> List[Dict]: """Find function definitions and their metrics.""" functions = [] # Language-specific function patterns patterns = { "python": r"def\s+(\w+)\s*\(([^)]*)\)", "typescript": r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>)", "javascript": r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>)", "go": r"func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(([^)]*)\)", "swift": r"func\s+(\w+)\s*\(([^)]*)\)", "kotlin": r"fun\s+(\w+)\s*\(([^)]*)\)", # C#: require at least one method modifier (public/private/etc. or static/async/...) # to distinguish declarations from invocations. "csharp": ( r"(?:(?:public|private|protected|internal|static|async|virtual|" r"override|sealed|abstract|partial|new|readonly|extern)\s+)+" r"(?:[\w<>?,\s\[\]\.]+?\s+)?(\w+)\s*\(([^)]*)\)" ), # Java: require at least one method modifier to distinguish # declarations from invocations (mirrors the C# approach). "java": ( r"(?:(?:public|private|protected|static|final|abstract|" r"synchronized|native|default|strictfp)\s+)+" r"(?:[\w<>?,\s\[\]\.]+?\s+)?(\w+)\s*\(([^)]*)\)" ), # C: require an opening brace after the parens so prototypes and # call sites don't get matched. Return type / qualifiers come first. # Skip C control-flow keywords that look like function calls. "c": ( r"^(?:static\s+|inline\s+|extern\s+|const\s+|unsigned\s+|" r"signed\s+|volatile\s+|register\s+)*" r"(?:[\w\*]+\s+\**)+" r"(?!(?:if|while|for|switch|return|sizeof)\b)" r"(\w+)\s*\(([^)]*)\)\s*\{" ), # C++: like C but also catches `ClassName::method(...)` definitions # and template return types like `std::vector`. "cpp": ( r"^(?:static\s+|inline\s+|extern\s+|const\s+|virtual\s+|" r"explicit\s+|constexpr\s+|noexcept\s+)*" r"(?:[\w:\*<>&,\s]+\s+\**)+" r"(?!(?:if|while|for|switch|return|sizeof)\b)" r"(\w+)(?:::\w+)?\s*\(([^)]*)\)\s*(?:const\s*)?" r"(?:noexcept\s*)?(?:override\s*)?(?:final\s*)?\{" ), # Rust: `fn` keyword is always present and unambiguous. "rust": ( r"(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?(?:unsafe\s+)?" r"(?:extern\s+\"[^\"]+\"\s+)?fn\s+(\w+)\s*" r"(?:<[^>]+>)?\s*\(([^)]*)\)" ), # Ruby: `def` keyword; params may be parenthesised or bare. "ruby": ( r"def\s+(?:self\.)?(\w+[?!=]?)(?:\s*\(([^)]*)\)|\s*$|\s+\w)" ), # PHP: `function` keyword is always present. "php": ( r"(?:(?:public|private|protected|static|abstract|final)\s+)*" r"function\s+(\w+)\s*\(([^)]*)\)" ), # Dart: typed return followed by name and parens. Constructors # (where name matches enclosing class) are not specially handled. "dart": ( r"^\s*(?:static\s+|external\s+)*" r"(?:Future<[^>]*>|Stream<[^>]*>|void|[\w<>?,\s]+?)\s+" r"(\w+)\s*\(([^)]*)\)\s*(?:async\*?\s*|sync\*?\s*)?\{" ), } pattern = patterns.get(language, patterns["python"]) matches = re.finditer(pattern, content, re.MULTILINE) for match in matches: name = next((g for g in match.groups() if g), "anonymous") params_str = match.group(2) if len(match.groups()) > 1 and match.group(2) else "" # Count parameters params = [p.strip() for p in params_str.split(",") if p.strip()] param_count = len(params) # Estimate function length start_pos = match.end() remaining = content[start_pos:] next_func = re.search(pattern, remaining) if next_func: func_body = remaining[:next_func.start()] else: func_body = remaining[:min(2000, len(remaining))] line_count = len(func_body.split("\n")) complexity = calculate_cyclomatic_complexity(func_body) functions.append({ "name": name, "parameters": param_count, "lines": line_count, "complexity": complexity }) return functions def find_classes(content: str, language: str) -> List[Dict]: """Find class definitions and their metrics.""" classes = [] patterns = { "python": r"class\s+(\w+)", "typescript": r"class\s+(\w+)", "javascript": r"class\s+(\w+)", "go": r"type\s+(\w+)\s+struct", "swift": r"class\s+(\w+)", "kotlin": r"class\s+(\w+)", "csharp": r"(?:class|struct|record|interface)\s+(\w+)", "java": r"(?:class|interface|enum|record)\s+(\w+)", # C has no classes; `struct` and `typedef struct` are the closest. "c": r"(?:typedef\s+)?struct\s+(\w+)", "cpp": r"(?:class|struct)\s+(\w+)", # Rust uses `struct`, `enum`, `trait`, `union` for type definitions. # `impl` blocks attach methods but are not type defs themselves. "rust": r"(?:pub(?:\([^)]+\))?\s+)?(?:struct|enum|trait|union)\s+(\w+)", "ruby": r"(?:class|module)\s+(\w+)", "php": ( r"(?:abstract\s+|final\s+)?" r"(?:class|interface|trait|enum)\s+(\w+)" ), # Dart 3 class modifiers: final / interface / base / sealed / mixin. "dart": ( r"(?:abstract\s+|sealed\s+|final\s+|base\s+|interface\s+)?" r"(?:class|mixin|enum|extension)\s+(\w+)" ), } pattern = patterns.get(language, patterns["python"]) matches = re.finditer(pattern, content) for match in matches: name = match.group(1) start_pos = match.end() remaining = content[start_pos:] next_class = re.search(pattern, remaining) if next_class: class_body = remaining[:next_class.start()] else: class_body = remaining # Count methods method_patterns = { "python": r"def\s+\w+\s*\(", "typescript": r"(?:public|private|protected)?\s*\w+\s*\([^)]*\)\s*[:{]", "javascript": r"\w+\s*\([^)]*\)\s*\{", "go": r"func\s+\(", "swift": r"func\s+\w+", "kotlin": r"fun\s+\w+", "csharp": ( r"(?:(?:public|private|protected|internal|static|async|virtual|" r"override|sealed|abstract|partial)\s+)+" r"(?:[\w<>?,\s\[\]\.]+?\s+)?\w+\s*\(" ), "java": ( r"(?:(?:public|private|protected|static|final|abstract|" r"synchronized|native|default|strictfp)\s+)+" r"(?:[\w<>?,\s\[\]\.]+?\s+)?\w+\s*\(" ), # C has no classes; struct members are typically function pointers # rather than methods. Use the function definition pattern. "c": ( r"^(?:static\s+|inline\s+)*(?:[\w\*]+\s+\**)+" r"(?!(?:if|while|for|switch|return|sizeof)\b)" r"\w+\s*\([^)]*\)\s*\{" ), "cpp": ( r"^(?:static\s+|inline\s+|virtual\s+|explicit\s+|" r"constexpr\s+)*(?:[\w:\*<>&,\s]+\s+\**)+" r"(?!(?:if|while|for|switch|return|sizeof)\b)" r"\w+(?:::\w+)?\s*\([^)]*\)" ), "rust": ( r"(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?(?:unsafe\s+)?" r"fn\s+\w+" ), "ruby": r"def\s+(?:self\.)?\w+[?!=]?", "php": ( r"(?:(?:public|private|protected|static|abstract|final)\s+)*" r"function\s+\w+\s*\(" ), "dart": ( r"^\s*(?:static\s+|external\s+)*" r"(?:Future<[^>]*>|Stream<[^>]*>|void|[\w<>?,\s]+?)\s+" r"\w+\s*\([^)]*\)\s*(?:async\*?\s*|sync\*?\s*)?\{" ), } method_pattern = method_patterns.get(language, method_patterns["python"]) methods = len(re.findall(method_pattern, class_body)) classes.append({ "name": name, "methods": methods, "lines": len(class_body.split("\n")) }) return classes def check_code_smells(content: str, functions: List[Dict], classes: List[Dict]) -> List[Dict]: """Check for code smells in the content.""" smells = [] # Long functions for func in functions: if func["lines"] > THRESHOLDS["long_function_lines"]: smells.append({ "type": "long_function", "severity": "medium", "message": f"Function '{func['name']}' has {func['lines']} lines (max: {THRESHOLDS['long_function_lines']})", "location": func["name"] }) # Too many parameters for func in functions: if func["parameters"] > THRESHOLDS["too_many_parameters"]: smells.append({ "type": "too_many_parameters", "severity": "low", "message": f"Function '{func['name']}' has {func['parameters']} parameters (max: {THRESHOLDS['too_many_parameters']})", "location": func["name"] }) # High complexity for func in functions: if func["complexity"] > THRESHOLDS["high_complexity"]: severity = "high" if func["complexity"] > 20 else "medium" smells.append({ "type": "high_complexity", "severity": severity, "message": f"Function '{func['name']}' has complexity {func['complexity']} (max: {THRESHOLDS['high_complexity']})", "location": func["name"] }) # God classes for cls in classes: if cls["methods"] > THRESHOLDS["god_class_methods"]: smells.append({ "type": "god_class", "severity": "high", "message": f"Class '{cls['name']}' has {cls['methods']} methods (max: {THRESHOLDS['god_class_methods']})", "location": cls["name"] }) # Magic numbers magic_pattern = r"\b(? str: """Remove // line comments and /* */ block comments so regex detectors don't match keywords inside prose.""" no_block = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) no_line = re.sub(r"//[^\n]*", "", no_block) return no_line def check_csharp_specific_smells(content: str) -> List[Dict]: """C# / .NET-specific code smells documented in SKILL.md.""" smells: List[Dict] = [] content = _strip_csharp_comments(content) # async void (event handler exception only — caller must justify) for match in re.finditer(r"\basync\s+void\s+(\w+)\s*\(", content): smells.append({ "type": "csharp_async_void", "severity": "high", "message": ( f"'async void {match.group(1)}' — only safe for event handlers; " "prefer 'async Task'" ), "location": match.group(1), }) # Blocking on async: .Result, .Wait(), .GetAwaiter().GetResult() for match in re.finditer( r"\.(?:Result\b|Wait\(\)|GetAwaiter\(\)\.GetResult\(\))", content ): smells.append({ "type": "csharp_blocking_async", "severity": "high", "message": ( "Blocking call on async operation ('.Result' / '.Wait()' / " "'.GetAwaiter().GetResult()') — can deadlock in ASP.NET contexts" ), "location": f"offset {match.start()}", }) # Bare catch / catch (Exception) that swallows swallow_pattern = re.compile( r"catch\s*(?:\(\s*(?:System\.)?Exception(?:\s+\w+)?\s*\))?\s*\{\s*\}" ) for match in swallow_pattern.finditer(content): smells.append({ "type": "csharp_swallowed_exception", "severity": "high", "message": "Empty catch block swallows exceptions silently", "location": f"offset {match.start()}", }) # IDisposable instantiated but not in `using` — heuristic: `new SomethingClient(` # / `new SomethingStream(` / `new SqlConnection(` outside a `using` line. disposable_hint = re.compile( r"^(?!\s*using\b)\s*(?:var|[\w<>]+)\s+\w+\s*=\s*new\s+" r"(\w*(?:Stream|Connection|Reader|Writer|Client|Context|Command))\s*\(", re.MULTILINE, ) for match in disposable_hint.finditer(content): smells.append({ "type": "csharp_undisposed_idisposable", "severity": "medium", "message": ( f"'{match.group(1)}' looks like IDisposable but is not wrapped in " "'using' / 'using var'" ), "location": f"offset {match.start()}", }) # HttpClient instantiated with `new` inside a method body (socket exhaustion) httpclient_inline = re.compile(r"new\s+HttpClient\s*\(\s*\)") for match in httpclient_inline.finditer(content): smells.append({ "type": "csharp_new_httpclient", "severity": "medium", "message": ( "'new HttpClient()' — prefer IHttpClientFactory or a long-lived " "static instance to avoid socket exhaustion" ), "location": f"offset {match.start()}", }) # Missing await: `Task.Run(` / async method call assigned but never awaited. # Heuristic: a statement ending in `Async()` or `Async(...)` followed by `;` # with no `await` keyword on the same line. for line_no, line in enumerate(content.split("\n"), 1): stripped = line.strip() if not stripped or stripped.startswith(("//", "/*", "*")): continue if re.search(r"\b\w+Async\s*\([^)]*\)\s*;\s*$", stripped) and "await " not in stripped: # Skip `return ...Async();` (forwarding the Task is legitimate) if stripped.startswith("return "): continue smells.append({ "type": "csharp_missing_await", "severity": "medium", "message": "Async method called without 'await' — Task is discarded", "location": f"line {line_no}", }) # Unnecessary `using` directives — heuristic: `using` directive whose # namespace tail isn't referenced anywhere else in the file. using_directives = re.findall( r"^using\s+(?:static\s+)?([A-Z]\w*(?:\.\w+)*)\s*;", content, re.MULTILINE ) body = re.sub(r"^using\s+[^;]+;\s*$", "", content, flags=re.MULTILINE) for ns in using_directives: tail = ns.split(".")[-1] if not re.search(rf"\b{re.escape(tail)}\b", body): smells.append({ "type": "csharp_unused_using", "severity": "low", "message": f"'using {ns};' appears unused", "location": ns, }) return smells def check_java_specific_smells(content: str) -> List[Dict]: """Java-specific code smells documented in languages/java.md.""" smells: List[Dict] = [] # Java comment syntax matches C#, so the same stripper applies. content = _strip_csharp_comments(content) # Empty catch block — swallows the exception silently. for match in re.finditer(r"catch\s*\([^)]*\)\s*\{\s*\}", content): smells.append({ "type": "java_empty_catch", "severity": "high", "message": "Empty catch block swallows exceptions silently", "location": f"offset {match.start()}", }) # printStackTrace() as error handling — use a logger instead. for match in re.finditer(r"\.printStackTrace\s*\(\s*\)", content): smells.append({ "type": "java_print_stack_trace", "severity": "medium", "message": ( "'printStackTrace()' is not real error handling — log via a " "proper logger or rethrow with context" ), "location": f"offset {match.start()}", }) # InterruptedException caught without restoring the interrupt flag. for match in re.finditer( r"catch\s*\(\s*InterruptedException\s+(\w+)\s*\)\s*\{(.*?)\}", content, re.DOTALL, ): if "interrupt()" not in match.group(2): smells.append({ "type": "java_swallowed_interrupt", "severity": "high", "message": ( "InterruptedException caught without " "'Thread.currentThread().interrupt()' — breaks cooperative " "cancellation" ), "location": f"offset {match.start()}", }) # Closeable resource instantiated outside try-with-resources (leak heuristic). resource_hint = re.compile( r"^(?!\s*try\b)\s*(?:final\s+)?[\w<>\[\]]+\s+\w+\s*=\s*new\s+" r"(\w*(?:InputStream|OutputStream|Reader|Writer|Stream|Connection))\s*\(", re.MULTILINE, ) for match in resource_hint.finditer(content): smells.append({ "type": "java_unclosed_resource", "severity": "medium", "message": ( f"'{match.group(1)}' looks like an AutoCloseable but is not in a " "try-with-resources statement" ), "location": f"offset {match.start()}", }) # Heavy object built per use instead of shared as a singleton. # A `static` field assignment is the recommended singleton form — skip it. heavy_object = re.compile( r"^(?!.*\bstatic\b).*\bnew\s+(ObjectMapper|Gson)\s*\(\s*\)", re.MULTILINE, ) for match in heavy_object.finditer(content): smells.append({ "type": "java_per_use_heavy_object", "severity": "medium", "message": ( f"'new {match.group(1)}()' is expensive — share a singleton " "instance instead of constructing per call" ), "location": f"offset {match.start()}", }) return smells def check_c_specific_smells(content: str) -> List[Dict]: """C-specific code smells documented in languages/c.md. Focuses on memory-safety and command/format-string patterns that the CERT C Coding Standard and the CWE catalogue rank as the highest-impact footguns. C uses the same line/block comment syntax as C# and Java, so the existing comment stripper applies. """ smells: List[Dict] = [] content = _strip_csharp_comments(content) # 1. Banned functions — no bounds check on any of them. banned = { "gets": "no bounds check, removed from C11 (CWE-242)", "strcpy": "no bounds check — prefer strncpy or strlcpy", "strcat": "no bounds check — prefer strncat or strlcat", "sprintf": "no bounds check — prefer snprintf", "vsprintf": "no bounds check — prefer vsnprintf", } for fn, reason in banned.items(): for m in re.finditer(rf"\b{fn}\s*\(", content): smells.append({ "type": f"c_banned_{fn}", "severity": "high", "message": f"'{fn}()' is unsafe: {reason}", "location": f"offset {m.start()}", }) # 2. Format-string vulnerability — printf/syslog called with a bare # identifier as the format argument (CWE-134). Skip when the first # arg is a string literal. for fn in ("printf", "syslog"): pattern = rf"\b{fn}\s*\(\s*(?!\")(\w+)\s*[,\)]" for m in re.finditer(pattern, content): smells.append({ "type": "c_format_string", "severity": "high", "message": ( f"'{fn}({m.group(1)})' uses a non-literal format string " "— CWE-134 format string vulnerability" ), "location": f"offset {m.start()}", }) # 3. Unbounded scanf — `%s` without a width specifier invites overflow. scanf_call = re.compile( r"\b(?:scanf|fscanf|sscanf)\s*\(\s*[^)]*?\"([^\"]*)\"" ) for m in scanf_call.finditer(content): fmt = m.group(1) if "%s" in fmt and not re.search(r"%\d+s", fmt): smells.append({ "type": "c_unbounded_scanf", "severity": "high", "message": ( "scanf '%s' without a width specifier — unbounded read " "can overflow the destination buffer" ), "location": f"offset {m.start()}", }) # 4. malloc / calloc / realloc result dereferenced without a NULL check # within 5 lines. CWE-690. lines = content.split("\n") malloc_assign = re.compile( r"^\s*(?:[\w\*]+\s+)?\*?(\w+)\s*=\s*\(?[\w\s\*]*\)?\s*" r"(?:m|c|re)alloc\s*\(" ) for i, line in enumerate(lines): m = malloc_assign.match(line) if not m: continue var = m.group(1) window = "\n".join(lines[i + 1 : i + 6]) null_check = re.compile( rf"\bif\s*\([^)]*(?:{re.escape(var)}\s*==\s*NULL" rf"|NULL\s*==\s*{re.escape(var)}" rf"|!\s*{re.escape(var)}\b" rf"|{re.escape(var)}\s*!=\s*NULL)" ) if not null_check.search(window): smells.append({ "type": "c_malloc_unchecked", "severity": "medium", "message": ( f"'{var}' from malloc/calloc/realloc is not NULL-checked " "within 5 lines — dereferencing NULL is UB (CWE-690)" ), "location": f"line {i + 1}", }) # 5. free(p) without setting p to NULL on the next real line. # CWE-416 use-after-free guardrail. free_call = re.compile(r"^\s*free\s*\(\s*(\w+)\s*\)\s*;") for i, line in enumerate(lines): m = free_call.match(line) if not m: continue var = m.group(1) for j in range(i + 1, min(i + 3, len(lines))): nxt = lines[j].strip() if not nxt: continue if re.match(rf"^{re.escape(var)}\s*=\s*NULL\s*;", nxt): break smells.append({ "type": "c_free_without_null", "severity": "low", "message": ( f"'free({var})' not followed by '{var} = NULL;' — " "dangling pointer can be reused (CWE-416)" ), "location": f"line {i + 1}", }) break # 6. system() with a non-string-literal argument — command injection. system_pattern = re.compile(r"\bsystem\s*\(\s*(?!\"|NULL\b)(\w+)\s*\)") for m in system_pattern.finditer(content): smells.append({ "type": "c_system_non_literal", "severity": "high", "message": ( f"'system({m.group(1)})' with a non-literal argument — " "command injection (CWE-78); use execve with validated args" ), "location": f"offset {m.start()}", }) return smells def check_solid_violations(content: str) -> List[Dict]: """Check for potential SOLID principle violations.""" violations = [] # OCP: Type checking instead of polymorphism type_checks = len(re.findall(r"isinstance\(|type\(.*\)\s*==|typeof\s+\w+\s*===", content)) if type_checks > 2: violations.append({ "principle": "OCP", "name": "Open/Closed Principle", "severity": "medium", "message": f"Found {type_checks} type checks - consider using polymorphism" }) # LSP/ISP: NotImplementedError not_impl = len(re.findall(r"raise\s+NotImplementedError|not\s+implemented", content, re.IGNORECASE)) if not_impl: violations.append({ "principle": "LSP/ISP", "name": "Liskov/Interface Segregation", "severity": "low", "message": f"Found {not_impl} unimplemented methods - may indicate oversized interface" }) # DIP: Too many direct imports imports = len(re.findall(r"^(?:import|from)\s+", content, re.MULTILINE)) if imports > THRESHOLDS["max_imports"]: violations.append({ "principle": "DIP", "name": "Dependency Inversion Principle", "severity": "low", "message": f"File has {imports} imports - consider dependency injection" }) return violations def calculate_quality_score( line_metrics: Dict, functions: List[Dict], classes: List[Dict], smells: List[Dict], violations: List[Dict] ) -> int: """Calculate overall quality score (0-100).""" score = 100 # Deduct for code smells for smell in smells: if smell["severity"] == "high": score -= 10 elif smell["severity"] == "medium": score -= 5 elif smell["severity"] == "low": score -= 2 # Deduct for SOLID violations for violation in violations: if violation["severity"] == "high": score -= 8 elif violation["severity"] == "medium": score -= 4 elif violation["severity"] == "low": score -= 2 # Bonus for good comment ratio (10-30%) if line_metrics["total"] > 0: comment_ratio = line_metrics["comment"] / line_metrics["total"] if 0.1 <= comment_ratio <= 0.3: score += 5 # Bonus for reasonable function sizes if functions: avg_lines = sum(f["lines"] for f in functions) / len(functions) if avg_lines < 30: score += 5 return max(0, min(100, score)) def get_grade(score: int) -> str: """Convert score to letter grade.""" if score >= 90: return "A" elif score >= 80: return "B" elif score >= 70: return "C" elif score >= 60: return "D" else: return "F" def analyze_file(filepath: Path) -> Dict: """Analyze a single file for code quality.""" language = detect_language(filepath) if not language: return {"error": f"Unsupported file type: {filepath.suffix}"} content = read_file_content(filepath) if not content: return {"error": f"Could not read file: {filepath}"} line_metrics = count_lines(content) functions = find_functions(content, language) classes = find_classes(content, language) smells = check_code_smells(content, functions, classes) if language == "csharp": smells.extend(check_csharp_specific_smells(content)) if language == "java": smells.extend(check_java_specific_smells(content)) if language == "c": smells.extend(check_c_specific_smells(content)) violations = check_solid_violations(content) score = calculate_quality_score(line_metrics, functions, classes, smells, violations) return { "file": str(filepath), "language": language, "metrics": { "lines": line_metrics, "functions": len(functions), "classes": len(classes), "avg_complexity": round(sum(f["complexity"] for f in functions) / max(1, len(functions)), 1) }, "quality_score": score, "grade": get_grade(score), "smells": smells, "solid_violations": violations, "function_details": functions[:10], "class_details": classes[:10] } def analyze_directory( dir_path: Path, recursive: bool = True, language: Optional[str] = None ) -> Dict: """Analyze all files in a directory.""" results = [] extensions = [] if language: extensions = LANGUAGE_EXTENSIONS.get(language, []) else: for exts in LANGUAGE_EXTENSIONS.values(): extensions.extend(exts) pattern = "**/*" if recursive else "*" for ext in extensions: for filepath in dir_path.glob(f"{pattern}{ext}"): if "node_modules" in str(filepath) or ".git" in str(filepath): continue result = analyze_file(filepath) if "error" not in result: results.append(result) if not results: return {"error": "No supported files found"} total_score = sum(r["quality_score"] for r in results) avg_score = total_score / len(results) total_smells = sum(len(r["smells"]) for r in results) total_violations = sum(len(r["solid_violations"]) for r in results) return { "directory": str(dir_path), "files_analyzed": len(results), "average_score": round(avg_score, 1), "overall_grade": get_grade(int(avg_score)), "total_code_smells": total_smells, "total_solid_violations": total_violations, "files": sorted(results, key=lambda x: x["quality_score"]) } def print_report(analysis: Dict) -> None: """Print human-readable analysis report.""" if "error" in analysis: print(f"Error: {analysis['error']}") return print("=" * 60) print("CODE QUALITY REPORT") print("=" * 60) if "file" in analysis: print(f"\nFile: {analysis['file']}") print(f"Language: {analysis['language']}") print(f"Quality Score: {analysis['quality_score']}/100 ({analysis['grade']})") metrics = analysis["metrics"] print(f"\nLines: {metrics['lines']['total']} ({metrics['lines']['code']} code, {metrics['lines']['comment']} comments)") print(f"Functions: {metrics['functions']}") print(f"Classes: {metrics['classes']}") print(f"Avg Complexity: {metrics['avg_complexity']}") if analysis["smells"]: print("\n--- CODE SMELLS ---") for smell in analysis["smells"][:10]: print(f" [{smell['severity'].upper()}] {smell['message']} ({smell['location']})") if analysis["solid_violations"]: print("\n--- SOLID VIOLATIONS ---") for v in analysis["solid_violations"]: print(f" [{v['principle']}] {v['message']}") else: print(f"\nDirectory: {analysis['directory']}") print(f"Files Analyzed: {analysis['files_analyzed']}") print(f"Average Score: {analysis['average_score']}/100 ({analysis['overall_grade']})") print(f"Total Code Smells: {analysis['total_code_smells']}") print(f"Total SOLID Violations: {analysis['total_solid_violations']}") print("\n--- FILES BY QUALITY ---") for f in analysis["files"][:10]: print(f" {f['quality_score']:3d}/100 [{f['grade']}] {f['file']}") print("\n" + "=" * 60) def main(): parser = argparse.ArgumentParser( description="Analyze code quality, smells, and SOLID violations" ) parser.add_argument( "path", help="File or directory to analyze" ) parser.add_argument( "--recursive", "-r", action="store_true", default=True, help="Recursively analyze directories (default: true)" ) parser.add_argument( "--language", "-l", choices=list(LANGUAGE_EXTENSIONS.keys()), help="Filter by programming language" ) parser.add_argument( "--json", action="store_true", help="Output in JSON format" ) parser.add_argument( "--output", "-o", help="Write output to file" ) args = parser.parse_args() target = Path(args.path).resolve() if not target.exists(): print(f"Error: Path does not exist: {target}", file=sys.stderr) sys.exit(1) if target.is_file(): analysis = analyze_file(target) else: analysis = analyze_directory(target, args.recursive, args.language) if args.json: output = json.dumps(analysis, indent=2, default=str) if args.output: with open(args.output, "w") as f: f.write(output) print(f"Results written to {args.output}") else: print(output) else: print_report(analysis) if __name__ == "__main__": main()