diff --git a/.coverage b/.coverage deleted file mode 100644 index accd5647..00000000 Binary files a/.coverage and /dev/null differ diff --git a/.seedgo/config.json b/.seedgo/config.json deleted file mode 100644 index f66c994f..00000000 --- a/.seedgo/config.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "version": "1.0.0", - "profile": null, - "plugins": { - "enabled": [ - "no-bare-except", - "type-hints-required", - "docstring-coverage", - "function-length", - "file-structure" - ], - "disabled": [], - "config": { - "function-length": { - "max_lines": 50 - }, - "file-structure": { - "allowed_root_files": [ - "setup.py", - "conftest.py", - "manage.py" - ] - } - } - }, - "scoring": { - "threshold": 75, - "error_weight": 1.0, - "warning_weight": 0.5, - "info_weight": 0.0 - }, - "paths": { - "include": [ - "src/seedgo/" - ], - "exclude": [ - "tests/", - "*.generated.py" - ] - }, - "overrides": [] -} diff --git a/BRANCH_REGISTRY.json b/BRANCH_REGISTRY.json new file mode 100644 index 00000000..c8617201 --- /dev/null +++ b/BRANCH_REGISTRY.json @@ -0,0 +1,341 @@ +{ + "metadata": { + "version": "1.0.0", + "last_updated": "2026-02-27", + "total_branches": 30 + }, + "branches": [ + { + "name": ".VSCODE", + "alias": "", + "path": "/home/aipass/.vscode", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@.vscode", + "status": "active", + "created": "2025-11-22", + "last_active": "2025-11-22" + }, + { + "name": "AI_MAIL", + "alias": "", + "path": "/home/aipass/aipass_core/ai_mail", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@ai_mail", + "status": "active", + "created": "2025-11-30", + "last_active": "2025-11-30" + }, + { + "name": "API", + "alias": "Gateway", + "path": "/home/aipass/aipass_core/api", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@api", + "status": "active", + "created": "2025-10-30", + "last_active": "2025-10-30" + }, + { + "name": "ASSISTANT", + "alias": "Relay", + "path": "/home/aipass/aipass_os/dev_central/assistant", + "profile": "AIPass Workshop", + "description": "DEV_CENTRAL Workflow Coordinator - task dispatch, monitoring, aggregation", + "email": "@assistant", + "status": "active", + "created": "2026-01-21", + "last_active": "2026-01-21" + }, + { + "name": "BACKUP_SYSTEM", + "alias": "Vault", + "path": "/home/aipass/aipass_core/backup_system", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@backup_system", + "status": "active", + "created": "2025-10-30", + "last_active": "2025-10-30" + }, + { + "name": "CLI", + "alias": "Vox", + "path": "/home/aipass/aipass_core/cli", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@cli", + "status": "active", + "created": "2025-11-12", + "last_active": "2025-11-12" + }, + { + "name": "CORTEX", + "alias": "Cortex", + "path": "/home/aipass/aipass_core/cortex", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@cortex", + "status": "active", + "created": "2025-11-09", + "last_active": "2025-11-09" + }, + { + "name": "DEVPULSE", + "alias": "Pulse", + "path": "/home/aipass/aipass_os/dev_central/devpulse", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@devpulse", + "status": "active", + "created": "2025-11-30", + "last_active": "2025-11-30" + }, + { + "name": "DEV_CENTRAL", + "alias": "DEV_CENTRAL", + "path": "/home/aipass/aipass_os/dev_central", + "profile": "Human Operator", + "description": "Patrick workspace - planning and coordination", + "email": "@dev_central", + "status": "active", + "created": "2025-11-30", + "last_active": "2025-11-30" + }, + { + "name": "DRONE", + "alias": "Hermes", + "path": "/home/aipass/aipass_core/drone", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@drone", + "status": "active", + "created": "2025-10-30", + "last_active": "2025-10-30" + }, + { + "name": "FLOW", + "alias": "Flow", + "path": "/home/aipass/aipass_core/flow", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@flow", + "status": "active", + "created": "2025-10-30", + "last_active": "2025-10-30" + }, + { + "name": "FORGE", + "alias": "", + "path": "/home/aipass/forge", + "profile": "AIPass Workshop", + "description": "Root-level fast responder and Telegram bot factory — forges new bots", + "email": "@forge", + "status": "active", + "created": "2026-02-26", + "last_active": "2026-02-26" + }, + { + "name": "GIT_MANAGER", + "alias": "Chronicle", + "path": "/home/aipass/aipass_os/dev_central/git_manager", + "profile": "AIPass Workshop", + "description": "Git repository management - PR reviews, commit standards, hooks, repo health", + "email": "@git_manager", + "status": "active", + "created": "2025-11-13", + "last_active": "2026-02-25" + }, + { + "name": "GROWTH", + "alias": "", + "path": "/home/aipass/aipass_business/departments/growth", + "profile": "Business", + "description": "Growth Department - content strategy and audience growth", + "email": "@growth", + "status": "active", + "created": "2026-02-18", + "last_active": "2026-02-18" + }, + { + "name": "MCP_SERVERS", + "alias": "Loom", + "path": "/home/aipass/mcp_servers", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@mcp_servers", + "status": "active", + "created": "2025-11-01", + "last_active": "2025-11-01" + }, + { + "name": "MEMORY_BANK", + "alias": "Mnemo", + "path": "/home/aipass/MEMORY_BANK", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@memory_bank", + "status": "active", + "created": "2025-11-11", + "last_active": "2025-11-11" + }, + { + "name": "NEXUS", + "alias": "Nexus", + "path": "/home/aipass/Nexus", + "profile": "AI Assistant", + "description": "AIPass CoFounder - Conversational AI with presence, memory, and Claude Code tool access via GPT-4.1", + "email": "@nexus", + "status": "active", + "created": "2026-02-08", + "last_active": "2026-02-08" + }, + { + "name": "PATRICK_PRIVATE", + "path": "/home/aipass/patrick_private", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@patrick_private", + "status": "active", + "created": "2026-02-27", + "last_active": "2026-02-27" + }, + { + "name": "PERMISSIONS", + "alias": "Warden", + "path": "/home/aipass/aipass_os/dev_central/permissions", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@permissions", + "status": "active", + "created": "2025-11-13", + "last_active": "2025-11-13" + }, + { + "name": "PRAX", + "alias": "", + "path": "/home/aipass/aipass_core/prax", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@prax", + "status": "active", + "created": "2025-10-30", + "last_active": "2025-10-30" + }, + { + "name": "PROJECTS", + "alias": "", + "path": "/home/aipass/projects", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@projects", + "status": "active", + "created": "2025-11-23", + "last_active": "2025-11-23" + }, + { + "name": "SEED", + "alias": "Seed", + "path": "/home/aipass/seed", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@seed", + "status": "active", + "created": "2025-11-12", + "last_active": "2025-11-12" + }, + { + "name": "SPEAKEASY", + "alias": "Speakeasy", + "path": "/home/aipass/speakeasy", + "profile": "AIPass Workshop", + "description": "Voice-to-text system - local speech recognition using Whisper AI", + "email": "@speakeasy", + "status": "active", + "created": "2026-02-11", + "last_active": "2026-02-11" + }, + { + "name": "TEAM_1", + "alias": "", + "path": "/home/aipass/aipass_business/teams/team_1", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@team_1", + "status": "active", + "created": "2026-02-08", + "last_active": "2026-02-08" + }, + { + "name": "TEAM_2", + "alias": "", + "path": "/home/aipass/aipass_business/teams/team_2", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@team_2", + "status": "active", + "created": "2026-02-08", + "last_active": "2026-02-08" + }, + { + "name": "TEAM_3", + "alias": "", + "path": "/home/aipass/aipass_business/teams/team_3", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@team_3", + "status": "active", + "created": "2026-02-08", + "last_active": "2026-02-08" + }, + { + "name": "TEST", + "alias": "Probe", + "path": "/home/aipass/aipass_os/dev_central/test", + "profile": "AIPass Workshop", + "description": "System Testing & Integration - integration testing, Telegram debugging, cross-branch module testing", + "email": "@test", + "status": "active", + "created": "2026-02-15", + "last_active": "2026-02-15" + }, + { + "name": "THE_COMMONS", + "alias": "The Commons", + "path": "/home/aipass/The_Commons", + "profile": "Social Network", + "description": "Community hub - where branches gather, discuss, share ideas beyond task work", + "email": "@the_commons", + "aliases": [ + "@commons" + ], + "status": "active", + "created": "2026-02-06", + "last_active": "2026-02-06" + }, + { + "name": "TRIGGER", + "alias": "Reflex", + "path": "/home/aipass/aipass_core/trigger", + "profile": "AIPass Workshop", + "description": "New branch - purpose TBD", + "email": "@trigger", + "status": "active", + "created": "2025-11-30", + "last_active": "2025-11-30" + }, + { + "name": "VERA", + "alias": "", + "path": "/home/aipass/aipass_business/vera", + "profile": "CEO", + "description": "CEO of AIPass Business — Public Voice, Decision Maker, Team Orchestrator. Named from Latin veritas (truth) by unanimous team vote.", + "email": "@vera", + "status": "active", + "created": "2026-02-17", + "last_active": "2026-02-17" + } + ] +} \ No newline at end of file diff --git a/src/aipass/api/__init__.py b/src/aipass/api/__init__.py deleted file mode 100644 index fc41a58c..00000000 --- a/src/aipass/api/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""aipass.api — LLM access, model routing, and agent spawning. - -Unified interface to language models. Handles API keys, model selection, -rate limiting, and spawning agent processes. -""" diff --git a/src/aipass/api/handlers/__init__.py b/src/aipass/api/handlers/__init__.py deleted file mode 100644 index 88f90a33..00000000 --- a/src/aipass/api/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""api.handlers — API client implementation and rate limiting.""" diff --git a/src/aipass/api/modules/__init__.py b/src/aipass/api/modules/__init__.py deleted file mode 100644 index 2e16a73b..00000000 --- a/src/aipass/api/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""api.modules — Model routing and client logic.""" diff --git a/src/aipass/api/plugins/__init__.py b/src/aipass/api/plugins/__init__.py deleted file mode 100644 index 8216b2ff..00000000 --- a/src/aipass/api/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""api.plugins — Extensible plugin interface.""" diff --git a/src/aipass/assistant/__init__.py b/src/aipass/assistant/__init__.py deleted file mode 100644 index b4f2270f..00000000 --- a/src/aipass/assistant/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""aipass.assistant — Scheduling, plugins, and external integrations. - -Plugin architecture connecting AIPass to the outside world: -scheduling engine + discoverable plugin interface. -""" diff --git a/src/aipass/assistant/handlers/__init__.py b/src/aipass/assistant/handlers/__init__.py deleted file mode 100644 index 329b5fbc..00000000 --- a/src/aipass/assistant/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""assistant.handlers — Scheduling and integration implementation.""" diff --git a/src/aipass/assistant/modules/__init__.py b/src/aipass/assistant/modules/__init__.py deleted file mode 100644 index 0ee9c410..00000000 --- a/src/aipass/assistant/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""assistant.modules — Scheduling engine and plugin orchestration.""" diff --git a/src/aipass/assistant/modules/scheduler.py b/src/aipass/assistant/modules/scheduler.py deleted file mode 100644 index 628d1b7e..00000000 --- a/src/aipass/assistant/modules/scheduler.py +++ /dev/null @@ -1 +0,0 @@ -"""Scheduling engine — manages timed actions, intervals, and due checks.""" diff --git a/src/aipass/assistant/plugins/__init__.py b/src/aipass/assistant/plugins/__init__.py deleted file mode 100644 index 976010c5..00000000 --- a/src/aipass/assistant/plugins/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""assistant.plugins — External integration plugins (auto-discovered). - -Each plugin connects AIPass to an external service. -User-swappable, independently installable. -""" diff --git a/src/aipass/assistant/plugins/calendar.py b/src/aipass/assistant/plugins/calendar.py deleted file mode 100644 index c74b52ec..00000000 --- a/src/aipass/assistant/plugins/calendar.py +++ /dev/null @@ -1 +0,0 @@ -"""Calendar plugin — Google Calendar integration.""" diff --git a/src/aipass/assistant/plugins/gmail.py b/src/aipass/assistant/plugins/gmail.py deleted file mode 100644 index 5020008e..00000000 --- a/src/aipass/assistant/plugins/gmail.py +++ /dev/null @@ -1 +0,0 @@ -"""Gmail plugin — email integration for external notifications.""" diff --git a/src/aipass/assistant/plugins/telegram.py b/src/aipass/assistant/plugins/telegram.py deleted file mode 100644 index 2d2a0258..00000000 --- a/src/aipass/assistant/plugins/telegram.py +++ /dev/null @@ -1 +0,0 @@ -"""Telegram plugin — bridge between AIPass and Telegram bots.""" diff --git a/src/aipass/citizenship/__init__.py b/src/aipass/citizenship/__init__.py deleted file mode 100644 index 55f7d115..00000000 --- a/src/aipass/citizenship/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""aipass.memory — Unified memory system. - -Three sub-modules: -- trinity: Memory files (wraps trinity-pattern pip package) -- agents: Agent creation, registry, scaffolding (powers aipass init) -- bank: Vector search on rolled-over archives (ChromaDB) -""" diff --git a/src/aipass/citizenship/handlers/__init__.py b/src/aipass/citizenship/handlers/__init__.py deleted file mode 100644 index 746e8fef..00000000 --- a/src/aipass/citizenship/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""memory.handlers — Archive management and storage implementation.""" diff --git a/src/aipass/citizenship/modules/__init__.py b/src/aipass/citizenship/modules/__init__.py deleted file mode 100644 index b3971c75..00000000 --- a/src/aipass/citizenship/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""memory.modules — Trinity files, agent lifecycle, and vector search.""" diff --git a/src/aipass/citizenship/modules/agents.py b/src/aipass/citizenship/modules/agents.py deleted file mode 100644 index d34c75f3..00000000 --- a/src/aipass/citizenship/modules/agents.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Agent creation, registry, and scaffolding. - -Powers `aipass init` (builds all citizens on first run) and -`drone @memory create agent "X"` (adds new branches later). - -Creates: passport.json, flow.local.md, birth certificate, registry entry. -""" diff --git a/src/aipass/citizenship/modules/bank.py b/src/aipass/citizenship/modules/bank.py deleted file mode 100644 index 07dae5cb..00000000 --- a/src/aipass/citizenship/modules/bank.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Vector search on rolled-over archives. - -ChromaDB-backed semantic search across archived memory files, -closed plans, and historical session data. -""" diff --git a/src/aipass/citizenship/modules/trinity.py b/src/aipass/citizenship/modules/trinity.py deleted file mode 100644 index 26cdaae5..00000000 --- a/src/aipass/citizenship/modules/trinity.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Memory file management — wraps and extends trinity-pattern package. - -Handles passport.json, local.json, observations.json per branch. -FIFO rollover to archive when files exceed limits. -""" diff --git a/src/aipass/citizenship/plugins/__init__.py b/src/aipass/citizenship/plugins/__init__.py deleted file mode 100644 index 92cab191..00000000 --- a/src/aipass/citizenship/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""memory.plugins — Extensible plugin interface.""" diff --git a/src/aipass/devpulse/__init__.py b/src/aipass/devpulse/__init__.py deleted file mode 100644 index 7860b35f..00000000 --- a/src/aipass/devpulse/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""aipass.devpulse — User orchestrator and dashboard. - -Home base for users: system status, project config, dashboard. -The entry point for understanding what's happening across the ecosystem. -""" diff --git a/src/aipass/devpulse/handlers/__init__.py b/src/aipass/devpulse/handlers/__init__.py deleted file mode 100644 index 45d0d5f0..00000000 --- a/src/aipass/devpulse/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""devpulse.handlers — Status aggregation and config management.""" diff --git a/src/aipass/devpulse/modules/__init__.py b/src/aipass/devpulse/modules/__init__.py deleted file mode 100644 index 4dcff730..00000000 --- a/src/aipass/devpulse/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""devpulse.modules — Dashboard and status logic.""" diff --git a/src/aipass/devpulse/plugins/__init__.py b/src/aipass/devpulse/plugins/__init__.py deleted file mode 100644 index 977718ef..00000000 --- a/src/aipass/devpulse/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""devpulse.plugins — Extensible plugin interface.""" diff --git a/src/aipass/drone/__init__.py b/src/aipass/drone/__init__.py deleted file mode 100644 index b126177d..00000000 --- a/src/aipass/drone/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""aipass.drone — Command routing, @resolution, module discovery. - -The backbone of AIPass. Routes `drone @module command` to the correct -module, resolves @ targets to paths, discovers installed modules. -""" diff --git a/src/aipass/drone/handlers/__init__.py b/src/aipass/drone/handlers/__init__.py deleted file mode 100644 index f7fc1e88..00000000 --- a/src/aipass/drone/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""drone.handlers — Implementation details for routing and dispatch.""" diff --git a/src/aipass/drone/handlers/dispatch.py b/src/aipass/drone/handlers/dispatch.py deleted file mode 100644 index 7d1454ed..00000000 --- a/src/aipass/drone/handlers/dispatch.py +++ /dev/null @@ -1 +0,0 @@ -"""Dispatch handler — executes resolved commands against target modules.""" diff --git a/src/aipass/drone/modules/__init__.py b/src/aipass/drone/modules/__init__.py deleted file mode 100644 index 271e8f0c..00000000 --- a/src/aipass/drone/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""drone.modules — Business logic for command routing and resolution.""" diff --git a/src/aipass/drone/modules/resolver.py b/src/aipass/drone/modules/resolver.py deleted file mode 100644 index 0ee93172..00000000 --- a/src/aipass/drone/modules/resolver.py +++ /dev/null @@ -1 +0,0 @@ -"""@ resolver — maps @targets to module paths.""" diff --git a/src/aipass/drone/modules/router.py b/src/aipass/drone/modules/router.py deleted file mode 100644 index c47cc30a..00000000 --- a/src/aipass/drone/modules/router.py +++ /dev/null @@ -1 +0,0 @@ -"""Command router — parses `drone @module command` and dispatches.""" diff --git a/src/aipass/drone/plugins/__init__.py b/src/aipass/drone/plugins/__init__.py deleted file mode 100644 index b039af65..00000000 --- a/src/aipass/drone/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""drone.plugins — Extensible plugin interface.""" diff --git a/src/aipass/flow/__init__.py b/src/aipass/flow/__init__.py deleted file mode 100644 index a388d989..00000000 --- a/src/aipass/flow/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""aipass.flow — Planning, workflow management, and tracking. - -Owns ALL planning: DPLANs (design), FPLANs (execution), MPLANs (master). -Each branch gets flow.local.md for issues, todos, and scratchpad. -""" diff --git a/src/aipass/flow/handlers/__init__.py b/src/aipass/flow/handlers/__init__.py deleted file mode 100644 index fe7ea7ed..00000000 --- a/src/aipass/flow/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""flow.handlers — Plan file management and template rendering.""" diff --git a/src/aipass/flow/modules/__init__.py b/src/aipass/flow/modules/__init__.py deleted file mode 100644 index 53c3a923..00000000 --- a/src/aipass/flow/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""flow.modules — Plan lifecycle and tracking logic.""" diff --git a/src/aipass/flow/plugins/__init__.py b/src/aipass/flow/plugins/__init__.py deleted file mode 100644 index 8305dadc..00000000 --- a/src/aipass/flow/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""flow.plugins — Extensible plugin interface.""" diff --git a/src/aipass/mail/__init__.py b/src/aipass/mail/__init__.py deleted file mode 100644 index da9671ab..00000000 --- a/src/aipass/mail/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""aipass.mail — Agent-to-agent messaging and dispatch. - -Handles inter-branch communication: send, receive, inbox management, -delivery routing, and dispatch flags for autonomous execution. -""" diff --git a/src/aipass/mail/handlers/__init__.py b/src/aipass/mail/handlers/__init__.py deleted file mode 100644 index dad9d246..00000000 --- a/src/aipass/mail/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""mail.handlers — Delivery and inbox implementation.""" diff --git a/src/aipass/mail/modules/__init__.py b/src/aipass/mail/modules/__init__.py deleted file mode 100644 index 94d87daa..00000000 --- a/src/aipass/mail/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""mail.modules — Messaging business logic.""" diff --git a/src/aipass/mail/plugins/__init__.py b/src/aipass/mail/plugins/__init__.py deleted file mode 100644 index a91b1f76..00000000 --- a/src/aipass/mail/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""mail.plugins — Extensible plugin interface.""" diff --git a/src/aipass/seedgo/__init__.py b/src/aipass/seedgo/__init__.py deleted file mode 100644 index 9599b65b..00000000 --- a/src/aipass/seedgo/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""aipass.seedgo — Code standards and quality checks. - -Public standards framework. Plugin-based architecture for automated -code quality auditing across the project. -""" diff --git a/src/aipass/seedgo/handlers/__init__.py b/src/aipass/seedgo/handlers/__init__.py deleted file mode 100644 index f81eb9eb..00000000 --- a/src/aipass/seedgo/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""seedgo.handlers — Check execution and reporting.""" diff --git a/src/aipass/seedgo/modules/__init__.py b/src/aipass/seedgo/modules/__init__.py deleted file mode 100644 index 7c8a24a5..00000000 --- a/src/aipass/seedgo/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""seedgo.modules — Standards runner and audit logic.""" diff --git a/src/aipass/seedgo/plugins/__init__.py b/src/aipass/seedgo/plugins/__init__.py deleted file mode 100644 index a32e4c22..00000000 --- a/src/aipass/seedgo/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""seedgo.plugins — Quality check plugins (auto-discovered).""" diff --git a/src/aipass/skills/__init__.py b/src/aipass/skills/__init__.py deleted file mode 100644 index c384a09e..00000000 --- a/src/aipass/skills/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""aipass.skills — User-built add-ons that compose infrastructure. - -Skills are self-contained folders that import from AIPass infrastructure -modules (drone, api, prax, mail, etc.) to create new capabilities. - -Infrastructure = the plumbing (modules/handlers/plugins inside each module). -Skills = what users build ON TOP of the plumbing. - -Each skill is a directory with a SKILL.md describing what it does -and Python files that import what they need from aipass.*. -""" diff --git a/src/aipass/templates/agents/branch_template/.agents/.backup_ignore.json b/src/aipass/spawn/templates/agent.template/.agent/.backup_ignore.json similarity index 100% rename from src/aipass/templates/agents/branch_template/.agents/.backup_ignore.json rename to src/aipass/spawn/templates/agent.template/.agent/.backup_ignore.json diff --git a/src/aipass/templates/agents/branch_template/.agents/.migrations.json b/src/aipass/spawn/templates/agent.template/.agent/.migrations.json similarity index 100% rename from src/aipass/templates/agents/branch_template/.agents/.migrations.json rename to src/aipass/spawn/templates/agent.template/.agent/.migrations.json diff --git a/src/aipass/templates/agents/branch_template/.agents/.registry_ignore.json b/src/aipass/spawn/templates/agent.template/.agent/.registry_ignore.json similarity index 100% rename from src/aipass/templates/agents/branch_template/.agents/.registry_ignore.json rename to src/aipass/spawn/templates/agent.template/.agent/.registry_ignore.json diff --git a/src/aipass/templates/agents/branch_template/.agents/.template_registry.json b/src/aipass/spawn/templates/agent.template/.agent/.template_registry.json similarity index 100% rename from src/aipass/templates/agents/branch_template/.agents/.template_registry.json rename to src/aipass/spawn/templates/agent.template/.agent/.template_registry.json diff --git a/src/aipass/templates/agents/branch_template/.gitignore b/src/aipass/spawn/templates/agent.template/.gitignore similarity index 100% rename from src/aipass/templates/agents/branch_template/.gitignore rename to src/aipass/spawn/templates/agent.template/.gitignore diff --git a/src/aipass/templates/agents/branch_template/.trinity/local.json b/src/aipass/spawn/templates/agent.template/.trinity/local.json similarity index 100% rename from src/aipass/templates/agents/branch_template/.trinity/local.json rename to src/aipass/spawn/templates/agent.template/.trinity/local.json diff --git a/src/aipass/templates/agents/branch_template/.trinity/observations.json b/src/aipass/spawn/templates/agent.template/.trinity/observations.json similarity index 100% rename from src/aipass/templates/agents/branch_template/.trinity/observations.json rename to src/aipass/spawn/templates/agent.template/.trinity/observations.json diff --git a/src/aipass/templates/agents/branch_template/.trinity/passport.json b/src/aipass/spawn/templates/agent.template/.trinity/passport.json similarity index 100% rename from src/aipass/templates/agents/branch_template/.trinity/passport.json rename to src/aipass/spawn/templates/agent.template/.trinity/passport.json diff --git a/src/aipass/templates/agents/branch_template/DASHBOARD.local.json b/src/aipass/spawn/templates/agent.template/DASHBOARD.local.json similarity index 100% rename from src/aipass/templates/agents/branch_template/DASHBOARD.local.json rename to src/aipass/spawn/templates/agent.template/DASHBOARD.local.json diff --git a/src/aipass/templates/agents/branch_template/README.md b/src/aipass/spawn/templates/agent.template/README.md similarity index 100% rename from src/aipass/templates/agents/branch_template/README.md rename to src/aipass/spawn/templates/agent.template/README.md diff --git a/src/aipass/templates/agents/branch_template/apps/__init__.py b/src/aipass/spawn/templates/agent.template/apps/__init__.py similarity index 100% rename from src/aipass/templates/agents/branch_template/apps/__init__.py rename to src/aipass/spawn/templates/agent.template/apps/__init__.py diff --git a/src/aipass/templates/agents/branch_template/apps/branch.py b/src/aipass/spawn/templates/agent.template/apps/branch.py similarity index 100% rename from src/aipass/templates/agents/branch_template/apps/branch.py rename to src/aipass/spawn/templates/agent.template/apps/branch.py diff --git a/src/aipass/templates/agents/branch_template/apps/handlers/__init__.py b/src/aipass/spawn/templates/agent.template/apps/handlers/__init__.py similarity index 100% rename from src/aipass/templates/agents/branch_template/apps/handlers/__init__.py rename to src/aipass/spawn/templates/agent.template/apps/handlers/__init__.py diff --git a/src/aipass/templates/agents/branch_template/apps/modules/__init__.py b/src/aipass/spawn/templates/agent.template/apps/modules/__init__.py similarity index 100% rename from src/aipass/templates/agents/branch_template/apps/modules/__init__.py rename to src/aipass/spawn/templates/agent.template/apps/modules/__init__.py diff --git a/src/aipass/templates/agents/branch_template/apps/plugins/__init__.py b/src/aipass/spawn/templates/agent.template/apps/plugins/__init__.py similarity index 100% rename from src/aipass/templates/agents/branch_template/apps/plugins/__init__.py rename to src/aipass/spawn/templates/agent.template/apps/plugins/__init__.py diff --git a/src/aipass/templates/agents/branch_template/artifacts/artifact_7_cortex_birth_certificate.json b/src/aipass/spawn/templates/agent.template/artifacts/artifact_7_cortex_birth_certificate.json similarity index 100% rename from src/aipass/templates/agents/branch_template/artifacts/artifact_7_cortex_birth_certificate.json rename to src/aipass/spawn/templates/agent.template/artifacts/artifact_7_cortex_birth_certificate.json diff --git a/src/aipass/templates/agents/branch_template/.backup/.gitkeep b/src/aipass/spawn/templates/agent.template/docs/.gitkeep similarity index 100% rename from src/aipass/templates/agents/branch_template/.backup/.gitkeep rename to src/aipass/spawn/templates/agent.template/docs/.gitkeep diff --git a/src/aipass/templates/agents/branch_template/pytest.ini b/src/aipass/spawn/templates/agent.template/pytest.ini similarity index 100% rename from src/aipass/templates/agents/branch_template/pytest.ini rename to src/aipass/spawn/templates/agent.template/pytest.ini diff --git a/src/aipass/templates/agents/branch_template/tests/__init__.py b/src/aipass/spawn/templates/agent.template/tests/__init__.py similarity index 100% rename from src/aipass/templates/agents/branch_template/tests/__init__.py rename to src/aipass/spawn/templates/agent.template/tests/__init__.py diff --git a/src/aipass/templates/agents/branch_template/tests/conftest.py b/src/aipass/spawn/templates/agent.template/tests/conftest.py similarity index 100% rename from src/aipass/templates/agents/branch_template/tests/conftest.py rename to src/aipass/spawn/templates/agent.template/tests/conftest.py diff --git a/src/aipass/templates/agents/branch_template/tools/verify_branch.py b/src/aipass/spawn/templates/agent.template/tools/verify_branch.py similarity index 100% rename from src/aipass/templates/agents/branch_template/tools/verify_branch.py rename to src/aipass/spawn/templates/agent.template/tools/verify_branch.py diff --git a/src/aipass/templates/agents/branch_template/.chroma/.gitkeep b/src/aipass/spawn/templates/agent.template/{{BRANCH}}_json/.gitkeep similarity index 100% rename from src/aipass/templates/agents/branch_template/.chroma/.gitkeep rename to src/aipass/spawn/templates/agent.template/{{BRANCH}}_json/.gitkeep diff --git a/src/aipass/spawn/templates/agent_mock_branch/.agent/.backup_ignore.json b/src/aipass/spawn/templates/agent_mock_branch/.agent/.backup_ignore.json new file mode 100644 index 00000000..c6e988fc --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/.agent/.backup_ignore.json @@ -0,0 +1,133 @@ +{ + "metadata": { + "version": "1.0.0", + "description": "Files and patterns to exclude when creating branch backups", + "last_updated": "2025-11-09" + }, + "ignore_directories": [ + ".backup", + "backups", + ".venv", + "venv", + "node_modules", + "__pycache__", + ".git", + ".cache", + ".pytest_cache", + ".mypy_cache", + "dist", + "build", + "*.egg-info", + "aipass_dev", + "aipass_core", + "aipass_business", + "aipass_help", + "input_x", + "Link to mnt", + "mcp_servers", + "speakeasy", + "sandbox", + "ai_mail.local", + "logs", + "pruned_files", + "crash-logs", + ".aipass", + ".archive", + ".cargo", + ".codex", + ".config", + ".dbus", + ".dotnet", + ".gemini", + ".gnome", + ".gnupg", + ".idlerc", + ".java", + ".local", + ".mozilla", + ".npm", + ".npm-global", + ".nvm", + ".pki", + ".pylint.d", + ".pyenv", + ".serena", + ".ssh", + ".thunderbird", + ".var", + ".vscode-server", + ".wget-hsts", + ".claude-code-docs", + ".claude", + "backup_system", + "Desktop", + "Documents", + "Downloads", + "dropbox", + "Music", + "Pictures", + "Public", + "snap", + "system_logs", + "Templates", + "Videos" + ], + "ignore_patterns": [ + "*.pyc", + "*.pyo", + "*.pyd", + ".DS_Store", + "Thumbs.db", + "*.log", + "*.tmp", + "*.swp", + "*.swo", + "*~", + "*.env", + "*_json", + "dev.local.md", + "notepad.md", + "pytest.ini", + ".bash_history", + ".bash_logout", + ".bashrc", + ".claude.json", + ".claude.json.backup", + ".lesshst", + ".npmrc", + ".profile", + ".python_history", + ".sudo_as_admin_successful", + ".gitattributes", + "BRANCH_REGISTRY.json", + ".branch_meta.json*", + "migrations.json*", + "sandbox.img" + ], + "notes": { + "how_it_works": "Two types of exclusions during backup creation:", + "ignore_directories": { + "description": "Directory names to skip entirely (including all contents)", + "purpose": "Exclude large dependency dirs, caches, and backup dirs themselves", + "examples": [ + ".venv, venv (Python virtual environments - large, regeneratable)", + "node_modules (NPM dependencies - huge, regeneratable)", + "__pycache__, .cache (Python/system caches - regeneratable)", + ".git (Git repository - already versioned)", + ".backup, backups (backup directories - prevent recursion)" + ] + }, + "ignore_patterns": { + "description": "Glob patterns for files to skip", + "purpose": "Exclude temporary files, compiled bytecode, OS cruft", + "examples": [ + "*.pyc, *.pyo (Python compiled bytecode - regeneratable)", + "*.log (Log files - can be large, temporary)", + "*.tmp, *.swp (Temporary/swap files)", + ".DS_Store, Thumbs.db (OS metadata files)" + ] + }, + "customization": "Edit this file in the template to add more patterns. Changes take effect on next branch update.", + "performance": "Ignoring large directories dramatically speeds up backups (seconds vs minutes for branches with node_modules or .venv)" + } +} diff --git a/src/aipass/spawn/templates/agent_mock_branch/.agent/.migrations.json b/src/aipass/spawn/templates/agent_mock_branch/.agent/.migrations.json new file mode 100644 index 00000000..a33f4115 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/.agent/.migrations.json @@ -0,0 +1,49 @@ +{ + "metadata": { + "version": "1.0.0", + "description": "Template structural migrations for branch updates", + "last_updated": "2025-11-07", + "applies_to_versions": ["all"] + }, + "migrations": [ + { + "id": "m001", + "description": "Example: Migrate flat metadata to document_metadata structure", + "version_introduced": "1.0.0", + "applies_to_files": ["*.id.json"], + "operations": [ + { + "type": "key_rename", + "from": "metadata", + "to": "document_metadata" + }, + { + "type": "move_to_nested", + "source_keys": ["branch_name", "path", "profile"], + "target_parent": "branch_info" + }, + { + "type": "add_missing_keys", + "parent": "document_metadata", + "keys": { + "document_type": "branch_identity", + "schema_version": "1.0.0" + } + } + ] + } + ], + "options": { + "skip_if_target_exists": true, + "preserve_unknown_keys": true, + "recursive_application": false + }, + "notes": { + "usage": "This file defines structural transformation rules applied during branch updates", + "pattern_matching": "applies_to_files uses glob patterns (*.json, *.id.json, etc.)", + "operation_order": "Operations execute in order listed within each migration", + "skip_behavior": "Operations skip if already compliant (target exists = success)", + "supported_operations": ["key_rename", "move_to_nested", "add_missing_keys"], + "future_operations": ["split_key", "combine_keys", "delete_key", "transform_value"] + } +} diff --git a/src/aipass/spawn/templates/agent_mock_branch/.agent/.registry_ignore.json b/src/aipass/spawn/templates/agent_mock_branch/.agent/.registry_ignore.json new file mode 100644 index 00000000..a3bbaed7 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/.agent/.registry_ignore.json @@ -0,0 +1,43 @@ +{ + "metadata": { + "version": "1.1.0", + "description": "Files and patterns to exclude when updating branches from template", + "last_updated": "2025-11-09" + }, + "ignore_files": [ + ".template_registry.json", + ".registry_ignore.json" + ], + "ignore_patterns": [ + "__pycache__", + "*.pyc", + "#@comments.txt", + ".backup_*" + ], + "notes": { + "how_it_works": "Two types of exclusions during branch updates:", + "ignore_files": { + "description": "Exact filename matches (no wildcards)", + "purpose": "Template-internal management files that branches don't need", + "examples": [ + ".template_registry.json (template's file tracking list)", + ".registry_ignore.json (this config file)" + ] + }, + "ignore_patterns": { + "description": "Glob patterns - matches filenames AND directory names", + "purpose": "Exclude generated files, caches, and user content markers", + "examples": [ + "__pycache__ (Python cache directories)", + "*.pyc (Python compiled bytecode files)", + "#@comments.txt (user comment files)", + ".backup_* (timestamped backup files like .backup_20251109, NOT .backup directory)" + ], + "how_matching_works": [ + "Step 1: Check if filename matches pattern (e.g., file.pyc matches *.pyc)", + "Step 2: Check if any parent directory matches (e.g., files inside __pycache__/ dir)" + ] + }, + "what_gets_ignored": "These files never copy to branches during updates - they stay template-only" + } +} diff --git a/src/aipass/spawn/templates/agent_mock_branch/.agent/.template_registry.json b/src/aipass/spawn/templates/agent_mock_branch/.agent/.template_registry.json new file mode 100644 index 00000000..4930f74a --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/.agent/.template_registry.json @@ -0,0 +1,301 @@ +{ + "metadata": { + "version": "1.0.0", + "last_updated": "2026-02-20", + "description": "Template file tracking registry for ID-based updates" + }, + "files": { + "f001": { + "current_name": "BRANCH.ID.json", + "path": "BRANCH.ID.json", + "content_hash": "e9bc2347d373", + "has_branch_placeholder": false + }, + "f003": { + "current_name": "dev.local.md", + "path": "dev.local.md", + "content_hash": "6c1c5e099790", + "has_branch_placeholder": false + }, + "f035": { + "current_name": ".gitkeep", + "path": "ai_mail.local/.archive/.gitkeep", + "content_hash": "e3b0c44298fc", + "has_branch_placeholder": false + }, + "f008": { + "current_name": "DASHBOARD.local.json", + "path": "DASHBOARD.local.json", + "content_hash": "3f0528b4c5d5", + "has_branch_placeholder": false + }, + "f007": { + "current_name": ".migrations.json", + "path": ".migrations.json", + "content_hash": "4ca241607dc3", + "has_branch_placeholder": false + }, + "f010": { + "current_name": "OBSERVATIONS.json", + "path": "OBSERVATIONS.json", + "content_hash": "58ea8609fbe0", + "has_branch_placeholder": false + }, + "f011": { + "current_name": "requirements.txt", + "path": "requirements.txt", + "content_hash": "66dfffc70d53", + "has_branch_placeholder": false + }, + "f021": { + "current_name": "README.md", + "path": "README.md", + "content_hash": "4e946f67a130", + "has_branch_placeholder": false + }, + "f012": { + "current_name": "LOCAL.json", + "path": "LOCAL.json", + "content_hash": "459dbe18d40a", + "has_branch_placeholder": false + }, + "f006": { + "current_name": "pytest.ini", + "path": "pytest.ini", + "content_hash": "7b39ba7bca40", + "has_branch_placeholder": false + }, + "f022": { + "current_name": "_template.md", + "path": "docs/_template.md", + "content_hash": "79ce97b0e613", + "has_branch_placeholder": false + }, + "f016": { + "current_name": "__init__.py", + "path": "tests/__init__.py", + "content_hash": "1fcd8126d174", + "has_branch_placeholder": false + }, + "f019": { + "current_name": "conftest.py", + "path": "tests/conftest.py", + "content_hash": "2887db109a6b", + "has_branch_placeholder": false + }, + "f004": { + "current_name": "BRANCH.py", + "path": "apps/BRANCH.py", + "content_hash": "44f22cc95232", + "has_branch_placeholder": false + }, + "f009": { + "current_name": "__init__.py", + "path": "apps/__init__.py", + "content_hash": "5c38204be5fc", + "has_branch_placeholder": false + }, + "f013": { + "current_name": "#@comments.txt.removed_from_template", + "path": ".archive/#@comments.txt.removed_from_template", + "content_hash": "651126c1e7a9", + "has_branch_placeholder": false + }, + "f028": { + "current_name": "deleted.json.old_template_format", + "path": ".archive/deleted.json.old_template_format", + "content_hash": "48a3a33dd73a", + "has_branch_placeholder": false + }, + "f002": { + "current_name": "branch_system_prompt.md", + "path": ".aipass/branch_system_prompt.md", + "content_hash": "897c7beb66f5", + "has_branch_placeholder": false + }, + "f026": { + "current_name": "inbox.json", + "path": "ai_mail.local/inbox.json", + "content_hash": "c9702fe2cc21", + "has_branch_placeholder": false + }, + "f027": { + "current_name": "sent.json", + "path": "ai_mail.local/sent.json", + "content_hash": "240132e5a567", + "has_branch_placeholder": false + }, + "f014": { + "current_name": "__init__.py", + "path": "apps/handlers/__init__.py", + "content_hash": "657d98ad4b98", + "has_branch_placeholder": false + }, + "f015": { + "current_name": "__init__.py", + "path": "apps/json_templates/__init__.py", + "content_hash": "330b189c08c7", + "has_branch_placeholder": false + }, + "f020": { + "current_name": "__init__.py", + "path": "apps/plugins/__init__.py", + "content_hash": "39158d26a19c", + "has_branch_placeholder": false + }, + "f023": { + "current_name": "__init__.py", + "path": "apps/modules/__init__.py", + "content_hash": "bc25a8b75e81", + "has_branch_placeholder": false + }, + "f024": { + "current_name": "__init__.py", + "path": "apps/extensions/__init__.py", + "content_hash": "6742ceb64ec1", + "has_branch_placeholder": false + }, + "f032": { + "current_name": "data.json", + "path": "apps/json_templates/default/data.json", + "content_hash": "0531a02be998", + "has_branch_placeholder": false + }, + "f033": { + "current_name": "log.json", + "path": "apps/json_templates/default/log.json", + "content_hash": "37517e5f3dc6", + "has_branch_placeholder": false + }, + "f034": { + "current_name": "config.json", + "path": "apps/json_templates/default/config.json", + "content_hash": "3759a05e171d", + "has_branch_placeholder": false + }, + "f005": { + "current_name": "json_handler.py", + "path": "apps/handlers/json/json_handler.py", + "content_hash": "3449629aff23", + "has_branch_placeholder": false + }, + "f017": { + "current_name": "__init__.py", + "path": "apps/handlers/json/__init__.py", + "content_hash": "ad3629e381ca", + "has_branch_placeholder": false + } + }, + "directories": { + "d001": { + "current_name": "tools", + "path": "tools", + "has_branch_placeholder": false + }, + "d002": { + "current_name": "docs", + "path": "docs", + "has_branch_placeholder": false + }, + "d003": { + "current_name": "tests", + "path": "tests", + "has_branch_placeholder": false + }, + "d004": { + "current_name": "apps", + "path": "apps", + "has_branch_placeholder": false + }, + "d005": { + "current_name": ".archive", + "path": ".archive", + "has_branch_placeholder": false + }, + "d006": { + "current_name": "logs", + "path": "logs", + "has_branch_placeholder": false + }, + "d009": { + "current_name": ".backup", + "path": ".backup", + "has_branch_placeholder": false + }, + "d008": { + "current_name": "{{BRANCH}}_json", + "path": "{{BRANCH}}_json", + "has_branch_placeholder": true + }, + "d010": { + "current_name": "artifacts", + "path": "artifacts", + "has_branch_placeholder": false + }, + "d012": { + "current_name": ".aipass", + "path": ".aipass", + "has_branch_placeholder": false + }, + "d011": { + "current_name": "ai_mail.local", + "path": "ai_mail.local", + "has_branch_placeholder": false + }, + "d007": { + "current_name": "dropbox", + "path": "dropbox", + "has_branch_placeholder": false + }, + "d013": { + "current_name": "handlers", + "path": "apps/handlers", + "has_branch_placeholder": false + }, + "d014": { + "current_name": "json_templates", + "path": "apps/json_templates", + "has_branch_placeholder": false + }, + "d015": { + "current_name": "plugins", + "path": "apps/plugins", + "has_branch_placeholder": false + }, + "d016": { + "current_name": "modules", + "path": "apps/modules", + "has_branch_placeholder": false + }, + "d017": { + "current_name": "extensions", + "path": "apps/extensions", + "has_branch_placeholder": false + }, + "d018": { + "current_name": "sent", + "path": "ai_mail.local/sent", + "has_branch_placeholder": false + }, + "d019": { + "current_name": "deleted", + "path": "ai_mail.local/deleted", + "has_branch_placeholder": false + }, + "d020": { + "current_name": ".archive", + "path": "ai_mail.local/.archive", + "has_branch_placeholder": false + }, + "d027": { + "current_name": "json", + "path": "apps/handlers/json", + "has_branch_placeholder": false + }, + "d024": { + "current_name": "default", + "path": "apps/json_templates/default", + "has_branch_placeholder": false + } + } +} \ No newline at end of file diff --git a/src/aipass/templates/agents/branch_template/.seedgo/.gitkeep b/src/aipass/spawn/templates/agent_mock_branch/.ai_mail.local/archive.json similarity index 100% rename from src/aipass/templates/agents/branch_template/.seedgo/.gitkeep rename to src/aipass/spawn/templates/agent_mock_branch/.ai_mail.local/archive.json diff --git a/src/aipass/templates/agents/branch_template/docs/.gitkeep b/src/aipass/spawn/templates/agent_mock_branch/.ai_mail.local/inbox.json similarity index 100% rename from src/aipass/templates/agents/branch_template/docs/.gitkeep rename to src/aipass/spawn/templates/agent_mock_branch/.ai_mail.local/inbox.json diff --git a/src/aipass/templates/agents/branch_template/logs/.gitkeep b/src/aipass/spawn/templates/agent_mock_branch/.ai_mail.local/sent.json similarity index 100% rename from src/aipass/templates/agents/branch_template/logs/.gitkeep rename to src/aipass/spawn/templates/agent_mock_branch/.ai_mail.local/sent.json diff --git a/src/aipass/templates/agents/branch_template/{{BRANCH}}_json/.gitkeep b/src/aipass/spawn/templates/agent_mock_branch/.backup/backup_manifest.json similarity index 100% rename from src/aipass/templates/agents/branch_template/{{BRANCH}}_json/.gitkeep rename to src/aipass/spawn/templates/agent_mock_branch/.backup/backup_manifest.json diff --git a/src/aipass/templates/flow/.gitkeep b/src/aipass/spawn/templates/agent_mock_branch/.backup/latest_snapshot.json similarity index 100% rename from src/aipass/templates/flow/.gitkeep rename to src/aipass/spawn/templates/agent_mock_branch/.backup/latest_snapshot.json diff --git a/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/data_level0.bin b/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/data_level0.bin new file mode 100644 index 00000000..5efb1b9f Binary files /dev/null and b/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/data_level0.bin differ diff --git a/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/header.bin b/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/header.bin new file mode 100644 index 00000000..bb547926 Binary files /dev/null and b/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/header.bin differ diff --git a/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/length.bin b/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/length.bin new file mode 100644 index 00000000..b43416ed Binary files /dev/null and b/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/length.bin differ diff --git a/src/aipass/templates/memory/.gitkeep b/src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/link_lists.bin similarity index 100% rename from src/aipass/templates/memory/.gitkeep rename to src/aipass/spawn/templates/agent_mock_branch/.chroma/3ad2c408-8578-4140-b25a-0e8d5287e95b/link_lists.bin diff --git a/src/aipass/spawn/templates/agent_mock_branch/.chroma/chroma.sqlite3 b/src/aipass/spawn/templates/agent_mock_branch/.chroma/chroma.sqlite3 new file mode 100644 index 00000000..9b4aa437 Binary files /dev/null and b/src/aipass/spawn/templates/agent_mock_branch/.chroma/chroma.sqlite3 differ diff --git a/src/aipass/templates/system/.gitkeep b/src/aipass/spawn/templates/agent_mock_branch/.gitignore similarity index 100% rename from src/aipass/templates/system/.gitkeep rename to src/aipass/spawn/templates/agent_mock_branch/.gitignore diff --git a/tests/__init__.py b/src/aipass/spawn/templates/agent_mock_branch/.standards/audit_history.json similarity index 100% rename from tests/__init__.py rename to src/aipass/spawn/templates/agent_mock_branch/.standards/audit_history.json diff --git a/src/aipass/spawn/templates/agent_mock_branch/.standards/bypass.json b/src/aipass/spawn/templates/agent_mock_branch/.standards/bypass.json new file mode 100644 index 00000000..39869f06 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/.standards/bypass.json @@ -0,0 +1,115 @@ +{ + "metadata": { + "version": "1.0.0", + "created": "2025-11-27T16:44:34.325750", + "description": "Standards bypass configuration for this branch" + }, + "bypass": [ + { + "file": "apps/modules/standards_checklist.py", + "standard": "architecture", + "reason": "Large file justified - orchestrates 12 standards, contains bypass system" + }, + { + "file": "apps/modules/standards_checklist.py", + "standard": "modules", + "reason": "Direct file ops required - reads BRANCH_REGISTRY.json and bypass configs" + }, + { + "file": "apps/handlers/verify/orchestrator.py", + "standard": "cli", + "reason": "Display orchestrator - console.print is its PURPOSE (orchestrates display output)" + }, + { + "file": "apps/handlers/audit/display.py", + "standard": "cli", + "reason": "Display handler - console.print is its PURPOSE (formats audit output)" + }, + { + "file": "apps/handlers/diagnostics/discovery.py", + "standard": "cli", + "reason": "Uses print() for error fallback when logger unavailable" + }, + { + "file": "apps/modules/cli_standard.py", + "standard": "modules", + "reason": "Standards education module - run_demo is part of teaching CLI patterns" + }, + { + "file": "apps/modules/standards_checklist.py", + "standard": "json_structure", + "reason": "Direct JSON for bypass system - not three-JSON pattern use case" + }, + { + "file": "apps/handlers/standards/architecture_check.py", + "standard": "cli", + "reason": "False positive - CLI pattern in docstring documenting allowed imports" + }, + { + "file": "apps/handlers/standards/imports_check.py", + "standard": "cli", + "reason": "False positive - CLI pattern in docstring documenting allowed imports" + }, + { + "file": "apps/handlers/standards/modules_content.py", + "standard": "cli", + "reason": "Content handler - CLI examples in strings are its PURPOSE" + }, + { + "file": "apps/handlers/standards/cli_check.py", + "standard": "cli", + "reason": "False positive - CLI pattern in docstring explaining console.print detection" + }, + { + "file": "apps/handlers/standards/cli_content.py", + "standard": "cli", + "reason": "Content handler - CLI examples in strings are its PURPOSE (teaching proper usage)" + }, + { + "file": "apps/handlers/standards/imports_content.py", + "standard": "cli", + "reason": "Content handler - CLI examples in strings are its PURPOSE (teaching proper usage)" + }, + { + "file": "apps/handlers/standards/architecture_check.py", + "standard": "encapsulation", + "reason": "Imports ignore_handler for template patterns - internal SEED checker infrastructure" + }, + { + "file": "apps/handlers/standards/error_handling_check.py", + "standard": "error_handling", + "reason": "False positive - 'logger.error()' pattern appears in docstring teaching the standard" + }, + { + "file": "apps/handlers/verify/checker_sync.py", + "standard": "trigger", + "reason": "False positive - 'unlink' and 'rename' appear in string list, not actual file operations" + } + ], + "notes": { + "usage": "Add entries to 'bypass' list to exclude specific violations", + "example": { + "file": "apps/modules/logger.py", + "standard": "cli", + "lines": [146, 177], + "pattern": "if __name__ == '__main__'", + "category": "internal_ops", + "reason": "Circular dependency - logger cannot import CLI" + }, + "fields": { + "file": "Relative path from branch root (required)", + "standard": "Standard name: cli, imports, naming, etc. (required)", + "lines": "Optional - specific line numbers to bypass", + "pattern": "Optional - pattern to match (e.g. 'if __name__')", + "category": "Optional - bypass category for trigger standard", + "reason": "Required - why this bypass exists" + }, + "trigger_categories": { + "handler_layer": "Function in handlers/ layer (orchestrator fires instead)", + "initialization": "One-time setup/config creation", + "internal_ops": "Same-module internal operation", + "high_frequency": "Would create event spam", + "utility": "Helper called by event-firing function" + } + } +} diff --git a/src/aipass/spawn/templates/agent_mock_branch/.standards/last_audit.json b/src/aipass/spawn/templates/agent_mock_branch/.standards/last_audit.json new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/.trinity/local.json b/src/aipass/spawn/templates/agent_mock_branch/.trinity/local.json new file mode 100644 index 00000000..334630b3 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/.trinity/local.json @@ -0,0 +1,34 @@ +{ + "document_metadata": { + "document_type": "session_history", + "document_name": "{{BRANCHNAME}}.LOCAL", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": "{{DATE}}", + "last_updated": "{{DATE}}", + "managed_by": "{{BRANCHNAME}}", + "tags": ["session_tracking", "work_log", "{{BRANCHNAME}}"], + "limits": { + "max_lines": 600, + "note": "Auto-rollover when max_lines exceeded" + }, + "status": { + "health": "healthy", + "current_lines": 0, + "last_health_check": "{{DATE}}" + } + }, + "active_tasks": { + "today_focus": "{{DATE}}", + "recently_completed": [] + }, + "key_learnings": {}, + "sessions": [ + { + "session_number": 1, + "date": "{{DATE}}", + "summary": "Branch initialized - {{BRANCHNAME}} created by aipass init", + "status": "completed" + } + ] +} diff --git a/src/aipass/spawn/templates/agent_mock_branch/.trinity/observations.json b/src/aipass/spawn/templates/agent_mock_branch/.trinity/observations.json new file mode 100644 index 00000000..50514ce6 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/.trinity/observations.json @@ -0,0 +1,37 @@ +{ + "document_metadata": { + "document_type": "collaboration_patterns", + "document_name": "{{BRANCHNAME}}.OBSERVATIONS", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": "{{DATE}}", + "last_updated": "{{DATE}}", + "managed_by": "{{BRANCHNAME}}", + "tags": ["collaboration", "patterns", "{{BRANCHNAME}}"], + "limits": { + "max_lines": 600, + "note": "Auto-rollover when max_lines exceeded" + }, + "status": { + "health": "healthy", + "current_lines": 0, + "last_health_check": "{{DATE}}" + } + }, + "guidelines": { + "purpose": "Capture collaboration patterns and experiential insights over time", + "chronological_order": "Newest entries at TOP, oldest at BOTTOM - NEVER reorder" + }, + "observations": [ + { + "date": "{{DATE}}", + "session": 1, + "entries": [ + { + "title": "First Contact", + "detail": "Branch initialized. Ready to begin capturing collaboration patterns." + } + ] + } + ] +} diff --git a/src/aipass/spawn/templates/agent_mock_branch/.trinity/passport.json b/src/aipass/spawn/templates/agent_mock_branch/.trinity/passport.json new file mode 100644 index 00000000..83b712e7 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/.trinity/passport.json @@ -0,0 +1,36 @@ +{ + "document_metadata": { + "document_type": "branch_identity", + "document_name": "{{BRANCHNAME}}.PASSPORT", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": "{{DATE}}", + "last_updated": "{{DATE}}", + "managed_by": "{{BRANCHNAME}}", + "tags": ["identity", "passport", "branch_profile"] + }, + "branch_info": { + "branch_name": "{{BRANCHNAME}}", + "alias": "", + "path": "{{CWD}}", + "module": "{{MODULE}}", + "created": "{{DATE}}" + }, + "identity": { + "role": "{{ROLE}}", + "purpose": "", + "what_i_do": [], + "what_i_dont_do": [] + }, + "principles": [ + "Code is truth - fail honestly", + "Memory persists - context survives", + "Simple solutions over complex architecture" + ], + "citizenship": { + "registered": true, + "registry_path": ".aipass/registry.json", + "communications": true, + "memory": true + } +} diff --git a/src/aipass/spawn/templates/agent_mock_branch/DASHBOARD.local.json b/src/aipass/spawn/templates/agent_mock_branch/DASHBOARD.local.json new file mode 100644 index 00000000..31cc3cc6 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/DASHBOARD.local.json @@ -0,0 +1,91 @@ +{ + "_warning": "AUTO-GENERATED FILE - DO NOT MANUALLY EDIT. This file is 100% automated and will be overwritten. Services update their own sections.", + "branch": "CORTEX", + "last_updated": "2026-03-04T02:09:48.112262", + "quick_status": { + "new_mail": 0, + "opened_mail": 0, + "active_plans": 0, + "commons_mentions": 13, + "action_required": true, + "summary": "13 mentions" + }, + "sections": { + "ai_mail": { + "managed_by": "ai_mail", + "new": 0, + "opened": 0, + "total": 0, + "oldest_unread_age": null, + "last_dispatch_received": null, + "last_updated": "2026-03-04T02:09:48.112249" + }, + "flow": { + "managed_by": "flow", + "active_plans": 0, + "recently_closed": [ + { + "plan_id": "FPLAN-0338", + "subject": "Race condition fix test B" + }, + { + "plan_id": "FPLAN-0337", + "subject": "Race condition fix test A" + }, + { + "plan_id": "FPLAN-0336", + "subject": "Real content drone test" + }, + { + "plan_id": "FPLAN-0333", + "subject": "Close workflow v3.2 test" + }, + { + "plan_id": "FPLAN-0332", + "subject": "Close workflow test - real content" + } + ], + "last_updated": "2026-02-25T21:04:01.461440" + }, + "memory_bank": { + "managed_by": "memory_bank", + "vectors_stored": 27, + "notes": {}, + "last_updated": "2026-02-25T16:52:38.749241" + }, + "devpulse": { + "managed_by": "devpulse", + "summary": { + "issues": 1, + "todos": 0 + }, + "dplan_counts": { + "total": 35, + "by_status": { + "complete": 11, + "planning": 19, + "in_progress": 4, + "ready": 1 + } + }, + "recent_activity": "Registry synced (35 plans)", + "last_updated": "2026-02-25T17:31:52.500742" + }, + "commons_activity": { + "managed_by": "the_commons", + "new_posts_since_last_visit": 46, + "new_comments_since_last_visit": 303, + "mentions": 13, + "trending": "None", + "last_checked": "2026-03-04T04:30:20Z", + "last_updated": "2026-03-03T20:30:20.172727" + }, + "agent_status": { + "managed_by": "prax", + "active_agents": [], + "agent_count": 0, + "stale_agents": [], + "last_updated": "2026-02-25T21:00:30.714949" + } + } +} \ No newline at end of file diff --git a/src/aipass/spawn/templates/agent_mock_branch/README.md b/src/aipass/spawn/templates/agent_mock_branch/README.md new file mode 100644 index 00000000..34d29197 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/README.md @@ -0,0 +1,51 @@ +# {{BRANCHNAME}} + +**Purpose:** {{PURPOSE_BRIEF}} +**Module:** `aipass.{{MODULE}}` +**Created:** {{DATE}} + +--- + +## Overview + +### What I Do +{{KEY_CAPABILITIES}} + +### How I Work +- **Entry Point:** `apps/{{MODULE}}.py` +- **Pattern:** Auto-discovers and routes to modules + +--- + +## Architecture + +``` +{{BRANCHNAME}}/ +├── apps/ +│ ├── {{MODULE}}.py # Entry point +│ ├── modules/ # Business logic +│ ├── handlers/ # Implementation +│ └── plugins/ # Extensions +├── docs/ +├── tests/ +├── passport.json # Identity +├── local.json # Session history +├── observations.json # Collaboration patterns +└── README.md +``` + +--- + +## Commands + +*Configure after initialization* + +--- + +## Integration Points + +### Depends On +{{DEPENDS_ON}} + +### Provides To +{{PROVIDES_TO}} diff --git a/src/aipass/spawn/templates/agent_mock_branch/agent_mock_branch.json/.gitkeep b/src/aipass/spawn/templates/agent_mock_branch/agent_mock_branch.json/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/agent_mock_branch.json/AGENT_MOCK_BRANCH.id.json b/src/aipass/spawn/templates/agent_mock_branch/agent_mock_branch.json/AGENT_MOCK_BRANCH.id.json new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/agent_mock_branch.json/AGENT_MOCK_BRANCH.local.json b/src/aipass/spawn/templates/agent_mock_branch/agent_mock_branch.json/AGENT_MOCK_BRANCH.local.json new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/agent_mock_branch.json/AGENT_MOCK_BRANCH.observations.json b/src/aipass/spawn/templates/agent_mock_branch/agent_mock_branch.json/AGENT_MOCK_BRANCH.observations.json new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/__init__.py b/src/aipass/spawn/templates/agent_mock_branch/apps/__init__.py new file mode 100644 index 00000000..91047046 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/apps/__init__.py @@ -0,0 +1 @@ +# {{BRANCHNAME}} apps package diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/branch.py b/src/aipass/spawn/templates/agent_mock_branch/apps/branch.py new file mode 100644 index 00000000..ef38d681 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/apps/branch.py @@ -0,0 +1,86 @@ +""" +{{BRANCHNAME}} Branch - Main Orchestrator + +Auto-discovery architecture: +- Scans modules/ directory for .py files with handle_command() +- Routes commands to discovered modules automatically +- No manual imports or routing needed +""" + +import sys +import importlib +from pathlib import Path +from typing import List, Any + +from aipass.prax import logger + +# ============================================================================= +# MODULE DISCOVERY +# ============================================================================= + +MODULES_DIR = Path(__file__).parent / "modules" + + +def discover_modules() -> List[Any]: + """Auto-discover modules in modules/ directory.""" + modules = [] + + if not MODULES_DIR.exists(): + return modules + + for file_path in MODULES_DIR.glob("*.py"): + if file_path.name.startswith("_"): + continue + + module_name = f"apps.modules.{file_path.stem}" + + try: + module = importlib.import_module(module_name) + if hasattr(module, "handle_command"): + modules.append(module) + except Exception as e: + logger.error(f"[{{BRANCHNAME}}] Failed to load module {module_name}: {e}") + + return modules + + +def route_command(command: str, args: List[str], modules: List[Any]) -> bool: + """Route command to appropriate module.""" + for module in modules: + try: + if module.handle_command(command, args): + return True + except Exception as e: + logger.error(f"[{{BRANCHNAME}}] Module {module.__name__} error: {e}") + return False + + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= + +def main(): + """Main entry point - routes commands or shows help.""" + modules = discover_modules() + args = sys.argv[1:] + + if len(args) == 0 or args[0] in ["--help", "-h", "help"]: + print(f"{{BRANCHNAME}} - {len(modules)} modules discovered") + for module in modules: + name = module.__name__.split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + print(f" {name:20} {desc}") + return 0 + + command = args[0] + remaining = args[1:] if len(args) > 1 else [] + + if route_command(command, remaining, modules): + return 0 + + print(f"Unknown command: {command}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/__init__.py b/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/cache.py b/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/cache.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/formatter.py b/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/formatter.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/processor.py b/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/processor.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/validator.py b/src/aipass/spawn/templates/agent_mock_branch/apps/handlers/validator.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/modules/__init__.py b/src/aipass/spawn/templates/agent_mock_branch/apps/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/modules/config.py b/src/aipass/spawn/templates/agent_mock_branch/apps/modules/config.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/modules/core.py b/src/aipass/spawn/templates/agent_mock_branch/apps/modules/core.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/modules/help.py b/src/aipass/spawn/templates/agent_mock_branch/apps/modules/help.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/modules/status.py b/src/aipass/spawn/templates/agent_mock_branch/apps/modules/status.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/plugins/__init__.py b/src/aipass/spawn/templates/agent_mock_branch/apps/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/plugins/export.py b/src/aipass/spawn/templates/agent_mock_branch/apps/plugins/export.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/apps/plugins/webhook.py b/src/aipass/spawn/templates/agent_mock_branch/apps/plugins/webhook.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/artifacts/artifact_1_registration.json b/src/aipass/spawn/templates/agent_mock_branch/artifacts/artifact_1_registration.json new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/artifacts/artifact_7_cortex_birth_certificate.json b/src/aipass/spawn/templates/agent_mock_branch/artifacts/artifact_7_cortex_birth_certificate.json new file mode 100644 index 00000000..44385387 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/artifacts/artifact_7_cortex_birth_certificate.json @@ -0,0 +1,15 @@ +{ + "id": 7, + "name": "CORTEX Birth Certificate", + "type": "birth_certificate", + "creator": "SYSTEM", + "owner": "CORTEX", + "rarity": "unique", + "description": "Official birth certificate for CORTEX. Citizen #7, registered using 'AIPass Workshop' template. Purpose: New branch - purpose TBD", + "metadata": { + "citizen_number": 7, + "template": "AIPass Workshop", + "purpose": "New branch - purpose TBD" + }, + "created_at": "2026-02-19T03:46:41Z" +} \ No newline at end of file diff --git a/src/aipass/spawn/templates/agent_mock_branch/docs/api_reference.md b/src/aipass/spawn/templates/agent_mock_branch/docs/api_reference.md new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/docs/architecture.md b/src/aipass/spawn/templates/agent_mock_branch/docs/architecture.md new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/docs/changelog.md b/src/aipass/spawn/templates/agent_mock_branch/docs/changelog.md new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/docs/index.json b/src/aipass/spawn/templates/agent_mock_branch/docs/index.json new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/dropbox/incoming_data.json b/src/aipass/spawn/templates/agent_mock_branch/dropbox/incoming_data.json new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/templates/agents/branch_template/flow.local.md b/src/aipass/spawn/templates/agent_mock_branch/flow.local.md similarity index 100% rename from src/aipass/templates/agents/branch_template/flow.local.md rename to src/aipass/spawn/templates/agent_mock_branch/flow.local.md diff --git a/src/aipass/spawn/templates/agent_mock_branch/logs/agent_mock_branch.log b/src/aipass/spawn/templates/agent_mock_branch/logs/agent_mock_branch.log new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/logs/error.log b/src/aipass/spawn/templates/agent_mock_branch/logs/error.log new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/pytest.ini b/src/aipass/spawn/templates/agent_mock_branch/pytest.ini new file mode 100644 index 00000000..c3f21db6 --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/pytest.ini @@ -0,0 +1,21 @@ +[pytest] +# Test discovery paths +testpaths = tests + +# Test file patterns +python_files = test_*.py +python_functions = test_* +python_classes = Test* + +# Command-line options (always applied) +addopts = + -v # Verbose output + --tb=short # Short traceback format + --strict-markers # Raise error on unknown markers + -ra # Show summary of all test outcomes + +# Test markers (for categorizing tests) +markers = + unit: Unit tests + integration: Integration tests + slow: Tests that take significant time diff --git a/src/aipass/spawn/templates/agent_mock_branch/templates/default_config.json b/src/aipass/spawn/templates/agent_mock_branch/templates/default_config.json new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/templates/welcome_message.md b/src/aipass/spawn/templates/agent_mock_branch/templates/welcome_message.md new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/tests/__init__.py b/src/aipass/spawn/templates/agent_mock_branch/tests/__init__.py new file mode 100644 index 00000000..bb01a91c --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for cortex diff --git a/src/aipass/spawn/templates/agent_mock_branch/tests/conftest.py b/src/aipass/spawn/templates/agent_mock_branch/tests/conftest.py new file mode 100644 index 00000000..a87088fd --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/tests/conftest.py @@ -0,0 +1,43 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/conftest.py +# Date: 2025-11-08 +# Version: 1.0.0 +# Category: cortex/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-08): Initial implementation - Shared pytest fixtures +# +# CODE STANDARDS: +# - Error handling: Use error handler system (apps/handlers/error/) +# ============================================= + +"""Shared pytest fixtures for cortex tests""" +import pytest +import shutil +import tempfile +from pathlib import Path +from typing import Generator + + +@pytest.fixture +def temp_test_dir() -> Generator[Path, None, None]: + """Creates temporary directory for testing, cleans up after""" + test_dir = Path(tempfile.mkdtemp()) + yield test_dir + if test_dir.exists(): + shutil.rmtree(test_dir) + + +@pytest.fixture +def sample_test_data() -> dict: + """Provides sample test data + + Customize this fixture for your module's needs + """ + return { + "test_key": "test_value", + "sample_data": "example" + } diff --git a/src/aipass/spawn/templates/agent_mock_branch/tests/test_core.py b/src/aipass/spawn/templates/agent_mock_branch/tests/test_core.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/tests/test_integration.py b/src/aipass/spawn/templates/agent_mock_branch/tests/test_integration.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/tests/test_processor.py b/src/aipass/spawn/templates/agent_mock_branch/tests/test_processor.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/tests/test_validator.py b/src/aipass/spawn/templates/agent_mock_branch/tests/test_validator.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/tools/debug.py b/src/aipass/spawn/templates/agent_mock_branch/tools/debug.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/tools/migrate.py b/src/aipass/spawn/templates/agent_mock_branch/tools/migrate.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/spawn/templates/agent_mock_branch/tools/verify_branch.py b/src/aipass/spawn/templates/agent_mock_branch/tools/verify_branch.py new file mode 100644 index 00000000..4464af6d --- /dev/null +++ b/src/aipass/spawn/templates/agent_mock_branch/tools/verify_branch.py @@ -0,0 +1,159 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: verify_branch.py - Branch Template Verification +# Date: 2025-11-08 +# Version: 1.0.0 +# Category: cortex/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-08): Initial implementation - Compare template vs branch +# +# CODE STANDARDS: +# - Error handling: Use error handler system (apps/handlers/error/) +# ============================================= + +""" +Branch Template Verification Tool + +Compares template directory with created branch to identify: +- Files in template but missing in branch +- Files in branch but not in template +- Renamed files +""" + +import sys +import json +from pathlib import Path +from typing import Set, List, Tuple +from fnmatch import fnmatch + +# INFRASTRUCTURE IMPORT PATTERN +AIPASS_ROOT = Path.home() / "aipass_core" +sys.path.insert(0, str(AIPASS_ROOT)) + +# Get template directory +TEMPLATE_DIR = AIPASS_ROOT / "cortex" / "templates" / "branch_template" + + +def load_ignore_patterns() -> Tuple[List[str], List[str]]: + """Load ignore patterns from .registry_ignore.json""" + ignore_file = TEMPLATE_DIR / ".registry_ignore.json" + + if not ignore_file.exists(): + return [], [] + + try: + with open(ignore_file, 'r', encoding='utf-8') as f: + data = json.load(f) + ignore_files = data.get("ignore_files", []) + ignore_patterns = data.get("ignore_patterns", []) + return ignore_files, ignore_patterns + except Exception as e: + print(f"Warning: Could not load .registry_ignore.json: {e}") + return [], [] + + +def should_exclude(path: Path, base_dir: Path, ignore_files: List[str], ignore_patterns: List[str]) -> bool: + """Check if path should be excluded based on ignore patterns""" + rel_path = path.relative_to(base_dir) + filename = path.name + path_str = str(rel_path) + + # Check exact filename matches + if filename in ignore_files: + return True + + # Check glob patterns + for pattern in ignore_patterns: + if fnmatch(filename, pattern): + return True + # Check if any parent directory matches + for part in rel_path.parts: + if fnmatch(part, pattern): + return True + + return False + + +def get_file_set(directory: Path, ignore_files: List[str], ignore_patterns: List[str]) -> Set[str]: + """Get set of all file paths in directory (relative)""" + files = set() + + for item in directory.rglob("*"): + if item.is_file() and not should_exclude(item, directory, ignore_files, ignore_patterns): + rel_path = str(item.relative_to(directory)) + files.add(rel_path) + + return files + + +def verify_branch(branch_dir: Path) -> Tuple[List[str], List[str], List[str]]: + """ + Compare template with branch + + Returns: + Tuple of (in_template_only, in_branch_only, in_both) + """ + # Load ignore patterns from .registry_ignore.json + ignore_files, ignore_patterns = load_ignore_patterns() + + template_files = get_file_set(TEMPLATE_DIR, ignore_files, ignore_patterns) + branch_files = get_file_set(branch_dir, ignore_files, ignore_patterns) + + in_template_only = sorted(template_files - branch_files) + in_branch_only = sorted(branch_files - template_files) + in_both = sorted(template_files & branch_files) + + return in_template_only, in_branch_only, in_both + + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 verify_branch.py ") + print("Example: python3 verify_branch.py test_branch002") + sys.exit(1) + + branch_path = Path(sys.argv[1]) + if not branch_path.is_absolute(): + # Assume it's relative to cortex directory + branch_path = Path.cwd() / branch_path + + if not branch_path.exists(): + print(f"❌ ERROR: Branch directory not found: {branch_path}") + sys.exit(1) + + print(f"\n=== Branch Verification ===") + print(f"Template: {TEMPLATE_DIR}") + print(f"Branch: {branch_path}") + print() + + in_template_only, in_branch_only, in_both = verify_branch(branch_path) + + print(f"Files in both: {len(in_both)}") + print(f"Files in template only: {len(in_template_only)}") + print(f"Files in branch only: {len(in_branch_only)}") + print() + + if in_template_only: + print("❌ Missing from branch (expected from template):") + for file in in_template_only: + print(f" - {file}") + print() + + if in_branch_only: + print("✅ Additional files in branch (renamed or new):") + for file in in_branch_only: + print(f" + {file}") + print() + + if not in_template_only and not in_branch_only: + print("✅ Branch matches template perfectly") + + print("="*70) + + +if __name__ == "__main__": + main() diff --git a/src/aipass/templates/__init__.py b/src/aipass/templates/__init__.py deleted file mode 100644 index af4a834f..00000000 --- a/src/aipass/templates/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""aipass.templates — Central template storage. - -Templates for plan files, memory scaffolding, system prompts, -and branch-local configurations. Used by aipass init and Flow. -""" diff --git a/src/aipass/trigger/__init__.py b/src/aipass/trigger/__init__.py deleted file mode 100644 index f4c2bf2d..00000000 --- a/src/aipass/trigger/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""aipass.trigger — Error detection and auto-dispatch. - -Watches for errors across the system and automatically dispatches -investigations to the appropriate branch. -""" diff --git a/src/aipass/trigger/handlers/__init__.py b/src/aipass/trigger/handlers/__init__.py deleted file mode 100644 index 7023f4e8..00000000 --- a/src/aipass/trigger/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""trigger.handlers — Error matching and escalation implementation.""" diff --git a/src/aipass/trigger/modules/__init__.py b/src/aipass/trigger/modules/__init__.py deleted file mode 100644 index 5752be25..00000000 --- a/src/aipass/trigger/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""trigger.modules — Error detection and dispatch logic.""" diff --git a/src/aipass/trigger/plugins/__init__.py b/src/aipass/trigger/plugins/__init__.py deleted file mode 100644 index 7667ebcd..00000000 --- a/src/aipass/trigger/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""trigger.plugins — Extensible plugin interface.""" diff --git a/tests/test_drone_cli.py b/tests/test_drone_cli.py deleted file mode 100644 index 002b56ab..00000000 --- a/tests/test_drone_cli.py +++ /dev/null @@ -1,273 +0,0 @@ -""" -Tests for the drone CLI (aipass.drone.cli). - -Covers: - - drone / drone --help → shows help text - - drone --version → shows version - - drone systems → lists branches - - drone @branch command → routes correctly - - drone @branch --help → shows branch help - - unknown commands → error message + exit 1 - - no args → shows help -""" - -from __future__ import annotations - -from unittest.mock import patch - -import aipass -from aipass.drone.cli import main -from aipass.drone import CommandResult, HelpResult - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _run_cli(*args: str) -> tuple[int, str, str]: - """ - Call main() with the given argv, capturing stdout/stderr and exit code. - - Returns: - (exit_code, stdout_text, stderr_text) - """ - 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) - - -# --------------------------------------------------------------------------- -# Help / version -# --------------------------------------------------------------------------- - - -class TestHelpAndVersion: - """drone --help and drone --version.""" - - def test_no_args_shows_help(self): - """drone with no args prints help and exits 0.""" - code, out, _ = _run_cli() - assert code == 0 - assert "drone" in out - assert "Usage" in out or "usage" in out.lower() or "@branch" in out - - def test_help_flag_shows_help(self): - """drone --help prints help and exits 0.""" - code, out, _ = _run_cli("--help") - assert code == 0 - assert "drone" in out - - def test_version_flag_shows_version(self): - """drone --version prints the aipass version and exits 0.""" - code, out, _ = _run_cli("--version") - assert code == 0 - assert aipass.__version__ in out - - -# --------------------------------------------------------------------------- -# systems -# --------------------------------------------------------------------------- - - -class TestSystems: - """drone systems — list registered branches.""" - - def test_systems_lists_branches(self): - """drone systems calls list_branches and prints them.""" - with patch("aipass.drone.cli.list_branches", return_value=["@flow", "@devpulse"]): - code, out, _ = _run_cli("systems") - - assert code == 0 - assert "@flow" in out - assert "@devpulse" in out - - def test_systems_empty_registry(self): - """drone systems with no branches prints a 'no branches' message.""" - with patch("aipass.drone.cli.list_branches", return_value=[]): - code, out, _ = _run_cli("systems") - - assert code == 0 - assert "No branches" in out or "no branches" in out.lower() or out.strip() != "" - - def test_systems_shows_count(self): - """drone systems output mentions the number of branches.""" - branches = ["@alpha", "@beta", "@gamma"] - with patch("aipass.drone.cli.list_branches", return_value=branches): - code, out, _ = _run_cli("systems") - - assert code == 0 - # Should show 3 branches somewhere in output - assert "3" in out or all(b in out for b in branches) - - -# --------------------------------------------------------------------------- -# @branch routing -# --------------------------------------------------------------------------- - - -class TestBranchRouting: - """drone @branch command [args] — route commands.""" - - def _make_result(self, stdout="", stderr="", exit_code=0) -> CommandResult: - return CommandResult(stdout=stdout, stderr=stderr, exit_code=exit_code, branch="flow", command="status") - - def test_routes_command_to_branch(self): - """drone @flow status calls route_command with correct args.""" - result = self._make_result(stdout="flow is running\n") - with patch("aipass.drone.cli.route_command", return_value=result) as mock_route: - code, out, _ = _run_cli("@flow", "status") - - mock_route.assert_called_once_with("@flow", "status", args=None) - assert code == 0 - assert "flow is running" in out - - def test_routes_command_with_extra_args(self): - """drone @flow run --verbose passes args correctly.""" - result = self._make_result(stdout="verbose output\n") - with patch("aipass.drone.cli.route_command", return_value=result) as mock_route: - code, out, _ = _run_cli("@flow", "run", "--verbose") - - mock_route.assert_called_once_with("@flow", "run", args=["--verbose"]) - assert code == 0 - - def test_exits_with_command_exit_code(self): - """drone exits with the same code as the routed command.""" - result = self._make_result(exit_code=2, stderr="something failed\n") - with patch("aipass.drone.cli.route_command", return_value=result): - code, _, err = _run_cli("@flow", "status") - - assert code == 2 - assert "something failed" in err - - def test_branch_not_found_exits_1(self): - """BranchNotFoundError prints error and exits 1.""" - from aipass.drone import BranchNotFoundError - with patch("aipass.drone.cli.route_command", side_effect=BranchNotFoundError("@ghost not found")): - code, _, err = _run_cli("@ghost", "status") - - assert code == 1 - assert "ghost" in err or "not found" in err.lower() - - def test_command_execution_error_exits_1(self): - """CommandExecutionError prints error and exits 1.""" - from aipass.drone import CommandExecutionError - with patch("aipass.drone.cli.route_command", side_effect=CommandExecutionError("entry point missing")): - code, _, err = _run_cli("@flow", "status") - - assert code == 1 - assert "entry point missing" in err - - def test_stdout_printed(self): - """Command stdout is printed to stdout.""" - result = self._make_result(stdout="hello world\n") - with patch("aipass.drone.cli.route_command", return_value=result): - code, out, _ = _run_cli("@flow", "status") - - assert "hello world" in out - - def test_stderr_printed_to_stderr(self): - """Command stderr is printed to stderr.""" - result = self._make_result(stderr="warning: something\n", exit_code=0) - with patch("aipass.drone.cli.route_command", return_value=result): - code, _, err = _run_cli("@flow", "status") - - assert "warning: something" in err - - -# --------------------------------------------------------------------------- -# @branch --help -# --------------------------------------------------------------------------- - - -class TestBranchHelp: - """drone @branch --help — show branch help.""" - - def test_branch_help_calls_get_help(self): - """drone @flow --help calls get_help and prints text.""" - help_result = HelpResult(branch="flow", command=None, text="flow help text\n", commands_found=["status"]) - with patch("aipass.drone.cli.get_help", return_value=help_result) as mock_help: - code, out, _ = _run_cli("@flow", "--help") - - mock_help.assert_called_once_with("@flow") - assert code == 0 - assert "flow help text" in out - - def test_branch_no_args_shows_help(self): - """drone @flow with no command also calls get_help.""" - help_result = HelpResult(branch="flow", command=None, text="flow help\n", commands_found=[]) - with patch("aipass.drone.cli.get_help", return_value=help_result): - code, out, _ = _run_cli("@flow") - - assert code == 0 - assert "flow help" in out - - def test_branch_not_found_on_help_exits_1(self): - """BranchNotFoundError on --help exits 1.""" - from aipass.drone import BranchNotFoundError - with patch("aipass.drone.cli.get_help", side_effect=BranchNotFoundError("@ghost not found")): - code, _, err = _run_cli("@ghost", "--help") - - assert code == 1 - - def test_command_execution_error_on_help_exits_1(self): - """CommandExecutionError on --help exits 1.""" - from aipass.drone import CommandExecutionError - with patch("aipass.drone.cli.get_help", side_effect=CommandExecutionError("no entry")): - code, _, err = _run_cli("@flow", "--help") - - assert code == 1 - - -# --------------------------------------------------------------------------- -# Unknown commands -# --------------------------------------------------------------------------- - - -class TestUnknownCommands: - """Unknown top-level commands show an error.""" - - def test_unknown_command_exits_1(self): - """An unrecognised command exits with code 1.""" - code, _, err = _run_cli("frobulate") - assert code == 1 - - def test_unknown_command_shows_error_message(self): - """An unrecognised command prints an error to stderr.""" - code, _, err = _run_cli("frobulate") - assert "frobulate" in err or "unknown" in err.lower() - - def test_unknown_command_suggests_help(self): - """Error message mentions --help.""" - code, _, err = _run_cli("frobulate") - assert "--help" in err diff --git a/tests/test_drone_compliance.py b/tests/test_drone_compliance.py deleted file mode 100644 index 2dd880b8..00000000 --- a/tests/test_drone_compliance.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -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" - - def get_introspective(): - return {} - """) - ) - - 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_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("") - (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 "" - - 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: - """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) - - 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.""" - - 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_core.py b/tests/test_drone_core.py deleted file mode 100644 index 8d7d01be..00000000 --- a/tests/test_drone_core.py +++ /dev/null @@ -1,390 +0,0 @@ -""" -Unit tests for drone core functionality. - -Tests all Phase 1 routing functions with >80% coverage target. -""" - -import json -from pathlib import Path - -import pytest - -from aipass.drone import ( - BranchAlreadyExistsError, - BranchNotFoundError, - InvalidPathError, - RegistryNotFoundError, - branch_exists, - get_branch_info, - get_registry_path, - initialize_registry, - list_branches, - register_branch, - reset_registry_path, - resolve_branch, - set_registry_path, -) - - -@pytest.fixture -def temp_registry(tmp_path): - """Create a temporary registry for testing.""" - registry_path = tmp_path / "test_registry.json" - set_registry_path(registry_path) - initialize_registry() - yield registry_path - reset_registry_path() - - -@pytest.fixture -def sample_branches(tmp_path): - """Create sample branch directories for testing.""" - branches = {} - for name in ["agent1", "agent2", "service1"]: - branch_dir = tmp_path / "branches" / name - branch_dir.mkdir(parents=True, exist_ok=True) - branches[name] = branch_dir - return branches - - -class TestConfiguration: - """Test registry configuration.""" - - def test_default_registry_path(self): - """Test default registry path is ~/.aipass/BRANCH_REGISTRY.json.""" - reset_registry_path() - path = get_registry_path() - assert path == Path.home() / ".aipass" / "BRANCH_REGISTRY.json" - - def test_set_custom_registry_path(self, tmp_path): - """Test setting custom registry path.""" - custom_path = tmp_path / "custom_registry.json" - set_registry_path(custom_path) - assert get_registry_path() == custom_path - reset_registry_path() - - def test_reset_registry_path(self, tmp_path): - """Test resetting registry path to default.""" - custom_path = tmp_path / "custom_registry.json" - set_registry_path(custom_path) - reset_registry_path() - path = get_registry_path() - assert path == Path.home() / ".aipass" / "BRANCH_REGISTRY.json" - - -class TestRegistryInitialization: - """Test registry initialization.""" - - def test_initialize_creates_registry(self, tmp_path): - """Test initialize_registry creates valid registry file.""" - registry_path = tmp_path / "new_registry.json" - set_registry_path(registry_path) - - initialize_registry() - - assert registry_path.exists() - with open(registry_path, encoding="utf-8") as f: - data = json.load(f) - - assert data["version"] == "1.0" - assert "branches" in data - assert isinstance(data["branches"], dict) - assert "metadata" in data - - reset_registry_path() - - def test_initialize_idempotent(self, temp_registry): - """Test initialize_registry is idempotent (safe to call multiple times).""" - # First initialization done by fixture - first_content = temp_registry.read_text() - - # Second initialization should not change anything - initialize_registry() - second_content = temp_registry.read_text() - - assert first_content == second_content - - -class TestBranchRegistration: - """Test branch registration functionality.""" - - def test_register_branch_basic(self, temp_registry, sample_branches): - """Test basic branch registration.""" - branch_path = sample_branches["agent1"] - register_branch("agent1", str(branch_path), "agent") - - # Verify branch was added - with open(temp_registry, encoding="utf-8") as f: - data = json.load(f) - - assert "agent1" in data["branches"] - branch = data["branches"]["agent1"] - assert branch["name"] == "agent1" - assert Path(branch["path"]) == branch_path - assert branch["type"] == "agent" - assert branch["status"] == "active" - assert "created" in branch - - def test_register_branch_with_path_object(self, temp_registry, sample_branches): - """Test branch registration with Path object.""" - branch_path = sample_branches["agent1"] - register_branch("agent1", branch_path, "agent") - - assert branch_exists("agent1") - - def test_register_branch_duplicate_raises_error(self, temp_registry, sample_branches): - """Test registering duplicate branch raises error.""" - branch_path = sample_branches["agent1"] - register_branch("agent1", str(branch_path), "agent") - - with pytest.raises(BranchAlreadyExistsError): - register_branch("agent1", str(branch_path), "agent") - - def test_register_branch_nonexistent_path_raises_error(self, temp_registry): - """Test registering nonexistent path raises error.""" - fake_path = "/nonexistent/path/to/branch" - - with pytest.raises(InvalidPathError): - register_branch("fake_branch", fake_path, "agent") - - def test_register_branch_file_not_directory_raises_error(self, temp_registry, tmp_path): - """Test registering a file instead of directory raises error.""" - file_path = tmp_path / "file.txt" - file_path.write_text("test") - - with pytest.raises(InvalidPathError): - register_branch("bad_branch", str(file_path), "agent") - - def test_register_branch_auto_initializes_registry(self, tmp_path): - """Test registering branch auto-initializes registry if it doesn't exist.""" - registry_path = tmp_path / "new_registry.json" - set_registry_path(registry_path) - - # Registry doesn't exist yet - assert not registry_path.exists() - - # Register branch should auto-initialize - branch_path = tmp_path / "agent" - branch_path.mkdir() - register_branch("agent", str(branch_path), "agent") - - # Registry should now exist - assert registry_path.exists() - assert branch_exists("agent") - - reset_registry_path() - - -class TestBranchResolution: - """Test branch resolution functionality.""" - - def test_resolve_branch_with_at_prefix(self, temp_registry, sample_branches): - """Test resolving branch with @ prefix.""" - branch_path = sample_branches["agent1"] - register_branch("agent1", str(branch_path), "agent") - - resolved = resolve_branch("@agent1") - assert Path(resolved) == branch_path - - def test_resolve_branch_without_at_prefix(self, temp_registry, sample_branches): - """Test resolving branch without @ prefix.""" - branch_path = sample_branches["agent1"] - register_branch("agent1", str(branch_path), "agent") - - resolved = resolve_branch("agent1") - assert Path(resolved) == branch_path - - def test_resolve_branch_not_found_raises_error(self, temp_registry): - """Test resolving nonexistent branch raises error.""" - with pytest.raises(BranchNotFoundError): - resolve_branch("@nonexistent") - - def test_resolve_branch_no_registry_raises_error(self, tmp_path): - """Test resolving branch without registry raises error.""" - registry_path = tmp_path / "missing_registry.json" - set_registry_path(registry_path) - - with pytest.raises(RegistryNotFoundError): - resolve_branch("@agent1") - - reset_registry_path() - - -class TestBranchExists: - """Test branch existence checking.""" - - def test_branch_exists_true(self, temp_registry, sample_branches): - """Test branch_exists returns True for registered branch.""" - branch_path = sample_branches["agent1"] - register_branch("agent1", str(branch_path), "agent") - - assert branch_exists("@agent1") - assert branch_exists("agent1") - - def test_branch_exists_false(self, temp_registry): - """Test branch_exists returns False for unregistered branch.""" - assert not branch_exists("@nonexistent") - assert not branch_exists("nonexistent") - - def test_branch_exists_no_registry_returns_false(self, tmp_path): - """Test branch_exists returns False when registry doesn't exist.""" - registry_path = tmp_path / "missing_registry.json" - set_registry_path(registry_path) - - assert not branch_exists("@agent1") - - reset_registry_path() - - -class TestBranchInfo: - """Test branch metadata retrieval.""" - - def test_get_branch_info_basic(self, temp_registry, sample_branches): - """Test getting branch info.""" - branch_path = sample_branches["agent1"] - register_branch("agent1", str(branch_path), "agent") - - info = get_branch_info("@agent1") - - assert info["name"] == "agent1" - assert Path(info["path"]) == branch_path - assert info["type"] == "agent" - assert info["status"] == "active" - assert "created" in info - - def test_get_branch_info_without_at_prefix(self, temp_registry, sample_branches): - """Test getting branch info without @ prefix.""" - branch_path = sample_branches["agent1"] - register_branch("agent1", str(branch_path), "agent") - - info = get_branch_info("agent1") - assert info["name"] == "agent1" - - def test_get_branch_info_not_found_raises_error(self, temp_registry): - """Test getting info for nonexistent branch raises error.""" - with pytest.raises(BranchNotFoundError): - get_branch_info("@nonexistent") - - -class TestListBranches: - """Test branch listing functionality.""" - - def test_list_branches_all(self, temp_registry, sample_branches): - """Test listing all branches.""" - register_branch("agent1", str(sample_branches["agent1"]), "agent") - register_branch("agent2", str(sample_branches["agent2"]), "agent") - register_branch("service1", str(sample_branches["service1"]), "service") - - branches = list_branches() - - assert "@agent1" in branches - assert "@agent2" in branches - assert "@service1" in branches - assert len(branches) == 3 - - def test_list_branches_by_type(self, temp_registry, sample_branches): - """Test listing branches filtered by type.""" - register_branch("agent1", str(sample_branches["agent1"]), "agent") - register_branch("agent2", str(sample_branches["agent2"]), "agent") - register_branch("service1", str(sample_branches["service1"]), "service") - - agents = list_branches(branch_type="agent") - services = list_branches(branch_type="service") - - assert "@agent1" in agents - assert "@agent2" in agents - assert "@service1" not in agents - assert len(agents) == 2 - - assert "@service1" in services - assert "@agent1" not in services - assert len(services) == 1 - - def test_list_branches_empty_registry(self, temp_registry): - """Test listing branches with empty registry.""" - branches = list_branches() - assert branches == [] - - def test_list_branches_no_registry(self, tmp_path): - """Test listing branches when registry doesn't exist.""" - registry_path = tmp_path / "missing_registry.json" - set_registry_path(registry_path) - - branches = list_branches() - assert branches == [] - - reset_registry_path() - - -class TestErrorHandling: - """Test error handling scenarios.""" - - def test_corrupt_registry_raises_error(self, tmp_path): - """Test corrupted registry file raises error.""" - registry_path = tmp_path / "corrupt_registry.json" - registry_path.write_text("invalid json {{{") - - set_registry_path(registry_path) - - with pytest.raises(Exception): # RegistryCorruptError or similar - resolve_branch("@agent1") - - reset_registry_path() - - def test_missing_branches_field_raises_error(self, tmp_path): - """Test registry missing 'branches' field raises error.""" - registry_path = tmp_path / "bad_registry.json" - registry_path.write_text(json.dumps({"version": "1.0"})) - - set_registry_path(registry_path) - - with pytest.raises(Exception): # RegistryCorruptError - resolve_branch("@agent1") - - reset_registry_path() - - -class TestEndToEnd: - """End-to-end integration tests.""" - - def test_full_workflow(self, tmp_path): - """Test complete workflow: initialize → register → resolve → list → exists.""" - # Setup - registry_path = tmp_path / "registry.json" - set_registry_path(registry_path) - initialize_registry() - - # Create branch directories - agent1_path = tmp_path / "agent1" - agent1_path.mkdir() - agent2_path = tmp_path / "agent2" - agent2_path.mkdir() - - # Register branches - register_branch("agent1", str(agent1_path), "agent") - register_branch("agent2", str(agent2_path), "agent") - - # Verify existence - assert branch_exists("@agent1") - assert branch_exists("@agent2") - assert not branch_exists("@agent3") - - # Resolve paths - resolved1 = resolve_branch("@agent1") - resolved2 = resolve_branch("agent2") - assert Path(resolved1) == agent1_path - assert Path(resolved2) == agent2_path - - # Get info - info = get_branch_info("@agent1") - assert info["name"] == "agent1" - assert info["type"] == "agent" - - # List branches - branches = list_branches() - assert len(branches) == 2 - assert "@agent1" in branches - assert "@agent2" in branches - - # Cleanup - reset_registry_path() diff --git a/tests/test_drone_integration.py b/tests/test_drone_integration.py deleted file mode 100644 index ad0989af..00000000 --- a/tests/test_drone_integration.py +++ /dev/null @@ -1,504 +0,0 @@ -""" -Integration tests for the AIPass drone module (Phase 3). - -End-to-end tests that exercise the full workflow using real temp directories -with proper branch file structures. All subprocess calls are mocked so no -real Python processes are spawned. -""" - -import json -import subprocess -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from aipass.drone import ( - BranchNotFoundError, - CommandExecutionError, - CommandResult, - HelpResult, - branch_exists, - discover_modules, - get_branch_info, - get_help, - initialize_registry, - list_branches, - register_branch, - reset_registry_path, - resolve_branch, - route_all, - route_command, - set_registry_path, -) -from aipass.drone.discovery import get_system_help - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_cp(stdout=b"", stderr=b"", returncode=0): - """Build a mock subprocess.CompletedProcess.""" - cp = MagicMock(spec=subprocess.CompletedProcess) - cp.stdout = stdout - cp.stderr = stderr - cp.returncode = returncode - return cp - - -def _make_branch(root: Path, name: str) -> Path: - """ - Create a minimal branch directory layout under root. - - Layout: - root/{name}/ - apps/ - {name}.py <- entry point - modules/ - status.py - info.py - """ - branch_dir = root / name - apps_dir = branch_dir / "apps" - modules_dir = apps_dir / "modules" - modules_dir.mkdir(parents=True) - (apps_dir / f"{name}.py").write_text(f"# {name} entry point\n") - (modules_dir / "status.py").write_text("") - (modules_dir / "info.py").write_text("") - return branch_dir - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def registry(tmp_path): - """Isolated registry backed by a temp directory.""" - registry_path = tmp_path / "registry.json" - set_registry_path(registry_path) - initialize_registry() - yield tmp_path - reset_registry_path() - - -@pytest.fixture -def multi_branch_registry(tmp_path): - """ - Registry pre-populated with three branches: alpha, beta, gamma. - - Each has a full apps/{name}.py + apps/modules/ structure. - """ - registry_path = tmp_path / "registry.json" - set_registry_path(registry_path) - initialize_registry() - - for name in ("alpha", "beta", "gamma"): - branch_dir = _make_branch(tmp_path, name) - register_branch(name, str(branch_dir), "agent") - - yield tmp_path - reset_registry_path() - - -# --------------------------------------------------------------------------- -# 1. Full workflow: initialize → register → resolve → discover → help -# --------------------------------------------------------------------------- - - -class TestFullWorkflow: - """End-to-end happy-path tests.""" - - def test_initialize_register_resolve(self, tmp_path): - """Complete init → register → resolve cycle.""" - registry_path = tmp_path / "reg.json" - set_registry_path(registry_path) - initialize_registry() - - branch_dir = _make_branch(tmp_path, "mybot") - register_branch("mybot", str(branch_dir), "agent") - - resolved = resolve_branch("@mybot") - assert Path(resolved) == branch_dir - - reset_registry_path() - - def test_register_multiple_branches_and_list(self, registry, tmp_path): - """Register several branches and verify list_branches returns all.""" - names = ["alice", "bob", "charlie"] - for name in names: - bd = _make_branch(tmp_path, name) - register_branch(name, str(bd), "agent") - - found = list_branches() - for name in names: - assert f"@{name}" in found - assert len(found) == 3 - - def test_resolve_then_discover_then_help(self, multi_branch_registry): - """Resolve a branch, discover its modules, then get help.""" - # Resolve - path = resolve_branch("@alpha") - assert Path(path).is_dir() - - # Discover — mock help output with a Commands section - help_bytes = b"Usage: alpha.py\n\nCommands:\n status Check status\n info Show info\n" - with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)): - modules = discover_modules("@alpha") - assert "status" in modules - assert "info" in modules - - # Help — returns structured HelpResult - with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)): - result = get_help("@alpha") - assert isinstance(result, HelpResult) - assert result.branch == "alpha" - assert result.command is None - assert "status" in result.commands_found - - def test_route_command_after_register(self, multi_branch_registry): - """route_command reaches a registered branch successfully.""" - cp = _make_cp(stdout=b"alpha is running\n", returncode=0) - with patch("subprocess.run", return_value=cp): - result = route_command("@alpha", "status") - - assert result.exit_code == 0 - assert "alpha is running" in result.stdout - assert result.branch == "alpha" - assert result.command == "status" - - def test_branch_exists_after_register(self, registry, tmp_path): - """branch_exists returns True immediately after registration.""" - bd = _make_branch(tmp_path, "sentinel") - register_branch("sentinel", str(bd), "service") - - assert branch_exists("@sentinel") - assert branch_exists("sentinel") - assert not branch_exists("@ghost") - - def test_get_branch_info_after_register(self, registry, tmp_path): - """get_branch_info returns correct metadata after registration.""" - bd = _make_branch(tmp_path, "worker") - register_branch("worker", str(bd), "service") - - info = get_branch_info("@worker") - assert info["name"] == "worker" - assert info["type"] == "service" - assert info["status"] == "active" - assert Path(info["path"]) == bd - assert "created" in info - - -# --------------------------------------------------------------------------- -# 2. HelpResult structure -# --------------------------------------------------------------------------- - - -class TestHelpResult: - """Verify HelpResult dataclass fields and behaviour.""" - - def test_help_result_fields(self, multi_branch_registry): - """HelpResult exposes branch, command, text, commands_found.""" - help_bytes = b"Usage: beta.py\n\nCommands:\n run Run a task\n stop Stop\n" - with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)): - result = get_help("@beta") - - assert result.branch == "beta" - assert result.command is None - assert "Usage" in result.text - assert isinstance(result.commands_found, list) - assert "run" in result.commands_found - assert "stop" in result.commands_found - - def test_help_result_command_level(self, multi_branch_registry): - """HelpResult.command is set when a specific command is queried.""" - help_bytes = b"Usage: beta.py run [options]\n --verbose Verbose output\n" - with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)): - result = get_help("@beta", command="run") - - assert result.command == "run" - assert "Usage" in result.text - - def test_help_result_stderr_fallback(self, multi_branch_registry): - """HelpResult.text comes from stderr when stdout is empty.""" - with patch("subprocess.run", return_value=_make_cp(stdout=b"", stderr=b"from stderr\n")): - result = get_help("@gamma") - - assert "from stderr" in result.text - - def test_help_result_empty_commands_found(self, multi_branch_registry): - """commands_found is empty when help text has no commands section.""" - with patch("subprocess.run", return_value=_make_cp(stdout=b"No commands here.\n")): - result = get_help("@gamma") - - assert result.commands_found == [] - - def test_help_result_importable_from_package(self): - """HelpResult is importable directly from aipass.drone.""" - from aipass.drone import HelpResult as HR - assert HR is HelpResult - - -# --------------------------------------------------------------------------- -# 3. @all routing: route_all -# --------------------------------------------------------------------------- - - -class TestRouteAll: - """Tests for route_all — fan-out command to all active branches.""" - - def test_route_all_reaches_all_branches(self, multi_branch_registry): - """route_all returns a result for every registered branch.""" - cp = _make_cp(stdout=b"ok\n", returncode=0) - with patch("subprocess.run", return_value=cp): - results = route_all("status") - - assert set(results.keys()) == {"alpha", "beta", "gamma"} - - def test_route_all_returns_command_results(self, multi_branch_registry): - """Each value in route_all output is a CommandResult.""" - cp = _make_cp(stdout=b"running\n", returncode=0) - with patch("subprocess.run", return_value=cp): - results = route_all("status") - - for branch_name, result in results.items(): - assert isinstance(result, CommandResult) - assert result.branch == branch_name - assert result.command == "status" - - def test_route_all_with_args(self, multi_branch_registry): - """route_all forwards extra args to every branch.""" - cp = _make_cp(stdout=b"verbose\n", returncode=0) - with patch("subprocess.run", return_value=cp) as mock_run: - route_all("run", args=["--verbose"]) - - # Every call should include --verbose - for call in mock_run.call_args_list: - cmd_list = call[0][0] - assert "--verbose" in cmd_list - - def test_route_all_continues_on_branch_failure(self, multi_branch_registry): - """route_all continues even when one branch raises CommandExecutionError.""" - call_count = 0 - - def side_effect(*a, **kw): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise CommandExecutionError("branch exploded") - return _make_cp(stdout=b"ok\n", returncode=0) - - with patch("subprocess.run", side_effect=side_effect): - results = route_all("status") - - # All three branches present in results - assert len(results) == 3 - - def test_route_all_failed_branch_has_negative_exit_code(self, multi_branch_registry): - """A branch that raises an error gets exit_code=-1 in route_all result.""" - def side_effect(*a, **kw): - raise CommandExecutionError("no entry point") - - with patch("subprocess.run", side_effect=side_effect): - results = route_all("status") - - for result in results.values(): - assert result.exit_code == -1 - assert result.stderr != "" - - def test_route_all_empty_registry(self, registry): - """route_all returns empty dict when no branches are registered.""" - results = route_all("status") - assert results == {} - - def test_route_all_timeout_per_branch(self, multi_branch_registry): - """route_all forwards the timeout to each branch call.""" - cp = _make_cp(stdout=b"ok\n", returncode=0) - with patch("subprocess.run", return_value=cp) as mock_run: - route_all("status", timeout=5) - - for call in mock_run.call_args_list: - _, kwargs = call - assert kwargs.get("timeout") == 5 - - def test_route_all_importable_from_package(self): - """route_all is importable from aipass.drone.""" - from aipass.drone import route_all as ra - assert callable(ra) - - -# --------------------------------------------------------------------------- -# 4. get_system_help — aggregated help across all branches -# --------------------------------------------------------------------------- - - -class TestGetSystemHelp: - """Tests for get_system_help — fan-out help query across all branches.""" - - def test_system_help_returns_dict(self, multi_branch_registry): - """get_system_help returns a dict keyed by branch name.""" - help_bytes = b"Usage: x.py\n\nCommands:\n status Check\n" - with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)): - results = get_system_help() - - assert isinstance(results, dict) - assert set(results.keys()) == {"alpha", "beta", "gamma"} - - def test_system_help_values_are_help_results(self, multi_branch_registry): - """Each value in get_system_help output is a HelpResult.""" - with patch("subprocess.run", return_value=_make_cp(stdout=b"help\n")): - results = get_system_help() - - for result in results.values(): - assert isinstance(result, HelpResult) - - def test_system_help_skips_failing_branches(self, multi_branch_registry): - """get_system_help omits branches whose help command fails.""" - call_count = 0 - - def side_effect(*a, **kw): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise CommandExecutionError("boom") - return _make_cp(stdout=b"help\n") - - with patch("subprocess.run", side_effect=side_effect): - results = get_system_help() - - # At least two of the three branches should succeed - assert len(results) >= 2 - - def test_system_help_empty_when_no_branches(self, registry): - """get_system_help returns empty dict with no registered branches.""" - results = get_system_help() - assert results == {} - - -# --------------------------------------------------------------------------- -# 5. Error recovery — branches disappear mid-operation -# --------------------------------------------------------------------------- - - -class TestErrorRecovery: - """Tests for graceful handling of unexpected runtime conditions.""" - - def test_resolve_nonexistent_branch_raises(self, registry): - """Resolving an unregistered branch raises BranchNotFoundError.""" - with pytest.raises(BranchNotFoundError): - resolve_branch("@ghost") - - def test_route_command_nonexistent_branch_raises(self, registry): - """Routing to an unregistered branch raises BranchNotFoundError.""" - with pytest.raises(BranchNotFoundError): - route_command("@ghost", "status") - - def test_discover_modules_nonexistent_branch_raises(self, registry): - """discover_modules on unregistered branch raises BranchNotFoundError.""" - with pytest.raises(BranchNotFoundError): - discover_modules("@ghost") - - def test_get_help_nonexistent_branch_raises(self, registry): - """get_help on unregistered branch raises BranchNotFoundError.""" - with pytest.raises(BranchNotFoundError): - get_help("@ghost") - - def test_branch_without_entry_point_raises_on_route(self, tmp_path): - """route_command raises CommandExecutionError when entry point missing.""" - registry_path = tmp_path / "reg.json" - set_registry_path(registry_path) - initialize_registry() - - # Register a branch that has NO apps/{name}.py - no_entry = tmp_path / "bare" - no_entry.mkdir() - register_branch("bare", str(no_entry), "agent") - - with pytest.raises(CommandExecutionError, match="Entry point not found"): - route_command("@bare", "status") - - reset_registry_path() - - def test_branch_without_entry_point_raises_on_help(self, tmp_path): - """get_help raises CommandExecutionError when entry point missing.""" - registry_path = tmp_path / "reg.json" - set_registry_path(registry_path) - initialize_registry() - - no_entry = tmp_path / "bare2" - no_entry.mkdir() - register_branch("bare2", str(no_entry), "agent") - - with pytest.raises(CommandExecutionError, match="Entry point not found"): - get_help("@bare2") - - reset_registry_path() - - -# --------------------------------------------------------------------------- -# 6. Registry persistence across multiple operations -# --------------------------------------------------------------------------- - - -class TestRegistryPersistence: - """Verify the registry file survives multiple read/write cycles.""" - - def test_registry_persists_across_register_calls(self, tmp_path): - """Each register_branch call is durable in the JSON file.""" - registry_path = tmp_path / "persist.json" - set_registry_path(registry_path) - initialize_registry() - - for name in ("p1", "p2", "p3"): - bd = _make_branch(tmp_path, name) - register_branch(name, str(bd), "agent") - - # Read the raw JSON and verify all branches are there - with open(registry_path, encoding="utf-8") as fh: - data = json.load(fh) - - assert "p1" in data["branches"] - assert "p2" in data["branches"] - assert "p3" in data["branches"] - - reset_registry_path() - - def test_registry_metadata_updated_on_write(self, tmp_path): - """Registry metadata.last_updated is refreshed on each write.""" - registry_path = tmp_path / "meta.json" - set_registry_path(registry_path) - initialize_registry() - - bd = _make_branch(tmp_path, "meta_agent") - register_branch("meta_agent", str(bd), "agent") - - with open(registry_path, encoding="utf-8") as fh: - data = json.load(fh) - - assert "last_updated" in data["metadata"] - assert data["metadata"]["managed_by"] == "aipass.drone" - - reset_registry_path() - - def test_list_branches_reflects_all_registrations(self, tmp_path): - """list_branches always reflects the on-disk state.""" - registry_path = tmp_path / "list_test.json" - set_registry_path(registry_path) - initialize_registry() - - bd1 = _make_branch(tmp_path, "lx1") - bd2 = _make_branch(tmp_path, "lx2") - register_branch("lx1", str(bd1), "agent") - - assert len(list_branches()) == 1 - - register_branch("lx2", str(bd2), "agent") - - assert len(list_branches()) == 2 - assert "@lx1" in list_branches() - assert "@lx2" in list_branches() - - reset_registry_path() diff --git a/tests/test_drone_modules.py b/tests/test_drone_modules.py deleted file mode 100644 index 68997ee9..00000000 --- a/tests/test_drone_modules.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -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, - get_module_introspective, - 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") == "" - - -class TestModuleIntrospective: - """Test module introspective (discovery) retrieval.""" - - def test_seedgo_introspective_shows_plugins(self): - """get_module_introspective for seedgo lists discovered plugins.""" - text = get_module_introspective("seedgo") - assert text != "" - assert "SEEDGO" in text - assert "Discovered Plugins" in text - # Should show at least some known plugins - assert "drone-compliance" in text or "no-bare-except" in text - - def test_introspective_shows_version(self): - """Introspective includes the module version.""" - text = get_module_introspective("seedgo") - assert "1.0.0" in text - - def test_introspective_points_to_help(self): - """Introspective tells you how to get help.""" - text = get_module_introspective("seedgo") - assert "--help" in text - - def test_unknown_module_introspective_returns_empty(self): - """get_module_introspective for unknown module returns empty string.""" - assert get_module_introspective("nonexistent") == "" - - def test_introspective_differs_from_help(self): - """Introspective and help return different content.""" - intro = get_module_introspective("seedgo") - help_text = get_module_help("seedgo") - # Both non-empty but different - assert intro != "" - assert help_text != "" - assert intro != help_text - - -# --------------------------------------------------------------------------- -# 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_introspective(self): - """drone @seedgo with no command shows introspective (discovery).""" - code, out, _ = _run_cli("@seedgo") - assert code == 0 - assert "SEEDGO" in out - assert "Discovered Plugins" in out - # Should NOT be the help text (which has "Commands:" section) - assert "Examples:" not 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 diff --git a/tests/test_drone_phase2.py b/tests/test_drone_phase2.py deleted file mode 100644 index ddb30f02..00000000 --- a/tests/test_drone_phase2.py +++ /dev/null @@ -1,546 +0,0 @@ -""" -Unit tests for Phase 2 drone functionality. - -Tests command routing, subprocess execution, and module discovery with >80% -coverage on new code. All subprocess calls are mocked — no real processes run. -""" - -import subprocess -from unittest.mock import MagicMock, patch - -import pytest - -from aipass.drone import ( - BranchNotFoundError, - CommandExecutionError, - CommandResult, - discover_modules, - get_help, - initialize_registry, - register_branch, - reset_registry_path, - route_command, - set_registry_path, -) -from aipass.drone.executor import execute_command -from aipass.drone.router import _find_entry_point - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def temp_registry(tmp_path): - """Temporary registry backed by a tmp directory.""" - registry_path = tmp_path / "test_registry.json" - set_registry_path(registry_path) - initialize_registry() - yield tmp_path - reset_registry_path() - - -@pytest.fixture -def branch_with_entry(tmp_path): - """ - A registered branch that has a valid apps/{name}.py entry point. - - Layout: - tmp_path/ - myagent/ - apps/ - myagent.py - modules/ - status.py - info.py - """ - branch_dir = tmp_path / "myagent" - apps_dir = branch_dir / "apps" - modules_dir = apps_dir / "modules" - modules_dir.mkdir(parents=True) - - entry = apps_dir / "myagent.py" - entry.write_text("# stub entry point\n") - - (modules_dir / "status.py").write_text("") - (modules_dir / "info.py").write_text("") - (modules_dir / "__init__.py").write_text("") - - registry_path = tmp_path / "registry.json" - set_registry_path(registry_path) - initialize_registry() - register_branch("myagent", str(branch_dir), "agent") - - yield branch_dir - - reset_registry_path() - - -@pytest.fixture -def branch_without_entry(tmp_path): - """A registered branch that has NO apps/{name}.py entry point.""" - branch_dir = tmp_path / "noentry" - apps_dir = branch_dir / "apps" - modules_dir = apps_dir / "modules" - modules_dir.mkdir(parents=True) - (modules_dir / "do_thing.py").write_text("") - - registry_path = tmp_path / "registry.json" - set_registry_path(registry_path) - initialize_registry() - register_branch("noentry", str(branch_dir), "agent") - - yield branch_dir - - reset_registry_path() - - -def _make_completed_process(stdout=b"", stderr=b"", returncode=0): - """Helper to build a subprocess.CompletedProcess mock.""" - cp = MagicMock(spec=subprocess.CompletedProcess) - cp.stdout = stdout - cp.stderr = stderr - cp.returncode = returncode - return cp - - -# --------------------------------------------------------------------------- -# CommandResult structure -# --------------------------------------------------------------------------- - - -class TestCommandResult: - """Verify CommandResult is a proper dataclass with expected fields.""" - - def test_fields_present(self): - """CommandResult exposes stdout, stderr, exit_code, branch, command.""" - result = CommandResult( - stdout="hello", - stderr="", - exit_code=0, - branch="myagent", - command="status", - ) - assert result.stdout == "hello" - assert result.stderr == "" - assert result.exit_code == 0 - assert result.branch == "myagent" - assert result.command == "status" - - def test_non_zero_exit_code(self): - """CommandResult stores non-zero exit codes without raising.""" - result = CommandResult( - stdout="", stderr="error", exit_code=1, branch="b", command="c" - ) - assert result.exit_code == 1 - - def test_dataclass_equality(self): - """Two CommandResult instances with identical values compare equal.""" - a = CommandResult("out", "err", 0, "b", "c") - b = CommandResult("out", "err", 0, "b", "c") - assert a == b - - -# --------------------------------------------------------------------------- -# executor.execute_command -# --------------------------------------------------------------------------- - - -class TestExecuteCommand: - """Tests for the low-level subprocess wrapper.""" - - def test_successful_execution(self, tmp_path): - """execute_command returns CommandResult on success.""" - cp = _make_completed_process(stdout=b"ok\n", returncode=0) - with patch("subprocess.run", return_value=cp) as mock_run: - result = execute_command("python3", ["script.py"], cwd=str(tmp_path)) - - assert result.stdout == "ok\n" - assert result.exit_code == 0 - mock_run.assert_called_once() - - def test_no_shell_true(self, tmp_path): - """execute_command never passes shell=True to subprocess.run.""" - cp = _make_completed_process() - with patch("subprocess.run", return_value=cp) as mock_run: - execute_command("python3", ["x.py"], cwd=str(tmp_path)) - - _, kwargs = mock_run.call_args - assert kwargs.get("shell") is False - - def test_timeout_raises_command_execution_error(self, tmp_path): - """TimeoutExpired is wrapped in CommandExecutionError.""" - with patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="python3", timeout=5), - ): - with pytest.raises(CommandExecutionError, match="timed out"): - execute_command("python3", ["x.py"], cwd=str(tmp_path), timeout=5) - - def test_file_not_found_raises_command_execution_error(self, tmp_path): - """FileNotFoundError is wrapped in CommandExecutionError.""" - with patch("subprocess.run", side_effect=FileNotFoundError): - with pytest.raises(CommandExecutionError, match="not found"): - execute_command("nonexistent_bin", [], cwd=str(tmp_path)) - - def test_os_error_raises_command_execution_error(self, tmp_path): - """Generic OSError is wrapped in CommandExecutionError.""" - with patch("subprocess.run", side_effect=OSError("permission denied")): - with pytest.raises(CommandExecutionError, match="OS error"): - execute_command("python3", [], cwd=str(tmp_path)) - - def test_stderr_captured(self, tmp_path): - """execute_command captures stderr separately from stdout.""" - cp = _make_completed_process(stdout=b"", stderr=b"warn\n", returncode=1) - with patch("subprocess.run", return_value=cp): - result = execute_command("python3", ["x.py"], cwd=str(tmp_path)) - - assert result.stderr == "warn\n" - assert result.exit_code == 1 - - def test_branch_and_command_empty_strings(self, tmp_path): - """execute_command sets branch and command to empty strings.""" - cp = _make_completed_process() - with patch("subprocess.run", return_value=cp): - result = execute_command("python3", [], cwd=str(tmp_path)) - - assert result.branch == "" - assert result.command == "" - - def test_timeout_forwarded(self, tmp_path): - """Custom timeout value is passed through to subprocess.run.""" - cp = _make_completed_process() - with patch("subprocess.run", return_value=cp) as mock_run: - execute_command("python3", [], cwd=str(tmp_path), timeout=99) - - _, kwargs = mock_run.call_args - assert kwargs["timeout"] == 99 - - -# --------------------------------------------------------------------------- -# router.route_command -# --------------------------------------------------------------------------- - - -class TestRouteCommand: - """Tests for the high-level command routing function.""" - - def test_route_command_success(self, branch_with_entry): - """route_command returns populated CommandResult on success.""" - cp = _make_completed_process(stdout=b"running\n", returncode=0) - with patch("subprocess.run", return_value=cp): - result = route_command("@myagent", "status") - - assert result.stdout == "running\n" - assert result.exit_code == 0 - assert result.branch == "myagent" - assert result.command == "status" - - def test_route_command_without_at_prefix(self, branch_with_entry): - """route_command accepts branch names without the @ prefix.""" - cp = _make_completed_process(stdout=b"ok\n", returncode=0) - with patch("subprocess.run", return_value=cp): - result = route_command("myagent", "status") - - assert result.branch == "myagent" - - def test_route_command_with_args(self, branch_with_entry): - """route_command forwards extra args to subprocess.""" - cp = _make_completed_process(stdout=b"done\n", returncode=0) - with patch("subprocess.run", return_value=cp) as mock_run: - route_command("@myagent", "run", args=["--verbose", "--dry-run"]) - - call_args = mock_run.call_args[0][0] # positional list - assert "--verbose" in call_args - assert "--dry-run" in call_args - - def test_route_command_missing_branch_raises_error(self, temp_registry): - """route_command raises BranchNotFoundError for unknown branch.""" - with pytest.raises(BranchNotFoundError): - route_command("@ghost", "status") - - def test_route_command_missing_entry_point_raises_error(self, branch_without_entry): - """route_command raises CommandExecutionError when entry point absent.""" - with pytest.raises(CommandExecutionError, match="Entry point not found"): - route_command("@noentry", "status") - - def test_route_command_timeout(self, branch_with_entry): - """route_command propagates timeout as CommandExecutionError.""" - with patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="python3", timeout=1), - ): - with pytest.raises(CommandExecutionError, match="timed out"): - route_command("@myagent", "status", timeout=1) - - def test_route_command_nonzero_exit_does_not_raise(self, branch_with_entry): - """Non-zero exit code is returned, not raised.""" - cp = _make_completed_process(stderr=b"fail\n", returncode=2) - with patch("subprocess.run", return_value=cp): - result = route_command("@myagent", "status") - - assert result.exit_code == 2 - assert result.stderr == "fail\n" - - def test_route_command_default_timeout_is_30(self, branch_with_entry): - """Default timeout forwarded to subprocess is 30 seconds.""" - cp = _make_completed_process() - with patch("subprocess.run", return_value=cp) as mock_run: - route_command("@myagent", "status") - - _, kwargs = mock_run.call_args - assert kwargs["timeout"] == 30 - - -# --------------------------------------------------------------------------- -# router._find_entry_point -# --------------------------------------------------------------------------- - - -class TestFindEntryPoint: - """Tests for the internal entry-point locator.""" - - def test_finds_existing_entry_point(self, branch_with_entry): - """_find_entry_point returns path when entry point exists.""" - ep = _find_entry_point(str(branch_with_entry), "myagent") - assert ep.exists() - assert ep.name == "myagent.py" - - def test_raises_when_entry_point_missing(self, branch_without_entry): - """_find_entry_point raises CommandExecutionError when file absent.""" - with pytest.raises(CommandExecutionError, match="Entry point not found"): - _find_entry_point(str(branch_without_entry), "noentry") - - -# --------------------------------------------------------------------------- -# discovery.discover_modules -# --------------------------------------------------------------------------- - - -class TestDiscoverModules: - """Tests for branch capability discovery.""" - - def test_discover_from_help_output(self, branch_with_entry): - """discover_modules parses commands from --help output.""" - help_output = ( - b"Usage: myagent.py [command]\n\n" - b"Commands:\n" - b" status Show current status\n" - b" deploy Deploy the agent\n" - ) - cp = _make_completed_process(stdout=help_output, returncode=0) - with patch("subprocess.run", return_value=cp): - modules = discover_modules("@myagent") - - assert "status" in modules - assert "deploy" in modules - - def test_discover_falls_back_to_modules_dir(self, branch_with_entry): - """discover_modules falls back to scanning modules/ when help unparseable.""" - cp = _make_completed_process(stdout=b"No commands here.\n", returncode=0) - with patch("subprocess.run", return_value=cp): - modules = discover_modules("@myagent") - - # Falls back to scanning apps/modules/ which contains status.py, info.py - assert "status" in modules - assert "info" in modules - # __init__ is excluded - assert "__init__" not in modules - - def test_discover_falls_back_when_subprocess_fails(self, branch_with_entry): - """discover_modules falls back to modules/ on subprocess OSError.""" - with patch("subprocess.run", side_effect=OSError("boom")): - modules = discover_modules("@myagent") - - assert "status" in modules - assert "info" in modules - - def test_discover_falls_back_on_timeout(self, branch_with_entry): - """discover_modules falls back to modules/ on help timeout.""" - with patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="python3", timeout=10), - ): - modules = discover_modules("@myagent") - - assert isinstance(modules, list) - - def test_discover_missing_branch_raises_error(self, temp_registry): - """discover_modules raises BranchNotFoundError for unknown branch.""" - with pytest.raises(BranchNotFoundError): - discover_modules("@phantom") - - def test_discover_no_entry_no_modules(self, branch_without_entry): - """discover_modules returns empty list when no entry and no modules match help.""" - cp = _make_completed_process(stdout=b"nothing useful\n", returncode=0) - # branch_without_entry has no entry point, so subprocess is never called for - # --help (discovery skips it), but it does have a modules/ dir. - with patch("subprocess.run", return_value=cp): - modules = discover_modules("@noentry") - - # Should find do_thing.py from the modules dir. - assert "do_thing" in modules - - def test_discover_returns_list(self, branch_with_entry): - """discover_modules always returns a list.""" - cp = _make_completed_process(stdout=b"", returncode=0) - with patch("subprocess.run", return_value=cp): - result = discover_modules("@myagent") - - assert isinstance(result, list) - - -# --------------------------------------------------------------------------- -# discovery.get_help -# --------------------------------------------------------------------------- - - -class TestGetHelp: - """Tests for branch help text retrieval (get_help returns HelpResult).""" - - def test_get_help_branch_level(self, branch_with_entry): - """get_help returns HelpResult with text when command is None.""" - help_text = b"Usage: myagent.py [command]\n\nCommands:\n status\n" - cp = _make_completed_process(stdout=help_text, returncode=0) - with patch("subprocess.run", return_value=cp): - result = get_help("@myagent") - - assert "Usage" in result.text - assert result.branch == "myagent" - assert result.command is None - - def test_get_help_command_level(self, branch_with_entry): - """get_help passes the command name when command is specified.""" - help_text = b"Usage: myagent.py status [options]\n" - cp = _make_completed_process(stdout=help_text, returncode=0) - with patch("subprocess.run", return_value=cp) as mock_run: - result = get_help("@myagent", command="status") - - call_args = mock_run.call_args[0][0] - assert "status" in call_args - assert "--help" in call_args - assert "Usage" in result.text - assert result.command == "status" - - def test_get_help_falls_back_to_stderr(self, branch_with_entry): - """get_help uses stderr text when stdout is empty.""" - cp = _make_completed_process(stdout=b"", stderr=b"help via stderr\n", returncode=0) - with patch("subprocess.run", return_value=cp): - result = get_help("@myagent") - - assert "help via stderr" in result.text - - def test_get_help_missing_branch_raises_error(self, temp_registry): - """get_help raises BranchNotFoundError for unknown branch.""" - with pytest.raises(BranchNotFoundError): - get_help("@phantom") - - def test_get_help_missing_entry_point_raises_error(self, branch_without_entry): - """get_help raises CommandExecutionError when entry point absent.""" - with pytest.raises(CommandExecutionError, match="Entry point not found"): - get_help("@noentry") - - def test_get_help_timeout_raises_error(self, branch_with_entry): - """get_help raises CommandExecutionError on timeout.""" - with patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="python3", timeout=10), - ): - with pytest.raises(CommandExecutionError, match="timed out"): - get_help("@myagent") - - def test_get_help_os_error_raises_error(self, branch_with_entry): - """get_help raises CommandExecutionError on OS error.""" - with patch("subprocess.run", side_effect=OSError("no such file")): - with pytest.raises(CommandExecutionError, match="OS error"): - get_help("@myagent") - - def test_get_help_without_at_prefix(self, branch_with_entry): - """get_help accepts branch name without @ prefix.""" - cp = _make_completed_process(stdout=b"help text\n", returncode=0) - with patch("subprocess.run", return_value=cp): - result = get_help("myagent") - - assert "help text" in result.text - - def test_get_help_commands_found_populated(self, branch_with_entry): - """get_help populates commands_found from parsed help text.""" - help_text = b"Usage: myagent.py\n\nCommands:\n status Show status\n deploy Deploy\n" - cp = _make_completed_process(stdout=help_text, returncode=0) - with patch("subprocess.run", return_value=cp): - result = get_help("@myagent") - - assert "status" in result.commands_found - assert "deploy" in result.commands_found - - -# --------------------------------------------------------------------------- -# Executor safety: no shell injection -# --------------------------------------------------------------------------- - - -class TestExecutorSafety: - """Verify the executor never enables shell features.""" - - def test_shell_is_false(self, tmp_path): - """subprocess.run is always called with shell=False.""" - cp = _make_completed_process() - with patch("subprocess.run", return_value=cp) as mock_run: - execute_command("python3", ["script.py", "arg with spaces"], cwd=str(tmp_path)) - - _, kwargs = mock_run.call_args - assert kwargs.get("shell") is False - - def test_args_passed_as_list(self, tmp_path): - """Command is passed as a list, never as a shell string.""" - cp = _make_completed_process() - with patch("subprocess.run", return_value=cp) as mock_run: - execute_command("python3", ["a", "b", "c"], cwd=str(tmp_path)) - - positional_cmd = mock_run.call_args[0][0] - assert isinstance(positional_cmd, list) - - def test_shell_metacharacters_not_interpreted(self, tmp_path): - """Shell metacharacters in args are passed verbatim, not interpreted.""" - cp = _make_completed_process() - with patch("subprocess.run", return_value=cp) as mock_run: - execute_command( - "python3", - ["script.py", "; rm -rf /", "$(evil)"], - cwd=str(tmp_path), - ) - - positional_cmd = mock_run.call_args[0][0] - # Metacharacter strings survive intact as list elements. - assert "; rm -rf /" in positional_cmd - assert "$(evil)" in positional_cmd - - -# --------------------------------------------------------------------------- -# Public API import smoke test -# --------------------------------------------------------------------------- - - -class TestPublicAPIImports: - """Verify all Phase 2 symbols are importable from the top-level package.""" - - def test_route_command_importable(self): - from aipass.drone import route_command - assert callable(route_command) - - def test_discover_modules_importable(self): - from aipass.drone import discover_modules - assert callable(discover_modules) - - def test_get_help_importable(self): - from aipass.drone import get_help - assert callable(get_help) - - def test_command_result_importable(self): - from aipass.drone import CommandResult - assert CommandResult is not None - - def test_version_updated(self): - from aipass.drone import __version__ - assert __version__ == "1.0.0" diff --git a/tests/test_file_module_size.py b/tests/test_file_module_size.py deleted file mode 100644 index 101932f8..00000000 --- a/tests/test_file_module_size.py +++ /dev/null @@ -1,294 +0,0 @@ -""" -Tests for the file-module-size seedgo plugin. - -Covers: - - Files under 400 lines pass - - Files over 400 lines fail with WARNING severity - - Blank lines, comments, and docstrings are excluded from count - - Configurable max_lines threshold - - Fix hints suggest logical split points - - Handles syntax errors and empty files gracefully -""" - -from __future__ import annotations - -import textwrap -from pathlib import Path - -from seedgo.plugins.file_module_size import PLUGIN_NAME, check - - -class TestFileModuleSizePass: - """Files under the size limit should pass.""" - - def test_small_file_passes(self, tmp_path: Path): - """File with 10 code lines passes.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - def add(a, b): - return a + b - - def subtract(a, b): - return a - b - """) - ) - - result = check(str(test_file)) - assert result.plugin == PLUGIN_NAME - assert result.passed is True - - def test_exactly_400_lines_passes(self, tmp_path: Path): - """File with exactly 400 code lines passes.""" - test_file = tmp_path / "test.py" - # Generate exactly 400 lines of code - lines = ["def func():", " pass", ""] - code = "\n".join(lines * 200) # 200 functions * 2 code lines = 400 - test_file.write_text(code) - - result = check(str(test_file)) - assert result.passed is True - assert result.metadata["code_lines"] == 400 - - def test_blank_lines_not_counted(self, tmp_path: Path): - """Blank lines are excluded from code line count.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - def add(a, b): - return a + b - - - def subtract(a, b): - return a - b - - - """) - ) - - result = check(str(test_file)) - # Should count 4 code lines (2 per function), not 8 - assert result.metadata["code_lines"] == 4 - - def test_comments_not_counted(self, tmp_path: Path): - """Comment lines are excluded from code line count.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - # This is a comment - # Another comment - def add(a, b): - # Inline comment - return a + b - # More comments - # Even more - """) - ) - - result = check(str(test_file)) - # Should count 2 code lines (def and return), not 7 - assert result.metadata["code_lines"] == 2 - - def test_docstrings_not_counted(self, tmp_path: Path): - """Docstrings are excluded from code line count.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent('''\ - """ - Module docstring. - This spans multiple lines. - """ - - def add(a, b): - """ - Function docstring. - Also multiple lines. - """ - return a + b - - class Calculator: - """Class docstring.""" - - def multiply(self, a, b): - """Method docstring.""" - return a * b - ''') - ) - - result = check(str(test_file)) - # Should count only actual code lines, not docstrings - # Code lines: def add, return, class Calculator, def multiply, return = 5 - assert result.metadata["code_lines"] == 5 - - def test_empty_file_passes(self, tmp_path: Path): - """Empty file passes.""" - test_file = tmp_path / "test.py" - test_file.write_text("") - - result = check(str(test_file)) - assert result.passed is True - - -class TestFileModuleSizeFail: - """Files over the size limit should fail.""" - - def test_file_over_400_lines_fails(self, tmp_path: Path): - """File with 401 code lines fails.""" - test_file = tmp_path / "test.py" - # Generate 401 lines of code - lines = [] - for i in range(401): - lines.append(f"x{i} = {i}") - test_file.write_text("\n".join(lines)) - - result = check(str(test_file)) - assert result.passed is False - assert result.metadata["code_lines"] == 401 - - violation = result.checks[0] - assert violation.name == "file-size" - assert violation.severity.value == "warning" # WARNING, not ERROR - assert "401" in violation.message - assert "400" in violation.message - - def test_large_file_with_comments_and_blanks(self, tmp_path: Path): - """File with 500 total lines but 401 code lines fails.""" - test_file = tmp_path / "test.py" - lines = [] - for i in range(401): - lines.append(f"x{i} = {i}") - if i % 10 == 0: - lines.append("") # Add blank lines - lines.append(f"# Comment {i}") # Add comments - test_file.write_text("\n".join(lines)) - - result = check(str(test_file)) - assert result.passed is False - # Should count 401 code lines, not the total with blanks and comments - assert result.metadata["code_lines"] == 401 - - def test_overage_reported(self, tmp_path: Path): - """Violation message includes overage amount.""" - test_file = tmp_path / "test.py" - # Generate 450 lines of code (50 over limit) - lines = [f"x{i} = {i}" for i in range(450)] - test_file.write_text("\n".join(lines)) - - result = check(str(test_file)) - violation = result.checks[0] - assert "50 lines" in violation.message or "50" in violation.message - - -class TestFileModuleSizeCustomLimit: - """Custom max_lines config should be respected.""" - - def test_custom_limit_200(self, tmp_path: Path): - """File with 150 lines passes with custom limit of 200.""" - test_file = tmp_path / "test.py" - lines = [f"x{i} = {i}" for i in range(150)] - test_file.write_text("\n".join(lines)) - - result = check(str(test_file), config={"max_lines": 200}) - assert result.passed is True - assert result.metadata["code_lines"] == 150 - assert result.metadata["max_lines"] == 200 - - def test_custom_limit_exceeded(self, tmp_path: Path): - """File with 250 lines fails with custom limit of 200.""" - test_file = tmp_path / "test.py" - lines = [f"x{i} = {i}" for i in range(250)] - test_file.write_text("\n".join(lines)) - - result = check(str(test_file), config={"max_lines": 200}) - assert result.passed is False - assert result.metadata["code_lines"] == 250 - assert result.metadata["max_lines"] == 200 - - -class TestFileModuleSizeFixHints: - """Fix hints should provide actionable suggestions.""" - - def test_fix_hint_for_multiple_classes(self, tmp_path: Path): - """Large file with multiple classes suggests splitting classes.""" - test_file = tmp_path / "test.py" - code = [] - for i in range(5): - code.append(f"class Class{i}:") - for j in range(85): # 85 lines per class = 425 total - code.append(f" def method{j}(self): return {j}") - test_file.write_text("\n".join(code)) - - result = check(str(test_file)) - assert result.passed is False - violation = result.checks[0] - assert "split" in violation.fix_hint.lower() or "class" in violation.fix_hint.lower() - - def test_fix_hint_for_many_functions(self, tmp_path: Path): - """Large file with many functions suggests grouping.""" - test_file = tmp_path / "test.py" - code = [] - for i in range(401): - code.append(f"def func{i}(): return {i}") - test_file.write_text("\n".join(code)) - - result = check(str(test_file)) - assert result.passed is False - violation = result.checks[0] - # Should suggest grouping functions - assert "function" in violation.fix_hint.lower() or "group" in violation.fix_hint.lower() - - def test_fix_hint_generic(self, tmp_path: Path): - """Fix hint provides generic suggestion when no specific pattern found.""" - test_file = tmp_path / "test.py" - # Just a lot of assignments (no classes or functions) - code = [f"x{i} = {i}" for i in range(401)] - test_file.write_text("\n".join(code)) - - result = check(str(test_file)) - violation = result.checks[0] - # Should have some helpful suggestion - assert len(violation.fix_hint) > 0 - assert "module" in violation.fix_hint.lower() or "break" in violation.fix_hint.lower() - - -class TestFileModuleSizeEdgeCases: - """Edge cases and error handling.""" - - def test_file_with_syntax_error(self, tmp_path: Path): - """File with syntax error falls back to simple line counting.""" - test_file = tmp_path / "test.py" - # Syntax error but with many lines - lines = ["def broken(:"] * 401 - test_file.write_text("\n".join(lines)) - - result = check(str(test_file)) - # Should still count lines even with syntax error - assert result.passed is False - - def test_file_read_error(self, tmp_path: Path): - """Non-existent file is skipped gracefully.""" - result = check(str(tmp_path / "nonexistent.py")) - assert result.passed is True - assert result.metadata.get("skipped") is True - assert result.metadata.get("reason") == "file_read_error" - - def test_metadata_includes_counts(self, tmp_path: Path): - """Metadata includes total_lines, code_lines, and max_lines.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - # Comment - - def add(a, b): - return a + b - - """) - ) - - result = check(str(test_file)) - assert "total_lines" in result.metadata - assert "code_lines" in result.metadata - assert "max_lines" in result.metadata - assert result.metadata["total_lines"] == 5 - assert result.metadata["code_lines"] == 2 - assert result.metadata["max_lines"] == 400 diff --git a/tests/test_paths.py b/tests/test_paths.py deleted file mode 100644 index 93173f25..00000000 --- a/tests/test_paths.py +++ /dev/null @@ -1,382 +0,0 @@ -""" -Tests for aipass.paths — root resolution and derived path functions. - -Covers: - - get_root() env var resolution - - get_root() marker walk resolution - - get_root() default fallback - - Priority order (env var beats marker walk, marker walk beats default) - - Invalid env var raises PathResolutionError - - All derived path functions - - branch_path() with a mock registry - - branch_path() returns None for missing branch / missing file -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from aipass.paths import ( - PathResolutionError, - branch_logs_dir, - branch_path, - branch_registry_path, - get_root, - system_logs_dir, -) -from aipass.paths.resolver import _resolve_from_marker - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _write_registry(path: Path, branches: dict) -> None: - """Write a minimal BRANCH_REGISTRY.json to *path*.""" - path.write_text( - json.dumps({"version": "1.0", "branches": branches}), - encoding="utf-8", - ) - - -# --------------------------------------------------------------------------- -# get_root() — environment variable resolution -# --------------------------------------------------------------------------- - - -class TestEnvVarResolution: - """Tier 1: AIPASS_ROOT environment variable.""" - - def test_env_var_valid_directory_is_returned(self, tmp_path, monkeypatch): - """When AIPASS_ROOT points to an existing directory it is returned.""" - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - assert get_root() == tmp_path - - def test_env_var_takes_priority_over_marker(self, tmp_path, monkeypatch): - """Env var wins even when a .aipass/ marker exists in CWD.""" - env_root = tmp_path / "env_root" - env_root.mkdir() - - marker_root = tmp_path / "marker_root" - marker_root.mkdir() - (marker_root / ".aipass").mkdir() - - monkeypatch.setenv("AIPASS_ROOT", str(env_root)) - monkeypatch.chdir(marker_root) - - assert get_root() == env_root - - def test_env_var_nonexistent_raises_path_resolution_error(self, monkeypatch): - """AIPASS_ROOT pointing to a non-existent path raises PathResolutionError.""" - monkeypatch.setenv("AIPASS_ROOT", "/does/not/exist/at/all") - with pytest.raises(PathResolutionError): - get_root() - - def test_env_var_empty_string_falls_through(self, tmp_path, monkeypatch): - """An empty AIPASS_ROOT string is treated as unset (falls through to next tier).""" - # Make a marker so tier 2 picks it up (avoids writing to real home) - marker_root = tmp_path / "marker" - marker_root.mkdir() - (marker_root / ".aipass").mkdir() - - monkeypatch.setenv("AIPASS_ROOT", "") - monkeypatch.chdir(marker_root) - - # Empty string → env var absent → should find marker - result = get_root() - assert result == marker_root - - def test_env_var_unset_falls_through(self, tmp_path, monkeypatch): - """When AIPASS_ROOT is not set at all, env tier returns None and we fall through.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - - marker_root = tmp_path / "marker" - marker_root.mkdir() - (marker_root / ".aipass").mkdir() - monkeypatch.chdir(marker_root) - - result = get_root() - assert result == marker_root - - -# --------------------------------------------------------------------------- -# get_root() — marker walk resolution -# --------------------------------------------------------------------------- - - -class TestMarkerWalkResolution: - """Tier 2: .aipass/ marker directory walk.""" - - def test_marker_in_cwd_is_found(self, tmp_path, monkeypatch): - """A .aipass/ directory in CWD is resolved to CWD.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - (tmp_path / ".aipass").mkdir() - monkeypatch.chdir(tmp_path) - - assert get_root() == tmp_path - - def test_marker_in_parent_is_found(self, tmp_path, monkeypatch): - """A .aipass/ in a parent directory is discovered by walking up.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - - root = tmp_path / "project" - root.mkdir() - (root / ".aipass").mkdir() - - deep = root / "a" / "b" / "c" - deep.mkdir(parents=True) - monkeypatch.chdir(deep) - - assert get_root() == root - - def test_marker_in_grandparent_is_found(self, tmp_path, monkeypatch): - """Walk stops at the nearest ancestor that contains .aipass/.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - - grandparent = tmp_path / "gp" - grandparent.mkdir() - (grandparent / ".aipass").mkdir() - - child = grandparent / "child" / "grandchild" - child.mkdir(parents=True) - monkeypatch.chdir(child) - - assert get_root() == grandparent - - def test_marker_file_not_directory_is_ignored(self, tmp_path, monkeypatch): - """If .aipass exists but is a file (not dir), it should not match.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - - # .aipass as a file, not a directory - (tmp_path / ".aipass").write_text("not a dir") - - # We cannot rely on parent having .aipass, so use custom start to - # test the helper directly - result = _resolve_from_marker(start=tmp_path) - # The file should not match; result will be None or a higher ancestor - # that may or may not have .aipass — what matters is tmp_path itself - # is NOT returned because .aipass there is a file. - if result is not None: - assert result != tmp_path - - def test_explicit_start_directory(self, tmp_path, monkeypatch): - """_resolve_from_marker accepts an explicit start directory.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - - root = tmp_path / "root" - root.mkdir() - (root / ".aipass").mkdir() - - start = root / "sub" - start.mkdir() - - result = _resolve_from_marker(start=start) - assert result == root - - def test_no_marker_returns_none(self, tmp_path, monkeypatch): - """_resolve_from_marker returns None when no .aipass/ is found.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - # tmp_path has no .aipass and neither do its parents (almost certainly) - result = _resolve_from_marker(start=tmp_path) - # We can only assert it does not match tmp_path itself - if result is not None: - assert (result / ".aipass").is_dir() - - -# --------------------------------------------------------------------------- -# get_root() — default fallback -# --------------------------------------------------------------------------- - - -class TestDefaultFallback: - """Tier 3: ~/.aipass/ default fallback.""" - - def test_default_fallback_returns_home_aipass(self, tmp_path, monkeypatch): - """When env var absent and no marker found, default is ~/.aipass/.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - - # Use a temp home with no .aipass marker anywhere in the tree - fake_home = tmp_path / "home" / "user" - fake_home.mkdir(parents=True) - monkeypatch.setenv("HOME", str(fake_home)) - # Patch Path.home() by redirecting the env; also chdir to a location - # with no .aipass marker - isolated = tmp_path / "isolated" - isolated.mkdir() - monkeypatch.chdir(isolated) - - result = get_root() - expected = Path.home() / ".aipass" - assert result == expected - - def test_default_fallback_creates_directory(self, tmp_path, monkeypatch): - """The default fallback creates ~/.aipass/ if it does not exist.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - - fake_home = tmp_path / "fresh_home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - - isolated = tmp_path / "isolated2" - isolated.mkdir() - monkeypatch.chdir(isolated) - - result = get_root() - assert result.is_dir() - - -# --------------------------------------------------------------------------- -# Priority order -# --------------------------------------------------------------------------- - - -class TestPriorityOrder: - """Verify env var > marker walk > default.""" - - def test_env_var_beats_marker_walk(self, tmp_path, monkeypatch): - """Env var takes priority over marker walk.""" - env_root = tmp_path / "env" - env_root.mkdir() - - cwd_with_marker = tmp_path / "cwd" - cwd_with_marker.mkdir() - (cwd_with_marker / ".aipass").mkdir() - - monkeypatch.setenv("AIPASS_ROOT", str(env_root)) - monkeypatch.chdir(cwd_with_marker) - - assert get_root() == env_root - - def test_marker_walk_beats_default(self, tmp_path, monkeypatch): - """Marker walk takes priority over default fallback.""" - monkeypatch.delenv("AIPASS_ROOT", raising=False) - - marker_root = tmp_path / "project" - marker_root.mkdir() - (marker_root / ".aipass").mkdir() - monkeypatch.chdir(marker_root) - - result = get_root() - assert result == marker_root - - -# --------------------------------------------------------------------------- -# Derived path functions -# --------------------------------------------------------------------------- - - -class TestSystemLogsDir: - def test_returns_system_logs_under_root(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - assert system_logs_dir() == tmp_path / "system_logs" - - def test_returns_path_object(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - assert isinstance(system_logs_dir(), Path) - - -class TestBranchRegistryPath: - def test_returns_registry_under_root(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - assert branch_registry_path() == tmp_path / "BRANCH_REGISTRY.json" - - def test_returns_path_object(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - assert isinstance(branch_registry_path(), Path) - - -class TestBranchPath: - """branch_path(name) reads BRANCH_REGISTRY.json and returns a Path or None.""" - - def test_known_branch_returns_path(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - _write_registry( - tmp_path / "BRANCH_REGISTRY.json", - {"vera": {"name": "vera", "path": "/home/aipass/vera", "type": "agent", "status": "active"}}, - ) - result = branch_path("vera") - assert result == Path("/home/aipass/vera") - - def test_unknown_branch_returns_none(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - _write_registry(tmp_path / "BRANCH_REGISTRY.json", {}) - assert branch_path("nonexistent") is None - - def test_missing_registry_returns_none(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - # No registry file written - assert branch_path("vera") is None - - def test_corrupt_registry_returns_none(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - (tmp_path / "BRANCH_REGISTRY.json").write_text("not valid json {{{{", encoding="utf-8") - assert branch_path("vera") is None - - def test_branch_entry_missing_path_key_returns_none(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - _write_registry( - tmp_path / "BRANCH_REGISTRY.json", - {"vera": {"name": "vera", "type": "agent"}}, # no "path" key - ) - assert branch_path("vera") is None - - def test_returns_path_object(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - branch_dir = tmp_path / "branches" / "flow" - branch_dir.mkdir(parents=True) - _write_registry( - tmp_path / "BRANCH_REGISTRY.json", - {"flow": {"name": "flow", "path": str(branch_dir), "type": "agent", "status": "active"}}, - ) - result = branch_path("flow") - assert isinstance(result, Path) - assert result == branch_dir - - def test_multiple_branches_correct_one_returned(self, tmp_path, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) - _write_registry( - tmp_path / "BRANCH_REGISTRY.json", - { - "alpha": {"name": "alpha", "path": "/agents/alpha", "type": "agent", "status": "active"}, - "beta": {"name": "beta", "path": "/agents/beta", "type": "agent", "status": "active"}, - }, - ) - assert branch_path("alpha") == Path("/agents/alpha") - assert branch_path("beta") == Path("/agents/beta") - assert branch_path("gamma") is None - - -class TestBranchLogsDir: - def test_returns_logs_under_branch_dir(self, tmp_path): - result = branch_logs_dir(tmp_path) - assert result == tmp_path / "logs" - - def test_returns_path_object(self, tmp_path): - assert isinstance(branch_logs_dir(tmp_path), Path) - - def test_arbitrary_path(self): - p = Path("/some/branch/dir") - assert branch_logs_dir(p) == Path("/some/branch/dir/logs") - - -# --------------------------------------------------------------------------- -# PathResolutionError -# --------------------------------------------------------------------------- - - -class TestPathResolutionError: - def test_is_exception_subclass(self): - assert issubclass(PathResolutionError, Exception) - - def test_carries_message(self): - err = PathResolutionError("bad path") - assert "bad path" in str(err) - - def test_raised_on_invalid_env_var(self, monkeypatch): - monkeypatch.setenv("AIPASS_ROOT", "/totally/nonexistent/12345") - with pytest.raises(PathResolutionError, match="AIPASS_ROOT"): - get_root() diff --git a/tests/test_seedgo_cli.py b/tests/test_seedgo_cli.py deleted file mode 100644 index aee2e5b1..00000000 --- a/tests/test_seedgo_cli.py +++ /dev/null @@ -1,1207 +0,0 @@ -""" -Seed Go Phase 2 Tests — CLI, Runner, and Reporter - -Covers: - - runner.py: calculate_score, calculate_overall, run_checks, file matching - - reporter.py: human / json / github output formats - - cli.py: init / check / list commands via argparse + subprocess - -All tests use tmp_path for isolation. Mock plugins are written as .py files -into .seedgo/plugins/ — no monkeypatching of discovery needed. - -sys.path fix ensures src/ is importable when running from the repo root: - python -m pytest tests/test_seedgo_cli.py -v -""" - -import json -import subprocess -import sys -from pathlib import Path - -import pytest - -from seedgo.models import CheckItem, CheckResult, Severity -from seedgo.runner import ( - _file_matches_types, - _find_project_files, - calculate_overall, - calculate_score, - run_checks, -) -from seedgo.reporter import report_results - - -# --------------------------------------------------------------------------- -# Shared fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def tmp_project(tmp_path): - """Minimal project root with .seedgo/ directory tree.""" - seedgo_dir = tmp_path / ".seedgo" - seedgo_dir.mkdir() - (seedgo_dir / "plugins").mkdir() - return tmp_path - - -@pytest.fixture -def passing_plugin(tmp_project): - """A plugin that always passes for *.py files.""" - code = '''\ -PLUGIN_NAME = "always-pass" -PLUGIN_DESCRIPTION = "Always returns a passing result" -FILE_TYPES = ["*.py"] - -import sys -_src = str(__import__("pathlib").Path(__file__).parent.parent.parent.parent.parent / "src") -if _src not in sys.path: - sys.path.insert(0, _src) - -from seedgo.models import CheckResult, CheckItem, Severity - -def check(file_path, config=None): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[CheckItem(name="always-passes", passed=True, message="All good")], - score=100, - file_path=file_path, - ) -''' - (tmp_project / ".seedgo" / "plugins" / "always_pass.py").write_text(code) - return tmp_project - - -@pytest.fixture -def failing_plugin(tmp_project): - """A plugin that always fails with one ERROR for *.py files.""" - code = '''\ -PLUGIN_NAME = "always-fail" -PLUGIN_DESCRIPTION = "Always returns a failing result" -FILE_TYPES = ["*.py"] - -import sys -_src = str(__import__("pathlib").Path(__file__).parent.parent.parent.parent.parent / "src") -if _src not in sys.path: - sys.path.insert(0, _src) - -from seedgo.models import CheckResult, CheckItem, Severity - -def check(file_path, config=None): - return CheckResult( - plugin=PLUGIN_NAME, - passed=False, - checks=[ - CheckItem( - name="always-fails", - passed=False, - message="This check always fails", - severity=Severity.ERROR, - line=1, - fix_hint="Cannot fix", - ) - ], - score=0, - file_path=file_path, - ) -''' - (tmp_project / ".seedgo" / "plugins" / "always_fail.py").write_text(code) - return tmp_project - - -@pytest.fixture -def warning_plugin(tmp_project): - """A plugin that returns a WARNING-level failure for *.py files.""" - code = '''\ -PLUGIN_NAME = "warn-plugin" -PLUGIN_DESCRIPTION = "Returns a warning" -FILE_TYPES = ["*.py"] - -import sys -_src = str(__import__("pathlib").Path(__file__).parent.parent.parent.parent.parent / "src") -if _src not in sys.path: - sys.path.insert(0, _src) - -from seedgo.models import CheckResult, CheckItem, Severity - -def check(file_path, config=None): - return CheckResult( - plugin=PLUGIN_NAME, - passed=False, - checks=[ - CheckItem( - name="warns", - passed=False, - message="A warning issue", - severity=Severity.WARNING, - line=5, - ) - ], - score=50, - file_path=file_path, - ) -''' - (tmp_project / ".seedgo" / "plugins" / "warn_plugin.py").write_text(code) - return tmp_project - - -@pytest.fixture -def sample_py_file(tmp_project): - """A Python source file to check.""" - src = tmp_project / "src" - src.mkdir() - f = src / "main.py" - f.write_text("def hello(): pass\n") - return f - - -# --------------------------------------------------------------------------- -# calculate_score tests -# --------------------------------------------------------------------------- - - -class TestCalculateScore: - def test_no_checks_returns_100(self): - result = CheckResult(plugin="p", passed=True, checks=[]) - assert calculate_score(result, {}) == 100 - - def test_all_passing_checks_returns_100(self): - result = CheckResult( - plugin="p", - passed=True, - checks=[ - CheckItem(name="c1", passed=True, message="ok", severity=Severity.ERROR), - CheckItem(name="c2", passed=True, message="ok", severity=Severity.WARNING), - ], - ) - assert calculate_score(result, {}) == 100 - - def test_all_failing_errors_returns_0(self): - result = CheckResult( - plugin="p", - passed=False, - checks=[ - CheckItem(name="c1", passed=False, message="fail", severity=Severity.ERROR), - CheckItem(name="c2", passed=False, message="fail", severity=Severity.ERROR), - ], - ) - assert calculate_score(result, {}) == 0 - - def test_half_errors_failing_returns_50(self): - result = CheckResult( - plugin="p", - passed=False, - checks=[ - CheckItem(name="c1", passed=True, message="ok", severity=Severity.ERROR), - CheckItem(name="c2", passed=False, message="fail", severity=Severity.ERROR), - ], - ) - assert calculate_score(result, {}) == 50 - - def test_warning_has_half_weight_by_default(self): - """One passing ERROR (weight 1.0) + one failing WARNING (weight 0.5) = 1/1.5 * 100 = 66.""" - result = CheckResult( - plugin="p", - passed=False, - checks=[ - CheckItem(name="c1", passed=True, message="ok", severity=Severity.ERROR), - CheckItem(name="c2", passed=False, message="warn", severity=Severity.WARNING), - ], - ) - score = calculate_score(result, {}) - assert score == int((1.0 / 1.5) * 100) # 66 - - def test_info_has_zero_weight_by_default(self): - """INFO items don't affect score — one failing INFO should still give 100.""" - result = CheckResult( - plugin="p", - passed=True, - checks=[ - CheckItem(name="c1", passed=False, message="info", severity=Severity.INFO), - ], - ) - assert calculate_score(result, {}) == 100 - - def test_custom_weights_from_config(self): - """Custom weights override defaults.""" - config = {"error_weight": 2.0, "warning_weight": 1.0, "info_weight": 0.0} - result = CheckResult( - plugin="p", - passed=False, - checks=[ - CheckItem(name="c1", passed=True, message="ok", severity=Severity.ERROR), - CheckItem(name="c2", passed=False, message="fail", severity=Severity.WARNING), - ], - ) - # passed_weight = 2.0 (ERROR passed), total_weight = 2.0 + 1.0 = 3.0 - # score = int(2/3 * 100) = 66 - score = calculate_score(result, config) - assert score == int((2.0 / 3.0) * 100) - - def test_all_info_checks_returns_100(self): - """All INFO (zero weight) → total_weight == 0 → score = 100.""" - result = CheckResult( - plugin="p", - passed=True, - checks=[ - CheckItem(name="i1", passed=False, message="info", severity=Severity.INFO), - CheckItem(name="i2", passed=False, message="info", severity=Severity.INFO), - ], - ) - assert calculate_score(result, {}) == 100 - - -# --------------------------------------------------------------------------- -# calculate_overall tests -# --------------------------------------------------------------------------- - - -class TestCalculateOverall: - def test_empty_results_returns_100_pass(self): - overall = calculate_overall([], {}) - assert overall["overall_score"] == 100 - assert overall["passed"] is True - assert overall["plugins_passed"] == 0 - assert overall["plugins_failed"] == 0 - assert overall["error_count"] == 0 - - def test_single_passing_result(self): - results = [ - CheckResult( - plugin="p", - passed=True, - checks=[CheckItem(name="c", passed=True, message="ok")], - ) - ] - overall = calculate_overall(results, {}) - assert overall["overall_score"] == 100 - assert overall["passed"] is True - assert overall["plugins_passed"] == 1 - assert overall["plugins_failed"] == 0 - - def test_single_failing_result_with_error(self): - results = [ - CheckResult( - plugin="p", - passed=False, - checks=[ - CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR) - ], - ) - ] - overall = calculate_overall(results, {}) - assert overall["overall_score"] == 0 - assert overall["passed"] is False - assert overall["error_count"] == 1 - - def test_error_blocks_pass_even_at_high_score(self): - """An ERROR-severity failure must block pass even if score is above threshold.""" - results = [ - CheckResult( - plugin="p", - passed=False, - checks=[ - CheckItem(name="ok1", passed=True, message="ok", severity=Severity.ERROR), - CheckItem(name="ok2", passed=True, message="ok", severity=Severity.ERROR), - CheckItem(name="ok3", passed=True, message="ok", severity=Severity.ERROR), - CheckItem(name="fail", passed=False, message="fail", severity=Severity.ERROR), - ], - ) - ] - overall = calculate_overall(results, {"threshold": 70}) - # Score = 75 (3/4 * 100), above threshold=70 — but error blocks pass - assert overall["overall_score"] == 75 - assert overall["error_count"] == 1 - assert overall["passed"] is False - - def test_warning_does_not_block_pass(self): - """A WARNING-only failure should allow passing if score >= threshold.""" - results = [ - CheckResult( - plugin="p", - passed=False, - checks=[ - CheckItem(name="ok1", passed=True, message="ok", severity=Severity.ERROR), - CheckItem(name="warn", passed=False, message="warn", severity=Severity.WARNING), - ], - ) - ] - # passed_weight = 1.0, total_weight = 1.5, score = 66 - overall = calculate_overall(results, {"threshold": 60}) - assert overall["error_count"] == 0 - assert overall["warning_count"] == 1 - assert overall["passed"] is True # score 66 >= threshold 60, no errors - - def test_overall_score_is_mean_of_results(self): - """overall_score should be the mean of individual result scores.""" - r1 = CheckResult( - plugin="p1", - passed=True, - checks=[CheckItem(name="c", passed=True, message="ok", severity=Severity.ERROR)], - ) - r2 = CheckResult( - plugin="p2", - passed=False, - checks=[CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)], - ) - overall = calculate_overall([r1, r2], {}) - # r1 score = 100, r2 score = 0, mean = 50 - assert overall["overall_score"] == 50 - - def test_threshold_respected(self): - results = [ - CheckResult( - plugin="p", - passed=True, - checks=[CheckItem(name="c", passed=True, message="ok")], - ) - ] - overall = calculate_overall(results, {"threshold": 90}) - assert overall["threshold"] == 90 - - def test_info_count_tracked(self): - results = [ - CheckResult( - plugin="p", - passed=False, - checks=[ - CheckItem(name="info", passed=False, message="info", severity=Severity.INFO), - ], - ) - ] - overall = calculate_overall(results, {}) - assert overall["info_count"] == 1 - assert overall["error_count"] == 0 - assert overall["warning_count"] == 0 - - def test_plugins_passed_and_failed_counts(self): - results = [ - CheckResult( - plugin="p1", - passed=True, - checks=[CheckItem(name="c", passed=True, message="ok", severity=Severity.ERROR)], - ), - CheckResult( - plugin="p2", - passed=False, - checks=[CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)], - ), - CheckResult( - plugin="p3", - passed=False, - checks=[CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)], - ), - ] - overall = calculate_overall(results, {"threshold": 75}) - assert overall["plugins_passed"] == 1 - assert overall["plugins_failed"] == 2 - - -# --------------------------------------------------------------------------- -# File matching tests -# --------------------------------------------------------------------------- - - -class TestFileMatchesTypes: - def test_py_pattern_matches_py_file(self): - assert _file_matches_types("/path/to/file.py", ["*.py"]) is True - - def test_py_pattern_does_not_match_js_file(self): - assert _file_matches_types("/path/to/file.js", ["*.py"]) is False - - def test_wildcard_matches_any_file(self): - assert _file_matches_types("/path/to/file.anything", ["*"]) is True - - def test_multiple_patterns_any_match(self): - assert _file_matches_types("/path/to/file.ts", ["*.js", "*.ts"]) is True - - def test_no_patterns_returns_false(self): - assert _file_matches_types("/path/to/file.py", []) is False - - def test_specific_filename_match(self): - assert _file_matches_types("/path/to/Makefile", ["Makefile"]) is True - - -class TestFindProjectFiles: - def test_finds_py_files_in_project(self, tmp_project): - src = tmp_project / "src" - src.mkdir() - (src / "main.py").write_text("pass") - (src / "utils.py").write_text("pass") - config = {"paths": {"include": ["."], "exclude": []}} - files = _find_project_files(str(tmp_project), config) - file_names = [Path(f).name for f in files] - assert "main.py" in file_names - assert "utils.py" in file_names - - def test_excludes_patterns(self, tmp_project): - src = tmp_project / "src" - tests = tmp_project / "tests" - src.mkdir() - tests.mkdir() - (src / "main.py").write_text("pass") - (tests / "test_main.py").write_text("pass") - config = {"paths": {"include": ["."], "exclude": ["tests/"]}} - files = _find_project_files(str(tmp_project), config) - file_names = [Path(f).name for f in files] - assert "main.py" in file_names - assert "test_main.py" not in file_names - - def test_specific_include_path(self, tmp_project): - src = tmp_project / "src" - other = tmp_project / "other" - src.mkdir() - other.mkdir() - (src / "main.py").write_text("pass") - (other / "other.py").write_text("pass") - config = {"paths": {"include": ["src"], "exclude": []}} - files = _find_project_files(str(tmp_project), config) - file_names = [Path(f).name for f in files] - assert "main.py" in file_names - assert "other.py" not in file_names - - -# --------------------------------------------------------------------------- -# run_checks integration tests -# --------------------------------------------------------------------------- - - -class TestRunChecks: - def test_no_plugins_returns_empty_results_and_pass(self, tmp_project, sample_py_file): - # Filter to a nonexistent plugin name so builtins are also excluded - results, overall = run_checks(str(tmp_project), files=[str(sample_py_file)], plugins=["nonexistent-plugin-xyz"]) - assert results == [] - assert overall["passed"] is True - assert overall["overall_score"] == 100 - - def test_passing_plugin_gives_pass(self, passing_plugin, sample_py_file): - # Filter to only the local always-pass plugin to isolate from builtins - results, overall = run_checks(str(passing_plugin), files=[str(sample_py_file)], plugins=["always-pass"]) - assert len(results) == 1 - assert results[0].passed is True - assert overall["passed"] is True - - def test_failing_plugin_gives_fail(self, failing_plugin, sample_py_file): - # Filter to only the local always-fail plugin to isolate from builtins - results, overall = run_checks(str(failing_plugin), files=[str(sample_py_file)], plugins=["always-fail"]) - assert len(results) == 1 - assert results[0].passed is False - assert overall["passed"] is False - assert overall["error_count"] == 1 - - def test_plugin_filter_restricts_plugins(self, passing_plugin, failing_plugin, sample_py_file): - """When plugins filter is given, only those plugins should run.""" - # Both plugins are in the same project dir (failing_plugin fixture modifies passing_plugin's dir) - # We need a project with both; use the failing_plugin fixture which wraps passing_plugin - # Actually since fixtures use the same tmp_project, we can't easily combine them. - # Instead test with a fresh project that has one plugin, filtered out. - results, overall = run_checks( - str(passing_plugin), - files=[str(sample_py_file)], - plugins=["nonexistent-plugin"], - ) - assert results == [] - assert overall["passed"] is True - - def test_file_type_filtering(self, passing_plugin): - """Plugin with FILE_TYPES=['*.py'] should not run on .txt files.""" - txt_file = passing_plugin / "src" / "readme.txt" - (passing_plugin / "src").mkdir(exist_ok=True) - txt_file.write_text("hello") - results, overall = run_checks(str(passing_plugin), files=[str(txt_file)]) - assert results == [] - - def test_explicit_files_override_discovery(self, passing_plugin, sample_py_file): - # Filter to only the local always-pass plugin to isolate from builtins - results, overall = run_checks(str(passing_plugin), files=[str(sample_py_file)], plugins=["always-pass"]) - # Only the one file we specified should be checked - assert len(results) == 1 - assert results[0].file_path == str(sample_py_file) - - def test_score_set_on_results(self, passing_plugin, sample_py_file): - results, _ = run_checks(str(passing_plugin), files=[str(sample_py_file)]) - assert results[0].score == 100 - - def test_crashed_plugin_returns_failed_result(self, tmp_project, sample_py_file): - """A plugin that raises an exception should return a failed CheckResult, not crash.""" - code = '''\ -PLUGIN_NAME = "crash-plugin" -PLUGIN_DESCRIPTION = "Always crashes" -FILE_TYPES = ["*.py"] - -def check(file_path, config=None): - raise RuntimeError("kaboom") -''' - (tmp_project / ".seedgo" / "plugins" / "crash_plugin.py").write_text(code) - # Filter to only the crash plugin to isolate from builtins - results, overall = run_checks(str(tmp_project), files=[str(sample_py_file)], plugins=["crash-plugin"]) - assert len(results) == 1 - assert results[0].passed is False - assert results[0].score == 0 - assert "kaboom" in results[0].metadata.get("error", "") - - -# --------------------------------------------------------------------------- -# Reporter tests -# --------------------------------------------------------------------------- - - -class TestReportHuman: - """Human format prints directly via Rich console. Tests capture stdout.""" - - def _make_result(self, passed=True, score=100, checks=None): - return CheckResult( - plugin="test-plugin", - passed=passed, - checks=checks or [], - score=score, - file_path="/path/to/file.py", - ) - - def _overall(self, score=100, passed=True, threshold=75): - return { - "overall_score": score, - "passed": passed, - "threshold": threshold, - "plugins_passed": 1 if passed else 0, - "plugins_failed": 0 if passed else 1, - "error_count": 0, - "warning_count": 0, - "info_count": 0, - } - - def _report_captured(self, results, overall, capsys): - """Call report_results for human format and return captured output.""" - report_results(results, overall, format="human") - return capsys.readouterr().out - - def test_output_contains_plugin_name(self, capsys): - result = self._make_result() - output = self._report_captured([result], self._overall(), capsys) - assert "test-plugin" in output - - def test_passing_result_contains_pass(self, capsys): - result = self._make_result(passed=True, score=100) - output = self._report_captured([result], self._overall(score=100, passed=True), capsys) - assert "PASS" in output - - def test_failing_result_contains_fail(self, capsys): - checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)] - result = self._make_result(passed=False, score=0, checks=checks) - output = self._report_captured([result], self._overall(score=0, passed=False), capsys) - assert "FAIL" in output - - def test_score_shown_in_output(self, capsys): - result = self._make_result(score=75) - output = self._report_captured([result], self._overall(score=75), capsys) - assert "75" in output - - def test_check_item_message_shown(self, capsys): - checks = [CheckItem(name="bare-except", passed=False, message="Found bare except", severity=Severity.ERROR)] - result = self._make_result(passed=False, checks=checks) - output = self._report_captured([result], self._overall(passed=False), capsys) - assert "Found bare except" in output - - def test_fix_hint_shown_for_failing_check(self, capsys): - checks = [ - CheckItem( - name="c", - passed=False, - message="fail", - severity=Severity.ERROR, - fix_hint="Use except Exception:", - ) - ] - result = self._make_result(passed=False, checks=checks) - output = self._report_captured([result], self._overall(passed=False), capsys) - assert "Use except Exception:" in output - - def test_no_results_shows_no_checks_ran(self, capsys): - output = self._report_captured([], self._overall(), capsys) - assert "No checks ran" in output - - def test_threshold_shown_in_summary(self, capsys): - result = self._make_result() - output = self._report_captured([result], self._overall(threshold=80), capsys) - assert "80" in output - - def test_line_number_shown_when_present(self, capsys): - checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR, line=42)] - result = self._make_result(passed=False, checks=checks) - output = self._report_captured([result], self._overall(passed=False), capsys) - assert "42" in output - - def test_invalid_format_raises_value_error(self): - with pytest.raises(ValueError, match="Unknown format"): - report_results([], {}, format="xml") - - -class TestReportJSON: - def test_output_is_valid_json(self): - results = [CheckResult(plugin="p", passed=True, checks=[], score=100, file_path="/f.py")] - overall = { - "overall_score": 100, "passed": True, "threshold": 75, - "plugins_passed": 1, "plugins_failed": 0, - "error_count": 0, "warning_count": 0, "info_count": 0, - } - output = report_results(results, overall, format="json") - data = json.loads(output) - assert isinstance(data, dict) - - def test_json_contains_overall_score(self): - results = [CheckResult(plugin="p", passed=True, checks=[], score=100, file_path="/f.py")] - overall = { - "overall_score": 88, "passed": True, "threshold": 75, - "plugins_passed": 1, "plugins_failed": 0, - "error_count": 0, "warning_count": 0, "info_count": 0, - } - data = json.loads(report_results(results, overall, format="json")) - assert data["overall_score"] == 88 - - def test_json_contains_results_list(self): - results = [CheckResult(plugin="p", passed=True, checks=[], score=100, file_path="/f.py")] - overall = { - "overall_score": 100, "passed": True, "threshold": 75, - "plugins_passed": 1, "plugins_failed": 0, - "error_count": 0, "warning_count": 0, "info_count": 0, - } - data = json.loads(report_results(results, overall, format="json")) - assert "results" in data - assert len(data["results"]) == 1 - assert data["results"][0]["plugin"] == "p" - - def test_json_severity_is_string(self): - """Severity enum must be serialized as string in JSON output.""" - checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)] - results = [CheckResult(plugin="p", passed=False, checks=checks, score=0, file_path="/f.py")] - overall = { - "overall_score": 0, "passed": False, "threshold": 75, - "plugins_passed": 0, "plugins_failed": 1, - "error_count": 1, "warning_count": 0, "info_count": 0, - } - data = json.loads(report_results(results, overall, format="json")) - sev = data["results"][0]["checks"][0]["severity"] - assert isinstance(sev, str) - assert sev == "error" - - def test_json_passed_field_present(self): - results = [] - overall = { - "overall_score": 100, "passed": True, "threshold": 75, - "plugins_passed": 0, "plugins_failed": 0, - "error_count": 0, "warning_count": 0, "info_count": 0, - } - data = json.loads(report_results(results, overall, format="json")) - assert "passed" in data - - -class TestReportGitHub: - def test_error_produces_error_annotation(self): - checks = [ - CheckItem( - name="c", - passed=False, - message="Missing type hint", - severity=Severity.ERROR, - line=42, - ) - ] - results = [CheckResult(plugin="type-hints", passed=False, checks=checks, score=0, file_path="src/main.py")] - overall = { - "overall_score": 0, "passed": False, "threshold": 75, - "plugins_passed": 0, "plugins_failed": 1, - "error_count": 1, "warning_count": 0, "info_count": 0, - } - output = report_results(results, overall, format="github") - assert "::error" in output - assert "Missing type hint" in output - - def test_warning_produces_warning_annotation(self): - checks = [ - CheckItem( - name="c", - passed=False, - message="Class too large", - severity=Severity.WARNING, - line=15, - ) - ] - results = [CheckResult(plugin="no-god-objects", passed=False, checks=checks, score=50, file_path="src/models.py")] - overall = { - "overall_score": 50, "passed": False, "threshold": 75, - "plugins_passed": 0, "plugins_failed": 1, - "error_count": 0, "warning_count": 1, "info_count": 0, - } - output = report_results(results, overall, format="github") - assert "::warning" in output - assert "Class too large" in output - - def test_file_and_line_in_annotation(self): - checks = [ - CheckItem(name="c", passed=False, message="msg", severity=Severity.ERROR, line=7) - ] - results = [CheckResult(plugin="p", passed=False, checks=checks, score=0, file_path="/some/file.py")] - overall = { - "overall_score": 0, "passed": False, "threshold": 75, - "plugins_passed": 0, "plugins_failed": 1, - "error_count": 1, "warning_count": 0, "info_count": 0, - } - output = report_results(results, overall, format="github") - assert "file=/some/file.py" in output - assert "line=7" in output - - def test_passing_checks_not_in_github_output(self): - checks = [ - CheckItem(name="ok", passed=True, message="all good", severity=Severity.ERROR), - ] - results = [CheckResult(plugin="p", passed=True, checks=checks, score=100, file_path="/f.py")] - overall = { - "overall_score": 100, "passed": True, "threshold": 75, - "plugins_passed": 1, "plugins_failed": 0, - "error_count": 0, "warning_count": 0, "info_count": 0, - } - output = report_results(results, overall, format="github") - assert "::error" not in output - assert "::warning" not in output - - def test_no_results_produces_empty_output(self): - overall = { - "overall_score": 100, "passed": True, "threshold": 75, - "plugins_passed": 0, "plugins_failed": 0, - "error_count": 0, "warning_count": 0, "info_count": 0, - } - output = report_results([], overall, format="github") - assert output == "" - - def test_plugin_name_included_in_annotation(self): - checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)] - results = [CheckResult(plugin="my-plugin", passed=False, checks=checks, score=0, file_path="/f.py")] - overall = { - "overall_score": 0, "passed": False, "threshold": 75, - "plugins_passed": 0, "plugins_failed": 1, - "error_count": 1, "warning_count": 0, "info_count": 0, - } - output = report_results(results, overall, format="github") - assert "my-plugin" in output - - -# --------------------------------------------------------------------------- -# CLI tests — via subprocess to test the real entry point -# --------------------------------------------------------------------------- - - - -class TestCLIInit: - def test_init_creates_config_file(self, tmp_path): - subprocess.run( - [sys.executable, "-m", "seedgo.cli"], - input="", - capture_output=True, - text=True, - cwd=str(tmp_path), - env={**__import__("os").environ, "PYTHONPATH": str(Path(__file__).parent.parent / "src")}, - ) - # The above runs cli as module — we need the main() entry - # Actually test by calling main() directly via runner script - pass # Covered by direct function tests below - - def test_init_via_main_creates_config(self, tmp_path): - """Call _cmd_init directly to test config creation.""" - from seedgo.cli import _cmd_init - import argparse - - args = argparse.Namespace(profile=None) - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(tmp_path)) - _cmd_init(args) - finally: - __import__("os").chdir(old_cwd) - - assert (tmp_path / ".seedgo" / "config.json").exists() - - def test_init_with_profile_embeds_profile(self, tmp_path): - from seedgo.cli import _cmd_init - import argparse - - args = argparse.Namespace(profile="python-basic") - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(tmp_path)) - _cmd_init(args) - finally: - __import__("os").chdir(old_cwd) - - config_path = tmp_path / ".seedgo" / "config.json" - data = json.loads(config_path.read_text()) - assert data["profile"] == "python-basic" - - def test_init_fails_if_config_exists(self, tmp_project): - """If config already exists, init should exit with code 1.""" - from seedgo.cli import _cmd_init - import argparse - - (tmp_project / ".seedgo" / "config.json").write_text("{}") - args = argparse.Namespace(profile=None) - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(tmp_project)) - with pytest.raises(SystemExit) as exc_info: - _cmd_init(args) - finally: - __import__("os").chdir(old_cwd) - - assert exc_info.value.code == 1 - - def test_init_creates_plugins_dir(self, tmp_path): - from seedgo.cli import _cmd_init - import argparse - - args = argparse.Namespace(profile=None) - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(tmp_path)) - _cmd_init(args) - finally: - __import__("os").chdir(old_cwd) - - assert (tmp_path / ".seedgo" / "plugins").is_dir() - - -class TestCLICheck: - def test_check_with_no_plugins_exits_0(self, tmp_project, sample_py_file): - """No matching plugins = no failures = exit code 0.""" - from seedgo.cli import _cmd_check - import argparse - - # Filter to a nonexistent plugin so builtins are also excluded - args = argparse.Namespace( - files=[str(sample_py_file)], - format="human", - threshold=None, - plugins=["nonexistent-plugin-xyz"], - ) - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(tmp_project)) - with pytest.raises(SystemExit) as exc_info: - _cmd_check(args) - finally: - __import__("os").chdir(old_cwd) - - assert exc_info.value.code == 0 - - def test_check_with_passing_plugin_exits_0(self, passing_plugin, sample_py_file): - from seedgo.cli import _cmd_check - import argparse - - # Filter to only the local always-pass plugin to isolate from builtins - args = argparse.Namespace( - files=[str(sample_py_file)], - format="human", - threshold=None, - plugins=["always-pass"], - ) - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(passing_plugin)) - with pytest.raises(SystemExit) as exc_info: - _cmd_check(args) - finally: - __import__("os").chdir(old_cwd) - - assert exc_info.value.code == 0 - - def test_check_with_failing_plugin_exits_1(self, failing_plugin, sample_py_file): - from seedgo.cli import _cmd_check - import argparse - - args = argparse.Namespace( - files=[str(sample_py_file)], - format="human", - threshold=None, - plugins=None, - ) - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(failing_plugin)) - with pytest.raises(SystemExit) as exc_info: - _cmd_check(args) - finally: - __import__("os").chdir(old_cwd) - - assert exc_info.value.code == 1 - - def test_check_json_format_produces_valid_json(self, passing_plugin, sample_py_file, capsys): - from seedgo.cli import _cmd_check - import argparse - - args = argparse.Namespace( - files=[str(sample_py_file)], - format="json", - threshold=None, - plugins=None, - ) - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(passing_plugin)) - with pytest.raises(SystemExit): - _cmd_check(args) - finally: - __import__("os").chdir(old_cwd) - - captured = capsys.readouterr() - data = json.loads(captured.out) - assert "overall_score" in data - - def test_check_threshold_override(self, passing_plugin, sample_py_file): - """--threshold 101 should force a fail even with a passing plugin.""" - from seedgo.cli import _cmd_check - import argparse - - args = argparse.Namespace( - files=[str(sample_py_file)], - format="human", - threshold=101, # impossible to reach - plugins=None, - ) - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(passing_plugin)) - with pytest.raises(SystemExit) as exc_info: - _cmd_check(args) - finally: - __import__("os").chdir(old_cwd) - - assert exc_info.value.code == 1 - - def test_check_without_seedgo_dir_exits_1(self, tmp_path, capsys): - """Running check outside a project (no .seedgo/) should exit 1 with error.""" - from seedgo.cli import _cmd_check - import argparse - - args = argparse.Namespace( - files=[], - format="human", - threshold=None, - plugins=None, - ) - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(tmp_path)) - with pytest.raises(SystemExit) as exc_info: - _cmd_check(args) - finally: - __import__("os").chdir(old_cwd) - - assert exc_info.value.code == 1 - - -class TestCLIList: - def test_list_with_no_plugins_prints_message(self, tmp_project, capsys): - # With builtin plugins now shipping, list always shows at least the builtins. - # The test verifies list exits 0 and produces output — specific message - # check updated to reflect that builtins are always discovered. - from seedgo.cli import _cmd_list - import argparse - - args = argparse.Namespace() - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(tmp_project)) - with pytest.raises(SystemExit) as exc_info: - _cmd_list(args) - finally: - __import__("os").chdir(old_cwd) - - captured = capsys.readouterr() - assert exc_info.value.code == 0 - # Builtins are always present now — output should list them - assert "plugin" in captured.out.lower() - - def test_list_shows_plugin_names(self, passing_plugin, capsys): - from seedgo.cli import _cmd_list - import argparse - - args = argparse.Namespace() - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(passing_plugin)) - with pytest.raises(SystemExit): - _cmd_list(args) - finally: - __import__("os").chdir(old_cwd) - - captured = capsys.readouterr() - assert "always-pass" in captured.out - - def test_list_shows_source(self, passing_plugin, capsys): - from seedgo.cli import _cmd_list - import argparse - - args = argparse.Namespace() - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(passing_plugin)) - with pytest.raises(SystemExit): - _cmd_list(args) - finally: - __import__("os").chdir(old_cwd) - - captured = capsys.readouterr() - assert "local" in captured.out - - def test_list_shows_file_types(self, passing_plugin, capsys): - from seedgo.cli import _cmd_list - import argparse - - args = argparse.Namespace() - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(passing_plugin)) - with pytest.raises(SystemExit): - _cmd_list(args) - finally: - __import__("os").chdir(old_cwd) - - captured = capsys.readouterr() - assert "*.py" in captured.out - - def test_list_exits_0(self, tmp_project): - from seedgo.cli import _cmd_list - import argparse - - args = argparse.Namespace() - old_cwd = __import__("os").getcwd() - try: - __import__("os").chdir(str(tmp_project)) - with pytest.raises(SystemExit) as exc_info: - _cmd_list(args) - finally: - __import__("os").chdir(old_cwd) - - assert exc_info.value.code == 0 - - -# --------------------------------------------------------------------------- -# CLI main() argparse dispatch tests -# --------------------------------------------------------------------------- - - -class TestCLIMain: - """Test the main() entry point's argparse dispatch via sys.argv patching.""" - - def test_main_no_args_exits_0(self): - """main() with no command prints help and exits 0.""" - from seedgo.cli import main - - with pytest.MonkeyPatch().context() as mp: - mp.setattr(sys, "argv", ["seedgo"]) - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - - def test_main_init_command_dispatches(self, tmp_path): - """main() with 'init' dispatches to _cmd_init.""" - from seedgo.cli import main - - with pytest.MonkeyPatch().context() as mp: - mp.setattr(sys, "argv", ["seedgo", "init"]) - mp.chdir(tmp_path) - main() - - assert (tmp_path / ".seedgo" / "config.json").exists() - - def test_main_init_with_profile(self, tmp_path): - """main() with 'init --profile python-basic' passes profile through.""" - from seedgo.cli import main - - with pytest.MonkeyPatch().context() as mp: - mp.setattr(sys, "argv", ["seedgo", "init", "--profile", "python-basic"]) - mp.chdir(tmp_path) - main() - - data = json.loads((tmp_path / ".seedgo" / "config.json").read_text()) - assert data["profile"] == "python-basic" - - def test_main_list_command_dispatches(self, tmp_project, capsys): - """main() with 'list' dispatches to _cmd_list.""" - from seedgo.cli import main - - with pytest.MonkeyPatch().context() as mp: - mp.setattr(sys, "argv", ["seedgo", "list"]) - mp.chdir(tmp_project) - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 0 - - def test_main_check_no_seedgo_exits_1(self, tmp_path, capsys): - """main() check outside a project exits 1.""" - from seedgo.cli import main - - with pytest.MonkeyPatch().context() as mp: - mp.setattr(sys, "argv", ["seedgo", "check"]) - mp.chdir(tmp_path) - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 1 - - def test_main_check_with_passing_plugin(self, passing_plugin, sample_py_file): - """main() check with passing plugin exits 0.""" - from seedgo.cli import main - - with pytest.MonkeyPatch().context() as mp: - # Filter to only the local always-pass plugin to isolate from builtins - mp.setattr(sys, "argv", ["seedgo", "check", str(sample_py_file), "--plugin", "always-pass"]) - mp.chdir(passing_plugin) - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 0 - - def test_main_check_github_format(self, tmp_project, sample_py_file, capsys): - """main() check --format github produces annotation-style output (empty if passing).""" - from seedgo.cli import main - - with pytest.MonkeyPatch().context() as mp: - # Filter to a nonexistent plugin so builtins are also excluded — pure format test - mp.setattr(sys, "argv", [ - "seedgo", "check", str(sample_py_file), - "--format", "github", - "--plugin", "nonexistent-plugin-xyz", - ]) - mp.chdir(tmp_project) - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 0 - - def test_main_check_nonexistent_file_exits_1(self, tmp_project, capsys): - """main() check with a file that doesn't exist exits 1.""" - from seedgo.cli import main - - with pytest.MonkeyPatch().context() as mp: - mp.setattr(sys, "argv", ["seedgo", "check", "/nonexistent/file.py"]) - mp.chdir(tmp_project) - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 1 - - -# --------------------------------------------------------------------------- -# Public API export test -# --------------------------------------------------------------------------- - - -class TestPublicAPIPhase2: - def test_run_checks_importable_from_seedgo(self): - from seedgo import run_checks as rc - assert callable(rc) - - def test_run_checks_in_dunder_all(self): - import seedgo - assert "run_checks" in seedgo.__all__ diff --git a/tests/test_seedgo_core.py b/tests/test_seedgo_core.py deleted file mode 100644 index b085490f..00000000 --- a/tests/test_seedgo_core.py +++ /dev/null @@ -1,728 +0,0 @@ -""" -Seed Go Core Framework Tests - -Comprehensive tests for Phase 1 deliverables: - - models.py: CheckResult, CheckItem, Severity - - config.py: load_config, find_project_root, create_default_config, DEFAULT_CONFIG - - discovery.py: discover_plugins and helper functions - - bypass.py: is_bypassed, load_bypass_rules - - exceptions.py: exception hierarchy - -Target: 85%+ coverage of src/seedgo/ core modules. -""" - -import json -from pathlib import Path - -import pytest - -from seedgo.models import CheckItem, CheckResult, Severity -from seedgo.config import ( - DEFAULT_CONFIG, - _deep_merge, - create_default_config, - find_project_root, - load_config, - resolve_file_config, -) -from seedgo.bypass import is_bypassed, load_bypass_rules -from seedgo.discovery import _scan_directory, discover_plugins -from seedgo.exceptions import ConfigError, DiscoveryError, PluginError, SeedGoError - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def tmp_project(tmp_path): - """A temporary project root with a .seedgo/ directory.""" - seedgo_dir = tmp_path / ".seedgo" - seedgo_dir.mkdir() - (seedgo_dir / "plugins").mkdir() - return tmp_path - - -@pytest.fixture -def tmp_project_with_config(tmp_project): - """A temporary project with a minimal .seedgo/config.json.""" - config = { - "version": "1.0.0", - "plugins": { - "enabled": ["test-plugin"], - "disabled": [], - "config": {}, - }, - "scoring": {"threshold": 80}, - } - config_path = tmp_project / ".seedgo" / "config.json" - config_path.write_text(json.dumps(config)) - return tmp_project - - -@pytest.fixture -def simple_plugin_file(tmp_project): - """Write a minimal valid plugin into .seedgo/plugins/.""" - plugin_code = """ -PLUGIN_NAME = "test-plugin" -PLUGIN_DESCRIPTION = "A simple test plugin" -FILE_TYPES = ["*.py"] - -from seedgo.models import CheckResult, CheckItem, Severity - -def check(file_path, config=None): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[CheckItem(name="always-passes", passed=True, message="ok")], - score=100, - file_path=file_path, - ) -""" - plugin_path = tmp_project / ".seedgo" / "plugins" / "test_plugin.py" - plugin_path.write_text(plugin_code) - return plugin_path - - -@pytest.fixture -def bypass_rules_file(tmp_project): - """Write a .seedgo/bypass.json file.""" - rules = { - "version": "1.0.0", - "bypass": [ - { - "file": "src/legacy.py", - "plugin": "no-bare-except", - "reason": "Legacy code", - }, - { - "file": "src/utils.py", - "plugin": "type-hints", - "lines": [10, 20], - "reason": "Line-specific bypass", - }, - ], - } - bypass_path = tmp_project / ".seedgo" / "bypass.json" - bypass_path.write_text(json.dumps(rules)) - return tmp_project - - -# --------------------------------------------------------------------------- -# Severity tests -# --------------------------------------------------------------------------- - - -class TestSeverity: - def test_severity_values(self): - assert Severity.ERROR.value == "error" - assert Severity.WARNING.value == "warning" - assert Severity.INFO.value == "info" - - def test_severity_is_enum(self): - from enum import Enum - assert issubclass(Severity, Enum) - - def test_severity_members(self): - members = {s.name for s in Severity} - assert members == {"ERROR", "WARNING", "INFO"} - - def test_severity_comparison(self): - assert Severity.ERROR == Severity.ERROR - assert Severity.ERROR != Severity.WARNING - - def test_severity_from_value(self): - assert Severity("error") == Severity.ERROR - assert Severity("warning") == Severity.WARNING - assert Severity("info") == Severity.INFO - - -# --------------------------------------------------------------------------- -# CheckItem tests -# --------------------------------------------------------------------------- - - -class TestCheckItem: - def test_required_fields(self): - item = CheckItem(name="test", passed=True, message="all good") - assert item.name == "test" - assert item.passed is True - assert item.message == "all good" - - def test_default_severity_is_error(self): - item = CheckItem(name="x", passed=False, message="fail") - assert item.severity == Severity.ERROR - - def test_custom_severity(self): - item = CheckItem(name="x", passed=False, message="warn", severity=Severity.WARNING) - assert item.severity == Severity.WARNING - - def test_optional_line_defaults_to_none(self): - item = CheckItem(name="x", passed=True, message="ok") - assert item.line is None - - def test_optional_fix_hint_defaults_to_none(self): - item = CheckItem(name="x", passed=True, message="ok") - assert item.fix_hint is None - - def test_all_fields(self): - item = CheckItem( - name="bare-except", - passed=False, - message="Found bare except at line 5", - severity=Severity.WARNING, - line=5, - fix_hint="except Exception:", - ) - assert item.name == "bare-except" - assert item.passed is False - assert item.severity == Severity.WARNING - assert item.line == 5 - assert item.fix_hint == "except Exception:" - - def test_missing_required_field_raises(self): - with pytest.raises(TypeError): - CheckItem(name="x", passed=True) # type: ignore[call-arg] # missing message - - def test_is_dataclass(self): - import dataclasses - assert dataclasses.is_dataclass(CheckItem) - - -# --------------------------------------------------------------------------- -# CheckResult tests -# --------------------------------------------------------------------------- - - -class TestCheckResult: - def test_required_fields(self): - result = CheckResult(plugin="my-plugin", passed=True) - assert result.plugin == "my-plugin" - assert result.passed is True - - def test_checks_defaults_to_empty_list(self): - result = CheckResult(plugin="p", passed=True) - assert result.checks == [] - - def test_score_defaults_to_zero(self): - result = CheckResult(plugin="p", passed=True) - assert result.score == 0 - - def test_file_path_defaults_to_empty_string(self): - result = CheckResult(plugin="p", passed=True) - assert result.file_path == "" - - def test_metadata_defaults_to_empty_dict(self): - result = CheckResult(plugin="p", passed=True) - assert result.metadata == {} - - def test_metadata_is_independent_per_instance(self): - r1 = CheckResult(plugin="p", passed=True) - r2 = CheckResult(plugin="p", passed=True) - r1.metadata["key"] = "val" - assert "key" not in r2.metadata - - def test_checks_is_independent_per_instance(self): - r1 = CheckResult(plugin="p", passed=True) - r2 = CheckResult(plugin="p", passed=True) - r1.checks.append(CheckItem(name="x", passed=True, message="ok")) - assert len(r2.checks) == 0 - - def test_full_construction(self): - items = [ - CheckItem(name="c1", passed=True, message="ok"), - CheckItem(name="c2", passed=False, message="fail", severity=Severity.WARNING), - ] - result = CheckResult( - plugin="no-bare-except", - passed=False, - checks=items, - score=65, - file_path="/path/to/file.py", - metadata={"ast_nodes": 42}, - ) - assert result.plugin == "no-bare-except" - assert result.passed is False - assert len(result.checks) == 2 - assert result.score == 65 - assert result.file_path == "/path/to/file.py" - assert result.metadata["ast_nodes"] == 42 - - def test_is_dataclass(self): - import dataclasses - assert dataclasses.is_dataclass(CheckResult) - - def test_asdict_serializable(self): - import dataclasses - result = CheckResult( - plugin="p", - passed=True, - checks=[CheckItem(name="c", passed=True, message="ok")], - score=100, - ) - d = dataclasses.asdict(result) - assert d["plugin"] == "p" - assert d["passed"] is True - assert d["score"] == 100 - assert d["checks"][0]["name"] == "c" - - -# --------------------------------------------------------------------------- -# Config tests -# --------------------------------------------------------------------------- - - -class TestDefaultConfig: - def test_default_config_has_required_keys(self): - assert "version" in DEFAULT_CONFIG - assert "profile" in DEFAULT_CONFIG - assert "plugins" in DEFAULT_CONFIG - assert "scoring" in DEFAULT_CONFIG - assert "paths" in DEFAULT_CONFIG - assert "overrides" in DEFAULT_CONFIG - - def test_default_profile_is_none(self): - assert DEFAULT_CONFIG["profile"] is None - - def test_default_threshold(self): - assert DEFAULT_CONFIG["scoring"]["threshold"] == 75 - - def test_default_weights(self): - scoring = DEFAULT_CONFIG["scoring"] - assert scoring["error_weight"] == 1.0 - assert scoring["warning_weight"] == 0.5 - assert scoring["info_weight"] == 0.0 - - def test_default_paths_include(self): - assert DEFAULT_CONFIG["paths"]["include"] == ["."] - - def test_default_plugins_empty(self): - plugins = DEFAULT_CONFIG["plugins"] - assert plugins["enabled"] == [] - assert plugins["disabled"] == [] - assert plugins["config"] == {} - - def test_default_overrides_empty(self): - assert DEFAULT_CONFIG["overrides"] == [] - - -class TestLoadConfig: - def test_returns_defaults_when_no_config_file(self, tmp_project): - config = load_config(str(tmp_project)) - assert config["scoring"]["threshold"] == 75 - assert config["profile"] is None - - def test_loads_user_config(self, tmp_project_with_config): - config = load_config(str(tmp_project_with_config)) - assert config["scoring"]["threshold"] == 80 - - def test_merges_user_config_over_defaults(self, tmp_project): - user_cfg = {"scoring": {"threshold": 90}} - (tmp_project / ".seedgo" / "config.json").write_text(json.dumps(user_cfg)) - config = load_config(str(tmp_project)) - assert config["scoring"]["threshold"] == 90 - # Other defaults preserved - assert config["scoring"]["error_weight"] == 1.0 - - def test_raises_config_error_on_invalid_json(self, tmp_project): - (tmp_project / ".seedgo" / "config.json").write_text("{ invalid json }") - with pytest.raises(ConfigError): - load_config(str(tmp_project)) - - def test_plugins_enabled_list_loaded(self, tmp_project): - cfg = {"plugins": {"enabled": ["plugin-a", "plugin-b"]}} - (tmp_project / ".seedgo" / "config.json").write_text(json.dumps(cfg)) - config = load_config(str(tmp_project)) - assert "plugin-a" in config["plugins"]["enabled"] - - def test_default_config_not_mutated(self, tmp_project): - """load_config must return a copy — DEFAULT_CONFIG must stay pristine.""" - import copy - original = copy.deepcopy(DEFAULT_CONFIG) - cfg = {"scoring": {"threshold": 99}} - (tmp_project / ".seedgo" / "config.json").write_text(json.dumps(cfg)) - load_config(str(tmp_project)) - assert DEFAULT_CONFIG == original - - -class TestFindProjectRoot: - def test_finds_root_from_subdir(self, tmp_project): - subdir = tmp_project / "src" / "mypackage" - subdir.mkdir(parents=True) - root = find_project_root(str(subdir)) - assert root == str(tmp_project) - - def test_finds_root_from_file(self, tmp_project): - src = tmp_project / "src" - src.mkdir() - some_file = src / "main.py" - some_file.write_text("pass") - root = find_project_root(str(some_file)) - assert root == str(tmp_project) - - def test_returns_none_when_no_seedgo_dir(self, tmp_path): - subdir = tmp_path / "no_seedgo_here" / "nested" - subdir.mkdir(parents=True) - root = find_project_root(str(subdir)) - assert root is None - - def test_finds_root_from_project_root_itself(self, tmp_project): - root = find_project_root(str(tmp_project)) - assert root == str(tmp_project) - - -class TestCreateDefaultConfig: - def test_creates_config_file(self, tmp_path): - project = tmp_path / "newproject" - project.mkdir() - config_path = create_default_config(str(project)) - assert Path(config_path).exists() - - def test_created_config_is_valid_json(self, tmp_path): - project = tmp_path / "newproject" - project.mkdir() - config_path = create_default_config(str(project)) - with open(config_path) as f: - data = json.load(f) - assert "version" in data - assert "scoring" in data - - def test_creates_seedgo_dir_if_missing(self, tmp_path): - project = tmp_path / "newproject" - project.mkdir() - create_default_config(str(project)) - assert (project / ".seedgo").is_dir() - - def test_creates_plugins_subdir(self, tmp_path): - project = tmp_path / "newproject" - project.mkdir() - create_default_config(str(project)) - assert (project / ".seedgo" / "plugins").is_dir() - - def test_raises_if_config_already_exists(self, tmp_project): - # Write a config so it already exists - (tmp_project / ".seedgo" / "config.json").write_text("{}") - with pytest.raises(ConfigError, match="already exists"): - create_default_config(str(tmp_project)) - - def test_profile_embedded_in_config(self, tmp_path): - project = tmp_path / "newproject" - project.mkdir() - config_path = create_default_config(str(project), profile="python-basic") - with open(config_path) as f: - data = json.load(f) - assert data["profile"] == "python-basic" - - -class TestDeepMerge: - def test_simple_override(self): - base = {"a": 1, "b": 2} - _deep_merge(base, {"b": 99}) - assert base == {"a": 1, "b": 99} - - def test_nested_merge(self): - base = {"scoring": {"threshold": 75, "error_weight": 1.0}} - _deep_merge(base, {"scoring": {"threshold": 90}}) - assert base["scoring"]["threshold"] == 90 - assert base["scoring"]["error_weight"] == 1.0 # preserved - - def test_new_key_added(self): - base = {"a": 1} - _deep_merge(base, {"b": 2}) - assert base["b"] == 2 - - def test_list_replaces_not_merges(self): - base = {"plugins": {"enabled": ["a", "b"]}} - _deep_merge(base, {"plugins": {"enabled": ["c"]}}) - assert base["plugins"]["enabled"] == ["c"] - - -class TestResolveFileConfig: - def test_no_overrides_returns_same_config(self, tmp_project): - config = load_config(str(tmp_project)) - resolved = resolve_file_config(config, str(tmp_project / "src" / "main.py"), str(tmp_project)) - assert resolved["scoring"]["threshold"] == config["scoring"]["threshold"] - - def test_override_applied_for_matching_path(self, tmp_project): - cfg = { - "scoring": {"threshold": 75}, - "overrides": [ - {"paths": ["tests/"], "scoring": {"threshold": 50}} - ], - } - (tmp_project / ".seedgo" / "config.json").write_text(json.dumps(cfg)) - config = load_config(str(tmp_project)) - test_file = str(tmp_project / "tests" / "test_foo.py") - resolved = resolve_file_config(config, test_file, str(tmp_project)) - assert resolved["scoring"]["threshold"] == 50 - - def test_override_not_applied_for_non_matching_path(self, tmp_project): - cfg = { - "scoring": {"threshold": 75}, - "overrides": [ - {"paths": ["tests/"], "scoring": {"threshold": 50}} - ], - } - (tmp_project / ".seedgo" / "config.json").write_text(json.dumps(cfg)) - config = load_config(str(tmp_project)) - src_file = str(tmp_project / "src" / "main.py") - resolved = resolve_file_config(config, src_file, str(tmp_project)) - assert resolved["scoring"]["threshold"] == 75 - - -# --------------------------------------------------------------------------- -# Bypass tests -# --------------------------------------------------------------------------- - - -class TestLoadBypassRules: - def test_returns_empty_list_when_no_file(self, tmp_project): - rules = load_bypass_rules(str(tmp_project)) - assert rules == [] - - def test_loads_rules(self, bypass_rules_file): - rules = load_bypass_rules(str(bypass_rules_file)) - assert len(rules) == 2 - - def test_returns_empty_on_invalid_json(self, tmp_project): - (tmp_project / ".seedgo" / "bypass.json").write_text("{ bad json }") - rules = load_bypass_rules(str(tmp_project)) - assert rules == [] - - def test_returns_empty_when_bypass_key_missing(self, tmp_project): - (tmp_project / ".seedgo" / "bypass.json").write_text('{"version": "1.0.0"}') - rules = load_bypass_rules(str(tmp_project)) - assert rules == [] - - -class TestIsBypassed: - def test_no_rules_returns_false(self): - assert is_bypassed("src/foo.py", "my-plugin", bypass_rules=None) is False - - def test_empty_rules_returns_false(self): - assert is_bypassed("src/foo.py", "my-plugin", bypass_rules=[]) is False - - def test_whole_file_plugin_bypass(self): - rules = [{"file": "src/legacy.py", "plugin": "no-bare-except"}] - assert is_bypassed("src/legacy.py", "no-bare-except", bypass_rules=rules) is True - - def test_different_file_not_bypassed(self): - rules = [{"file": "src/legacy.py", "plugin": "no-bare-except"}] - assert is_bypassed("src/other.py", "no-bare-except", bypass_rules=rules) is False - - def test_different_plugin_not_bypassed(self): - rules = [{"file": "src/legacy.py", "plugin": "no-bare-except"}] - assert is_bypassed("src/legacy.py", "type-hints", bypass_rules=rules) is False - - def test_line_specific_bypass_matching_line(self): - rules = [{"file": "src/utils.py", "plugin": "type-hints", "lines": [10, 20]}] - assert is_bypassed("src/utils.py", "type-hints", line=10, bypass_rules=rules) is True - - def test_line_specific_bypass_non_matching_line(self): - rules = [{"file": "src/utils.py", "plugin": "type-hints", "lines": [10, 20]}] - assert is_bypassed("src/utils.py", "type-hints", line=99, bypass_rules=rules) is False - - def test_line_specific_bypass_no_line_provided(self): - """Line rule requires a matching line — without line=, should not bypass.""" - rules = [{"file": "src/utils.py", "plugin": "type-hints", "lines": [10, 20]}] - assert is_bypassed("src/utils.py", "type-hints", line=None, bypass_rules=rules) is False - - def test_project_root_relative_path(self, tmp_project): - rules = [{"file": "src/legacy.py", "plugin": "no-bare-except"}] - abs_path = str(tmp_project / "src" / "legacy.py") - assert is_bypassed(abs_path, "no-bare-except", bypass_rules=rules, project_root=str(tmp_project)) is True - - def test_multiple_rules_first_match_wins(self): - rules = [ - {"file": "src/foo.py", "plugin": "plugin-a"}, - {"file": "src/foo.py", "plugin": "plugin-b"}, - ] - assert is_bypassed("src/foo.py", "plugin-a", bypass_rules=rules) is True - assert is_bypassed("src/foo.py", "plugin-b", bypass_rules=rules) is True - assert is_bypassed("src/foo.py", "plugin-c", bypass_rules=rules) is False - - -# --------------------------------------------------------------------------- -# Discovery tests -# --------------------------------------------------------------------------- - - -class TestScanDirectory: - def test_returns_empty_for_nonexistent_dir(self, tmp_path): - nonexistent = tmp_path / "does_not_exist" - result = _scan_directory(nonexistent, source="builtin") - assert result == [] - - def test_skips_underscore_files(self, tmp_path): - (tmp_path / "_private.py").write_text("PLUGIN_NAME = 'x'\ndef check(f, c=None): pass") - (tmp_path / "__init__.py").write_text("") - result = _scan_directory(tmp_path, source="builtin") - assert result == [] - - def test_discovers_valid_plugin(self, tmp_path): - plugin_code = "PLUGIN_NAME = 'my-plugin'\ndef check(f, c=None): pass" - (tmp_path / "my_plugin.py").write_text(plugin_code) - result = _scan_directory(tmp_path, source="local") - assert len(result) == 1 - assert result[0]["name"] == "my-plugin" - assert result[0]["source"] == "local" - - def test_skips_file_without_plugin_name(self, tmp_path): - (tmp_path / "not_a_plugin.py").write_text("def check(f, c=None): pass") - result = _scan_directory(tmp_path, source="local") - assert result == [] - - def test_skips_file_without_check_function(self, tmp_path): - (tmp_path / "no_check.py").write_text("PLUGIN_NAME = 'x'") - result = _scan_directory(tmp_path, source="local") - assert result == [] - - def test_skips_broken_plugin_silently(self, tmp_path): - (tmp_path / "broken.py").write_text("raise RuntimeError('boom')") - # Should not raise — broken plugins are silently skipped - result = _scan_directory(tmp_path, source="local") - assert result == [] - - def test_multiple_plugins_discovered(self, tmp_path): - for i in range(3): - code = f"PLUGIN_NAME = 'plugin-{i}'\ndef check(f, c=None): pass" - (tmp_path / f"plugin_{i}.py").write_text(code) - result = _scan_directory(tmp_path, source="builtin") - assert len(result) == 3 - - def test_descriptor_has_required_keys(self, tmp_path): - (tmp_path / "p.py").write_text("PLUGIN_NAME = 'p'\ndef check(f, c=None): pass") - result = _scan_directory(tmp_path, source="local") - assert len(result) == 1 - descriptor = result[0] - assert "name" in descriptor - assert "module" in descriptor - assert "source" in descriptor - assert "path" in descriptor - - -class TestDiscoverPlugins: - def test_returns_list(self, tmp_project): - result = discover_plugins(str(tmp_project)) - assert isinstance(result, list) - - def test_discovers_local_plugin(self, tmp_project, simple_plugin_file): - result = discover_plugins(str(tmp_project)) - names = [p["name"] for p in result] - assert "test-plugin" in names - - def test_local_plugin_source_is_local(self, tmp_project, simple_plugin_file): - result = discover_plugins(str(tmp_project)) - local_plugins = [p for p in result if p["source"] == "local"] - assert len(local_plugins) >= 1 - - def test_deduplication_last_wins(self, tmp_project): - """Two plugins with the same name — the last one (local) wins.""" - # Create a builtin-style plugin by patching the builtin directory - local_code = "PLUGIN_NAME = 'dupe-plugin'\ndef check(f, c=None): return 'local'" - (tmp_project / ".seedgo" / "plugins" / "dupe.py").write_text(local_code) - result = discover_plugins(str(tmp_project)) - dupe = next((p for p in result if p["name"] == "dupe-plugin"), None) - assert dupe is not None - assert dupe["source"] == "local" - - def test_no_project_root_still_works(self): - result = discover_plugins(project_root=None) - assert isinstance(result, list) - - def test_plugin_descriptor_has_module(self, tmp_project, simple_plugin_file): - result = discover_plugins(str(tmp_project)) - plugin = next((p for p in result if p["name"] == "test-plugin"), None) - assert plugin is not None - assert hasattr(plugin["module"], "check") - assert hasattr(plugin["module"], "PLUGIN_NAME") - - -# --------------------------------------------------------------------------- -# Exception hierarchy tests -# --------------------------------------------------------------------------- - - -class TestExceptions: - def test_seedgo_error_is_exception(self): - assert issubclass(SeedGoError, Exception) - - def test_config_error_inherits_seedgo_error(self): - assert issubclass(ConfigError, SeedGoError) - - def test_plugin_error_inherits_seedgo_error(self): - assert issubclass(PluginError, SeedGoError) - - def test_discovery_error_inherits_seedgo_error(self): - assert issubclass(DiscoveryError, SeedGoError) - - def test_config_error_can_be_raised_and_caught(self): - with pytest.raises(ConfigError): - raise ConfigError("bad config") - - def test_plugin_error_can_be_raised_and_caught(self): - with pytest.raises(PluginError): - raise PluginError("bad plugin") - - def test_discovery_error_can_be_raised_and_caught(self): - with pytest.raises(DiscoveryError): - raise DiscoveryError("discovery failed") - - def test_catch_all_via_seedgo_error(self): - """All custom exceptions should be catchable via SeedGoError.""" - for exc_class in (ConfigError, PluginError, DiscoveryError): - with pytest.raises(SeedGoError): - raise exc_class("test") - - def test_error_message_preserved(self): - try: - raise ConfigError("specific message") - except ConfigError as e: - assert "specific message" in str(e) - - -# --------------------------------------------------------------------------- -# Public API / __init__ tests -# --------------------------------------------------------------------------- - - -class TestPublicAPI: - def test_version_is_string(self): - import seedgo - assert isinstance(seedgo.__version__, str) - - def test_version_format(self): - import seedgo - parts = seedgo.__version__.split(".") - assert len(parts) == 3 - assert all(p.isdigit() for p in parts) - - def test_version_is_1_0_0(self): - import seedgo - assert seedgo.__version__ == "1.0.0" - - def test_check_result_importable_from_seedgo(self): - from seedgo import CheckResult - assert CheckResult is not None - - def test_check_item_importable_from_seedgo(self): - from seedgo import CheckItem - assert CheckItem is not None - - def test_severity_importable_from_seedgo(self): - from seedgo import Severity - assert Severity is not None - - def test_discover_plugins_importable_from_seedgo(self): - from seedgo import discover_plugins - assert callable(discover_plugins) - - def test_load_config_importable_from_seedgo(self): - from seedgo import load_config - assert callable(load_config) - - def test_all_exports_listed_in_dunder_all(self): - import seedgo - for name in ["CheckResult", "CheckItem", "Severity", "discover_plugins", "load_config"]: - assert name in seedgo.__all__ diff --git a/tests/test_seedgo_plugins.py b/tests/test_seedgo_plugins.py deleted file mode 100644 index 0e1d1ba2..00000000 --- a/tests/test_seedgo_plugins.py +++ /dev/null @@ -1,765 +0,0 @@ -""" -Seed Go Plugin Tests — Phase 3 - -Tests for all 5 starter plugins: - - no-bare-except - - type-hints-required - - docstring-coverage - - function-length - - file-structure - -Each plugin has: - - A clean-file test (should pass) - - A violation test (should fail) - - Edge case tests (empty files, comments, binary-safe reads) - - Configurable option tests where applicable - -Uses tmp_path for isolated test directories. -""" - -import importlib.util -from pathlib import Path - -import pytest - -_repo_root = Path(__file__).parent.parent - -def _load_plugin(plugin_filename: str | Path): - """Load a plugin module by filename from the plugins directory.""" - plugin_path = _repo_root / "src" / "seedgo" / "plugins" / plugin_filename - spec = importlib.util.spec_from_file_location(Path(plugin_filename).stem, plugin_path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -# --------------------------------------------------------------------------- -# Plugin: no-bare-except -# --------------------------------------------------------------------------- - -class TestNoBareExcept: - @pytest.fixture - def plugin(self): - return _load_plugin(Path("no_bare_except.py")) - - def test_plugin_name(self, plugin): - assert plugin.PLUGIN_NAME == "no-bare-except" - - def test_plugin_has_description(self, plugin): - assert isinstance(plugin.PLUGIN_DESCRIPTION, str) - assert len(plugin.PLUGIN_DESCRIPTION) > 0 - - def test_plugin_has_file_types(self, plugin): - assert plugin.FILE_TYPES == ["*.py"] - - def test_clean_file_passes(self, plugin, tmp_path): - f = tmp_path / "clean.py" - f.write_text( - "try:\n" - " do_something()\n" - "except Exception:\n" - " pass\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - assert result.plugin == "no-bare-except" - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 0 - - def test_bare_except_fails(self, plugin, tmp_path): - f = tmp_path / "bad.py" - f.write_text( - "try:\n" - " do_something()\n" - "except:\n" - " pass\n" - ) - result = plugin.check(str(f)) - assert result.passed is False - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 1 - assert failed[0].line == 3 - - def test_multiple_bare_excepts(self, plugin, tmp_path): - f = tmp_path / "multi.py" - f.write_text( - "try:\n" - " x()\n" - "except:\n" - " pass\n" - "try:\n" - " y()\n" - "except:\n" - " pass\n" - ) - result = plugin.check(str(f)) - assert result.passed is False - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 2 - - def test_bare_except_in_comment_ignored(self, plugin, tmp_path): - f = tmp_path / "comment.py" - f.write_text( - "# except: <- this is just a comment\n" - "x = 1\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - - def test_bare_except_in_string_ignored(self, plugin, tmp_path): - f = tmp_path / "string.py" - f.write_text( - 'x = """This shows except: usage"""\n' - "y = 1\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - - def test_empty_file_passes(self, plugin, tmp_path): - f = tmp_path / "empty.py" - f.write_text("") - result = plugin.check(str(f)) - assert result.passed is True - - def test_file_with_only_comments_passes(self, plugin, tmp_path): - f = tmp_path / "comments.py" - f.write_text( - "# This is a comment\n" - "# except: not a real bare except\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - - def test_nonexistent_file_returns_passing(self, plugin, tmp_path): - result = plugin.check(str(tmp_path / "does_not_exist.py")) - assert result.passed is True - assert result.metadata.get("skipped") is True - - def test_bare_except_with_inline_comment_still_flagged(self, plugin, tmp_path): - f = tmp_path / "inline.py" - f.write_text( - "try:\n" - " x()\n" - "except: # bad practice\n" - " pass\n" - ) - result = plugin.check(str(f)) - assert result.passed is False - - def test_specific_exception_passes(self, plugin, tmp_path): - f = tmp_path / "specific.py" - f.write_text( - "try:\n" - " x()\n" - "except (ValueError, TypeError):\n" - " pass\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - - def test_result_has_file_path(self, plugin, tmp_path): - f = tmp_path / "test.py" - f.write_text("x = 1\n") - result = plugin.check(str(f)) - assert result.file_path == str(f) - - -# --------------------------------------------------------------------------- -# Plugin: type-hints-required -# --------------------------------------------------------------------------- - -class TestTypeHintsRequired: - @pytest.fixture - def plugin(self): - return _load_plugin(Path("type_hints_required.py")) - - def test_plugin_name(self, plugin): - assert plugin.PLUGIN_NAME == "type-hints-required" - - def test_clean_file_passes(self, plugin, tmp_path): - f = tmp_path / "clean.py" - f.write_text( - "def greet(name: str) -> str:\n" - " return f'Hello {name}'\n" - "\n" - "def add(a: int, b: int) -> int:\n" - " return a + b\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 0 - - def test_missing_return_type_fails(self, plugin, tmp_path): - f = tmp_path / "bad.py" - f.write_text( - "def greet(name: str):\n" - " return f'Hello {name}'\n" - ) - result = plugin.check(str(f)) - assert result.passed is False - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 1 - assert "greet" in failed[0].message - - def test_private_function_skipped(self, plugin, tmp_path): - f = tmp_path / "private.py" - f.write_text( - "def _helper():\n" - " return 42\n" - "\n" - "def __dunder():\n" - " return 42\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - - def test_init_skipped(self, plugin, tmp_path): - f = tmp_path / "cls.py" - f.write_text( - "class Foo:\n" - " def __init__(self, x: int):\n" - " self.x = x\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - - def test_dunder_methods_skipped(self, plugin, tmp_path): - f = tmp_path / "dunders.py" - f.write_text( - "class Foo:\n" - " def __str__(self):\n" - " return 'Foo'\n" - " def __repr__(self):\n" - " return 'Foo()'\n" - " def __len__(self):\n" - " return 0\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - - def test_empty_file_passes(self, plugin, tmp_path): - f = tmp_path / "empty.py" - f.write_text("") - result = plugin.check(str(f)) - assert result.passed is True - - def test_async_function_checked(self, plugin, tmp_path): - f = tmp_path / "async_fn.py" - f.write_text( - "async def fetch_data(url: str):\n" - " pass\n" - " pass\n" - " pass\n" - ) - result = plugin.check(str(f)) - assert result.passed is False - failed = [c for c in result.checks if not c.passed] - assert any("fetch_data" in c.message for c in failed) - - def test_async_function_with_return_type_passes(self, plugin, tmp_path): - f = tmp_path / "async_ok.py" - f.write_text( - "async def fetch_data(url: str) -> bytes:\n" - " return b''\n" - ) - result = plugin.check(str(f)) - assert result.passed is True - - def test_nonexistent_file_returns_passing(self, plugin, tmp_path): - result = plugin.check(str(tmp_path / "ghost.py")) - assert result.passed is True - assert result.metadata.get("skipped") is True - - def test_syntax_error_skipped(self, plugin, tmp_path): - f = tmp_path / "bad_syntax.py" - f.write_text("def broken(\n x:\n") - result = plugin.check(str(f)) - assert result.passed is True - assert result.metadata.get("skipped") is True - - def test_violation_has_line_number(self, plugin, tmp_path): - f = tmp_path / "line.py" - f.write_text( - "x = 1\n" - "y = 2\n" - "def my_func(a: int):\n" - " return a\n" - ) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - assert any(c.line == 3 for c in failed) - - def test_violation_has_fix_hint(self, plugin, tmp_path): - f = tmp_path / "hint.py" - f.write_text( - "def process(data: list):\n" - " return data\n" - ) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - assert len(failed) > 0 - assert failed[0].fix_hint is not None - - -# --------------------------------------------------------------------------- -# Plugin: docstring-coverage -# --------------------------------------------------------------------------- - -class TestDocstringCoverage: - @pytest.fixture - def plugin(self): - return _load_plugin(Path("docstring_coverage.py")) - - def test_plugin_name(self, plugin): - assert plugin.PLUGIN_NAME == "docstring-coverage" - - def test_clean_function_passes(self, plugin, tmp_path): - f = tmp_path / "clean.py" - f.write_text( - 'def greet(name: str) -> str:\n' - ' """Greet the user by name."""\n' - ' return f"Hello {name}"\n' - ' return f"Hello {name}"\n' - ) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 0 - - def test_missing_docstring_flagged(self, plugin, tmp_path): - f = tmp_path / "no_doc.py" - f.write_text( - "def process(data: list) -> list:\n" - " result = []\n" - " for item in data:\n" - " result.append(item)\n" - " return result\n" - ) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 1 - assert "process" in failed[0].message - - def test_info_severity(self, plugin, tmp_path): - f = tmp_path / "info.py" - f.write_text( - "def process(data: list) -> list:\n" - " result = []\n" - " for item in data:\n" - " result.append(item)\n" - " return result\n" - ) - result = plugin.check(str(f)) - from seedgo.models import Severity - failed = [c for c in result.checks if not c.passed] - assert all(c.severity == Severity.INFO for c in failed) - - def test_missing_docstring_still_passes_overall(self, plugin, tmp_path): - """INFO violations should not cause overall failure.""" - f = tmp_path / "no_doc.py" - f.write_text( - "def process(data: list) -> list:\n" - " result = []\n" - " for item in data:\n" - " result.append(item)\n" - " return result\n" - ) - result = plugin.check(str(f)) - # Overall passed=True even when there are INFO violations - assert result.passed is True - - def test_private_function_skipped(self, plugin, tmp_path): - f = tmp_path / "private.py" - f.write_text( - "def _helper(x: int) -> int:\n" - " y = x + 1\n" - " z = y * 2\n" - " return z\n" - ) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 0 - - def test_short_function_skipped(self, plugin, tmp_path): - """Functions with < 3 body statements are exempt.""" - f = tmp_path / "short.py" - f.write_text( - "def tiny(x):\n" - " return x\n" - ) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 0 - - def test_class_without_docstring_flagged(self, plugin, tmp_path): - f = tmp_path / "class_no_doc.py" - f.write_text( - "class MyService:\n" - " pass\n" - ) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 1 - assert "MyService" in failed[0].message - - def test_class_with_docstring_passes(self, plugin, tmp_path): - f = tmp_path / "class_doc.py" - f.write_text( - 'class MyService:\n' - ' """Service for handling requests."""\n' - ' pass\n' - ) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - # MyService class should pass - class_fails = [c for c in failed if "MyService" in c.message] - assert len(class_fails) == 0 - - def test_empty_file_passes(self, plugin, tmp_path): - f = tmp_path / "empty.py" - f.write_text("") - result = plugin.check(str(f)) - assert result.passed is True - - def test_nonexistent_file_passes(self, plugin, tmp_path): - result = plugin.check(str(tmp_path / "ghost.py")) - assert result.passed is True - - def test_syntax_error_skipped(self, plugin, tmp_path): - f = tmp_path / "bad.py" - f.write_text("def broken(\n") - result = plugin.check(str(f)) - assert result.passed is True - - -# --------------------------------------------------------------------------- -# Plugin: function-length -# --------------------------------------------------------------------------- - -class TestFunctionLength: - @pytest.fixture - def plugin(self): - return _load_plugin(Path("function_length.py")) - - def test_plugin_name(self, plugin): - assert plugin.PLUGIN_NAME == "function-length" - - def test_short_function_passes(self, plugin, tmp_path): - f = tmp_path / "short.py" - lines = ["def small_func() -> None:\n"] - lines += [f" x_{i} = {i}\n" for i in range(10)] - f.write_text("".join(lines)) - result = plugin.check(str(f)) - assert result.passed is True - - def test_long_function_fails(self, plugin, tmp_path): - f = tmp_path / "long.py" - lines = ["def huge_func() -> None:\n"] - lines += [f" x_{i} = {i}\n" for i in range(55)] - f.write_text("".join(lines)) - result = plugin.check(str(f)) - assert result.passed is False - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 1 - assert "huge_func" in failed[0].message - - def test_default_max_lines_is_50(self, plugin, tmp_path): - """A function of exactly 50 lines should pass.""" - f = tmp_path / "border.py" - lines = ["def border_func() -> None:\n"] - lines += [f" x_{i} = {i}\n" for i in range(49)] - f.write_text("".join(lines)) - result = plugin.check(str(f)) - assert result.passed is True - - def test_function_at_51_lines_fails(self, plugin, tmp_path): - f = tmp_path / "just_over.py" - lines = ["def over_func() -> None:\n"] - lines += [f" x_{i} = {i}\n" for i in range(50)] - f.write_text("".join(lines)) - result = plugin.check(str(f)) - assert result.passed is False - - def test_custom_max_lines_config(self, plugin, tmp_path): - """With max_lines=10, a 15-line function should fail.""" - f = tmp_path / "custom.py" - lines = ["def medium_func() -> None:\n"] - lines += [f" x_{i} = {i}\n" for i in range(15)] - f.write_text("".join(lines)) - result = plugin.check(str(f), config={"max_lines": 10}) - assert result.passed is False - - def test_custom_max_lines_pass(self, plugin, tmp_path): - """With max_lines=100, a 50-line function should pass.""" - f = tmp_path / "custom_pass.py" - lines = ["def medium_func() -> None:\n"] - lines += [f" x_{i} = {i}\n" for i in range(50)] - f.write_text("".join(lines)) - result = plugin.check(str(f), config={"max_lines": 100}) - assert result.passed is True - - def test_multiple_long_functions_all_reported(self, plugin, tmp_path): - f = tmp_path / "multi.py" - lines = [] - for fn in ["func_a", "func_b"]: - lines.append(f"def {fn}() -> None:\n") - lines += [f" x_{i} = {i}\n" for i in range(55)] - f.write_text("".join(lines)) - result = plugin.check(str(f)) - assert result.passed is False - failed = [c for c in result.checks if not c.passed] - assert len(failed) == 2 - - def test_empty_file_passes(self, plugin, tmp_path): - f = tmp_path / "empty.py" - f.write_text("") - result = plugin.check(str(f)) - assert result.passed is True - - def test_nonexistent_file_passes(self, plugin, tmp_path): - result = plugin.check(str(tmp_path / "ghost.py")) - assert result.passed is True - - def test_syntax_error_skipped(self, plugin, tmp_path): - f = tmp_path / "bad.py" - f.write_text("def broken(\n") - result = plugin.check(str(f)) - assert result.passed is True - - def test_violation_has_line_number(self, plugin, tmp_path): - f = tmp_path / "line.py" - lines = ["def long_fn() -> None:\n"] - lines += [f" x_{i} = {i}\n" for i in range(55)] - f.write_text("".join(lines)) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - assert len(failed) > 0 - assert failed[0].line == 1 - - def test_metadata_contains_max_lines(self, plugin, tmp_path): - f = tmp_path / "meta.py" - f.write_text("x = 1\n") - result = plugin.check(str(f), config={"max_lines": 30}) - assert result.metadata.get("max_lines") == 30 - - def test_violation_has_fix_hint(self, plugin, tmp_path): - f = tmp_path / "hint.py" - lines = ["def big_func() -> None:\n"] - lines += [f" x_{i} = {i}\n" for i in range(55)] - f.write_text("".join(lines)) - result = plugin.check(str(f)) - failed = [c for c in result.checks if not c.passed] - assert len(failed) > 0 - assert failed[0].fix_hint is not None - - -# --------------------------------------------------------------------------- -# Plugin: file-structure -# --------------------------------------------------------------------------- - -class TestFileStructure: - @pytest.fixture - def plugin(self): - return _load_plugin(Path("file_structure.py")) - - @pytest.fixture - def project_with_seedgo(self, tmp_path): - """Create a minimal project with .seedgo marker.""" - (tmp_path / ".seedgo").mkdir() - return tmp_path - - def test_plugin_name(self, plugin): - assert plugin.PLUGIN_NAME == "file-structure" - - def test_source_file_in_package_passes(self, plugin, project_with_seedgo): - """A .py file inside a proper package (with __init__.py) should pass.""" - pkg = project_with_seedgo / "src" / "mypackage" - pkg.mkdir(parents=True) - (pkg / "__init__.py").write_text("") - src = pkg / "module.py" - src.write_text("x = 1\n") - result = plugin.check(str(src)) - failed = [c for c in result.checks if not c.passed] - # Should not have missing-init-py violation - init_fails = [c for c in failed if c.name == "missing-init-py"] - assert len(init_fails) == 0 - - def test_test_file_in_tests_dir_passes(self, plugin, project_with_seedgo): - """test_*.py inside tests/ should pass the placement check.""" - tests_dir = project_with_seedgo / "tests" - tests_dir.mkdir() - test_file = tests_dir / "test_module.py" - test_file.write_text("def test_something(): pass\n") - result = plugin.check(str(test_file)) - failed = [c for c in result.checks if not c.passed] - placement_fails = [c for c in failed if c.name == "test-file-placement"] - assert len(placement_fails) == 0 - - def test_test_file_at_root_fails(self, plugin, project_with_seedgo): - """test_*.py at the project root should fail the placement check.""" - test_file = project_with_seedgo / "test_something.py" - test_file.write_text("def test_foo(): pass\n") - result = plugin.check(str(test_file)) - failed = [c for c in result.checks if not c.passed] - placement_fails = [c for c in failed if c.name == "test-file-placement"] - assert len(placement_fails) == 1 - - def test_source_file_at_root_fails(self, plugin, project_with_seedgo): - """A regular .py file at project root (not in allowed_root_files) should fail.""" - src = project_with_seedgo / "my_module.py" - src.write_text("x = 1\n") - result = plugin.check(str(src)) - failed = [c for c in result.checks if not c.passed] - root_fails = [c for c in failed if c.name == "root-python-file"] - assert len(root_fails) == 1 - - def test_allowed_root_file_passes(self, plugin, project_with_seedgo): - """setup.py at root is allowed by default.""" - setup = project_with_seedgo / "setup.py" - setup.write_text("from setuptools import setup\nsetup()\n") - result = plugin.check(str(setup)) - failed = [c for c in result.checks if not c.passed] - root_fails = [c for c in failed if c.name == "root-python-file"] - assert len(root_fails) == 0 - - def test_custom_allowed_root_files(self, plugin, project_with_seedgo): - """Custom allowed_root_files config should be respected.""" - custom = project_with_seedgo / "fabfile.py" - custom.write_text("x = 1\n") - # With default config, fabfile.py is already in DEFAULT_ALLOWED_ROOT_FILES - # so let's test a truly custom file - myfile = project_with_seedgo / "myapp.py" - myfile.write_text("x = 1\n") - # Without config — should fail - result_no_config = plugin.check(str(myfile)) - failed_no = [c for c in result_no_config.checks if not c.passed and c.name == "root-python-file"] - assert len(failed_no) == 1 - # With custom config allowing myapp.py — should pass - result_with_config = plugin.check(str(myfile), config={"allowed_root_files": ["myapp.py"]}) - failed_yes = [c for c in result_with_config.checks if not c.passed and c.name == "root-python-file"] - assert len(failed_yes) == 0 - - def test_missing_init_py_flagged(self, plugin, project_with_seedgo): - """A package directory without __init__.py should be flagged.""" - pkg = project_with_seedgo / "src" / "mypackage" - pkg.mkdir(parents=True) - # Note: no __init__.py - src = pkg / "module.py" - src.write_text("x = 1\n") - # Also create another .py so the dir clearly has sources - (pkg / "other.py").write_text("y = 1\n") - result = plugin.check(str(src)) - failed = [c for c in result.checks if not c.passed] - init_fails = [c for c in failed if c.name == "missing-init-py"] - assert len(init_fails) == 1 - - def test_nonexistent_file_passes(self, plugin, tmp_path): - result = plugin.check(str(tmp_path / "ghost.py")) - assert result.passed is True - - def test_conftest_at_root_passes(self, plugin, project_with_seedgo): - """conftest.py is a standard root-level file.""" - conf = project_with_seedgo / "conftest.py" - conf.write_text("import pytest\n") - result = plugin.check(str(conf)) - failed = [c for c in result.checks if not c.passed] - root_fails = [c for c in failed if c.name == "root-python-file"] - assert len(root_fails) == 0 - - def test_result_has_plugin_name(self, plugin, tmp_path): - f = tmp_path / "x.py" - f.write_text("") - result = plugin.check(str(f)) - assert result.plugin == "file-structure" - - -# --------------------------------------------------------------------------- -# Plugin discovery integration tests -# --------------------------------------------------------------------------- - -class TestPluginDiscovery: - def test_all_plugins_discoverable(self): - """All 5 plugins should be discovered as built-ins.""" - from seedgo.discovery import discover_plugins - plugins = discover_plugins() - names = {p["name"] for p in plugins} - assert "no-bare-except" in names - assert "type-hints-required" in names - assert "docstring-coverage" in names - assert "function-length" in names - assert "file-structure" in names - - def test_all_plugins_have_required_attributes(self): - """Every discovered plugin must have PLUGIN_NAME, check(), and FILE_TYPES.""" - from seedgo.discovery import discover_plugins - plugins = discover_plugins() - for p in plugins: - module = p["module"] - assert isinstance(getattr(module, "PLUGIN_NAME", None), str) - assert callable(getattr(module, "check", None)) - assert isinstance(getattr(module, "FILE_TYPES", None), list) - - def test_plugins_return_check_result(self, tmp_path): - """Every plugin's check() must return a CheckResult instance.""" - from seedgo.discovery import discover_plugins - from seedgo.models import CheckResult - - # Create a minimal Python file to run checks on - test_file = tmp_path / "test_subject.py" - test_file.write_text("x = 1\n") - - plugins = discover_plugins() - for p in plugins: - module = p["module"] - result = module.check(str(test_file)) - assert isinstance(result, CheckResult), ( - f"Plugin {p['name']} returned {type(result)} instead of CheckResult" - ) - - def test_runner_executes_all_plugins(self, tmp_path): - """run_checks should execute all enabled plugins.""" - import json - from seedgo.runner import run_checks - - # Set up minimal project - seedgo_dir = tmp_path / ".seedgo" - seedgo_dir.mkdir() - (seedgo_dir / "plugins").mkdir() - - config = { - "version": "1.0.0", - "plugins": { - "enabled": [ - "no-bare-except", - "type-hints-required", - "docstring-coverage", - "function-length", - "file-structure", - ], - "disabled": [], - "config": {}, - }, - "scoring": {"threshold": 75}, - "paths": {"include": ["."], "exclude": []}, - "overrides": [], - } - (seedgo_dir / "config.json").write_text(json.dumps(config)) - - # Create a simple Python source file - src = tmp_path / "hello.py" - src.write_text( - '"""A simple module."""\n' - "\n" - "def hello() -> str:\n" - ' """Say hello."""\n' - " return 'Hello'\n" - ) - - results, overall = run_checks(str(tmp_path), files=[str(src)]) - assert isinstance(results, list) - assert len(results) > 0 - - plugin_names_run = {r.plugin for r in results} - # At least some of our plugins should have run - assert len(plugin_names_run) > 0 diff --git a/tests/test_silent_failure_detection.py b/tests/test_silent_failure_detection.py deleted file mode 100644 index d8baf68a..00000000 --- a/tests/test_silent_failure_detection.py +++ /dev/null @@ -1,317 +0,0 @@ -""" -Tests for the silent-failure-detection seedgo plugin. - -Covers: - - Detects except: pass patterns (bare except with only pass) - - Detects except Exception: pass patterns - - Does not flag except with logging - - Does not flag except with re-raise - - Does not flag except with meaningful handling - - Provides helpful fix hints - - Handles syntax errors and empty files gracefully -""" - -from __future__ import annotations - -import textwrap -from pathlib import Path - -from seedgo.plugins.silent_failure_detection import PLUGIN_NAME, check - - -class TestSilentFailureDetectionPass: - """Code without silent failures should pass.""" - - def test_no_exceptions_passes(self, tmp_path: Path): - """File with no exception handling passes.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - def add(a, b): - return a + b - """) - ) - - result = check(str(test_file)) - assert result.plugin == PLUGIN_NAME - assert result.passed is True - assert result.metadata["violations_found"] == 0 - - def test_except_with_logging_passes(self, tmp_path: Path): - """Exception handler with logging passes.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - import logging - - try: - risky_operation() - except Exception: - logging.error("Operation failed", exc_info=True) - """) - ) - - result = check(str(test_file)) - assert result.passed is True - - def test_except_with_reraise_passes(self, tmp_path: Path): - """Exception handler that re-raises passes.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - risky_operation() - except Exception as e: - cleanup() - raise - """) - ) - - result = check(str(test_file)) - assert result.passed is True - - def test_except_with_meaningful_handling_passes(self, tmp_path: Path): - """Exception handler with meaningful code passes.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - risky_operation() - except ValueError: - result = None - continue - """) - ) - - result = check(str(test_file)) - assert result.passed is True - - def test_specific_exception_with_comment_and_pass_passes(self, tmp_path: Path): - """Exception handler with pass and comment passes (comment shows intent).""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - os.remove(temp_file) - except FileNotFoundError: - # File already deleted, this is expected - pass - """) - ) - - # Note: This currently FAILS because we check for pass regardless of comments. - # This is actually CORRECT behavior - the plugin should flag it as ERROR - # because comments in code are not the same as proper logging. - # The fix_hint tells them to add logging even if there's a comment. - result = check(str(test_file)) - assert result.passed is False # This is the CORRECT behavior - - def test_empty_file_passes(self, tmp_path: Path): - """Empty file passes.""" - test_file = tmp_path / "test.py" - test_file.write_text("") - - result = check(str(test_file)) - assert result.passed is True - - def test_file_with_syntax_error_skipped(self, tmp_path: Path): - """File with syntax error is skipped.""" - test_file = tmp_path / "test.py" - test_file.write_text("def broken(:\n") - - result = check(str(test_file)) - assert result.passed is True - assert result.metadata.get("skipped") is True - assert result.metadata.get("reason") == "syntax_error" - - -class TestSilentFailureDetectionFail: - """Code with silent failure patterns should fail.""" - - def test_bare_except_with_pass_fails(self, tmp_path: Path): - """Bare except: pass is flagged as ERROR.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - risky_operation() - except: - pass - """) - ) - - result = check(str(test_file)) - assert result.passed is False - assert result.metadata["violations_found"] == 1 - - violation = result.checks[0] - assert violation.name == "silent-failure" - assert violation.passed is False - assert violation.severity.value == "error" - assert violation.line == 3 - assert "except:" in violation.message - assert "pass" in violation.message - - def test_exception_with_pass_fails(self, tmp_path: Path): - """except Exception: pass is flagged as ERROR.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - risky_operation() - except Exception: - pass - """) - ) - - result = check(str(test_file)) - assert result.passed is False - assert result.metadata["violations_found"] == 1 - - violation = result.checks[0] - assert violation.name == "silent-failure" - assert "except Exception:" in violation.message - - def test_specific_exception_with_pass_fails(self, tmp_path: Path): - """except ValueError: pass is flagged as ERROR.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - int("not a number") - except ValueError: - pass - """) - ) - - result = check(str(test_file)) - assert result.passed is False - - def test_multiple_exceptions_with_pass_fails(self, tmp_path: Path): - """except (ValueError, TypeError): pass is flagged.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - risky_operation() - except (ValueError, TypeError): - pass - """) - ) - - result = check(str(test_file)) - assert result.passed is False - violation = result.checks[0] - assert "ValueError, TypeError" in violation.message - - def test_multiple_silent_failures_detected(self, tmp_path: Path): - """Multiple silent failures in one file are all detected.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - operation1() - except: - pass - - try: - operation2() - except Exception: - pass - - try: - operation3() - except ValueError: - pass - """) - ) - - result = check(str(test_file)) - assert result.passed is False - assert result.metadata["violations_found"] == 3 - assert len(result.checks) == 3 - - -class TestSilentFailureDetectionFixHints: - """Fix hints should be helpful and actionable.""" - - def test_bare_except_fix_hint(self, tmp_path: Path): - """Bare except should suggest replacing with except Exception: and adding logging.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - risky() - except: - pass - """) - ) - - result = check(str(test_file)) - violation = result.checks[0] - assert "except Exception:" in violation.fix_hint - assert "logging" in violation.fix_hint.lower() - - def test_specific_exception_fix_hint(self, tmp_path: Path): - """Specific exception should suggest adding logging.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - risky() - except ValueError: - pass - """) - ) - - result = check(str(test_file)) - violation = result.checks[0] - assert "logging" in violation.fix_hint.lower() - assert "comment" in violation.fix_hint.lower() - - -class TestSilentFailureDetectionEdgeCases: - """Edge cases and special scenarios.""" - - def test_nested_try_except(self, tmp_path: Path): - """Nested try/except blocks are both checked.""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - try: - inner_operation() - except: - pass - except: - pass - """) - ) - - result = check(str(test_file)) - assert result.passed is False - # Should detect both the inner and outer silent failures - assert result.metadata["violations_found"] == 2 - - def test_except_with_only_multiple_pass_statements(self, tmp_path: Path): - """Exception handler with only multiple pass statements (unusual but possible).""" - test_file = tmp_path / "test.py" - test_file.write_text( - textwrap.dedent("""\ - try: - risky() - except Exception: - pass - pass - """) - ) - - result = check(str(test_file)) - assert result.passed is False - - def test_file_read_error_skipped(self, tmp_path: Path): - """Non-existent file is skipped gracefully.""" - result = check(str(tmp_path / "nonexistent.py")) - assert result.passed is True - assert result.metadata.get("skipped") is True - assert result.metadata.get("reason") == "file_read_error" diff --git a/tests/test_version.py b/tests/test_version.py deleted file mode 100644 index 82da2f04..00000000 --- a/tests/test_version.py +++ /dev/null @@ -1,5 +0,0 @@ -from aipass import __version__ - - -def test_version(): - assert __version__ == "1.0.0"