diff --git a/CHANGELOG.md b/CHANGELOG.md index b144e6a8..c74eee59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,23 @@ PyPI version — not the changelog header. ## [2026-07-09] +### Added + +- **Exit-code contract foundation — failing commands can now exit non-zero + (issue #661, in progress).** CLI error paths printed an error but returned exit + `0`, so `$?`-checking callers (core to running `drone` as a subprocess) were + told success on failure. The dispatch contract was a 2-state bool (`handled` / + `not-mine`) with no way to say "handled *and* failed". `@cli` now exposes a + process-level failure flag + `resolve_exit(handled)` (→ `0`/`1`/`2`), and + `error()` auto-trips the flag — so any failure routed through `error()` gets a + correct non-zero exit with zero per-site edits, and it can't regress. Inert + until a branch's `main()` adopts it. `@seedgo` added an `output_routing` + standard (39th checker) flagging user-facing status output that bypasses the + cli helpers — 254 sites across 14 branches, the migration checklist. `devpulse` + is the first adopter (`main()`→`resolve_exit`, feedback migrated to `error()`, + exit `2`/`0`/`1` verified, 100% seedgo). Fleet migration to follow. + (built by @cli + @seedgo) + ### Fixed - **macOS session lock-out: the boot wrapper can now see tmux sessions on diff --git a/src/aipass/cli/apps/modules/__init__.py b/src/aipass/cli/apps/modules/__init__.py index 566d4749..764cc9fd 100644 --- a/src/aipass/cli/apps/modules/__init__.py +++ b/src/aipass/cli/apps/modules/__init__.py @@ -26,6 +26,9 @@ from aipass.cli.apps.modules.display import console, err_console # Display functions from aipass.cli.apps.modules.display import header, success, error, warning, fatal, section +# Exit-code failure-flag API +from aipass.cli.apps.modules.display import mark_command_failed, command_failed, reset_command_state, resolve_exit + # Operation templates from aipass.cli.apps.modules.templates import operation_start, operation_complete @@ -40,6 +43,11 @@ __all__ = [ "warning", "fatal", "section", + # Exit-code failure-flag API + "mark_command_failed", + "command_failed", + "reset_command_state", + "resolve_exit", # Templates "operation_start", "operation_complete", diff --git a/src/aipass/cli/apps/modules/display.py b/src/aipass/cli/apps/modules/display.py index 333fec27..cc7cde91 100755 --- a/src/aipass/cli/apps/modules/display.py +++ b/src/aipass/cli/apps/modules/display.py @@ -48,6 +48,36 @@ err_console = Console(stderr=True, force_terminal=sys.stderr.isatty()) # Stderr _TRIGGER = None _TRIGGER_LOADED = False +# Process-level command failure flag — mutable container avoids global statement +_CMD_STATE = {"failed": False} + + +def mark_command_failed() -> None: + """Set the process-level failure flag (called automatically by error()).""" + _CMD_STATE["failed"] = True + + +def command_failed() -> bool: + """Return whether mark_command_failed() has been called since last reset.""" + return _CMD_STATE["failed"] + + +def reset_command_state() -> None: + """Reset the failure flag to False (for tests and main() entry).""" + _CMD_STATE["failed"] = False + + +def resolve_exit(handled: bool) -> int: + """Map handled/failed state to an exit code. + + Returns 1 if not handled, 2 if handled but failed, 0 otherwise. + """ + if not handled: + return 1 + if _CMD_STATE["failed"]: + return 2 + return 0 + # ============================================================================ # MODULE PATTERN FUNCTIONS (SEEDGO compliant) @@ -341,6 +371,7 @@ def error(message: str, suggestion: str | None = None) -> None: Example: error('Branch not found', suggestion='Check branch name spelling') """ + mark_command_failed() err_console.print(f"❌ [red bold]{message}[/red bold]") if suggestion: err_console.print(f" [yellow]→ Try: {suggestion}[/yellow]") @@ -411,6 +442,10 @@ __all__ = [ "warning", "fatal", "section", + "mark_command_failed", + "command_failed", + "reset_command_state", + "resolve_exit", ] # ============================================================================ diff --git a/src/aipass/cli/tests/test_display.py b/src/aipass/cli/tests/test_display.py index ebdf1eb7..bd561b05 100644 --- a/src/aipass/cli/tests/test_display.py +++ b/src/aipass/cli/tests/test_display.py @@ -522,3 +522,62 @@ class TestInfrastructureMocking: module_key = "aipass.cli.apps.modules.display" assert module_key in sys.modules assert sys.modules[module_key] is display + + +# ============================================================================= +# Exit-code failure-flag tests +# ============================================================================= + + +class TestCommandState: + """Verify the process-level failure flag and resolve_exit truth table.""" + + def setup_method(self): + display.reset_command_state() + + def teardown_method(self): + display.reset_command_state() + + def test_initial_state_is_not_failed(self): + assert display.command_failed() is False + + def test_mark_command_failed_sets_flag(self): + display.mark_command_failed() + assert display.command_failed() is True + + def test_reset_command_state_clears_flag(self): + display.mark_command_failed() + display.reset_command_state() + assert display.command_failed() is False + + def test_resolve_exit_not_handled(self): + assert display.resolve_exit(handled=False) == 1 + + def test_resolve_exit_handled_ok(self): + assert display.resolve_exit(handled=True) == 0 + + def test_resolve_exit_handled_failed(self): + display.mark_command_failed() + assert display.resolve_exit(handled=True) == 2 + + def test_resolve_exit_not_handled_ignores_flag(self): + display.mark_command_failed() + assert display.resolve_exit(handled=False) == 1 + + def test_error_trips_failure_flag(self): + cons, _ = _make_capture_console() + with patch.object(display, "err_console", cons): + display.error("something broke") + assert display.command_failed() is True + + def test_warning_does_not_trip_flag(self): + cons, _ = _make_capture_console() + with patch.object(display, "err_console", cons): + display.warning("just a warning") + assert display.command_failed() is False + + def test_success_does_not_trip_flag(self): + cons, _ = _make_capture_console() + with patch.object(display, "CONSOLE", cons): + display.success("all good") + assert display.command_failed() is False diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index c35c8e70..49436858 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -79,6 +79,11 @@ drone @flow create . "Subject" aplan # APLAN (FPLAN/DPLAN drone @flow list open # active plans ``` +# Dispatch — in-flight comms + + - **Steer a working agent with `email` (no wake), NOT `dispatch`.** `dispatch` = send **+ wake** (hand NEW work to a sleeping agent). An agent already running is awake, so `drone @ai_mail email @target "Subject" "Msg"` reaches it mid-task via its hook — no re-wake, no interrupt. Forgot something / need to correct a brief / add context → **email it in-flight**, don't re-dispatch. (`drone @ai_mail --help`) + - **No backticks in dispatch/email body strings** — bash runs `` `word` `` as command-substitution and silently eats it. Use single quotes or plain text (hit this live: a backtick'd word vanished from a brief). + # Watchdog Devpulse module. After dispatch, arm as a background task — it polls the dispatch lock and exits when the agent finishes. Resolves @target → branch path → `.ai_mail.local/.dispatch.lock`. Default timeout 1800s; `drone @devpulse watchdog --help` for the full reference. diff --git a/src/aipass/devpulse/apps/devpulse.py b/src/aipass/devpulse/apps/devpulse.py index cc62ce17..ae1bd565 100644 --- a/src/aipass/devpulse/apps/devpulse.py +++ b/src/aipass/devpulse/apps/devpulse.py @@ -35,7 +35,7 @@ if sys.platform == "win32": _reconfigure(encoding="utf-8", errors="replace") from aipass.prax import logger -from aipass.cli.apps.modules import err_console +from aipass.cli.apps.modules import err_console, resolve_exit, reset_command_state console = err_console @@ -161,13 +161,14 @@ def _handle_command(command: str, args: list) -> bool: def main(): """Main entry point - routes commands or shows help.""" + reset_command_state() args = sys.argv[1:] if len(args) == 0: print_introspection() return 0 - return 0 if _handle_command(args[0], args[1:]) else 1 + return resolve_exit(_handle_command(args[0], args[1:])) if __name__ == "__main__": diff --git a/src/aipass/devpulse/apps/handlers/feedback/compose.py b/src/aipass/devpulse/apps/handlers/feedback/compose.py index 84b751ef..0b8e4225 100644 --- a/src/aipass/devpulse/apps/handlers/feedback/compose.py +++ b/src/aipass/devpulse/apps/handlers/feedback/compose.py @@ -25,7 +25,7 @@ from aipass.devpulse.apps.handlers.feedback.storage import ( generate_id, ) -from aipass.cli.apps.modules import err_console +from aipass.cli.apps.modules import err_console, error from aipass.devpulse.apps.handlers.json import json_handler console = err_console @@ -133,7 +133,7 @@ def reply_to(msg_id: str, body: str) -> bool: break if msg is None: - console.print(f"[red]Message {msg_id} not found.[/red]") + error(f"Message {msg_id} not found.") return False now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") diff --git a/src/aipass/devpulse/apps/handlers/feedback/inbox.py b/src/aipass/devpulse/apps/handlers/feedback/inbox.py index ac6cad9d..b7ab8abc 100644 --- a/src/aipass/devpulse/apps/handlers/feedback/inbox.py +++ b/src/aipass/devpulse/apps/handlers/feedback/inbox.py @@ -17,7 +17,7 @@ from rich.table import Table from aipass.devpulse.apps.handlers.feedback.storage import load_inbox, save_inbox -from aipass.cli.apps.modules import err_console +from aipass.cli.apps.modules import err_console, error from aipass.devpulse.apps.handlers.json import json_handler console = err_console @@ -67,7 +67,7 @@ def view_message(msg_id: str) -> None: msg = _find_message(messages, msg_id) if msg is None: - console.print(f"[red]Message {msg_id} not found.[/red]") + error(f"Message {msg_id} not found.") return # Mark as read @@ -105,7 +105,7 @@ def clear_message(msg_id: str) -> None: msg = _find_message(messages, msg_id) if msg is None: - console.print(f"[red]Message {msg_id} not found.[/red]") + error(f"Message {msg_id} not found.") return was_unread = not msg.get("read") diff --git a/src/aipass/devpulse/apps/modules/feedback.py b/src/aipass/devpulse/apps/modules/feedback.py index 6dc7e297..876c3b9f 100644 --- a/src/aipass/devpulse/apps/modules/feedback.py +++ b/src/aipass/devpulse/apps/modules/feedback.py @@ -27,7 +27,7 @@ from aipass.devpulse.apps.handlers.feedback.compose import ( ) from aipass.prax import logger -from aipass.cli.apps.modules import err_console +from aipass.cli.apps.modules import err_console, error from aipass.devpulse.apps.handlers.json import json_handler console = err_console @@ -93,14 +93,14 @@ def handle_command(command: str, args: list[str]) -> bool: if subcommand == "view": if not sub_args: - console.print("[red]Usage: feedback view [/red]") + error("Usage: feedback view ") return True view_message(sub_args[0]) return True if subcommand == "reply": if len(sub_args) < 2: - console.print('[red]Usage: feedback reply "message"[/red]') + error('Usage: feedback reply "message"') return True msg_id = sub_args[0] body = " ".join(sub_args[1:]) @@ -112,7 +112,7 @@ def handle_command(command: str, args: list[str]) -> bool: if subcommand == "clear": if not sub_args: - logger.error("Usage: feedback clear | feedback clear --all") + error("Usage: feedback clear | feedback clear --all") return True if sub_args[0] == "--all": clear_all_read() @@ -120,8 +120,8 @@ def handle_command(command: str, args: list[str]) -> bool: clear_message(sub_args[0]) return True - console.print(f"[red]Unknown feedback subcommand: {subcommand}[/red]") - console.print("Use [bold]feedback --help[/bold] for usage.") + logger.warning("[feedback] unknown subcommand: %s", subcommand) + error(f"Unknown feedback subcommand: {subcommand}", suggestion="Use 'feedback --help' for usage") return True @@ -138,7 +138,7 @@ def _handle_send(args: list[str]) -> bool: bool: Always True (command was handled). """ if len(args) < 2: - console.print('[red]Usage: feedback send "subject" "body"[/red]') + error('Usage: feedback send "subject" "body"') console.print("[dim]Tip: from_branch is auto-detected or pass as first arg.[/dim]") return True diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/output_routing.md b/src/aipass/seedgo/apps/handlers/aipass_standards/output_routing.md new file mode 100644 index 00000000..6010a17d --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/output_routing.md @@ -0,0 +1,87 @@ +# Output Routing Standard + +**Status:** Active +**Date:** 2026-07-09 + +--- + +## What This Standard Is + +User-facing error, success, and warning output must route through `@cli`'s semantic helpers (`error()`, `success()`, `warning()`) instead of raw `console.print()` with status markup or emojis. + +## Why It Matters + +1. **Consistent formatting** — all agents display errors, successes, and warnings the same way. +2. **Exit-code correctness** — `error()` carries the GitHub #661 failure-flag fix. Raw `console.print("[red]...")` bypasses it, causing error paths to exit 0. +3. **Stderr routing** — `error()` and `warning()` write to stderr; raw `console.print()` writes to stdout. + +## What the Checker Scans For + +Detects `console.print()` or `err_console.print()` calls containing status indicators: + +- `[red]` or `[bold red]` markup (error-style output) +- Status emojis: `❌ ✅ ✓ ✗ ✘ ⚠ ✔` +- `[green]` paired with check emojis (`✓ ✔ ✅`) +- `[yellow]` paired with warning indicators (`⚠`, "warning", "WARN", "FAIL") + +### Exclusions + +- Lines inside docstrings (triple-quoted regions) +- Comment lines (`# ...`) +- `__init__.py` files +- Test files (`test_*.py`, `*_test.py`, `conftest.py`) +- `console.print()` with no status markup (tables, panels, informational text) +- Non-status color like `[cyan]`, `[dim]`, `[blue]` + +## Code Examples + +### Violation + +```python +console.print(f"[red]Error: {msg}[/red]") +console.print("[bold red]Failed to process[/bold red]") +console.print(f"[green]✓[/green] Task complete") +console.print(f"❌ Something went wrong") +``` + +### Fix + +```python +from aipass.cli.apps.modules import error, success, warning + +error(f"Error: {msg}") +error("Failed to process") +success("Task complete") +error("Something went wrong") +``` + +## Scoring + +- Single check per file: pass (0 violations) or fail (any violations) +- Score: 100 if passed, 0 if failed +- Threshold: score >= 75 to pass overall +- Line-level bypass filtering is supported + +## Bypass + +Add an entry to `.seedgo/bypass.json`: + +```json +{"standard": "output_routing", "file": "path/to/file.py"} +``` + +Or bypass specific lines: + +```json +{"standard": "output_routing", "file": "file.py", "lines": [42, 78]} +``` + +## Audit Scope + +`AUDIT_SCOPE = all_files` — runs against every `.py` file in the branch. Skips `__init__.py` and test files. + +## Reference + +- Checker: `output_routing_check.py` +- Standards pack: seedgo standards (output_routing) +- Related: GitHub #661 (error paths exit 0) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/output_routing_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/output_routing_check.py new file mode 100644 index 00000000..9c2091e9 --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/output_routing_check.py @@ -0,0 +1,222 @@ +# =================== AIPass ==================== +# Name: output_routing_check.py +# Description: Output Routing Standards Checker Handler +# Version: 1.0.0 +# Created: 2026-07-09 +# Modified: 2026-07-09 +# ============================================= + +""" +Output Routing Standards Checker Handler + +Detects user-facing error/success/warning output that bypasses @cli's +semantic helpers (error(), success(), warning()) by using raw +console.print() with status markup or status emojis. +""" + +import re +import sys +from pathlib import Path +from typing import Dict + +from aipass.prax import logger +from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed + +if sys.stdout and hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] +if sys.stderr and hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + +AUDIT_SCOPE = "all_files" + +_TEST_FILE_RE = re.compile(r"^(test_.+|.+_test|conftest)\.py$") + +# console.print( or err_console.print( at any indentation +_CONSOLE_PRINT_RE = re.compile(r"(?:console|err_console)\.print\(") + +# Status color markup — error indicators +_RED_MARKUP_RE = re.compile(r"\[(?:bold\s+)?red(?:\s+bold)?\]") + +# Status color markup — warning indicators +_YELLOW_STATUS_RE = re.compile(r"\[yellow\].*(?:⚠|[Ww]arning|WARN|FAIL)") + +# Status emojis: ❌ ✅ ✓ ✗ ✘ ⚠ ✔ +_STATUS_EMOJI_RE = re.compile(r"[❌✅✓✗✘⚠✔]") + +# Green check pattern: [green] followed by check emoji +_GREEN_CHECK_RE = re.compile(r"\[green\].*[✓✔✅]") + + +def _is_status_console_print(code: str) -> bool: + if not _CONSOLE_PRINT_RE.search(code): + return False + if _RED_MARKUP_RE.search(code): + return True + if _STATUS_EMOJI_RE.search(code): + return True + if _YELLOW_STATUS_RE.search(code): + return True + if _GREEN_CHECK_RE.search(code): + return True + return False + + +def _scan_file(file_path: Path) -> tuple[list[int], str | None]: + try: + source = file_path.read_text(encoding="utf-8", errors="ignore") + except OSError as exc: + logger.info("Cannot read %s: %s", file_path, exc) + return [], f"cannot read: {exc}" + + lines = source.splitlines() + hit_lines: list[int] = [] + in_docstring = False + docstring_char: str | None = None + + for lineno, line in enumerate(lines, start=1): + stripped = line.strip() + + for tq in ('"""', "'''"): + count = line.count(tq) + if count == 0: + continue + if not in_docstring: + in_docstring = True + docstring_char = tq + if count >= 2: + in_docstring = False + docstring_char = None + elif docstring_char == tq: + in_docstring = False + docstring_char = None + + if in_docstring: + continue + + if stripped.startswith("#"): + continue + + code_part = line.split("#")[0] + + if _is_status_console_print(code_part): + hit_lines.append(lineno) + + return hit_lines, None + + +def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: + """Check a Python file for user-facing output bypassing @cli helpers.""" + path = Path(module_path) + + if is_bypassed(module_path, "output_routing", bypass_rules=bypass_rules): + return { + "passed": True, + "checks": [ + { + "name": "Bypassed", + "passed": True, + "message": "Standard bypassed via .seedgo/bypass.json", + } + ], + "score": 100, + "standard": "OUTPUT_ROUTING", + } + + if path.name == "__init__.py": + return { + "passed": True, + "checks": [ + { + "name": "Output routing", + "passed": True, + "message": "__init__.py skipped", + } + ], + "score": 100, + "standard": "OUTPUT_ROUTING", + } + + if _TEST_FILE_RE.match(path.name): + return { + "passed": True, + "checks": [ + { + "name": "Output routing", + "passed": True, + "message": "Test file skipped", + } + ], + "score": 100, + "standard": "OUTPUT_ROUTING", + } + + if not path.exists(): + return { + "passed": False, + "checks": [ + { + "name": "File exists", + "passed": False, + "message": f"File not found: {module_path}", + } + ], + "score": 0, + "standard": "OUTPUT_ROUTING", + } + + hit_lines, error = _scan_file(path) + + if error is not None: + return { + "passed": False, + "checks": [ + { + "name": "File readable", + "passed": False, + "message": f"Error reading file: {error}", + } + ], + "score": 0, + "standard": "OUTPUT_ROUTING", + } + + non_bypassed = [ln for ln in hit_lines if not is_bypassed(module_path, "output_routing", ln, bypass_rules)] + + checks: list[Dict] = [] + + if not non_bypassed: + checks.append( + { + "name": "Output routing", + "passed": True, + "message": "All user-facing status output uses @cli helpers", + } + ) + else: + sample = ", ".join(str(ln) for ln in non_bypassed[:5]) + suffix = f" (and {len(non_bypassed) - 5} more)" if len(non_bypassed) > 5 else "" + checks.append( + { + "name": "Output routing", + "passed": False, + "message": f"{len(non_bypassed)} raw status output(s) on lines {sample}{suffix}", + } + ) + + passed_checks = sum(1 for c in checks if c["passed"]) + total_checks = len(checks) + score = int(passed_checks / total_checks * 100) if total_checks > 0 else 0 + overall_passed = score >= 75 + + json_handler.log_operation( + "check_completed", + {"file": str(module_path), "score": score, "standard": "output_routing"}, + ) + + return { + "passed": overall_passed, + "checks": checks, + "score": score, + "standard": "OUTPUT_ROUTING", + } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/output_routing_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/output_routing_content.py new file mode 100644 index 00000000..0ec57e87 --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/output_routing_content.py @@ -0,0 +1,106 @@ +# =================== AIPass ==================== +# Name: output_routing_content.py +# Description: Output Routing Standards Content Handler +# Version: 1.0.0 +# Created: 2026-07-09 +# Modified: 2026-07-09 +# ============================================= + +""" +Output Routing Standards Content Handler + +Provides formatted Output Routing standards content. +Module orchestrates, handler implements. +""" + +import sys + +from aipass.seedgo.apps.handlers.json import json_handler + +if sys.stdout and hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] +if sys.stderr and hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + + +def get_output_routing_standards() -> str: + """Return formatted output_routing standards content with Rich markup + + Returns: + str: Formatted standards text with Rich styling + """ + lines = [ + "[bold cyan]CORE PRINCIPLE:[/bold cyan]", + " User-facing error, success, and warning output MUST route through", + " @cli's semantic helpers — [dim]error()[/dim], [dim]success()[/dim],", + " [dim]warning()[/dim] — not raw [red]console.print()[/red] with", + " status markup or emojis.", + "", + "[bold cyan]WHY IT MATTERS:[/bold cyan]", + " 1. [yellow]Consistent formatting[/yellow] across all agents", + " 2. [yellow]Exit-code correctness[/yellow] — error() carries the #661", + " failure-flag fix; raw markup bypasses it (errors exit 0)", + " 3. [yellow]Stderr routing[/yellow] — error()/warning() write to stderr;", + " raw console.print() writes to stdout", + "", + "[bold cyan]WHAT IT CHECKS:[/bold cyan]", + " Scans every .py file for [dim]console.print()[/dim] or", + " [dim]err_console.print()[/dim] calls containing status indicators:", + "", + " [red]Flagged patterns:[/red]", + ' - [dim]console.print(f"[red]Error: ...[/red]")[/dim] → use error()', + ' - [dim]console.print("[bold red]Failed[/bold red]")[/dim] → use error()', + ' - [dim]console.print("[green]...[/green]")[/dim] with check emojis → use success()', + ' - [dim]console.print("...")[/dim] with status emojis → use helpers', + "", + " [green]NOT flagged (legitimate Rich usage):[/green]", + " - [dim]console.print(table)[/dim] — Rich Table objects", + " - [dim]console.print(Panel(...))[/dim] — decorative panels", + ' - [dim]console.print(f"[cyan]Info...[/cyan]")[/dim] — non-status color', + ' - [dim]console.print(f"[dim]...[/dim]")[/dim] — decorative formatting', + " - Lines inside docstrings, comments, test files", + "", + "[bold cyan]VIOLATIONS:[/bold cyan]", + "", + " [red]Bad — raw status output:[/red]", + ' [dim]console.print(f"[red]Error: {msg}[/red]")[/dim]', + ' [dim]console.print("[green]...[/green] Done")[/dim]', + ' [dim]console.print("... Failed")[/dim]', + "", + " [green]Good — use @cli helpers:[/green]", + " [dim]from aipass.cli.apps.modules import error, success, warning[/dim]", + ' [dim]error(f"Error: {msg}")[/dim]', + ' [dim]success("Done")[/dim]', + ' [dim]warning("Check configuration")[/dim]', + "", + "[bold cyan]HOW TO FIX:[/bold cyan]", + " 1. Import the helpers: [dim]from aipass.cli.apps.modules import error, success, warning[/dim]", + ' 2. Replace [dim]console.print(f"[red]...")[/dim] with [dim]error(msg)[/dim]', + " 3. Replace status-emoji prints with [dim]success(msg)[/dim] or [dim]warning(msg)[/dim]", + " 4. Keep [dim]console.print()[/dim] for tables, panels, and non-status output", + "", + "[yellow]SCOPE:[/yellow]", + " AUDIT_SCOPE = [bold]all_files[/bold]", + " Runs against every .py file in the branch.", + " Skips __init__.py and test files (test_*.py, *_test.py, conftest.py).", + "", + "[bold cyan]SCORING:[/bold cyan]", + " Single check per file: [green]pass[/green] (0 violations) or [red]fail[/red]", + " Score: 100 if passed, 0 if failed", + " Threshold: score >= 75 to pass overall", + " Line-level bypass filtering is supported.", + "", + "[bold cyan]BYPASS:[/bold cyan]", + " Add an entry to [dim].seedgo/bypass.json[/dim]:", + ' [dim]{"standard": "output_routing", "file": "path/to/file.py"}[/dim]', + " Or bypass specific lines:", + ' [dim]{"standard": "output_routing", "file": "file.py", "lines": [42]}[/dim]', + "", + "[bold cyan]REFERENCE:[/bold cyan]", + " [dim]See: seedgo standards pack (output_routing)[/dim]", + " [dim]Checker: output_routing_check.py[/dim]", + " [dim]Related: GitHub #661 (error paths exit 0)[/dim]", + ] + + json_handler.log_operation("standard_content_queried", {"standard": "output_routing"}) + return "\n".join(lines) diff --git a/src/aipass/seedgo/tests/test_output_routing.py b/src/aipass/seedgo/tests/test_output_routing.py new file mode 100644 index 00000000..8c199c72 --- /dev/null +++ b/src/aipass/seedgo/tests/test_output_routing.py @@ -0,0 +1,424 @@ +"""Tests for output_routing_check.py.""" + +# =================== META ==================== +# Name: test_output_routing.py +# Description: Unit tests for output_routing_check +# Version: 1.0.0 +# Created: 2026-07-09 +# Modified: 2026-07-09 +# ============================================= + +import pytest +from unittest.mock import MagicMock + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports for standards checkers.""" + import sys + + mock_logger = MagicMock() + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + + prax_mod = MagicMock() + prax_mod.logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod) + + json_pkg = MagicMock() + json_pkg.json_handler = mock_json_handler + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json.json_handler", json_mod) + + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed as real_is_bypassed + + bypass_pkg = MagicMock() + bypass_utils = MagicMock() + bypass_utils.is_bypassed = real_is_bypassed + bypass_pkg.utils = bypass_utils + bypass_ignore = MagicMock() + bypass_ignore.get_template_ignore_patterns = MagicMock(return_value=[]) + bypass_pkg.ignore_handler = bypass_ignore + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg) + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.utils", bypass_utils) + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.bypass.ignore_handler", + bypass_ignore, + ) + + for mod_name in [ + "aipass.seedgo.apps.handlers.aipass_standards.output_routing_check", + ]: + monkeypatch.delitem(sys.modules, mod_name, raising=False) + + +# =========================================================================== +# 1. _is_status_console_print — detection logic +# =========================================================================== + + +class TestIsStatusConsolePrint: + """Tests for the _is_status_console_print helper.""" + + def test_red_markup_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('console.print(f"[red]Error: {msg}[/red]")') + + def test_bold_red_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('console.print("[bold red]Failed[/bold red]")') + + def test_red_bold_order_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('console.print("[red bold]Error[/red bold]")') + + def test_status_emoji_cross_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('console.print("❌ Something failed")') + + def test_status_emoji_check_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('console.print("✅ Done")') + + def test_status_emoji_checkmark_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('console.print("✓ Complete")') + + def test_status_emoji_cross_mark_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('console.print("✗ Failed")') + + def test_green_check_pattern_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('console.print("[green]✓[/green] Done")') + + def test_yellow_warning_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('console.print("[yellow]⚠ Warning: check config[/yellow]")') + + def test_err_console_detected(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert _is_status_console_print('err_console.print(f"[red]Error[/red]")') + + def test_plain_console_print_not_flagged(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert not _is_status_console_print('console.print("Hello world")') + + def test_cyan_markup_not_flagged(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert not _is_status_console_print('console.print(f"[cyan]Info: {msg}[/cyan]")') + + def test_dim_markup_not_flagged(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert not _is_status_console_print('console.print(f"[dim]{details}[/dim]")') + + def test_table_object_not_flagged(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert not _is_status_console_print("console.print(table)") + + def test_panel_object_not_flagged(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert not _is_status_console_print("console.print(Panel(title))") + + def test_no_console_print_not_flagged(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert not _is_status_console_print('logger.info("[red]error[/red]")') + + def test_green_without_check_emoji_not_flagged(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert not _is_status_console_print('console.print("[green]name[/green]")') + + def test_yellow_without_warning_not_flagged(self): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import ( + _is_status_console_print, + ) + + assert not _is_status_console_print('console.print("[yellow]note[/yellow]")') + + +# =========================================================================== +# 2. _scan_file — file scanning +# =========================================================================== + + +class TestScanFile: + """Tests for the _scan_file helper.""" + + def test_detects_red_markup(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import _scan_file + + f = tmp_path / "test.py" + f.write_text('console.print(f"[red]Error: {e}[/red]")\n', encoding="utf-8") + lines, err = _scan_file(f) + assert err is None + assert lines == [1] + + def test_skips_docstrings(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import _scan_file + + f = tmp_path / "test.py" + f.write_text( + '"""\nconsole.print("[red]error[/red]")\n"""\npass\n', + encoding="utf-8", + ) + lines, err = _scan_file(f) + assert err is None + assert lines == [] + + def test_skips_comments(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import _scan_file + + f = tmp_path / "test.py" + f.write_text('# console.print("[red]error[/red]")\n', encoding="utf-8") + lines, err = _scan_file(f) + assert err is None + assert lines == [] + + def test_skips_inline_comment(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import _scan_file + + f = tmp_path / "test.py" + f.write_text('x = 1 # console.print("[red]error[/red]")\n', encoding="utf-8") + lines, err = _scan_file(f) + assert err is None + assert lines == [] + + def test_detects_multiple_lines(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import _scan_file + + f = tmp_path / "test.py" + f.write_text( + 'x = 1\nconsole.print("[red]a[/red]")\ny = 2\nconsole.print("✅ done")\n', + encoding="utf-8", + ) + lines, err = _scan_file(f) + assert err is None + assert lines == [2, 4] + + def test_clean_file(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import _scan_file + + f = tmp_path / "test.py" + f.write_text('console.print("[cyan]info[/cyan]")\nprint("hello")\n', encoding="utf-8") + lines, err = _scan_file(f) + assert err is None + assert lines == [] + + def test_unreadable_file(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import _scan_file + + f = tmp_path / "missing.py" + lines, err = _scan_file(f) + assert err is not None + assert lines == [] + + +# =========================================================================== +# 3. check_module — full checker +# =========================================================================== + + +class TestCheckModule: + """Tests for check_module.""" + + def test_clean_file_passes(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "clean.py" + f.write_text('from aipass.cli.apps.modules import error\nerror("fail")\n', encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is True + assert result["score"] == 100 + assert result["standard"] == "OUTPUT_ROUTING" + + def test_violation_detected(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "bad.py" + f.write_text('console.print(f"[red]Error: {e}[/red]")\n', encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is False + assert result["score"] == 0 + + def test_init_py_skipped(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "__init__.py" + f.write_text('console.print("[red]error[/red]")\n', encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is True + assert result["score"] == 100 + + def test_test_file_skipped(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "test_something.py" + f.write_text('console.print("[red]error[/red]")\n', encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is True + assert result["score"] == 100 + + def test_conftest_skipped(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "conftest.py" + f.write_text('console.print("[red]error[/red]")\n', encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is True + assert result["score"] == 100 + + def test_bypass_returns_100(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "bypassed.py" + f.write_text('console.print("[red]error[/red]")\n', encoding="utf-8") + bypass = [{"standard": "output_routing", "file": str(f)}] + result = check_module(str(f), bypass_rules=bypass) + assert result["passed"] is True + assert result["score"] == 100 + + def test_missing_file(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + result = check_module(str(tmp_path / "no_such.py")) + assert result["passed"] is False + assert result["score"] == 0 + + def test_line_bypass_all_lines_pass(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "partial.py" + f.write_text( + 'console.print("[red]a[/red]")\nconsole.print("[red]b[/red]")\n', + encoding="utf-8", + ) + bypass = [{"standard": "output_routing", "file": "partial.py", "lines": [1, 2]}] + result = check_module(str(f), bypass_rules=bypass) + assert result["passed"] is True + + def test_violation_message_shows_lines(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "multi.py" + f.write_text('console.print("[red]a[/red]")\nconsole.print("✅ b")\n', encoding="utf-8") + result = check_module(str(f)) + assert "2 raw" in result["checks"][0]["message"] + assert "1, 2" in result["checks"][0]["message"] + + def test_more_than_five_violations_truncated(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "many.py" + lines = [f'console.print("[red]err{i}[/red]")\n' for i in range(8)] + f.write_text("".join(lines), encoding="utf-8") + result = check_module(str(f)) + assert "and 3 more" in result["checks"][0]["message"] + + +# =========================================================================== +# 4. False-positive avoidance +# =========================================================================== + + +class TestFalsePositiveAvoidance: + """Verify that legitimate patterns are NOT flagged.""" + + def test_table_print_not_flagged(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "tables.py" + f.write_text("console.print(table)\nconsole.print(Panel(content))\n", encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is True + + def test_blue_markup_not_flagged(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "blue.py" + f.write_text('console.print("[blue]Processing...[/blue]")\n', encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is True + + def test_empty_console_print_not_flagged(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "blank.py" + f.write_text('console.print("")\nconsole.print()\n', encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is True + + def test_docstring_with_markup_not_flagged(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "docs.py" + content = '"""\nconsole.print("[red]error[/red]")\n"""\ndef foo(): pass\n' + f.write_text(content, encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is True + + def test_green_text_without_emoji_not_flagged(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.output_routing_check import check_module + + f = tmp_path / "green.py" + f.write_text('console.print("[green]branch_name[/green]")\n', encoding="utf-8") + result = check_module(str(f)) + assert result["passed"] is True