#!/usr/bin/env python3 """AST-based smoke test for all plugin tools. Why AST and not import? The /a0/ framework root is a namespace package (no __init__.py) and cannot be reliably imported from a standalone venv. AST parsing avoids the venv/sys.path issue while still verifying: - file parses cleanly (no syntax errors) - module defines exactly one Tool subclass - class name matches expected convention (CamelCase of module name) - class inherits from framework Tool base - class defines `async def execute(self, **kwargs) -> Response` - the execute() signature is `async def execute(self, ...)` Run from the plugin root: python3 scripts/test_all_tools.py """ from __future__ import annotations import ast import os import sys from pathlib import Path PLUGIN_ROOT = Path(__file__).resolve().parent.parent TOOLS_DIR = PLUGIN_ROOT / "tools" EXPECTED_TOOLS = [ "artifact_guard", "block_compactor", "block_resume", "capability_check", "context_compactor", "handover_to_specialist", "next_step", "orchestrator_state", "plan_mode_guard", "project_registry", "quality_gate", "read_briefing", "repo_manifest", "resume_checker", "scorecard_update", "tool_registry", "orchestrator_self_check", ] def to_camel(name: str) -> str: return "".join(part.capitalize() for part in name.split("_")) def check_tool_file(py_path: Path) -> dict: result = { "file": py_path.name, "module": py_path.stem, "parses": False, "class_found": None, "inherits_tool": False, "has_execute": False, "execute_async": False, "errors": [], } src = py_path.read_text(encoding="utf-8") try: tree = ast.parse(src, filename=str(py_path)) result["parses"] = True except SyntaxError as e: result["errors"].append(f"SyntaxError: {e}") return result expected_class = to_camel(py_path.stem) found_class = None for node in ast.walk(tree): if isinstance(node, ast.ClassDef) and node.name == expected_class: found_class = node break if found_class is None: result["errors"].append(f"class {expected_class!r} not found") return result result["class_found"] = found_class.name for base in found_class.bases: is_tool = (isinstance(base, ast.Name) and base.id == "Tool") or \ (isinstance(base, ast.Attribute) and base.attr == "Tool") if is_tool: result["inherits_tool"] = True break if not result["inherits_tool"]: result["errors"].append(f"{found_class.name} does not inherit from Tool") for item in found_class.body: if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == "execute": result["has_execute"] = True if isinstance(item, ast.AsyncFunctionDef): result["execute_async"] = True else: result["errors"].append("execute() is not async") break if not result["has_execute"]: result["errors"].append("no execute() method found") return result def main(): print(f"AST smoke test for {len(EXPECTED_TOOLS)} tools in {TOOLS_DIR}") print("=" * 78) passed = 0 failed = 0 for tool_name in EXPECTED_TOOLS: py_path = TOOLS_DIR / f"{tool_name}.py" if not py_path.exists(): print(f" [FAIL] {tool_name:30s} -> file not found: {py_path}") failed += 1 continue r = check_tool_file(py_path) if not r["errors"]: print(f" [PASS] {tool_name:30s} -> class {r['class_found']!r} " f"(inherits Tool, async execute())") passed += 1 else: print(f" [FAIL] {tool_name:30s} -> {'; '.join(r['errors'])}") failed += 1 print("=" * 78) print(f"Result: {passed}/{len(EXPECTED_TOOLS)} passed, {failed} failed") sys.exit(0 if failed == 0 else 1) if __name__ == "__main__": main()