diff --git a/src/workshop_mcp/dead_code_detection/__init__.py b/src/workshop_mcp/dead_code_detection/__init__.py new file mode 100644 index 0000000..e7a6028 --- /dev/null +++ b/src/workshop_mcp/dead_code_detection/__init__.py @@ -0,0 +1,18 @@ +"""Dead code detection tools for identifying unused Python code.""" + +__version__ = "0.1.0" + +from .detector import DeadCodeDetector, DeadCodeResult, DeadCodeSummary, detect_dead_code +from .patterns import DeadCodeCategory, DeadCodeIssue +from .usage_graph import UsageGraph, build_usage_graph + +__all__ = [ + "DeadCodeCategory", + "DeadCodeDetector", + "DeadCodeIssue", + "DeadCodeResult", + "DeadCodeSummary", + "UsageGraph", + "build_usage_graph", + "detect_dead_code", +] diff --git a/src/workshop_mcp/dead_code_detection/detector.py b/src/workshop_mcp/dead_code_detection/detector.py new file mode 100644 index 0000000..10dcbbb --- /dev/null +++ b/src/workshop_mcp/dead_code_detection/detector.py @@ -0,0 +1,450 @@ +"""Main dead code detector that orchestrates all checks.""" + +import re +from dataclasses import dataclass, field +from pathlib import Path + +import astroid + +from ..core.ast_utils import extract_imports, parse_source +from .patterns import ( + EXTERNALLY_USED_DECORATORS, + IGNORED_PARAMETERS, + DeadCodeCategory, + DeadCodeIssue, + Severity, +) +from .usage_graph import build_usage_graph + + +@dataclass +class DeadCodeSummary: + """Summary counts by category.""" + + unused_imports: int = 0 + unused_functions: int = 0 + unused_variables: int = 0 + unused_parameters: int = 0 + unreachable_blocks: int = 0 + redundant_conditions: int = 0 + + +@dataclass +class DeadCodeResult: + """Full result from dead code detection.""" + + issues: list[DeadCodeIssue] = field(default_factory=list) + summary: DeadCodeSummary = field(default_factory=DeadCodeSummary) + + +class DeadCodeDetector: + """Detects dead code patterns in Python source code.""" + + def __init__( + self, + source_code: str | None = None, + *, + file_path: str | None = None, + check_imports: bool = True, + check_variables: bool = True, + check_functions: bool = True, + ignore_patterns: list[str] | None = None, + ): + """Initialize the detector. + + Args: + source_code: Python source code to analyze. + file_path: Path to a Python file to analyze. + check_imports: Whether to check for unused imports. + check_variables: Whether to check for unused variables. + check_functions: Whether to check for unused functions. + ignore_patterns: Regex patterns for names to skip. + + Raises: + ValueError: If neither source_code nor file_path is provided. + FileNotFoundError: If file_path doesn't exist. + SyntaxError: If source code cannot be parsed. + """ + if source_code is None and file_path is None: + raise ValueError("Either source_code or file_path must be provided") + + if file_path and source_code is None: + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + source_code = path.read_text(encoding="utf-8") + + self.source_code = source_code + self.file_path = file_path + self.tree = parse_source(source_code, file_path) + self.graph = build_usage_graph(self.tree) + self.check_imports = check_imports + self.check_variables = check_variables + self.check_functions = check_functions + self._ignore_res = [re.compile(p) for p in (ignore_patterns or [])] + + def detect_all(self) -> DeadCodeResult: + """Run all enabled dead code checks. + + Returns: + DeadCodeResult with issues and summary. + """ + result = DeadCodeResult() + + if self.check_imports: + issues = self._detect_unused_imports() + result.issues.extend(issues) + result.summary.unused_imports = len(issues) + + if self.check_variables: + issues = self._detect_unused_variables() + result.issues.extend(issues) + result.summary.unused_variables = len(issues) + + if self.check_functions: + issues = self._detect_unused_functions() + result.issues.extend(issues) + result.summary.unused_functions = len(issues) + + param_issues = self._detect_unused_parameters() + result.issues.extend(param_issues) + result.summary.unused_parameters = len(param_issues) + + unreachable_issues = self._detect_unreachable_code() + result.issues.extend(unreachable_issues) + result.summary.unreachable_blocks = len(unreachable_issues) + + redundant_issues = self._detect_redundant_conditions() + result.issues.extend(redundant_issues) + result.summary.redundant_conditions = len(redundant_issues) + + result.issues.sort(key=lambda i: i.line) + return result + + def _should_ignore(self, name: str) -> bool: + """Check if a name matches any ignore pattern.""" + return any(r.fullmatch(name) for r in self._ignore_res) + + # ------------------------------------------------------------------ + # Unused imports + # ------------------------------------------------------------------ + def _detect_unused_imports(self) -> list[DeadCodeIssue]: + issues: list[DeadCodeIssue] = [] + imports = extract_imports(self.tree) + + for imp in imports: + for name in imp.names: + local_name = imp.aliases.get(name, name) + + if self._should_ignore(local_name): + continue + if self.graph.is_in_all(local_name): + continue + if not self.graph.is_referenced(local_name): + if imp.is_from_import: + msg = f"Import '{name}' from '{imp.module}' is never used" + else: + msg = f"Import '{name}' is never used" + issues.append( + DeadCodeIssue( + category=DeadCodeCategory.UNUSED_IMPORT, + severity=Severity.INFO, + message=msg, + line=imp.line_number, + name=local_name, + suggestion="Remove unused import or use '# noqa' if intentional", + ) + ) + return issues + + # ------------------------------------------------------------------ + # Unused variables + # ------------------------------------------------------------------ + def _detect_unused_variables(self) -> list[DeadCodeIssue]: + issues: list[DeadCodeIssue] = [] + + for node in self.tree.nodes_of_class(astroid.Assign): + for target in node.targets: + if isinstance(target, astroid.AssignName): + name = target.name + if name.startswith("_"): + continue + if self._should_ignore(name): + continue + if self.graph.is_in_all(name): + continue + if not self.graph.is_referenced(name): + issues.append( + DeadCodeIssue( + category=DeadCodeCategory.UNUSED_VARIABLE, + severity=Severity.WARNING, + message=f"Variable '{name}' is assigned but never used", + line=target.lineno, + name=name, + suggestion="Remove the assignment or use the variable", + ) + ) + return issues + + # ------------------------------------------------------------------ + # Unused functions + # ------------------------------------------------------------------ + def _detect_unused_functions(self) -> list[DeadCodeIssue]: + issues: list[DeadCodeIssue] = [] + + # Only check top-level and class-level functions (not nested) + for node in self.tree.body: + if isinstance(node, (astroid.FunctionDef, astroid.AsyncFunctionDef)): + self._check_function_unused(node, issues) + elif isinstance(node, astroid.ClassDef): + for item in node.body: + if isinstance(item, (astroid.FunctionDef, astroid.AsyncFunctionDef)): + self._check_function_unused(item, issues) + + return issues + + def _check_function_unused( + self, + node: astroid.FunctionDef | astroid.AsyncFunctionDef, + issues: list[DeadCodeIssue], + ) -> None: + """Check if a single function is unused and add issue if so.""" + name = node.name + + if name.startswith("test_"): + return + if name.startswith("__") and name.endswith("__"): + return + decorators = ( + [self._decorator_name(d) for d in node.decorators.nodes] if node.decorators else [] + ) + if any(d in EXTERNALLY_USED_DECORATORS for d in decorators): + return + if self._should_ignore(name): + return + if self.graph.is_in_all(name): + return + + if not self.graph.is_referenced(name): + sev = Severity.INFO if not name.startswith("_") else Severity.WARNING + issues.append( + DeadCodeIssue( + category=DeadCodeCategory.UNUSED_FUNCTION, + severity=sev, + message=f"Function '{name}' is defined but never called", + line=node.lineno, + name=name, + suggestion="Remove unused function or verify it's used externally", + ) + ) + + # ------------------------------------------------------------------ + # Unused parameters + # ------------------------------------------------------------------ + def _detect_unused_parameters(self) -> list[DeadCodeIssue]: + issues: list[DeadCodeIssue] = [] + + for node in self.tree.nodes_of_class((astroid.FunctionDef, astroid.AsyncFunctionDef)): + if self._is_stub_function(node): + continue + decorators = ( + [self._decorator_name(d) for d in node.decorators.nodes] if node.decorators else [] + ) + if any(d in EXTERNALLY_USED_DECORATORS for d in decorators): + continue + + body_refs: set[str] = set() + for child in node.get_children(): + self._collect_names(child, body_refs) + + for arg in node.args.args: + name = arg.name + if name in IGNORED_PARAMETERS: + continue + if name.startswith("_"): + continue + if self._should_ignore(name): + continue + if name not in body_refs: + issues.append( + DeadCodeIssue( + category=DeadCodeCategory.UNUSED_PARAMETER, + severity=Severity.INFO, + message=f"Parameter '{name}' in function '{node.name}' is never used", + line=node.lineno, + name=name, + suggestion=f"Remove parameter or prefix with underscore: _{name}", + ) + ) + + return issues + + # ------------------------------------------------------------------ + # Unreachable code + # ------------------------------------------------------------------ + def _detect_unreachable_code(self) -> list[DeadCodeIssue]: + issues: list[DeadCodeIssue] = [] + self._find_unreachable(self.tree, issues) + return issues + + def _find_unreachable(self, node: astroid.NodeNG, issues: list[DeadCodeIssue]) -> None: + """Recursively find code after return/raise/break/continue.""" + if hasattr(node, "body") and isinstance(node.body, list): + self._check_block_for_unreachable(node.body, issues) + + for attr in ("orelse", "handlers", "finalbody"): + block = getattr(node, attr, None) + if isinstance(block, list): + self._check_block_for_unreachable(block, issues) + + for child in node.get_children(): + if isinstance(child, (astroid.FunctionDef, astroid.AsyncFunctionDef, astroid.ClassDef)): + self._find_unreachable(child, issues) + elif hasattr(child, "body"): + self._find_unreachable(child, issues) + + def _check_block_for_unreachable( + self, stmts: list[astroid.NodeNG], issues: list[DeadCodeIssue] + ) -> None: + """Check a list of statements for code after terminating statements.""" + terminal_types = (astroid.Return, astroid.Raise, astroid.Break, astroid.Continue) + + for i, stmt in enumerate(stmts): + if isinstance(stmt, terminal_types) and i < len(stmts) - 1: + next_stmt = stmts[i + 1] + kind = type(stmt).__name__.lower() + issues.append( + DeadCodeIssue( + category=DeadCodeCategory.UNREACHABLE_CODE, + severity=Severity.WARNING, + message=f"Code after '{kind}' statement is unreachable", + line=next_stmt.lineno, + name=f"after_{kind}", + suggestion="Remove unreachable code", + ) + ) + break + + # ------------------------------------------------------------------ + # Redundant conditions + # ------------------------------------------------------------------ + def _detect_redundant_conditions(self) -> list[DeadCodeIssue]: + issues: list[DeadCodeIssue] = [] + + for node in self.tree.nodes_of_class(astroid.If): + if isinstance(node.test, astroid.Const): + if node.test.value is True: + issues.append( + DeadCodeIssue( + category=DeadCodeCategory.REDUNDANT_CONDITION, + severity=Severity.WARNING, + message="Condition is always True", + line=node.lineno, + name="if True", + suggestion="Remove the if statement and keep only the body", + ) + ) + elif node.test.value is False: + issues.append( + DeadCodeIssue( + category=DeadCodeCategory.REDUNDANT_CONDITION, + severity=Severity.WARNING, + message="Condition is always False — block never executes", + line=node.lineno, + name="if False", + suggestion="Remove the dead code block", + ) + ) + + for node in self.tree.nodes_of_class(astroid.While): + if isinstance(node.test, astroid.Const) and node.test.value is False: + issues.append( + DeadCodeIssue( + category=DeadCodeCategory.REDUNDANT_CONDITION, + severity=Severity.WARNING, + message="while False loop never executes", + line=node.lineno, + name="while False", + suggestion="Remove the dead loop", + ) + ) + + return issues + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + @staticmethod + def _is_stub_function(node: astroid.FunctionDef) -> bool: + """Check if a function body is just pass/Ellipsis/docstring (stub).""" + body = node.body + if len(body) == 0: + return True + if len(body) == 1: + stmt = body[0] + if isinstance(stmt, astroid.Pass): + return True + if isinstance(stmt, astroid.Expr) and isinstance(stmt.value, astroid.Const): + return True + if len(body) == 2: + if isinstance(body[0], astroid.Expr) and isinstance(body[1], astroid.Pass): + return True + return False + + @staticmethod + def _decorator_name(dec: astroid.NodeNG) -> str: + if isinstance(dec, astroid.Name): + return dec.name + if isinstance(dec, astroid.Attribute): + return dec.as_string() + if isinstance(dec, astroid.Call): + if isinstance(dec.func, astroid.Name): + return dec.func.name + if isinstance(dec.func, astroid.Attribute): + return dec.func.as_string() + return "" + + @staticmethod + def _collect_names(node: astroid.NodeNG, names: set[str]) -> None: + """Collect all Name references in a subtree.""" + if isinstance(node, astroid.Name): + names.add(node.name) + for child in node.get_children(): + DeadCodeDetector._collect_names(child, names) + + +def detect_dead_code( + source_code: str | None = None, + *, + file_path: str | None = None, + check_imports: bool = True, + check_variables: bool = True, + check_functions: bool = True, + ignore_patterns: list[str] | None = None, +) -> DeadCodeResult: + """Convenience function to run dead code detection. + + Args: + source_code: Python source code to analyze. + file_path: Path to a Python file to analyze. + check_imports: Whether to check for unused imports. + check_variables: Whether to check for unused variables. + check_functions: Whether to check for unused functions. + ignore_patterns: Regex patterns for names to skip. + + Returns: + DeadCodeResult with issues and summary. + + Raises: + ValueError: If neither source_code nor file_path is provided. + SyntaxError: If source code cannot be parsed. + """ + detector = DeadCodeDetector( + source_code=source_code, + file_path=file_path, + check_imports=check_imports, + check_variables=check_variables, + check_functions=check_functions, + ignore_patterns=ignore_patterns, + ) + return detector.detect_all() diff --git a/src/workshop_mcp/dead_code_detection/patterns.py b/src/workshop_mcp/dead_code_detection/patterns.py new file mode 100644 index 0000000..0cb4026 --- /dev/null +++ b/src/workshop_mcp/dead_code_detection/patterns.py @@ -0,0 +1,57 @@ +"""Dead code pattern definitions and categories.""" + +from dataclasses import dataclass +from enum import Enum + + +class DeadCodeCategory(Enum): + """Categories of dead code issues.""" + + UNUSED_IMPORT = "unused_import" + UNUSED_VARIABLE = "unused_variable" + UNUSED_FUNCTION = "unused_function" + UNUSED_PARAMETER = "unused_parameter" + UNREACHABLE_CODE = "unreachable_code" + REDUNDANT_CONDITION = "redundant_condition" + + +class Severity(Enum): + """Severity levels for dead code issues.""" + + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +@dataclass +class DeadCodeIssue: + """Represents a detected dead code issue.""" + + category: DeadCodeCategory + severity: Severity + message: str + line: int + name: str + suggestion: str + + +# Decorators that indicate a function is used externally +EXTERNALLY_USED_DECORATORS = { + "property", + "staticmethod", + "classmethod", + "abstractmethod", + "overload", + "pytest.fixture", + "fixture", + "app.route", + "router.get", + "router.post", + "router.put", + "router.delete", + "click.command", + "celery.task", +} + +# Parameters that are conventionally unused +IGNORED_PARAMETERS = {"self", "cls", "args", "kwargs"} diff --git a/src/workshop_mcp/dead_code_detection/usage_graph.py b/src/workshop_mcp/dead_code_detection/usage_graph.py new file mode 100644 index 0000000..80c0900 --- /dev/null +++ b/src/workshop_mcp/dead_code_detection/usage_graph.py @@ -0,0 +1,99 @@ +"""Symbol usage and reference tracking for dead code detection.""" + +from dataclasses import dataclass, field + +import astroid + + +@dataclass +class SymbolDefinition: + """A symbol definition in the source code.""" + + name: str + line: int + kind: str # "import", "variable", "function", "parameter", "class" + scope: str | None = None # enclosing function name, or None for module-level + + +@dataclass +class UsageGraph: + """Tracks symbol definitions and references in a module.""" + + definitions: list[SymbolDefinition] = field(default_factory=list) + references: set[str] = field(default_factory=set) + all_exports: list[str] = field(default_factory=list) + + def is_referenced(self, name: str) -> bool: + """Check if a symbol name appears in the set of references.""" + return name in self.references + + def is_in_all(self, name: str) -> bool: + """Check if a symbol is listed in __all__.""" + return name in self.all_exports + + +def build_usage_graph(tree: astroid.Module) -> UsageGraph: + """Build a usage graph from an Astroid module AST. + + Walks the AST to record all Name and Attribute references, then + returns a UsageGraph with both definitions and references populated. + + Args: + tree: Parsed Astroid module. + + Returns: + Populated UsageGraph instance. + """ + graph = UsageGraph() + + # Extract __all__ if defined + _extract_all_exports(tree, graph) + + # Collect all name references (reads) + _collect_references(tree, graph) + + return graph + + +def _extract_all_exports(tree: astroid.Module, graph: UsageGraph) -> None: + """Extract names from __all__ assignment.""" + for node in tree.body: + if isinstance(node, astroid.Assign): + for target in node.targets: + if isinstance(target, astroid.AssignName) and target.name == "__all__": + if isinstance(node.value, (astroid.List, astroid.Tuple)): + for elt in node.value.elts: + if isinstance(elt, astroid.Const) and isinstance(elt.value, str): + graph.all_exports.append(elt.value) + + +def _collect_references(node: astroid.NodeNG, graph: UsageGraph) -> None: + """Recursively collect all Name references in the AST. + + Only collects Name nodes that represent reads (not definitions). + Also collects the root of Attribute chains (e.g., 'os' in 'os.path.join'). + """ + # Name node in a read context (not assignment target) + if isinstance(node, astroid.Name): + # Only count as reference if it's not the target of an assignment + parent = node.parent + if isinstance(parent, astroid.Assign) and node in parent.targets: + pass # This is a definition, not a reference + elif isinstance(parent, astroid.AugAssign) and node is parent.target: + # AugAssign target is both read and write — count as reference + graph.references.add(node.name) + else: + graph.references.add(node.name) + + # For attribute access like 'os.path.join', we still want 'os' as referenced + elif isinstance(node, astroid.Attribute): + # Walk down to the leftmost Name in the chain + leftmost = node + while isinstance(leftmost, astroid.Attribute): + leftmost = leftmost.expr + if isinstance(leftmost, astroid.Name): + graph.references.add(leftmost.name) + + # Recurse into children + for child in node.get_children(): + _collect_references(child, graph) diff --git a/src/workshop_mcp/server.py b/src/workshop_mcp/server.py index 6c9d887..64747fb 100644 --- a/src/workshop_mcp/server.py +++ b/src/workshop_mcp/server.py @@ -15,6 +15,7 @@ from typing import Any from .complexity_analysis import analyze_complexity +from .dead_code_detection import DeadCodeDetector from .keyword_search import KeywordSearchTool from .logging_context import CorrelationIdFilter, correlation_id_var, request_context from .performance_profiler import PerformanceChecker @@ -336,6 +337,57 @@ def _handle_list_tools(self, request_id: Any) -> dict[str, Any]: ], }, }, + { + "name": "dead_code_detection", + "description": ( + "Analyze Python code to detect dead and unused code including " + "unused imports, unused variables, unused functions, unused " + "parameters, unreachable code after return/raise, and redundant " + "conditions like if True/if False." + ), + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the Python file to analyze", + "minLength": 1, + }, + "source_code": { + "type": "string", + "description": ( + "Python source code string to analyze instead of file" + ), + }, + "check_imports": { + "type": "boolean", + "description": "Check for unused imports (default true)", + "default": True, + }, + "check_variables": { + "type": "boolean", + "description": "Check for unused variables (default true)", + "default": True, + }, + "check_functions": { + "type": "boolean", + "description": "Check for unused functions (default true)", + "default": True, + }, + "ignore_patterns": { + "type": "array", + "description": ( + "Regex patterns for names to ignore during analysis" + ), + "items": {"type": "string"}, + }, + }, + "oneOf": [ + {"required": ["file_path"]}, + {"required": ["source_code"]}, + ], + }, + }, ] } return self._success_response(request_id, result) @@ -356,6 +408,8 @@ def _handle_call_tool(self, request_id: Any, params: dict[str, Any] | None) -> d return self._execute_performance_check(request_id, arguments) elif name == "complexity_analysis": return self._execute_complexity_analysis(request_id, arguments) + elif name == "dead_code_detection": + return self._execute_dead_code_detection(request_id, arguments) else: return self._error_response( request_id, @@ -734,6 +788,139 @@ def _execute_complexity_analysis( ), ) + def _execute_dead_code_detection( + self, request_id: Any, arguments: dict[str, Any] + ) -> dict[str, Any]: + if not isinstance(arguments, dict): + return self._error_response( + request_id, + JsonRpcError(-32602, "Invalid params", {"expected": "object"}), + ) + + file_path = arguments.get("file_path") + source_code = arguments.get("source_code") + + if not file_path and not source_code: + return self._error_response( + request_id, + JsonRpcError(-32602, "Either file_path or source_code must be provided"), + ) + if file_path and source_code: + return self._error_response( + request_id, + JsonRpcError(-32602, "Provide only one of file_path or source_code"), + ) + + if file_path is not None and not isinstance(file_path, str): + return self._error_response( + request_id, + JsonRpcError(-32602, "file_path must be a string"), + ) + + if file_path: + try: + self.path_validator.validate_exists(file_path, must_be_file=True) + except PathValidationError as e: + return self._error_response( + request_id, + JsonRpcError(-32602, str(e)), + ) + + check_imports = arguments.get("check_imports", True) + check_variables = arguments.get("check_variables", True) + check_functions = arguments.get("check_functions", True) + ignore_patterns = arguments.get("ignore_patterns") + + if ignore_patterns is not None and ( + not isinstance(ignore_patterns, list) + or not all(isinstance(p, str) for p in ignore_patterns) + ): + return self._error_response( + request_id, + JsonRpcError(-32602, "ignore_patterns must be a list of strings"), + ) + + try: + logger.info("Executing dead code detection") + + # Read source from file if needed + if file_path and not source_code: + source_code = Path(file_path).read_text(encoding="utf-8") + + detector = DeadCodeDetector( + source_code=source_code, + file_path=file_path, + check_imports=check_imports, + check_variables=check_variables, + check_functions=check_functions, + ignore_patterns=ignore_patterns, + ) + + detection_result = detector.detect_all() + + issues_data = [ + { + "tool": "dead_code", + "category": issue.category.value, + "severity": issue.severity.value, + "message": issue.message, + "line": issue.line, + "name": issue.name, + "suggestion": issue.suggestion, + } + for issue in detection_result.issues + ] + + result = { + "content": [ + { + "type": "json", + "json": { + "success": True, + "file_analyzed": file_path or "source_code", + "issues": issues_data, + "summary": asdict(detection_result.summary), + }, + } + ], + } + return self._success_response(request_id, result) + + except ValueError as exc: + logger.warning("ValueError in dead_code_detection: %s", exc) + return self._error_response( + request_id, + JsonRpcError(-32602, "Invalid parameters"), + ) + except FileNotFoundError as exc: + logger.warning("FileNotFoundError in dead_code_detection: %s", exc) + return self._error_response( + request_id, + JsonRpcError(-32602, "Resource not found"), + ) + except SyntaxError as exc: + logger.warning("SyntaxError in dead_code_detection: %s", exc) + return self._error_response( + request_id, + JsonRpcError(-32602, "Invalid source code syntax"), + ) + except SecurityValidationError as exc: + logger.warning("Security validation error: %s", exc) + return self._error_response( + request_id, + JsonRpcError(-32602, str(exc)), + ) + except Exception: + logger.exception("Error executing dead_code_detection") + return self._error_response( + request_id, + JsonRpcError( + -32603, + "Internal error", + {"correlation_id": correlation_id_var.get()}, + ), + ) + def _success_response(self, request_id: Any, result: dict[str, Any]) -> dict[str, Any]: return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result} diff --git a/tests/test_dead_code_detection.py b/tests/test_dead_code_detection.py new file mode 100644 index 0000000..8a233ff --- /dev/null +++ b/tests/test_dead_code_detection.py @@ -0,0 +1,702 @@ +"""Tests for the dead code detection module.""" + +import pytest + +from workshop_mcp.dead_code_detection.detector import ( + DeadCodeDetector, + DeadCodeResult, + detect_dead_code, +) +from workshop_mcp.dead_code_detection.patterns import DeadCodeCategory, Severity + + +class TestDeadCodeDetectorInitialization: + """Test detector initialization.""" + + def test_init_with_source_code(self): + """Test initialization with source code string.""" + detector = DeadCodeDetector("x = 1\nprint(x)") + assert detector.source_code is not None + + def test_init_with_syntax_error(self): + """Test initialization fails with invalid syntax.""" + with pytest.raises(SyntaxError): + DeadCodeDetector("def foo(:\n pass") + + +class TestUnusedImports: + """Test unused import detection.""" + + def test_unused_import_x(self): + """Detect 'import os' when os is never used.""" + source = "import os\nx = 1\nprint(x)\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + assert len(import_issues) == 1 + assert import_issues[0].name == "os" + assert import_issues[0].severity == Severity.INFO + + def test_unused_from_import(self): + """Detect 'from typing import List, Dict' where Dict is unused.""" + source = "from typing import List, Dict\nx: List[int] = []\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + assert len(import_issues) == 1 + assert import_issues[0].name == "Dict" + + def test_used_import_not_flagged(self): + """Used imports should not be flagged.""" + source = "import os\nprint(os.path.join('a', 'b'))\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + assert len(import_issues) == 0 + + def test_aliased_import_checks_alias(self): + """Aliased import should check alias, not original name.""" + source = "import numpy as np\nx = np.array([1, 2, 3])\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + assert len(import_issues) == 0 + + def test_unused_aliased_import(self): + """Unused aliased import should be flagged with the alias name.""" + source = "import numpy as np\nx = 1\nprint(x)\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + assert len(import_issues) == 1 + assert import_issues[0].name == "np" + + def test_import_in_all_not_flagged(self): + """Imports listed in __all__ should not be flagged.""" + source = 'import os\n__all__ = ["os"]\n' + detector = DeadCodeDetector(source) + result = detector.detect_all() + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + assert len(import_issues) == 0 + + def test_check_imports_disabled(self): + """When check_imports=False, no import issues should be reported.""" + source = "import os\nx = 1\nprint(x)\n" + detector = DeadCodeDetector(source, check_imports=False) + result = detector.detect_all() + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + assert len(import_issues) == 0 + + def test_from_import_multiple_names(self): + """From import with multiple names, some used and some not.""" + source = "from os.path import join, exists\nprint(join('a', 'b'))\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + assert len(import_issues) == 1 + assert import_issues[0].name == "exists" + + +class TestUnusedVariables: + """Test unused variable detection.""" + + def test_unused_variable(self): + """Detect variable assigned but never read.""" + source = "x = 42\ny = 10\nprint(y)\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + var_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_VARIABLE] + assert len(var_issues) == 1 + assert var_issues[0].name == "x" + assert var_issues[0].severity == Severity.WARNING + + def test_used_variable_not_flagged(self): + """Used variables should not be flagged.""" + source = "x = 42\nprint(x)\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + var_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_VARIABLE] + assert len(var_issues) == 0 + + def test_underscore_prefixed_variable_not_flagged(self): + """Underscore-prefixed variables should be skipped.""" + source = "_private = 42\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + var_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_VARIABLE] + assert len(var_issues) == 0 + + def test_dunder_variable_not_flagged(self): + """Dunder variables like __version__ should be skipped.""" + source = '__version__ = "1.0"\n' + detector = DeadCodeDetector(source) + result = detector.detect_all() + var_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_VARIABLE] + assert len(var_issues) == 0 + + def test_check_variables_disabled(self): + """When check_variables=False, no variable issues should be reported.""" + source = "x = 42\ny = 10\nprint(y)\n" + detector = DeadCodeDetector(source, check_variables=False) + result = detector.detect_all() + var_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_VARIABLE] + assert len(var_issues) == 0 + + def test_augmented_assignment_counts_as_reference(self): + """x += 1 should count x as referenced.""" + source = "x = 0\nx += 1\nprint(x)\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + var_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_VARIABLE] + assert len(var_issues) == 0 + + +class TestUnusedFunctions: + """Test unused function detection.""" + + def test_unused_function(self): + """Detect function defined but never called.""" + source = "def foo():\n return 1\n\ndef bar():\n return 2\n\nbar()\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + assert len(func_issues) == 1 + assert func_issues[0].name == "foo" + + def test_called_function_not_flagged(self): + """Called functions should not be flagged.""" + source = "def foo():\n return 1\n\nfoo()\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + assert len(func_issues) == 0 + + def test_test_function_not_flagged(self): + """Functions prefixed with test_ should not be flagged.""" + source = "def test_something():\n assert True\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + assert len(func_issues) == 0 + + def test_dunder_method_not_flagged(self): + """Dunder methods like __init__ should not be flagged.""" + source = "class Foo:\n def __init__(self):\n pass\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + func_names = [i.name for i in func_issues] + assert "__init__" not in func_names + + def test_decorated_property_not_flagged(self): + """Functions with @property decorator should not be flagged.""" + source = "class Foo:\n @property\n def bar(self):\n return 1\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + func_names = [i.name for i in func_issues] + assert "bar" not in func_names + + def test_function_in_all_not_flagged(self): + """Functions in __all__ should not be flagged.""" + source = 'def foo():\n return 1\n\n__all__ = ["foo"]\n' + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + assert len(func_issues) == 0 + + def test_public_function_lower_confidence(self): + """Public unused functions should have INFO severity (lower confidence).""" + source = "def public_func():\n return 1\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + assert len(func_issues) == 1 + assert func_issues[0].severity == Severity.INFO + + def test_private_function_higher_confidence(self): + """Private unused functions should have WARNING severity (higher confidence).""" + source = "def _private_func():\n return 1\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + assert len(func_issues) == 1 + assert func_issues[0].severity == Severity.WARNING + + def test_check_functions_disabled(self): + """When check_functions=False, no function issues should be reported.""" + source = "def foo():\n return 1\n" + detector = DeadCodeDetector(source, check_functions=False) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + assert len(func_issues) == 0 + + +class TestUnusedParameters: + """Test unused parameter detection.""" + + def test_unused_parameter(self): + """Detect parameter never used in function body.""" + source = "def foo(x, y):\n return x\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + param_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_PARAMETER] + assert len(param_issues) == 1 + assert param_issues[0].name == "y" + assert param_issues[0].category == DeadCodeCategory.UNUSED_PARAMETER + + def test_used_parameter_not_flagged(self): + """Used parameters should not be flagged.""" + source = "def foo(x, y):\n return x + y\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + param_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_PARAMETER] + assert len(param_issues) == 0 + + def test_self_not_flagged(self): + """self parameter should never be flagged.""" + source = "class Foo:\n def bar(self):\n return 1\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + param_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_PARAMETER] + assert len(param_issues) == 0 + + def test_cls_not_flagged(self): + """cls parameter should never be flagged.""" + source = "class Foo:\n @classmethod\n def bar(cls):\n return 1\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + param_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_PARAMETER] + assert len(param_issues) == 0 + + def test_stub_function_not_flagged(self): + """Parameters in stub functions (pass) should not be flagged.""" + source = "def foo(x, y):\n pass\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + param_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_PARAMETER] + assert len(param_issues) == 0 + + def test_ellipsis_stub_not_flagged(self): + """Parameters in functions with only Ellipsis should not be flagged.""" + source = "def foo(x, y):\n ...\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + param_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_PARAMETER] + assert len(param_issues) == 0 + + def test_docstring_plus_pass_stub_not_flagged(self): + """Parameters in functions with docstring + pass should not be flagged.""" + source = 'def foo(x, y):\n """Docs."""\n pass\n' + detector = DeadCodeDetector(source) + result = detector.detect_all() + param_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_PARAMETER] + assert len(param_issues) == 0 + + +class TestUnreachableCode: + """Test unreachable code detection.""" + + def test_code_after_return(self): + """Detect code after return statement.""" + source = "def foo():\n return 1\n x = 2\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + unreach = [i for i in result.issues if i.category == DeadCodeCategory.UNREACHABLE_CODE] + assert len(unreach) == 1 + assert "return" in unreach[0].message + + def test_code_after_raise(self): + """Detect code after raise statement.""" + source = "def foo():\n raise ValueError('err')\n x = 2\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + unreach = [i for i in result.issues if i.category == DeadCodeCategory.UNREACHABLE_CODE] + assert len(unreach) == 1 + assert "raise" in unreach[0].message + + def test_code_after_break(self): + """Detect code after break statement.""" + source = "for i in range(10):\n break\n x = 2\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + unreach = [i for i in result.issues if i.category == DeadCodeCategory.UNREACHABLE_CODE] + assert len(unreach) == 1 + assert "break" in unreach[0].message + + def test_code_after_continue(self): + """Detect code after continue statement.""" + source = "for i in range(10):\n continue\n x = 2\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + unreach = [i for i in result.issues if i.category == DeadCodeCategory.UNREACHABLE_CODE] + assert len(unreach) == 1 + assert "continue" in unreach[0].message + + def test_no_unreachable_code(self): + """No unreachable code should produce no issues.""" + source = "def foo():\n x = 1\n return x\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + unreach = [i for i in result.issues if i.category == DeadCodeCategory.UNREACHABLE_CODE] + assert len(unreach) == 0 + + +class TestRedundantConditions: + """Test redundant condition detection.""" + + def test_if_true(self): + """Detect 'if True:' as always-true condition.""" + source = "if True:\n x = 1\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + redundant = [i for i in result.issues if i.category == DeadCodeCategory.REDUNDANT_CONDITION] + assert len(redundant) == 1 + assert "True" in redundant[0].message + + def test_if_false(self): + """Detect 'if False:' as never-executed block.""" + source = "if False:\n x = 1\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + redundant = [i for i in result.issues if i.category == DeadCodeCategory.REDUNDANT_CONDITION] + assert len(redundant) == 1 + assert "False" in redundant[0].message + + def test_while_false(self): + """Detect 'while False:' as never-executed loop.""" + source = "while False:\n x = 1\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + redundant = [i for i in result.issues if i.category == DeadCodeCategory.REDUNDANT_CONDITION] + assert len(redundant) == 1 + assert "False" in redundant[0].message + + def test_normal_if_not_flagged(self): + """Normal if conditions should not be flagged.""" + source = "x = 1\nif x > 0:\n print(x)\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + redundant = [i for i in result.issues if i.category == DeadCodeCategory.REDUNDANT_CONDITION] + assert len(redundant) == 0 + + +class TestIgnorePatterns: + """Test ignore_patterns parameter.""" + + def test_ignore_patterns_imports(self): + """Ignore patterns should skip matching imports.""" + source = "import os\nimport sys\nx = 1\nprint(x)\n" + detector = DeadCodeDetector(source, ignore_patterns=["os"]) + result = detector.detect_all() + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + names = [i.name for i in import_issues] + assert "os" not in names + assert "sys" in names + + def test_ignore_patterns_functions(self): + """Ignore patterns should skip matching functions.""" + source = "def handler_foo():\n return 1\n\ndef helper_bar():\n return 2\n" + detector = DeadCodeDetector(source, ignore_patterns=["handler_foo"]) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + names = [i.name for i in func_issues] + assert "handler_foo" not in names + assert "helper_bar" in names + + def test_ignore_patterns_variables(self): + """Ignore patterns should skip matching variables.""" + source = "CONSTANT = 42\ntemp = 10\nprint(temp)\n" + detector = DeadCodeDetector(source, ignore_patterns=["CONSTANT"]) + result = detector.detect_all() + var_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_VARIABLE] + names = [i.name for i in var_issues] + assert "CONSTANT" not in names + + +class TestDetectAll: + """Test the combined detect_all method.""" + + def test_empty_module(self): + """Empty module should produce no issues.""" + detector = DeadCodeDetector("") + result = detector.detect_all() + assert len(result.issues) == 0 + + def test_all_used_module(self): + """Module with all symbols used should produce no issues.""" + source = "import os\npath = os.path.join('a', 'b')\nprint(path)\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + assert len(result.issues) == 0 + + def test_multiple_issue_types(self): + """Test detecting multiple types of issues at once.""" + source = ( + "import os\n" + "import sys\n" + "def unused_func():\n" + " return 1\n" + "dead_var = 42\n" + "print(sys.argv)\n" + ) + detector = DeadCodeDetector(source) + result = detector.detect_all() + categories = {i.category for i in result.issues} + assert DeadCodeCategory.UNUSED_IMPORT in categories + assert DeadCodeCategory.UNUSED_FUNCTION in categories + assert DeadCodeCategory.UNUSED_VARIABLE in categories + + def test_result_has_summary(self): + """detect_all should return DeadCodeResult with summary.""" + source = "import os\nimport sys\ndead_var = 42\nprint(sys.argv)\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + assert isinstance(result, DeadCodeResult) + assert result.summary.unused_imports == 1 + assert result.summary.unused_variables == 1 + + def test_empty_summary(self): + """Summary of clean code should have all zeros.""" + source = "import os\nprint(os.getcwd())\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + assert result.summary.unused_imports == 0 + assert result.summary.unused_variables == 0 + assert result.summary.unused_functions == 0 + assert result.summary.unreachable_blocks == 0 + assert result.summary.redundant_conditions == 0 + + +class TestConvenienceFunction: + """Test the detect_dead_code convenience function.""" + + def test_returns_dead_code_result(self): + """Convenience function should return DeadCodeResult.""" + result = detect_dead_code("import os\nx = 1\nprint(x)\n") + assert isinstance(result, DeadCodeResult) + assert len(result.issues) == 1 + assert result.issues[0].category == DeadCodeCategory.UNUSED_IMPORT + + def test_convenience_function_with_options(self): + """Convenience function should accept optional parameters.""" + result = detect_dead_code("import os\nx = 1\nprint(x)\n", check_imports=False) + import_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_IMPORT] + assert len(import_issues) == 0 + + +class TestEdgeCases: + """Test edge cases and special scenarios.""" + + def test_decorated_with_fixture(self): + """Functions decorated with @pytest.fixture should not be flagged.""" + source = "import pytest\n\n@pytest.fixture\ndef my_fixture():\n return 42\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + func_names = [i.name for i in func_issues] + assert "my_fixture" not in func_names + + def test_decorated_with_staticmethod(self): + """Functions decorated with @staticmethod should not be flagged.""" + source = "class Foo:\n @staticmethod\n def bar():\n return 1\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + func_names = [i.name for i in func_issues] + assert "bar" not in func_names + + def test_class_not_flagged_as_function(self): + """Classes should not be reported as unused functions.""" + source = "class Foo:\n pass\n" + detector = DeadCodeDetector(source) + result = detector.detect_all() + func_issues = [i for i in result.issues if i.category == DeadCodeCategory.UNUSED_FUNCTION] + names = [i.name for i in func_issues] + assert "Foo" not in names + + +class TestMCPIntegration: + """Test dead_code_detection tool via MCP server.""" + + def test_tool_listed(self): + """dead_code_detection appears in list_tools.""" + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + response = server._handle_request({"jsonrpc": "2.0", "id": 1, "method": "list_tools"}) + tools = response["result"]["tools"] + tool_names = [t["name"] for t in tools] + assert "dead_code_detection" in tool_names + + def test_tool_schema(self): + """dead_code_detection has the expected input schema.""" + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + response = server._handle_request({"jsonrpc": "2.0", "id": 1, "method": "list_tools"}) + tools = response["result"]["tools"] + tool = next(t for t in tools if t["name"] == "dead_code_detection") + props = tool["inputSchema"]["properties"] + assert "file_path" in props + assert "source_code" in props + assert "check_imports" in props + assert "check_variables" in props + assert "check_functions" in props + assert "ignore_patterns" in props + + def test_tool_callable_with_source_code(self): + """dead_code_detection returns results when called with source_code.""" + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + source = "import os\nx = 1\nprint(x)\n" + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "call_tool", + "params": { + "name": "dead_code_detection", + "arguments": {"source_code": source}, + }, + } + response = server._handle_request(request) + assert "result" in response + result_json = response["result"]["content"][0]["json"] + assert result_json["success"] is True + assert "issues" in result_json + assert "summary" in result_json + assert len(result_json["issues"]) == 1 + assert result_json["issues"][0]["category"] == "unused_import" + assert result_json["summary"]["unused_imports"] == 1 + + def test_tool_callable_with_file_path(self, tmp_path, monkeypatch): + """dead_code_detection works with a file path.""" + monkeypatch.setenv("MCP_ALLOWED_ROOTS", str(tmp_path)) + + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + test_file = tmp_path / "test.py" + test_file.write_text("import os\nprint(os.getcwd())\n") + + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "call_tool", + "params": { + "name": "dead_code_detection", + "arguments": {"file_path": str(test_file)}, + }, + } + response = server._handle_request(request) + assert "result" in response + result_json = response["result"]["content"][0]["json"] + assert result_json["success"] is True + + def test_tool_error_no_input(self): + """dead_code_detection returns error when no input is given.""" + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "call_tool", + "params": { + "name": "dead_code_detection", + "arguments": {}, + }, + } + response = server._handle_request(request) + assert "error" in response + + def test_tool_error_syntax(self): + """dead_code_detection returns error for invalid syntax.""" + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "call_tool", + "params": { + "name": "dead_code_detection", + "arguments": {"source_code": "def broken(:"}, + }, + } + response = server._handle_request(request) + assert "error" in response + + def test_tool_with_check_options(self): + """dead_code_detection respects check_imports=False.""" + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + source = "import os\nx = 1\nprint(x)\n" + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "call_tool", + "params": { + "name": "dead_code_detection", + "arguments": { + "source_code": source, + "check_imports": False, + }, + }, + } + response = server._handle_request(request) + result_json = response["result"]["content"][0]["json"] + assert result_json["summary"]["unused_imports"] == 0 + + def test_tool_error_both_inputs(self): + """dead_code_detection returns error when both file_path and source_code are given.""" + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "call_tool", + "params": { + "name": "dead_code_detection", + "arguments": {"file_path": "/tmp/x.py", "source_code": "x = 1"}, + }, + } + response = server._handle_request(request) + assert "error" in response + + def test_tool_error_invalid_ignore_patterns(self): + """dead_code_detection returns error for non-list ignore_patterns.""" + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "call_tool", + "params": { + "name": "dead_code_detection", + "arguments": {"source_code": "x = 1", "ignore_patterns": "not_a_list"}, + }, + } + response = server._handle_request(request) + assert "error" in response + + def test_tool_error_file_path_not_string(self): + """dead_code_detection returns error when file_path is not a string.""" + from workshop_mcp.server import WorkshopMCPServer + + server = WorkshopMCPServer() + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "call_tool", + "params": { + "name": "dead_code_detection", + "arguments": {"file_path": 123}, + }, + } + response = server._handle_request(request) + assert "error" in response