From 2435a70c7915ec029896c2cb6c75926247918fcf Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 2 Mar 2026 22:44:41 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(drone):=20add=20internal=20mod?= =?UTF-8?q?ule=20routing=20system=20for=20drone=20commands=20-=20introduce?= =?UTF-8?q?=20drone=20module=20registry=20with=20metadata=20and=20command?= =?UTF-8?q?=20routing=20-=20add=20seedgo=20as=20a=20registered=20internal?= =?UTF-8?q?=20drone=20module=20with=20adapter=20-=20update=20drone=20CLI?= =?UTF-8?q?=20to=20route=20commands=20to=20modules=20or=20branches=20by=20?= =?UTF-8?q?@target=20-=20enhance=20`drone=20systems`=20to=20list=20registe?= =?UTF-8?q?red=20modules=20with=20descriptions=20-=20provide=20module=20he?= =?UTF-8?q?lp=20integration=20alongside=20branch=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ feat(seedgo): create drone adapter for seedgo module commands - implement handle_command forwarding to seedgo CLI subprocess - provide get_help function with command-specific help text - support commands: check, audit, list, init with options and examples ✨ feat(seedgo): add __main__.py to support `python -m seedgo` execution ✨ feat(seedgo plugin): add drone-compliance plugin to verify drone adapter compliance - checks existence of drone_adapter.py and required interface elements - validates presence of DRONE_MODULE dict, handle_command(), and get_help() - reports compliance results with pass/fail and score with detailed checks - skips non-target packages and handles syntax errors gracefully ✅ test(drone modules): add comprehensive tests for drone module registry and routing - test listing, info retrieval, command routing, and help retrieval - test CLI integration for `drone systems` and `drone @seedgo` commands - verify module priority over branch routing and error handling ✅ test(seedgo drone compliance): add tests for drone-compliance plugin - test passing cases with proper adapter and interface - test failures for missing adapter, metadata, or functions - test warnings for missing get_help function - test skipping non-target packages and file-not-found cases - test handling of syntax errors in adapter files --- .coverage | Bin 0 -> 53248 bytes src/aipass/drone/cli.py | 90 +++++++-- src/aipass/drone/modules.py | 100 ++++++++++ src/seedgo/__main__.py | 6 + src/seedgo/drone_adapter.py | 101 ++++++++++ src/seedgo/plugins/drone_compliance.py | 212 ++++++++++++++++++++ tests/test_drone_compliance.py | 176 +++++++++++++++++ tests/test_drone_modules.py | 262 +++++++++++++++++++++++++ 8 files changed, 933 insertions(+), 14 deletions(-) create mode 100644 .coverage create mode 100644 src/aipass/drone/modules.py create mode 100644 src/seedgo/__main__.py create mode 100644 src/seedgo/drone_adapter.py create mode 100644 src/seedgo/plugins/drone_compliance.py create mode 100644 tests/test_drone_compliance.py create mode 100644 tests/test_drone_modules.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..accd56473480f6d0f15f262a26393cc8d00e3c74 GIT binary patch literal 53248 zcmeI)PiP!f90%|>Gn;HDf0&jMmJmwbMU!CuboHRMv}x2JX$$>h@gj6PyZe%i-JRLa z%;wJ_*li0%QM`$W6fYuzUKJ5~(W4-G5KqNpK`I52g00lwZ~kO=+iXa@l*I3_f9B77 zZ{GX7e>*$LzINuM6Dm3H`BgiV&xyT4)5I|;g%Bn^`{|i3Sz5`Y8}zQVtan;9#pKf4 z{nqQEulqe=o$o(oP4s=-|8(z9eO2?F-WyrlEYSfh5P$##An^YdXq@TG<^~6~D{G-$ ztfXgTN#;(5Alg;hluU%JBnQXsAkKVS@(2izN=H@A(lINE> z_eI}!ON%NPm-c!IOCIHOC5%h!d)2AXlNHB}is=SU=ylvRe8e#jx$=i5MFC( zD6I=zpbK%#g^=gePhhoGh4!uq> zJH-vXq_A7An+@u+S>CqlOv$-M<5+dqxl~u{l|L?T61fknMyHipF5iLl7N)cC)(OLvy=x}ta!!twidOR zKv4&wu^_dA-}%b(-`c| z=7xr}D`wP;lG;k%vTaQ$t;r3B(ezMN*+Jb;2a?SO$uvfDluY|ba)gRAk?qdS40S3_ z60VR>R`xWqnQU%gKx@Q3g2yF(cC{6P_cXh}BNM^avtP5rMfrH%c{gQzq^mo3c%W0p zISYk6zjj}JLyxk2eK69m(xA^zqbfWclAaH0}bOIUW;qh5%x|v3>%4sYtjYN4$Cl?;(&rzpK`xmWLtF~KC<}03t<5X$v z=g!IJi3bsiC`YJM2Gm6#Ni$6}Im#nen*L;sOgPo5U5>juS4vBY%Zuu<i*0LSQlCS6>&81@_#%9fcYpamwSH4Q#!yn}u!&)}?%rjb}o^-}|oGAn} zsaNeZ+sUTh)<_;X*laJSXuzdUJFV@6?OHf^Gd=?kZgCA3V}&$S@>!>6j4$vt>%O1| z76?E90uX=z1Rwwb2tWV=5P$##cAtQu=~|Yr|8?s(Vf{fbSReoa2tWV=5P$##AOHaf zKmY;|*pUK#x^cit|H9+oUd$dgRjw~QD4FL#100Izz z00bZa0SG_<0uX?}x-9f={|{{!0uX=z1Rwwb z2tWV=5P$##Ah4?i4Eno)e!l;&Sq}s~us{F;5P$##AOHafKmY;|fB*y_unPqYqu-4G z|9?$bzgS;bYrC+JC=vu9009U<00Izz00bZa0SG|gNefIE8Phzki_6Qp5I=nL>AOcR z-dmv6Uyt1XK!{Hs=9gc+|Gv2Uk+m}Jn&RlFp3%&+x-M#sX-$X+x_<1(Z^JJ?AFiGF z`saI@j1aZYx-yq<{l0Hh67U;Q%>|Ns9TVcoI* zwEk`jiwy`s00Izz00bZa0SG_<0uX=z1a_i87k%BJ8Qt{VfTrtR^ky1-|6lCgiG@L) zApijgKmY;|fB*y_009U<00I!$ssP{r$MyeKwGa*h5P$##AOHafKmY;|fB*y_urmd4 f|9@xJ8*&W+2tWV=5P$##AOHafKmY;|*p9%zx)0a} literal 0 HcmV?d00001 diff --git a/src/aipass/drone/cli.py b/src/aipass/drone/cli.py index 9ae75271..a2725121 100644 --- a/src/aipass/drone/cli.py +++ b/src/aipass/drone/cli.py @@ -7,9 +7,10 @@ Usage: drone Show available commands drone --help Show help drone --version Show version - drone systems List registered branches + drone systems List registered branches and modules drone @branch command [args] Route command to branch - drone @branch --help Show help for branch + drone @module command [args] Route command to internal module + drone @branch --help Show help for branch or module Zero external dependencies — stdlib only (sys). """ @@ -26,22 +27,30 @@ from aipass.drone import ( list_branches, route_command, ) +from aipass.drone.modules import ( + is_module, + list_modules, + get_module_info, + route_module_command, + get_module_help, +) _HELP_TEXT = """\ -drone — AIPass branch command router +drone — AIPass command router Usage: drone Show this help drone --help Show this help drone --version Show version - drone systems List registered branches - drone @branch command [args] Route command to branch - drone @branch --help Show help for branch + drone systems List registered branches and modules + drone @target command [args] Route command to branch or module + drone @target --help Show help for branch or module Examples: drone systems + drone @seedgo audit src/ + drone @seedgo check src/myfile.py drone @flow status - drone @flow run --verbose drone @flow --help """ @@ -82,23 +91,49 @@ def main() -> None: def _cmd_systems() -> None: - """Handle `drone systems` — list registered branches.""" + """Handle `drone systems` — list registered branches and modules.""" branches = list_branches() - if not branches: - print("No branches registered.") + modules = list_modules() + + if not branches and not modules: + print("No branches or modules registered.") sys.exit(0) - print(f"Registered branches ({len(branches)}):") - for name in sorted(branches): - print(f" {name}") + if modules: + print(f"Modules ({len(modules)}):") + for name in modules: + info = get_module_info(name) + if info: + print(f" @{name} {info.description}") + else: + print(f" @{name} (not available)") + if branches: + print() + + if branches: + print(f"Branches ({len(branches)}):") + for name in sorted(branches): + print(f" {name}") + sys.exit(0) def _cmd_branch(args: list[str]) -> None: - """Handle `drone @branch command [args]` or `drone @branch --help`.""" + """Handle `drone @target command [args]` or `drone @target --help`. + + Checks internal modules first (e.g. @seedgo), then falls back + to branch registry for external branches. + """ target = args[0] rest = args[1:] + module_name = target.lstrip("@") + # Check if this is a registered internal module + if is_module(module_name): + _cmd_module(module_name, rest) + return + + # Fall through to branch routing # drone @branch --help if not rest or rest == ["--help"]: try: @@ -133,3 +168,30 @@ def _cmd_branch(args: list[str]) -> None: if result.stderr: print(result.stderr, end="", file=sys.stderr) sys.exit(result.exit_code) + + +def _cmd_module(name: str, args: list[str]) -> None: + """Handle routing to an internal module (e.g. @seedgo).""" + # drone @module --help (or no args) + if not args or args == ["--help"]: + help_text = get_module_help(name) + if help_text: + print(help_text, end="") + else: + print(f"No help available for @{name}.") + sys.exit(0) + + command = args[0] + cmd_args = args[1:] if len(args) > 1 else None + + try: + result = route_module_command(name, command, cmd_args) + except (ImportError, AttributeError) as exc: + print(f"drone: module @{name} is registered but not available: {exc}", file=sys.stderr) + sys.exit(1) + + if result.get("stdout"): + print(result["stdout"], end="") + if result.get("stderr"): + print(result["stderr"], end="", file=sys.stderr) + sys.exit(result.get("exit_code", 0)) diff --git a/src/aipass/drone/modules.py b/src/aipass/drone/modules.py new file mode 100644 index 00000000..8c403244 --- /dev/null +++ b/src/aipass/drone/modules.py @@ -0,0 +1,100 @@ +"""Internal module registry for drone. + +Routes @module commands to Python packages installed alongside drone, +as opposed to external branches in BRANCH_REGISTRY.json. + +Modules register by providing a drone_adapter module with: +- DRONE_MODULE dict (name, version, description) +- handle_command(command, args) -> dict with stdout/stderr/exit_code +- get_help(command=None) -> str +""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass + +# Maps module name -> import path for its drone_adapter +_MODULE_REGISTRY: dict[str, str] = { + "seedgo": "seedgo.drone_adapter", + # Future modules register here: + # "prax": "aipass.prax.drone_adapter", + # "cortex": "aipass.cortex.drone_adapter", +} + + +@dataclass +class ModuleInfo: + """Metadata about a registered module.""" + + name: str + version: str + description: str + adapter_path: str + + +def list_modules() -> list[str]: + """Return sorted list of registered module names.""" + return sorted(_MODULE_REGISTRY.keys()) + + +def get_module_info(name: str) -> ModuleInfo | None: + """Get module metadata without executing anything. + + Returns None if the module is not registered or not importable. + """ + adapter_path = _MODULE_REGISTRY.get(name) + if adapter_path is None: + return None + try: + mod = importlib.import_module(adapter_path) + meta = getattr(mod, "DRONE_MODULE", {}) + return ModuleInfo( + name=meta.get("name", name), + version=meta.get("version", "unknown"), + description=meta.get("description", ""), + adapter_path=adapter_path, + ) + except ImportError: + return None + + +def is_module(name: str) -> bool: + """Check if name is a registered module (doesn't verify importability).""" + return name in _MODULE_REGISTRY + + +def route_module_command(name: str, command: str, args: list[str] | None = None) -> dict: + """Route a command to a module's drone adapter. + + Returns dict with keys: stdout, stderr, exit_code. + Raises: KeyError if module not registered, ImportError if adapter missing, + AttributeError if adapter lacks handle_command. + """ + adapter_path = _MODULE_REGISTRY[name] + mod = importlib.import_module(adapter_path) + handler = getattr(mod, "handle_command") + return handler(command, args) + + +def get_module_help(name: str, command: str | None = None) -> str: + """Get help text from a module's drone adapter. + + Returns help string, or empty string if unavailable. + """ + adapter_path = _MODULE_REGISTRY.get(name) + if adapter_path is None: + return "" + try: + mod = importlib.import_module(adapter_path) + help_fn = getattr(mod, "get_help", None) + if help_fn is None: + return "" + return help_fn(command) + except (ImportError, AttributeError): + return "" + + +def register_module(name: str, adapter_path: str) -> None: + """Register a new module. Used for dynamic registration (e.g., plugins).""" + _MODULE_REGISTRY[name] = adapter_path diff --git a/src/seedgo/__main__.py b/src/seedgo/__main__.py new file mode 100644 index 00000000..5cbe795c --- /dev/null +++ b/src/seedgo/__main__.py @@ -0,0 +1,6 @@ +"""Allow running seedgo as a module: python -m seedgo.""" + +from seedgo.cli import main + +if __name__ == "__main__": + main() diff --git a/src/seedgo/drone_adapter.py b/src/seedgo/drone_adapter.py new file mode 100644 index 00000000..e6364671 --- /dev/null +++ b/src/seedgo/drone_adapter.py @@ -0,0 +1,101 @@ +"""Drone adapter for seedgo — code standards framework. + +Provides the drone module interface so `drone @seedgo` commands work. +Delegates to the seedgo CLI via subprocess for clean stdout/stderr capture. +""" + +from __future__ import annotations + +import subprocess +import sys + +DRONE_MODULE = { + "name": "seedgo", + "version": "1.0.0", + "description": "Code standards framework — check and audit code against configurable standards", +} + +_HELP_TEXT = """\ +seedgo — code standards framework + +Commands: + check [FILE ...] Run checks on files (or project if no files given) + audit [PATH ...] Alias for check (run audit on paths) + list Show all discovered plugins + init Initialize .seedgo/ config in current directory + +Options (for check/audit): + --format FORMAT Output format: human, json, github (default: human) + --threshold N Pass threshold 0-100 (default: 75) + --plugin NAME Run only this plugin + +Examples: + drone @seedgo check src/myfile.py + drone @seedgo audit src/ + drone @seedgo list + drone @seedgo init --profile strict +""" + + +def handle_command(command: str, args: list[str] | None = None) -> dict: + """Handle a command routed by drone. + + Returns dict with stdout, stderr, exit_code. + """ + cmd_args = args or [] + + # Map commands to seedgo CLI arguments + if command == "audit": + # audit is an alias for check + cli_args = ["check"] + cmd_args + elif command in ("check", "list", "init"): + cli_args = [command] + cmd_args + else: + return { + "stdout": "", + "stderr": f"seedgo: unknown command '{command}'\nRun 'drone @seedgo --help' for usage.\n", + "exit_code": 1, + } + + try: + result = subprocess.run( + [sys.executable, "-m", "seedgo"] + cli_args, + capture_output=True, + text=True, + timeout=120, + ) + return { + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.returncode, + } + except subprocess.TimeoutExpired: + return { + "stdout": "", + "stderr": "seedgo: command timed out after 120 seconds\n", + "exit_code": 1, + } + except FileNotFoundError: + return { + "stdout": "", + "stderr": "seedgo: Python executable not found\n", + "exit_code": 1, + } + + +def get_help(command: str | None = None) -> str: + """Return help text for drone @seedgo.""" + if command is None: + return _HELP_TEXT + + # For specific command help, delegate to seedgo + try: + result = subprocess.run( + [sys.executable, "-m", "seedgo", command, "--help"], + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout if result.stdout else _HELP_TEXT + except (subprocess.TimeoutExpired, FileNotFoundError): + return _HELP_TEXT diff --git a/src/seedgo/plugins/drone_compliance.py b/src/seedgo/plugins/drone_compliance.py new file mode 100644 index 00000000..cbe3a570 --- /dev/null +++ b/src/seedgo/plugins/drone_compliance.py @@ -0,0 +1,212 @@ +"""Drone compliance check — verifies modules have proper drone integration. + +Checks that Python packages in the aipass ecosystem provide: +1. A drone_adapter module +2. DRONE_MODULE metadata dict (name, version, description) +3. handle_command() function +4. get_help() function + +This check runs on __init__.py files to identify packages, then looks +for their drone_adapter.py sibling. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +from seedgo.models import CheckItem, CheckResult, Severity + +PLUGIN_NAME = "drone-compliance" +PLUGIN_DESCRIPTION = "Verify modules provide drone adapter interface" +PLUGIN_VERSION = "1.0.0" +FILE_TYPES = ["__init__.py"] + + +def check(file_path: str, config: dict | None = None) -> CheckResult: + """Check if the package containing this __init__.py is drone-compliant. + + Args: + file_path: Absolute path to an __init__.py file. + config: Optional plugin config dict. Supports: + target_packages (list[str]): Package names to check. + Defaults to ["aipass", "seedgo"]. + + Returns: + CheckResult with compliance check items. + """ + path = Path(file_path) + + if not path.exists(): + return CheckResult( + plugin=PLUGIN_NAME, + passed=True, + checks=[], + file_path=file_path, + metadata={"skipped": True, "reason": "file_not_found"}, + ) + + package_dir = path.parent + package_name = package_dir.name + + # Determine if this package should be checked + cfg = config or {} + target_packages = cfg.get("target_packages", ["aipass", "seedgo"]) + + should_check = package_name in target_packages + + if not should_check: + return CheckResult( + plugin=PLUGIN_NAME, + passed=True, + checks=[ + CheckItem( + name="scope", + passed=True, + message=f"Package '{package_name}' is not a drone-target module, skipped.", + severity=Severity.INFO, + ) + ], + score=100, + file_path=file_path, + metadata={"skipped": True}, + ) + + checks: list[CheckItem] = [] + adapter_path = package_dir / "drone_adapter.py" + + # Check 1: drone_adapter.py exists + has_adapter = adapter_path.exists() + checks.append( + CheckItem( + name="adapter-exists", + passed=has_adapter, + message=( + f"drone_adapter.py found in {package_name}/" + if has_adapter + else f"Missing drone_adapter.py in {package_name}/ — module is not drone-routable" + ), + severity=Severity.ERROR if not has_adapter else Severity.INFO, + fix_hint=( + "Create drone_adapter.py with DRONE_MODULE, handle_command(), and get_help()" + if not has_adapter + else None + ), + ) + ) + + if not has_adapter: + return CheckResult( + plugin=PLUGIN_NAME, + passed=False, + checks=checks, + score=0, + file_path=file_path, + metadata={}, + ) + + # Parse the adapter file with AST + try: + source = adapter_path.read_text() + tree = ast.parse(source) + except (SyntaxError, OSError) as exc: + checks.append( + CheckItem( + name="adapter-parseable", + passed=False, + message=f"drone_adapter.py has syntax error: {exc}", + severity=Severity.ERROR, + ) + ) + return CheckResult( + plugin=PLUGIN_NAME, + passed=False, + checks=checks, + score=0, + file_path=file_path, + metadata={}, + ) + + # Check 2: DRONE_MODULE dict exists + has_meta = any( + isinstance(node, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "DRONE_MODULE" for t in node.targets) + for node in ast.walk(tree) + ) + checks.append( + CheckItem( + name="drone-module-meta", + passed=has_meta, + message=( + "DRONE_MODULE metadata dict found" + if has_meta + else "Missing DRONE_MODULE dict — drone can't read module metadata" + ), + severity=Severity.ERROR if not has_meta else Severity.INFO, + fix_hint=( + 'Add: DRONE_MODULE = {"name": "...", "version": "...", "description": "..."}' + if not has_meta + else None + ), + ) + ) + + # Check 3: handle_command() function exists + functions = [ + node.name for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + + has_handle = "handle_command" in functions + checks.append( + CheckItem( + name="handle-command", + passed=has_handle, + message=( + "handle_command() function found" + if has_handle + else "Missing handle_command() — drone can't route commands to this module" + ), + severity=Severity.ERROR if not has_handle else Severity.INFO, + fix_hint=( + "Add: def handle_command(command: str, args: list[str] | None = None) -> dict:" + if not has_handle + else None + ), + ) + ) + + # Check 4: get_help() function exists + has_help = "get_help" in functions + checks.append( + CheckItem( + name="get-help", + passed=has_help, + message=( + "get_help() function found" + if has_help + else "Missing get_help() — drone can't show help for this module" + ), + severity=Severity.WARNING if not has_help else Severity.INFO, + fix_hint="Add: def get_help(command: str | None = None) -> str:" if not has_help 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 + + # Simple scoring: deduct for failures + failed_errors = len(error_checks) + failed_warnings = len([c for c in checks if not c.passed and c.severity == Severity.WARNING]) + total_weight = len(checks) + deductions = failed_errors * 1.0 + failed_warnings * 0.5 + score = int(((total_weight - deductions) / max(total_weight, 1)) * 100) + + return CheckResult( + plugin=PLUGIN_NAME, + passed=all_passed, + checks=checks, + score=score, + file_path=file_path, + metadata={"adapter_path": str(adapter_path)}, + ) diff --git a/tests/test_drone_compliance.py b/tests/test_drone_compliance.py new file mode 100644 index 00000000..c9acb2b0 --- /dev/null +++ b/tests/test_drone_compliance.py @@ -0,0 +1,176 @@ +""" +Tests for the drone-compliance seedgo plugin. + +Covers: + - Passes on packages with proper drone_adapter.py + - Fails on packages missing drone_adapter.py + - Fails on adapter missing DRONE_MODULE / handle_command / get_help + - Skips non-target packages + - Handles syntax errors in adapter files +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from seedgo.plugins.drone_compliance import PLUGIN_NAME, check + + +class TestDroneCompliancePass: + """Packages with proper drone adapters should pass.""" + + def test_seedgo_adapter_passes(self, tmp_path: Path): + """seedgo's own drone_adapter.py passes all checks.""" + # Create a fake "seedgo" package with a proper adapter + pkg = tmp_path / "seedgo" + pkg.mkdir() + (pkg / "__init__.py").write_text('"""Seedgo package."""\n') + (pkg / "drone_adapter.py").write_text( + textwrap.dedent("""\ + DRONE_MODULE = {"name": "seedgo", "version": "1.0.0", "description": "test"} + + def handle_command(command, args=None): + return {"stdout": "", "stderr": "", "exit_code": 0} + + def get_help(command=None): + return "help text" + """) + ) + + result = check(str(pkg / "__init__.py"), config={"target_packages": ["seedgo"]}) + + assert result.plugin == PLUGIN_NAME + 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.""" + pkg = tmp_path / "mymod" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "drone_adapter.py").write_text( + textwrap.dedent("""\ + DRONE_MODULE = {"name": "mymod", "version": "0.1.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"]}) + assert result.passed is True + + +class TestDroneComplianceFail: + """Packages missing drone adapter components should fail.""" + + def test_missing_adapter_fails(self, tmp_path: Path): + """Package without drone_adapter.py fails.""" + pkg = tmp_path / "seedgo" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + + result = check(str(pkg / "__init__.py"), config={"target_packages": ["seedgo"]}) + + assert result.passed is False + assert result.score == 0 + error_names = [c.name for c in result.checks if not c.passed] + assert "adapter-exists" in error_names + + def test_missing_drone_module_dict(self, tmp_path: Path): + """Adapter without DRONE_MODULE fails.""" + pkg = tmp_path / "mymod" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "drone_adapter.py").write_text( + textwrap.dedent("""\ + 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-meta" in failed + + def test_missing_handle_command(self, tmp_path: Path): + """Adapter without handle_command fails.""" + 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 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 "handle-command" in failed + + def test_missing_get_help_is_warning(self, tmp_path: Path): + """Adapter without get_help 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 {} + """) + ) + + result = check(str(pkg / "__init__.py"), config={"target_packages": ["mymod"]}) + # Missing get_help 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-help" for c in warnings) + + +class TestDroneComplianceSkip: + """Non-target packages should be skipped.""" + + def test_non_target_package_skipped(self, tmp_path: Path): + """Packages not in target_packages are skipped with pass.""" + pkg = tmp_path / "unrelated" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + + result = check(str(pkg / "__init__.py"), config={"target_packages": ["seedgo"]}) + assert result.passed is True + assert result.metadata.get("skipped") is True + + def test_file_not_found_passes(self, tmp_path: Path): + """Non-existent file returns pass (skipped).""" + result = check(str(tmp_path / "nonexistent" / "__init__.py")) + assert result.passed is True + + +class TestDroneComplianceEdgeCases: + """Edge cases for the compliance check.""" + + def test_syntax_error_in_adapter(self, tmp_path: Path): + """Adapter with syntax error fails gracefully.""" + pkg = tmp_path / "mymod" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "drone_adapter.py").write_text("def broken(:\n") + + 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 "adapter-parseable" in failed diff --git a/tests/test_drone_modules.py b/tests/test_drone_modules.py new file mode 100644 index 00000000..782be9a3 --- /dev/null +++ b/tests/test_drone_modules.py @@ -0,0 +1,262 @@ +""" +Tests for the drone module routing system (aipass.drone.modules). + +Covers: + - Module registry: list, lookup, register + - Module info retrieval + - Module command routing + - Module help retrieval + - CLI integration: drone systems shows modules, drone @seedgo routes correctly +""" + +from __future__ import annotations + +from unittest.mock import patch + +from aipass.drone.cli import main +from aipass.drone.modules import ( + ModuleInfo, + get_module_help, + get_module_info, + is_module, + list_modules, + register_module, + route_module_command, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_cli(*args: str) -> tuple[int, str, str]: + """Call main() with the given argv, capturing stdout/stderr and exit code.""" + stdout_lines: list[str] = [] + stderr_lines: list[str] = [] + + class _Cap: + def __init__(self, buf: list[str]) -> None: + self._buf = buf + + def write(self, s: str) -> int: + self._buf.append(s) + return len(s) + + def flush(self) -> None: + pass + + exit_code = 0 + + def _exit(code: int = 0) -> None: + nonlocal exit_code + exit_code = code + raise SystemExit(code) + + with ( + patch("sys.argv", ["drone", *args]), + patch("sys.stdout", _Cap(stdout_lines)), + patch("sys.stderr", _Cap(stderr_lines)), + patch("sys.exit", side_effect=_exit), + ): + try: + main() + except SystemExit: + pass + + return exit_code, "".join(stdout_lines), "".join(stderr_lines) + + +# --------------------------------------------------------------------------- +# Module registry +# --------------------------------------------------------------------------- + + +class TestModuleRegistry: + """Test the internal module registry.""" + + def test_seedgo_is_registered(self): + """seedgo is in the default module registry.""" + assert is_module("seedgo") + + def test_unknown_module_not_registered(self): + """Unknown names return False.""" + assert not is_module("nonexistent") + + def test_list_modules_includes_seedgo(self): + """list_modules includes seedgo.""" + modules = list_modules() + assert "seedgo" in modules + + def test_list_modules_returns_sorted(self): + """list_modules returns sorted names.""" + modules = list_modules() + assert modules == sorted(modules) + + def test_register_module_adds_new(self): + """register_module adds a new module.""" + register_module("test_mod", "test.path.adapter") + assert is_module("test_mod") + # Clean up + from aipass.drone.modules import _MODULE_REGISTRY + del _MODULE_REGISTRY["test_mod"] + + +class TestModuleInfo: + """Test module info retrieval.""" + + def test_seedgo_info_returns_module_info(self): + """get_module_info for seedgo returns valid ModuleInfo.""" + info = get_module_info("seedgo") + assert info is not None + assert isinstance(info, ModuleInfo) + assert info.name == "seedgo" + assert info.version == "1.0.0" + assert info.description != "" + + def test_unknown_module_returns_none(self): + """get_module_info for unknown module returns None.""" + assert get_module_info("nonexistent") is None + + def test_unimportable_module_returns_none(self): + """get_module_info returns None if adapter can't be imported.""" + register_module("broken", "nonexistent.module.path") + info = get_module_info("broken") + assert info is None + # Clean up + from aipass.drone.modules import _MODULE_REGISTRY + del _MODULE_REGISTRY["broken"] + + +# --------------------------------------------------------------------------- +# Module command routing +# --------------------------------------------------------------------------- + + +class TestModuleRouting: + """Test routing commands to modules.""" + + def test_route_seedgo_list(self): + """route_module_command to seedgo 'list' returns output.""" + result = route_module_command("seedgo", "list") + assert isinstance(result, dict) + assert "stdout" in result + assert "exit_code" in result + # seedgo list should succeed and show plugins + assert result["exit_code"] == 0 + assert "plugin" in result["stdout"].lower() or "PLUGIN" in result["stdout"] + + def test_route_unknown_command(self): + """Unknown seedgo command returns error.""" + result = route_module_command("seedgo", "nonexistent") + assert result["exit_code"] == 1 + assert "unknown command" in result["stderr"] + + def test_route_unknown_module_raises(self): + """Routing to unregistered module raises KeyError.""" + import pytest + with pytest.raises(KeyError): + route_module_command("nonexistent", "list") + + +# --------------------------------------------------------------------------- +# Module help +# --------------------------------------------------------------------------- + + +class TestModuleHelp: + """Test module help retrieval.""" + + def test_seedgo_help_returns_text(self): + """get_module_help for seedgo returns non-empty help text.""" + help_text = get_module_help("seedgo") + assert help_text != "" + assert "seedgo" in help_text + assert "check" in help_text or "audit" in help_text + + def test_unknown_module_help_returns_empty(self): + """get_module_help for unknown module returns empty string.""" + assert get_module_help("nonexistent") == "" + + +# --------------------------------------------------------------------------- +# CLI integration — drone systems shows modules +# --------------------------------------------------------------------------- + + +class TestCLISystemsModules: + """drone systems includes modules.""" + + def test_systems_shows_modules(self): + """drone systems output includes modules section.""" + with patch("aipass.drone.cli.list_branches", return_value=[]): + code, out, _ = _run_cli("systems") + assert code == 0 + assert "Modules" in out + assert "@seedgo" in out + + def test_systems_shows_module_description(self): + """drone systems shows module descriptions.""" + with patch("aipass.drone.cli.list_branches", return_value=[]): + code, out, _ = _run_cli("systems") + assert "standards" in out.lower() or "Standards" in out + + def test_systems_shows_both_modules_and_branches(self): + """drone systems shows both modules and branches.""" + with patch("aipass.drone.cli.list_branches", return_value=["@flow", "@prax"]): + code, out, _ = _run_cli("systems") + assert code == 0 + assert "Modules" in out + assert "Branches" in out + assert "@seedgo" in out + assert "@flow" in out + + +# --------------------------------------------------------------------------- +# CLI integration — drone @seedgo routes to module +# --------------------------------------------------------------------------- + + +class TestCLIModuleRouting: + """drone @seedgo commands route through module system.""" + + def test_seedgo_help(self): + """drone @seedgo --help shows seedgo help text.""" + code, out, _ = _run_cli("@seedgo", "--help") + assert code == 0 + assert "seedgo" in out + assert "check" in out + + def test_seedgo_no_args_shows_help(self): + """drone @seedgo with no command shows help.""" + code, out, _ = _run_cli("@seedgo") + assert code == 0 + assert "seedgo" in out + + def test_seedgo_list(self): + """drone @seedgo list shows plugins.""" + code, out, _ = _run_cli("@seedgo", "list") + assert code == 0 + assert "plugin" in out.lower() or "PLUGIN" in out + + def test_seedgo_unknown_command(self): + """drone @seedgo nonexistent exits with error.""" + code, _, err = _run_cli("@seedgo", "nonexistent") + assert code == 1 + assert "unknown command" in err + + def test_seedgo_check_specific_file(self): + """drone @seedgo check on a file runs checks.""" + code, out, _ = _run_cli("@seedgo", "check", "src/seedgo/__init__.py") + assert code == 0 or code == 1 # May pass or fail, but should run + # Should produce output from seedgo + assert out != "" or _ != "" + + def test_module_takes_priority_over_branch(self): + """Module routing takes priority over branch registry.""" + # Even if a branch named 'seedgo' existed, the module should handle it + # We verify by NOT mocking route_command — if branch routing were tried, + # it would fail because no registry exists + code, out, _ = _run_cli("@seedgo", "--help") + assert code == 0 + assert "seedgo" in out