diff --git a/src/seedgo/plugins/drone_compliance.py b/src/seedgo/plugins/drone_compliance.py index cbe3a570..c6a9ab65 100644 --- a/src/seedgo/plugins/drone_compliance.py +++ b/src/seedgo/plugins/drone_compliance.py @@ -17,6 +17,38 @@ from pathlib import Path from seedgo.models import CheckItem, CheckResult, Severity + +def _get_missing_drone_module_keys(tree: ast.AST, required_keys: set[str]) -> set[str]: + """Extract keys from DRONE_MODULE dict and return missing required keys. + + Args: + tree: AST tree to search + required_keys: Set of required key names + + Returns: + Set of missing key names + """ + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + if not any(isinstance(t, ast.Name) and t.id == "DRONE_MODULE" for t in node.targets): + continue + + # Found DRONE_MODULE assignment, check if value is a dict + if not isinstance(node.value, ast.Dict): + continue + + # Extract string keys from the dict + found_keys = set() + for key in node.value.keys: + if isinstance(key, ast.Constant) and isinstance(key.value, str): + found_keys.add(key.value) + + return required_keys - found_keys + + # DRONE_MODULE not found or not a dict + return required_keys + PLUGIN_NAME = "drone-compliance" PLUGIN_DESCRIPTION = "Verify modules provide drone adapter interface" PLUGIN_VERSION = "1.0.0" @@ -151,6 +183,29 @@ def check(file_path: str, config: dict | None = None) -> CheckResult: ) ) + # Check 2b: DRONE_MODULE dict has required keys (name, version, description) + if has_meta: + required_keys = {"name", "version", "description"} + missing_keys = _get_missing_drone_module_keys(tree, required_keys) + has_all_keys = len(missing_keys) == 0 + checks.append( + CheckItem( + name="drone-module-keys", + passed=has_all_keys, + message=( + "DRONE_MODULE has all required keys (name, version, description)" + if has_all_keys + else f"DRONE_MODULE missing required keys: {', '.join(sorted(missing_keys))}" + ), + severity=Severity.ERROR if not has_all_keys else Severity.INFO, + fix_hint=( + f"Add missing keys to DRONE_MODULE: {', '.join(sorted(missing_keys))}" + if not has_all_keys + else None + ), + ) + ) + # Check 3: handle_command() function exists functions = [ node.name for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) @@ -191,6 +246,22 @@ def check(file_path: str, config: dict | None = None) -> CheckResult: ) ) + # Check 5: get_introspective() function exists (optional but recommended) + has_introspective = "get_introspective" in functions + checks.append( + CheckItem( + name="get-introspective", + passed=has_introspective, + message=( + "get_introspective() function found — module supports introspection" + if has_introspective + else "Missing get_introspective() — introspection pattern recommended for drone modules" + ), + severity=Severity.WARNING if not has_introspective else Severity.INFO, + fix_hint="Add: def get_introspective() -> dict:" if not has_introspective else None, + ) + ) + # Score: errors block pass, warnings degrade error_checks = [c for c in checks if not c.passed and c.severity == Severity.ERROR] all_passed = len(error_checks) == 0 diff --git a/src/seedgo/plugins/naming_convention.py b/src/seedgo/plugins/naming_convention.py new file mode 100644 index 00000000..e9cb57e9 --- /dev/null +++ b/src/seedgo/plugins/naming_convention.py @@ -0,0 +1,267 @@ +""" +Seed Go Plugin: naming-convention + +Enforces Python naming conventions: +- snake_case for functions, variables, and module names +- PascalCase for classes +- UPPER_CASE for module-level constants + +This is a fundamental Python style rule (PEP 8) that improves code readability +and consistency across projects. +""" + +import ast +import re +from pathlib import Path + +from seedgo.models import CheckItem, CheckResult, Severity + +PLUGIN_NAME = "naming-convention" +PLUGIN_DESCRIPTION = "Enforce Python naming conventions (PEP 8)" +FILE_TYPES = ["*.py"] +PLUGIN_VERSION = "1.0.0" + +# Naming patterns +_SNAKE_CASE_RE = re.compile(r"^[a-z_][a-z0-9_]*$") +_PASCAL_CASE_RE = re.compile(r"^[A-Z][a-zA-Z0-9]*$") +_UPPER_CASE_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") + +# Common exceptions that are acceptable +_COMMON_EXCEPTIONS = { + # Common single-letter variables + "i", "j", "k", "n", "x", "y", "z", "a", "b", "c", + # Common acronyms/names that are conventionally capitalized + "T", "K", "V", + # Special methods and variables + "_", "__", +} + + +def _is_constant(node: ast.Assign | ast.AnnAssign, scope: str) -> bool: + """Determine if an assignment is a module-level constant. + + Constants are defined as module-level assignments where: + 1. The name is in UPPER_CASE + 2. The assignment is at module level (not in a function/class) + + Args: + node: Assignment node + scope: "module", "class", or "function" + + Returns: + True if this appears to be a constant + """ + if scope != "module": + return False + + # Get target names + if isinstance(node, ast.Assign): + targets = node.targets + else: # AnnAssign + targets = [node.target] + + for target in targets: + if isinstance(target, ast.Name): + # Consider it a constant if it matches UPPER_CASE pattern + if _UPPER_CASE_RE.match(target.id): + return True + + return False + + +def check(file_path: str, config: dict | None = None) -> CheckResult: + """Check a Python file for naming convention violations. + + Args: + file_path: Absolute path to the Python file to check. + config: Optional plugin config dict (unused by this plugin). + + Returns: + CheckResult with one CheckItem per naming violation found. + """ + _ = config # Part of plugin interface contract + + try: + source = Path(file_path).read_text(encoding="utf-8", errors="replace") + except OSError: + return CheckResult( + plugin=PLUGIN_NAME, + passed=True, + checks=[], + file_path=file_path, + metadata={"skipped": True, "reason": "file_read_error"}, + ) + + if not source.strip(): + return CheckResult( + plugin=PLUGIN_NAME, + passed=True, + checks=[ + CheckItem( + name="naming-convention", + passed=True, + message="Empty file — no names to check.", + severity=Severity.WARNING, + ) + ], + file_path=file_path, + ) + + try: + tree = ast.parse(source, filename=file_path) + except SyntaxError: + return CheckResult( + plugin=PLUGIN_NAME, + passed=True, + checks=[], + file_path=file_path, + metadata={"skipped": True, "reason": "syntax_error"}, + ) + + violations: list[CheckItem] = [] + + # Check module name (file name) + module_name = Path(file_path).stem + if module_name not in ("__init__", "__main__") and not _SNAKE_CASE_RE.match(module_name): + violations.append( + CheckItem( + name="module-name", + passed=False, + message=f"Module name '{module_name}' should be snake_case", + severity=Severity.WARNING, + fix_hint=f"Rename file to use snake_case (e.g., {_to_snake_case(module_name)}.py)", + ) + ) + + # Walk the AST and check naming + for node in ast.walk(tree): + # Check class names (should be PascalCase) + if isinstance(node, ast.ClassDef): + if not _PASCAL_CASE_RE.match(node.name) and node.name not in _COMMON_EXCEPTIONS: + violations.append( + CheckItem( + name="class-name", + passed=False, + message=f"Class '{node.name}' at line {node.lineno} should be PascalCase", + severity=Severity.WARNING, + line=node.lineno, + fix_hint=f"Rename to PascalCase (e.g., {_to_pascal_case(node.name)})", + ) + ) + + # Check function names (should be snake_case) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + # Skip dunder methods + if node.name.startswith("__") and node.name.endswith("__"): + continue + + if not _SNAKE_CASE_RE.match(node.name) and node.name not in _COMMON_EXCEPTIONS: + violations.append( + CheckItem( + name="function-name", + passed=False, + message=f"Function '{node.name}' at line {node.lineno} should be snake_case", + severity=Severity.WARNING, + line=node.lineno, + fix_hint=f"Rename to snake_case (e.g., {_to_snake_case(node.name)})", + ) + ) + + # Check module-level variables and constants + for node in tree.body: + if isinstance(node, (ast.Assign, ast.AnnAssign)): + # Determine if this is a constant or variable + is_const = _is_constant(node, "module") + + # Get target names + if isinstance(node, ast.Assign): + targets = node.targets + else: # AnnAssign + targets = [node.target] + + for target in targets: + if isinstance(target, ast.Name): + name = target.id + + # Skip special variables + if name.startswith("__") and name.endswith("__"): + continue + + if name in _COMMON_EXCEPTIONS: + continue + + # Check naming based on whether it's a constant + if is_const: + if not _UPPER_CASE_RE.match(name): + violations.append( + CheckItem( + name="constant-name", + passed=False, + message=f"Module-level constant '{name}' at line {node.lineno} should be UPPER_CASE", + severity=Severity.WARNING, + line=node.lineno, + fix_hint=f"Rename to UPPER_CASE (e.g., {name.upper()})", + ) + ) + else: + if not _SNAKE_CASE_RE.match(name): + violations.append( + CheckItem( + name="variable-name", + passed=False, + message=f"Module-level variable '{name}' at line {node.lineno} should be snake_case", + severity=Severity.WARNING, + line=node.lineno, + fix_hint=f"Rename to snake_case (e.g., {_to_snake_case(name)})", + ) + ) + + if violations: + passed = False + checks = violations + else: + passed = True + checks = [ + CheckItem( + name="naming-convention", + passed=True, + message="All names follow Python naming conventions (PEP 8).", + severity=Severity.WARNING, + ) + ] + + return CheckResult( + plugin=PLUGIN_NAME, + passed=passed, + checks=checks, + file_path=file_path, + metadata={"violations_found": len(violations)}, + ) + + +def _to_snake_case(name: str) -> str: + """Convert a name to snake_case suggestion. + + Args: + name: Original name + + Returns: + Suggested snake_case version + """ + # Simple conversion: insert underscores before uppercase letters + result = re.sub(r"(? str: + """Convert a name to PascalCase suggestion. + + Args: + name: Original name + + Returns: + Suggested PascalCase version + """ + # Split on underscores and capitalize each word + words = name.split("_") + return "".join(word.capitalize() for word in words if word) diff --git a/tests/test_drone_compliance.py b/tests/test_drone_compliance.py index c9acb2b0..2dd880b8 100644 --- a/tests/test_drone_compliance.py +++ b/tests/test_drone_compliance.py @@ -35,6 +35,9 @@ class TestDroneCompliancePass: def get_help(command=None): return "help text" + + def get_introspective(): + return {} """) ) @@ -44,8 +47,8 @@ class TestDroneCompliancePass: assert result.passed is True assert result.score == 100 - def test_all_four_checks_present(self, tmp_path: Path): - """All 4 checks pass: adapter exists, DRONE_MODULE, handle_command, get_help.""" + def test_all_checks_present(self, tmp_path: Path): + """All checks pass: adapter exists, DRONE_MODULE with keys, handle_command, get_help, get_introspective.""" pkg = tmp_path / "mymod" pkg.mkdir() (pkg / "__init__.py").write_text("") @@ -58,11 +61,15 @@ class TestDroneCompliancePass: def get_help(command=None): return "" + + def get_introspective(): + return {} """) ) result = check(str(pkg / "__init__.py"), config={"target_packages": ["mymod"]}) assert result.passed is True + assert result.score == 100 class TestDroneComplianceFail: @@ -140,6 +147,51 @@ class TestDroneComplianceFail: warnings = [c for c in result.checks if not c.passed] assert any(c.name == "get-help" for c in warnings) + def test_missing_drone_module_keys(self, tmp_path: Path): + """Adapter with DRONE_MODULE but missing required keys fails.""" + pkg = tmp_path / "mymod" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "drone_adapter.py").write_text( + textwrap.dedent("""\ + DRONE_MODULE = {"name": "mymod"} + + def handle_command(command, args=None): + return {} + + def get_help(command=None): + return "" + """) + ) + + result = check(str(pkg / "__init__.py"), config={"target_packages": ["mymod"]}) + assert result.passed is False + failed = [c.name for c in result.checks if not c.passed] + assert "drone-module-keys" in failed + + def test_missing_get_introspective_is_warning(self, tmp_path: Path): + """Adapter without get_introspective is a warning, not an error — still passes.""" + pkg = tmp_path / "mymod" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "drone_adapter.py").write_text( + textwrap.dedent("""\ + DRONE_MODULE = {"name": "mymod", "version": "1.0.0", "description": "test"} + + def handle_command(command, args=None): + return {} + + def get_help(command=None): + return "" + """) + ) + + result = check(str(pkg / "__init__.py"), config={"target_packages": ["mymod"]}) + # Missing get_introspective is WARNING severity, not ERROR — should still pass + assert result.passed is True + warnings = [c for c in result.checks if not c.passed] + assert any(c.name == "get-introspective" for c in warnings) + class TestDroneComplianceSkip: """Non-target packages should be skipped."""