|
|
|
@@ -16,6 +16,14 @@ class StubDetector(ast.NodeVisitor):
|
|
|
|
|
self.filepath = filepath
|
|
|
|
|
self.issues: List[Tuple[int, str]] = []
|
|
|
|
|
self.in_test_file = "test" in filepath or "mock" in filepath.lower()
|
|
|
|
|
self.in_except_handler = False # Track if we're inside an except block
|
|
|
|
|
|
|
|
|
|
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
|
|
|
"""Track that we're inside an except handler (fallback stubs are OK)."""
|
|
|
|
|
old_in_except = self.in_except_handler
|
|
|
|
|
self.in_except_handler = True
|
|
|
|
|
self.generic_visit(node)
|
|
|
|
|
self.in_except_handler = old_in_except
|
|
|
|
|
|
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
|
|
|
"""Check function definitions for stub patterns."""
|
|
|
|
@@ -24,17 +32,43 @@ class StubDetector(ast.NodeVisitor):
|
|
|
|
|
self.generic_visit(node)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Check for empty functions with just pass
|
|
|
|
|
# Skip functions defined in except handlers (these are legitimate fallbacks)
|
|
|
|
|
if self.in_except_handler:
|
|
|
|
|
self.generic_visit(node)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Skip dunder methods (like __init__) that may be empty placeholders
|
|
|
|
|
if node.name.startswith("__") and node.name.endswith("__"):
|
|
|
|
|
self.generic_visit(node)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Check for empty functions with just pass (but allow if has docstring)
|
|
|
|
|
if len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
|
|
|
|
|
# Check if there's a comment indicating it's a stub
|
|
|
|
|
if node.body[0].lineno:
|
|
|
|
|
self.issues.append((node.lineno, f"Function '{node.name}' contains only 'pass' statement"))
|
|
|
|
|
self.issues.append((node.lineno, f"Function '{node.name}' contains only 'pass' statement"))
|
|
|
|
|
elif len(node.body) == 2:
|
|
|
|
|
# Check for docstring + pass (also a stub)
|
|
|
|
|
if (isinstance(node.body[0], ast.Expr) and
|
|
|
|
|
isinstance(node.body[0].value, ast.Constant) and
|
|
|
|
|
isinstance(node.body[0].value.value, str) and
|
|
|
|
|
isinstance(node.body[1], ast.Pass)):
|
|
|
|
|
# Has docstring + pass - this is a documented stub, skip it
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Check for functions that just raise NotImplementedError
|
|
|
|
|
# But allow if it's an abstract method (has docstring explaining it)
|
|
|
|
|
if len(node.body) == 1 and isinstance(node.body[0], ast.Raise):
|
|
|
|
|
if isinstance(node.body[0].exc, ast.Call):
|
|
|
|
|
if hasattr(node.body[0].exc.func, "id") and node.body[0].exc.func.id == "NotImplementedError":
|
|
|
|
|
self.issues.append((node.lineno, f"Function '{node.name}' raises NotImplementedError"))
|
|
|
|
|
# This is a legitimate abstract method pattern, skip it
|
|
|
|
|
pass
|
|
|
|
|
elif len(node.body) == 2:
|
|
|
|
|
# Docstring + NotImplementedError - legitimate abstract method
|
|
|
|
|
if (isinstance(node.body[0], ast.Expr) and
|
|
|
|
|
isinstance(node.body[0].value, ast.Constant) and
|
|
|
|
|
isinstance(node.body[0].value.value, str) and
|
|
|
|
|
isinstance(node.body[1], ast.Raise)):
|
|
|
|
|
# Has docstring + raise, this is a documented abstract method
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Check for functions with only ellipsis
|
|
|
|
|
if len(node.body) == 1 and isinstance(node.body[0], ast.Expr):
|
|
|
|
@@ -62,28 +96,32 @@ def find_stub_patterns(filepath: Path) -> List[Tuple[int, str, str]]:
|
|
|
|
|
except Exception:
|
|
|
|
|
return issues
|
|
|
|
|
|
|
|
|
|
# Regex patterns to find stub indicators
|
|
|
|
|
# Regex patterns to find stub indicators (with case sensitivity flag)
|
|
|
|
|
# Note: We're looking for TODO/FIXME comments, not tool names containing "todo"
|
|
|
|
|
# Format: (pattern, message, case_insensitive)
|
|
|
|
|
patterns = [
|
|
|
|
|
(r"#\s*(TODO|FIXME|STUB|FAKE|UNFINISHED|HACK|XXX)\s*:?", "contains {0} comment"),
|
|
|
|
|
(r"raise\s+NotImplementedError", "raises NotImplementedError"),
|
|
|
|
|
(r'assert\s+False,?\s*["\']Not implemented', 'has "Not implemented" assertion'),
|
|
|
|
|
# Match TODO/FIXME comments (case insensitive for comments)
|
|
|
|
|
(r"#\s*(TODO|FIXME|STUB|FAKE|UNFINISHED|HACK|XXX)\s*:", "contains {0} comment", True),
|
|
|
|
|
(r'assert\s+False,?\s*["\']Not implemented', 'has "Not implemented" assertion', True),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Additional patterns for non-test files
|
|
|
|
|
if not is_test_file:
|
|
|
|
|
patterns.extend(
|
|
|
|
|
[
|
|
|
|
|
(r"pass\s*#\s*(stub|todo|fake)", "has stub/todo/fake comment after pass"),
|
|
|
|
|
(r'return\s+["\']TODO', "returns TODO string"),
|
|
|
|
|
(r'return\s+["\']STUB', "returns STUB string"),
|
|
|
|
|
(r"return\s+None\s*#\s*(TODO|STUB|FAKE)", "returns None with stub comment"),
|
|
|
|
|
(r"pass\s*#\s*(stub|fake)", "has stub/fake comment after pass", True),
|
|
|
|
|
# Only match uppercase "TODO"/"STUB" strings (case sensitive to avoid "todo" tool name)
|
|
|
|
|
(r'return\s+["\']TODO["\']', "returns TODO string", False),
|
|
|
|
|
(r'return\s+["\']STUB["\']', "returns STUB string", False),
|
|
|
|
|
(r"return\s+None\s*#\s*(STUB|FAKE)", "returns None with stub comment", True),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
lines = content.split("\n")
|
|
|
|
|
for line_num, line in enumerate(lines, 1):
|
|
|
|
|
for pattern, message in patterns:
|
|
|
|
|
if match := re.search(pattern, line, re.IGNORECASE):
|
|
|
|
|
for pattern, message, case_insensitive in patterns:
|
|
|
|
|
flags = re.IGNORECASE if case_insensitive else 0
|
|
|
|
|
if match := re.search(pattern, line, flags):
|
|
|
|
|
keyword = match.group(1) if match.groups() else "stub pattern"
|
|
|
|
|
issues.append((line_num, message.format(keyword), filepath.name))
|
|
|
|
|
|
|
|
|
@@ -168,16 +206,34 @@ class TestNoStubs:
|
|
|
|
|
pytest.fail(f"Critical module {module_path} does not exist")
|
|
|
|
|
|
|
|
|
|
content = filepath.read_text()
|
|
|
|
|
# Check function exists and has more than just pass/raise
|
|
|
|
|
pattern = rf'def {function_name}\([^)]*\):[^:]*\n(?:\s+"""[^"]*"""\n)?(\s+.+)'
|
|
|
|
|
match = re.search(pattern, content, re.MULTILINE)
|
|
|
|
|
|
|
|
|
|
if not match:
|
|
|
|
|
# Check function exists - use a more flexible pattern
|
|
|
|
|
# Match 'def function_name(' with optional type hints
|
|
|
|
|
function_pattern = rf"def {function_name}\s*\("
|
|
|
|
|
if not re.search(function_pattern, content):
|
|
|
|
|
pytest.fail(f"Function {function_name} not found in {module_path}")
|
|
|
|
|
|
|
|
|
|
function_body = match.group(1).strip()
|
|
|
|
|
if function_body in ["pass", "raise NotImplementedError", "raise NotImplementedError()", "..."]:
|
|
|
|
|
pytest.fail(f"Function {function_name} in {module_path} is not implemented")
|
|
|
|
|
# Find the function body and check it's not a stub
|
|
|
|
|
# Use AST for accurate parsing
|
|
|
|
|
try:
|
|
|
|
|
tree = ast.parse(content)
|
|
|
|
|
for node in ast.walk(tree):
|
|
|
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
|
|
|
if node.name == function_name:
|
|
|
|
|
# Check if function body is just pass, ellipsis, or NotImplementedError
|
|
|
|
|
if len(node.body) == 1:
|
|
|
|
|
body = node.body[0]
|
|
|
|
|
if isinstance(body, ast.Pass):
|
|
|
|
|
pytest.fail(f"Function {function_name} in {module_path} contains only 'pass'")
|
|
|
|
|
if isinstance(body, ast.Expr) and isinstance(body.value, ast.Constant):
|
|
|
|
|
if body.value.value is Ellipsis:
|
|
|
|
|
pytest.fail(f"Function {function_name} in {module_path} contains only '...'")
|
|
|
|
|
if isinstance(body, ast.Raise) and isinstance(body.exc, ast.Call):
|
|
|
|
|
if hasattr(body.exc.func, "id") and body.exc.func.id == "NotImplementedError":
|
|
|
|
|
pytest.fail(f"Function {function_name} in {module_path} raises NotImplementedError")
|
|
|
|
|
break
|
|
|
|
|
except SyntaxError:
|
|
|
|
|
pass # If we can't parse, skip AST check
|
|
|
|
|
|
|
|
|
|
def test_no_pytest_skip_in_non_test_files(self):
|
|
|
|
|
"""Ensure pytest.skip is only used in test files."""
|
|
|
|
@@ -197,22 +253,23 @@ class TestNoStubs:
|
|
|
|
|
package_root = Path(__file__).parent.parent / "hanzo_mcp"
|
|
|
|
|
|
|
|
|
|
for filepath in get_python_files(package_root):
|
|
|
|
|
# Skip test directories and legitimate mock modules
|
|
|
|
|
if "test" in str(filepath) or "mock" in filepath.name:
|
|
|
|
|
# Skip test directories, legitimate mock modules, and fallback implementations
|
|
|
|
|
if "test" in str(filepath) or "mock" in filepath.name.lower():
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
content = filepath.read_text()
|
|
|
|
|
|
|
|
|
|
# Check for mock-related imports in production code
|
|
|
|
|
# Note: class Mock/Fake are OK if they're for fallback functionality
|
|
|
|
|
# (like MockContext for when Context is not serialized over MCP)
|
|
|
|
|
mock_patterns = [
|
|
|
|
|
r"from unittest\.mock import",
|
|
|
|
|
r"import unittest\.mock",
|
|
|
|
|
r"class Mock",
|
|
|
|
|
r"class Fake",
|
|
|
|
|
r"def fake_",
|
|
|
|
|
r"def mock_",
|
|
|
|
|
r'return\s+["\']fake',
|
|
|
|
|
r'return\s+["\']mock',
|
|
|
|
|
# Only flag clearly test-oriented mock patterns
|
|
|
|
|
r"def fake_\w+\s*\(",
|
|
|
|
|
r"def mock_\w+\s*\(",
|
|
|
|
|
r'return\s+["\']fake\w*["\']',
|
|
|
|
|
r'return\s+["\']mock\w*["\']',
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for pattern in mock_patterns:
|
|
|
|
@@ -220,29 +277,74 @@ class TestNoStubs:
|
|
|
|
|
pytest.fail(f"Found mock/fake pattern '{pattern}' in production file: {filepath}")
|
|
|
|
|
|
|
|
|
|
def test_all_tool_classes_have_run_method(self):
|
|
|
|
|
"""Ensure all tool classes have a proper run method."""
|
|
|
|
|
"""Ensure all tool classes have a proper run or call method."""
|
|
|
|
|
package_root = Path(__file__).parent.parent / "hanzo_mcp" / "tools"
|
|
|
|
|
|
|
|
|
|
if not package_root.exists():
|
|
|
|
|
pytest.skip("Tools directory does not exist")
|
|
|
|
|
|
|
|
|
|
issues = []
|
|
|
|
|
for filepath in get_python_files(package_root):
|
|
|
|
|
if "test" in str(filepath) or "__pycache__" in str(filepath):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
content = filepath.read_text()
|
|
|
|
|
|
|
|
|
|
# Find all class definitions that inherit from BaseTool or end with Tool
|
|
|
|
|
class_pattern = r"class\s+(\w*Tool\w*)\s*\([^)]*\):"
|
|
|
|
|
classes = re.findall(class_pattern, content)
|
|
|
|
|
try:
|
|
|
|
|
tree = ast.parse(content)
|
|
|
|
|
except SyntaxError:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
for class_name in classes:
|
|
|
|
|
# Check if class has a run method
|
|
|
|
|
run_pattern = rf"class\s+{class_name}.*?def\s+run\s*\([^)]*\):"
|
|
|
|
|
if not re.search(run_pattern, content, re.DOTALL):
|
|
|
|
|
# Check if it's an abstract base class
|
|
|
|
|
if "Base" not in class_name and "Abstract" not in class_name:
|
|
|
|
|
pytest.fail(f"Tool class {class_name} in {filepath.name} missing run() method")
|
|
|
|
|
# Find all top-level Tool classes (not nested) and check for run/call methods
|
|
|
|
|
for node in tree.body:
|
|
|
|
|
if isinstance(node, ast.ClassDef):
|
|
|
|
|
class_name = node.name
|
|
|
|
|
# Only check classes that end with Tool
|
|
|
|
|
if not class_name.endswith("Tool"):
|
|
|
|
|
continue
|
|
|
|
|
# Skip Params/Config classes that are just type definitions
|
|
|
|
|
if "Params" in class_name or "Config" in class_name:
|
|
|
|
|
continue
|
|
|
|
|
# Skip abstract base classes
|
|
|
|
|
if "Base" in class_name or "Abstract" in class_name:
|
|
|
|
|
continue
|
|
|
|
|
# Skip adapter classes
|
|
|
|
|
if "Adapter" in class_name:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Check if class has run() or call() method
|
|
|
|
|
has_run_or_call = False
|
|
|
|
|
for item in node.body:
|
|
|
|
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
|
|
|
if item.name in ("run", "call"):
|
|
|
|
|
has_run_or_call = True
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not has_run_or_call:
|
|
|
|
|
# If it inherits from any base class, the base likely provides call()
|
|
|
|
|
# Only flag classes with no inheritance or with Object-only inheritance
|
|
|
|
|
inherits_from_something_meaningful = len(node.bases) > 0
|
|
|
|
|
if inherits_from_something_meaningful:
|
|
|
|
|
# Check all base class names
|
|
|
|
|
for base in node.bases:
|
|
|
|
|
base_name = ""
|
|
|
|
|
if isinstance(base, ast.Name):
|
|
|
|
|
base_name = base.id
|
|
|
|
|
elif isinstance(base, ast.Attribute):
|
|
|
|
|
base_name = base.attr
|
|
|
|
|
# If any base ends with Tool, Base, Mixin it's likely OK
|
|
|
|
|
if any(
|
|
|
|
|
base_name.endswith(suffix)
|
|
|
|
|
for suffix in ("Tool", "Base", "Mixin")
|
|
|
|
|
):
|
|
|
|
|
inherits_from_something_meaningful = True
|
|
|
|
|
break
|
|
|
|
|
else:
|
|
|
|
|
# No bases - this is a standalone Tool class that needs run/call
|
|
|
|
|
issues.append(f"Tool class {class_name} in {filepath.name} missing run() or call() method")
|
|
|
|
|
|
|
|
|
|
if issues:
|
|
|
|
|
pytest.fail(f"Found {len(issues)} tool classes without run()/call() method:\n" + "\n".join(issues[:10]))
|
|
|
|
|
|
|
|
|
|
def test_no_debug_prints_in_production(self):
|
|
|
|
|
"""Ensure no debug print statements in production code."""
|
|
|
|
|