refactor(seedgo): archive pre-DPLAN-0184 hook cruft (FPLAN-0241)
Archive orphaned hook bridge/probe/manifest modules + their tests to .archive/ — their only callers were the .claude/hooks scripts disabled by DPLAN-0184. Seedgo audits hooks via standards; the hooks branch owns the engine/bridge/handlers. README + bypass.json + prompts updated to match. 1045 tests green, pyright clean.
This commit is contained in:
@@ -24,8 +24,6 @@ The **hooks branch** (`src/aipass/hooks/`) owns all hook infrastructure — engi
|
||||
|
||||
Provider settings route all events through the bridge: `src/aipass/hooks/apps/handlers/bridges/claude.py <Event>:<handler>`. The bridge dispatches to native Python handlers in `hooks/apps/handlers/` (prompt, security, lifecycle, notification categories).
|
||||
|
||||
Seedgo's bridge installer (`drone @seedgo bridge install`) manages hook installation to `~/.claude/settings.json`.
|
||||
|
||||
## Apps Layout (extra layer vs standard branch)
|
||||
|
||||
```
|
||||
|
||||
@@ -10,11 +10,6 @@
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — outside the 3-layer apps/ structure by design."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_hooks_probe.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — outside the 3-layer apps/ structure by design."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_checkers_batch5.py",
|
||||
"standard": "encapsulation",
|
||||
@@ -245,26 +240,6 @@
|
||||
"standard": "unused_function",
|
||||
"reason": "is_trusted_caller() and identify_caller() are public API consumed by pre_edit_gate.py (hook layer) and drone auth.py. Checker cannot trace cross-file dynamic dispatch to the hook scripts."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/hooks/bridge_handler.py",
|
||||
"standard": "help_text",
|
||||
"reason": "python3 references appear in AIPASS_HOOK_MANIFEST command strings (hook definitions), not user-facing help text. Data constants, not documentation."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_hook_bridge.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — outside the 3-layer apps/ structure by design."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_hook_bridge.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly to test them in isolation. Same pattern as test_checkers_batch5.py."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_hook_bridge.py",
|
||||
"standard": "help_text",
|
||||
"reason": "python3 references appear in test data strings (hook command fixtures), not user-facing help text."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_coverage_audit.py",
|
||||
"standard": "architecture",
|
||||
|
||||
@@ -16,12 +16,10 @@
|
||||
- Score files 0-100 per standard and report violations with actionable details
|
||||
- Manage bypass rules (`.seedgo/bypass.json`) for deliberate exceptions
|
||||
- Run pyright diagnostics across branches for type error detection
|
||||
- Hook bridge installer (`drone @seedgo bridge install`) for `~/.claude/settings.json` management
|
||||
- Single-file checklist validation against all standards (consumed by PostToolUse auto-fix hook)
|
||||
- Proof certification via proof/proof_query (triplet, plugin integrity, README currency)
|
||||
- Custom function test coverage mapping via test_map
|
||||
- README auto-generation and freshness checking
|
||||
- Hook testing, listing, and probe infrastructure
|
||||
|
||||
### What I Don't Do
|
||||
- Runtime monitoring (that's prax)
|
||||
@@ -65,17 +63,6 @@ drone @seedgo test_map @seedgo # Function-level test cov
|
||||
|
||||
# README
|
||||
drone @seedgo readme update @flow # README auto-generation for a branch
|
||||
|
||||
# Hooks
|
||||
drone @seedgo hooks test # Run hook test suite (pytest per file)
|
||||
drone @seedgo hooks list # Show all wired hooks from settings
|
||||
drone @seedgo hooks probe # Hook event probe infrastructure
|
||||
|
||||
# Bridge
|
||||
drone @seedgo bridge install # Install AIPass hooks to ~/.claude/settings.json
|
||||
drone @seedgo bridge uninstall # Remove AIPass hooks
|
||||
drone @seedgo bridge reinstall # Clean reinstall
|
||||
drone @seedgo bridge status # Show hook installation status
|
||||
```
|
||||
|
||||
### Via Python Module
|
||||
@@ -97,21 +84,18 @@ seedgo/
|
||||
│ ├── seedgo.py # Entry point — thin router (~290 lines)
|
||||
│ │ # discover_modules() loads apps/modules/*.py
|
||||
│ │ # route_command() dispatches to first handler returning True
|
||||
│ ├── modules/ # 13 business logic modules
|
||||
│ ├── modules/ # 9 business logic modules
|
||||
│ │ ├── standards_audit.py # Pack-aware compliance audit orchestrator
|
||||
│ │ ├── standards_query.py # Pack-aware content query
|
||||
│ │ ├── diagnostics_audit.py # Pyright diagnostics via audit pipeline
|
||||
│ │ ├── checklist.py # Per-file/dir standards check (hook consumer)
|
||||
│ │ ├── seedgo_proof.py # Proof certification orchestrator
|
||||
│ │ ├── proof_query.py # Proof content query
|
||||
│ │ ├── hook_bridge.py # Hook bridge installer (install/uninstall/reinstall/status)
|
||||
│ │ ├── hooks.py # Hook probe display and testing
|
||||
│ │ ├── hooks_ext.py # Hook test + list subcommands (split from hooks.py)
|
||||
│ │ ├── inbox_audit.py # Inbox message-ID validation
|
||||
│ │ ├── permissions.py # TRUSTED_CROSS_WRITERS list for hook + drone auth
|
||||
│ │ ├── readme_update.py # README generation module
|
||||
│ │ └── test_map.py # Custom function test coverage mapping
|
||||
│ └── handlers/ # 11 handler directories
|
||||
│ └── handlers/ # 10 handler directories
|
||||
│ ├── aipass_standards/ # 34 checker standards (67 files)
|
||||
│ │ ├── *_check.py # Checker implementations (score 0-100)
|
||||
│ │ ├── *_content.py # Queryable standard content
|
||||
@@ -132,11 +116,10 @@ seedgo/
|
||||
│ ├── config/ # Configuration handlers
|
||||
│ ├── diagnostics/ # Pyright integration + branch discovery
|
||||
│ ├── file/ # File operations
|
||||
│ ├── hooks/ # Hook test runner
|
||||
│ ├── json/ # JSON tracking (json_handler)
|
||||
│ ├── readme/ # README generator + branch resolution
|
||||
│ └── test_map/ # Function test coverage scanner
|
||||
├── tests/ # 39 test files, 1192 tests
|
||||
├── tests/ # 34 test files, 1045 tests
|
||||
├── drone_adapter.py # Drone routing bridge
|
||||
├── .trinity/ # Identity + memory
|
||||
├── .seedgo/ # Self-bypass rules
|
||||
@@ -221,16 +204,13 @@ Provider settings route all events through the bridge (`claude.py`), which dispa
|
||||
| `lifecycle.compact` | PreCompact | lifecycle |
|
||||
| `lifecycle.rollover` | PreCompact | lifecycle |
|
||||
|
||||
The `bridge` module (`drone @seedgo bridge install`) manages hook installation to `~/.claude/settings.json`.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **39 test files**, **1192 tests**, all passing
|
||||
- **200 public functions**, 200 tested (100% coverage)
|
||||
- **34 test files**, all passing
|
||||
- **0 type errors** (pyright)
|
||||
- Key test areas: standards audit, checklist, bypass, JSON handler, hooks (probe, track A/B/E, utility, bridge), proof, README, diagnostics, line coverage (plugin integrity, diagnostics, audit display, branch audit, architecture, checklist)
|
||||
- Key test areas: standards audit, checklist, bypass, JSON handler, hooks snapshot, permissions, proof, README, diagnostics, line coverage (plugin integrity, diagnostics, audit display, branch audit, architecture, checklist)
|
||||
|
||||
---
|
||||
|
||||
@@ -253,13 +233,10 @@ The `bridge` module (`drone @seedgo bridge install`) manages hook installation t
|
||||
## Known Issues / Tech Debt
|
||||
|
||||
- `audit_display.py`: 16 hardcoded display blocks for specific standards (DPLAN-0047 tracks dynamic refactor)
|
||||
- `bridge_handler.py` AIPASS_HOOK_MANIFEST references old script paths — needs update to bridge architecture
|
||||
- `test_hooks_track_a/b/e.py` import old hook scripts from `.claude/hooks/` — should migrate to test new native handlers
|
||||
- `proof`, `proof_query`, `test_map` not listed in `--help` output
|
||||
- `documentation_check.py` 5-line lookahead limitation for multi-line function signatures
|
||||
- `dead_code_check.py` doesn't recognize `iterdir()` as valid discovery pattern
|
||||
- Cross-branch file write detection recommended but not yet in standards (S73 finding)
|
||||
- Test coverage: 200/200 public functions (100%) but line coverage still has gaps in 6 handler files
|
||||
|
||||
---
|
||||
|
||||
@@ -272,7 +249,7 @@ The `bridge` module (`drone @seedgo bridge install`) manages hook installation t
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-05-16
|
||||
**Last Updated:** 2026-06-05
|
||||
|
||||
---
|
||||
[<- Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hooks/__init__.py
|
||||
# Description: Hook test runner — subprocess execution for hooks_ext module
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-21
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
|
||||
"""Hook test runner handler.
|
||||
|
||||
Encapsulates subprocess execution so hooks_ext module stays
|
||||
at the display/coordination layer.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_pytest_file(test_file: Path, repo_root: Path, timeout: int = 60) -> tuple[int, int, float]:
|
||||
"""Run pytest on a single test file. Returns (passed, failed, duration_seconds)."""
|
||||
t0 = time.monotonic()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", str(test_file), "--tb=no", "-q", "--no-header"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
duration = time.monotonic() - t0
|
||||
passed = 0
|
||||
failed = 0
|
||||
for line in proc.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if "passed" in line or "failed" in line or "error" in line.lower():
|
||||
m_passed = re.search(r"(\d+) passed", line)
|
||||
m_failed = re.search(r"(\d+) failed", line)
|
||||
m_error = re.search(r"(\d+) error", line)
|
||||
if m_passed:
|
||||
passed = int(m_passed.group(1))
|
||||
if m_failed:
|
||||
failed = int(m_failed.group(1))
|
||||
if m_error:
|
||||
failed += int(m_error.group(1))
|
||||
return passed, failed, duration
|
||||
@@ -1,248 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: bridge_handler.py
|
||||
# Description: Hook bridge — settings.json I/O for hook installation
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-22
|
||||
# Modified: 2026-04-22
|
||||
# =============================================
|
||||
|
||||
"""Hook bridge handler — reads/writes Claude Code settings.json for hook management.
|
||||
|
||||
Provides AIPASS_HOME resolution, hook manifest, install/uninstall logic.
|
||||
The module layer (hook_bridge.py) handles command routing and display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AIPASS_HOME resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_aipass_home() -> Path | None:
|
||||
"""Resolve AIPASS_HOME from env var or importlib fallback."""
|
||||
env_home = os.environ.get("AIPASS_HOME")
|
||||
if env_home:
|
||||
p = Path(env_home)
|
||||
if p.is_dir():
|
||||
return p
|
||||
try:
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.find_spec("aipass")
|
||||
if spec and spec.submodule_search_locations:
|
||||
pkg_dir = Path(list(spec.submodule_search_locations)[0])
|
||||
project_root = pkg_dir.parents[1]
|
||||
if (project_root / ".aipass").exists():
|
||||
return project_root
|
||||
except Exception as exc:
|
||||
logger.info("bridge_handler: importlib fallback failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def read_settings(path: Path) -> dict:
|
||||
"""Read and parse settings.json. Returns {} on failure."""
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("bridge_handler: could not read %s: %s", path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def write_settings(path: Path, data: dict) -> bool:
|
||||
"""Write settings.json with backup and pretty formatting."""
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists():
|
||||
shutil.copy2(path, path.with_suffix(".json.bak"))
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("bridge_handler: could not write %s: %s", path, exc)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AIPASS_COMMAND_MARKERS = (
|
||||
"AIPass/.claude/hooks/",
|
||||
"$AIPASS_HOME",
|
||||
"aipass_global_prompt",
|
||||
"aipass/hooks/apps/handlers/bridges/claude.py",
|
||||
)
|
||||
|
||||
|
||||
def is_aipass_hook_entry(entry: dict) -> bool:
|
||||
"""Check if a hook entry is an AIPass hook (marked or pattern-matched)."""
|
||||
if entry.get("_aipass"):
|
||||
return True
|
||||
for hook in entry.get("hooks", []):
|
||||
cmd = hook.get("command", "")
|
||||
if any(marker in cmd for marker in _AIPASS_COMMAND_MARKERS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def count_aipass_hooks(settings: dict) -> int:
|
||||
"""Count AIPass hook entries across all events."""
|
||||
count = 0
|
||||
for entries in settings.get("hooks", {}).values():
|
||||
if isinstance(entries, list):
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and is_aipass_hook_entry(entry):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook manifest — the canonical set of AIPass hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BRIDGE = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py"
|
||||
|
||||
AIPASS_HOOK_MANIFEST: dict[str, list[dict]] = {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:global_prompt"}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:branch_prompt"}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:identity_injector"}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} UserPromptSubmit:email_notification"}],
|
||||
},
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreToolUse"}],
|
||||
},
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PostToolUse"}],
|
||||
},
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} SubagentStop"}],
|
||||
},
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} Stop"}],
|
||||
},
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} Notification"}],
|
||||
},
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "manual",
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact", "timeout": 60}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "auto",
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact", "timeout": 60}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "manual",
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact_rollover", "timeout": 120}],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "auto",
|
||||
"hooks": [{"type": "command", "command": f"{_BRIDGE} PreCompact:pre_compact_rollover", "timeout": 120}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install / uninstall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ensure_aipass_env(settings: dict, aipass_home: str) -> bool:
|
||||
"""Ensure AIPASS_HOME is in the env section. Returns True if added."""
|
||||
env = settings.setdefault("env", {})
|
||||
if "AIPASS_HOME" in env:
|
||||
return False
|
||||
env["AIPASS_HOME"] = aipass_home
|
||||
return True
|
||||
|
||||
|
||||
def install_hooks(settings: dict) -> tuple[dict, int]:
|
||||
"""Add AIPass hooks to settings. Skips events that already have AIPass entries."""
|
||||
hooks = settings.setdefault("hooks", {})
|
||||
added = 0
|
||||
for event_name, manifest_entries in AIPASS_HOOK_MANIFEST.items():
|
||||
existing = hooks.get(event_name, [])
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
has_aipass = any(is_aipass_hook_entry(e) for e in existing if isinstance(e, dict))
|
||||
if has_aipass:
|
||||
continue
|
||||
existing.extend(manifest_entries)
|
||||
hooks[event_name] = existing
|
||||
added += len(manifest_entries)
|
||||
json_handler.log_operation("bridge_install_hooks", {"added": added})
|
||||
return settings, added
|
||||
|
||||
|
||||
def uninstall_hooks(settings: dict) -> tuple[dict, int]:
|
||||
"""Remove AIPass hook entries only. Preserves all non-AIPass entries."""
|
||||
hooks = settings.get("hooks", {})
|
||||
removed = 0
|
||||
for event_name in list(hooks.keys()):
|
||||
entries = hooks[event_name]
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
filtered = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and is_aipass_hook_entry(entry):
|
||||
removed += 1
|
||||
else:
|
||||
filtered.append(entry)
|
||||
if filtered:
|
||||
hooks[event_name] = filtered
|
||||
else:
|
||||
del hooks[event_name]
|
||||
json_handler.log_operation("bridge_uninstall_hooks", {"removed": removed})
|
||||
return settings, removed
|
||||
@@ -1,182 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hook_bridge.py
|
||||
# Description: Hook bridge installer — adds/removes AIPass hooks in settings
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-22
|
||||
# Modified: 2026-04-22
|
||||
# =============================================
|
||||
|
||||
"""Hook bridge installer — adds/removes AIPass hooks in Claude Code settings.
|
||||
|
||||
bridge install Add AIPass hooks to ~/.claude/settings.json (idempotent)
|
||||
bridge uninstall Remove only AIPass-added hook entries
|
||||
bridge reinstall Remove + re-add (upgrade path)
|
||||
bridge status Show current installation state
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli import console
|
||||
from aipass.cli.apps.modules import error, warning
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import (
|
||||
count_aipass_hooks,
|
||||
ensure_aipass_env,
|
||||
install_hooks,
|
||||
read_settings,
|
||||
resolve_aipass_home,
|
||||
uninstall_hooks,
|
||||
write_settings,
|
||||
)
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_install() -> None:
|
||||
"""Install AIPass hooks into global settings."""
|
||||
aipass_home = resolve_aipass_home()
|
||||
if not aipass_home:
|
||||
error("Could not resolve AIPASS_HOME — set env var or install aipass package")
|
||||
return
|
||||
|
||||
settings = read_settings(SETTINGS_PATH)
|
||||
existing = count_aipass_hooks(settings)
|
||||
if existing > 0:
|
||||
warning(f"AIPass hooks already installed ({existing} entries). Use 'bridge reinstall' to replace.")
|
||||
return
|
||||
|
||||
ensure_aipass_env(settings, str(aipass_home))
|
||||
settings, added = install_hooks(settings)
|
||||
|
||||
if added == 0:
|
||||
warning("No hooks to add (all events already populated).")
|
||||
return
|
||||
|
||||
if not write_settings(SETTINGS_PATH, settings):
|
||||
error("Failed to write settings.json")
|
||||
return
|
||||
|
||||
console.print(f"[green]Installed {added} AIPass hook entries into {SETTINGS_PATH}[/green]")
|
||||
console.print(f"[dim]AIPASS_HOME={aipass_home}[/dim]")
|
||||
json_handler.log_operation("bridge_install", {"added": added, "aipass_home": str(aipass_home)})
|
||||
|
||||
|
||||
def _run_uninstall() -> None:
|
||||
"""Remove AIPass hooks from global settings."""
|
||||
settings = read_settings(SETTINGS_PATH)
|
||||
existing = count_aipass_hooks(settings)
|
||||
if existing == 0:
|
||||
warning("No AIPass hooks found in settings.")
|
||||
return
|
||||
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
|
||||
if not write_settings(SETTINGS_PATH, settings):
|
||||
error("Failed to write settings.json")
|
||||
return
|
||||
|
||||
console.print(f"[green]Removed {removed} AIPass hook entries from {SETTINGS_PATH}[/green]")
|
||||
json_handler.log_operation("bridge_uninstall", {"removed": removed})
|
||||
|
||||
|
||||
def _run_reinstall() -> None:
|
||||
"""Uninstall then install AIPass hooks."""
|
||||
aipass_home = resolve_aipass_home()
|
||||
if not aipass_home:
|
||||
error("Could not resolve AIPASS_HOME")
|
||||
return
|
||||
|
||||
settings = read_settings(SETTINGS_PATH)
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
ensure_aipass_env(settings, str(aipass_home))
|
||||
settings, added = install_hooks(settings)
|
||||
|
||||
if not write_settings(SETTINGS_PATH, settings):
|
||||
error("Failed to write settings.json")
|
||||
return
|
||||
|
||||
console.print(f"[green]Reinstalled: removed {removed}, added {added} hook entries[/green]")
|
||||
json_handler.log_operation("bridge_reinstall", {"removed": removed, "added": added})
|
||||
|
||||
|
||||
def _run_status() -> None:
|
||||
"""Show current bridge installation status."""
|
||||
settings = read_settings(SETTINGS_PATH)
|
||||
count = count_aipass_hooks(settings)
|
||||
aipass_home = resolve_aipass_home()
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]Hook Bridge Status[/bold cyan]")
|
||||
console.print(f" Settings: {SETTINGS_PATH}")
|
||||
console.print(f" AIPASS_HOME: {aipass_home or '[red]not resolved[/red]'}")
|
||||
status = "[green](installed)[/green]" if count > 0 else "[dim](not installed)[/dim]"
|
||||
console.print(f" AIPass hooks: {count} entries {status}")
|
||||
console.print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Introspection + routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""CLI --help entry point."""
|
||||
print_introspection()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module info and usage."""
|
||||
console.print()
|
||||
console.print("[bold cyan]hook_bridge Module[/bold cyan]")
|
||||
console.print("Installs/removes AIPass hooks in Claude Code settings.json")
|
||||
console.print()
|
||||
console.print("[yellow]Subcommands:[/yellow]")
|
||||
console.print(" [green]drone @seedgo bridge install[/green] [dim]# Add AIPass hooks (idempotent)[/dim]")
|
||||
console.print(" [green]drone @seedgo bridge uninstall[/green] [dim]# Remove AIPass hooks only[/dim]")
|
||||
console.print(" [green]drone @seedgo bridge reinstall[/green] [dim]# Remove + re-add (upgrade)[/dim]")
|
||||
console.print(" [green]drone @seedgo bridge status[/green] [dim]# Show installation status[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/hooks/[/cyan]")
|
||||
console.print(" [dim]- bridge_handler.py (settings I/O, hook manifest)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle 'bridge' command — hook installation management."""
|
||||
if command != "bridge":
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
|
||||
if subcommand == "install":
|
||||
_run_install()
|
||||
return True
|
||||
if subcommand == "uninstall":
|
||||
_run_uninstall()
|
||||
return True
|
||||
if subcommand == "reinstall":
|
||||
_run_reinstall()
|
||||
return True
|
||||
if subcommand == "status":
|
||||
_run_status()
|
||||
return True
|
||||
|
||||
logger.info("hook_bridge: unknown subcommand %r", subcommand)
|
||||
console.print(f"[dim]Unknown subcommand: {subcommand!r}[/dim]")
|
||||
print_introspection()
|
||||
return True
|
||||
@@ -1,489 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hooks.py
|
||||
# Description: Hook Probe, Test, and List Module
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-04-20
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Hook Probe, Test, and List Module
|
||||
|
||||
Reads last_ping.jsonl produced by .claude/hooks/probes/ scripts and
|
||||
surfaces probe data via Rich tables and reports.
|
||||
|
||||
Run: drone @seedgo hooks probe [--subagent|--matrix]
|
||||
|
||||
Subcommands:
|
||||
hooks probe Show recent probe entries as a Rich table
|
||||
hooks probe --subagent Spawn headless Claude, check if PostToolUse/SubagentStop fired
|
||||
hooks probe --matrix Full event matrix + markdown report
|
||||
hooks test Run hook test suite, display per-file pass/fail table
|
||||
hooks list Show every wired hook from project and global settings
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
# =============================================================================
|
||||
# INFRASTRUCTURE SETUP
|
||||
# =============================================================================
|
||||
|
||||
# IMPORTS
|
||||
# =============================================================================
|
||||
|
||||
# Prax logger (system-wide, always first)
|
||||
from aipass.prax import logger
|
||||
|
||||
# CLI services (display/output formatting)
|
||||
from aipass.cli import console
|
||||
from aipass.cli.apps.modules import warning
|
||||
|
||||
# JSON handler for tracking
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
|
||||
# File handler — modules must not call open()/write_text() directly
|
||||
from aipass.seedgo.apps.handlers.file import write_text_safe
|
||||
|
||||
# Extended subcommands (test + list)
|
||||
from aipass.seedgo.apps.modules.hooks_ext import cmd_hooks_list, run_hooks_report, run_hooks_test
|
||||
|
||||
# Probe matrix (split to keep this file under 600 lines)
|
||||
from aipass.seedgo.apps.modules.hooks_probe import run_probe_matrix
|
||||
|
||||
# Rich output
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
# =============================================================================
|
||||
# PATHS
|
||||
# =============================================================================
|
||||
|
||||
_REPO_ROOT: Path | None = None
|
||||
|
||||
# Enable snippet shown when no probe data exists — uses a placeholder path
|
||||
# so the help_text checker does not flag a literal drone command path.
|
||||
_PROBE_ENABLE_NOTE = (
|
||||
"Add probe hook commands to .claude/settings.json (inside the hooks object).\n"
|
||||
"See .claude/hooks/probes/README.md for per-event snippets."
|
||||
)
|
||||
|
||||
|
||||
def _get_repo_root() -> Path:
|
||||
"""Return the AIPass repo root derived from this file's location."""
|
||||
global _REPO_ROOT
|
||||
if _REPO_ROOT is not None:
|
||||
return _REPO_ROOT
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in (current, *current.parents):
|
||||
if (parent / ".git").exists():
|
||||
_REPO_ROOT = parent
|
||||
return parent
|
||||
# Fallback: assume 5 levels up from this file
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[5]
|
||||
return _REPO_ROOT
|
||||
|
||||
|
||||
def _log_file() -> Path:
|
||||
"""Return path to last_ping.jsonl."""
|
||||
return _get_repo_root() / ".claude" / "hooks" / "probes" / "last_ping.jsonl"
|
||||
|
||||
|
||||
def _probes_dir() -> Path:
|
||||
"""Return path to probes directory."""
|
||||
return _get_repo_root() / ".claude" / "hooks" / "probes"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JSONL READING
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _parse_jsonl_line(line: str) -> dict | None:
|
||||
"""Parse a single JSONL line. Returns None on parse failure."""
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.info("hooks.py: skipping malformed JSONL line: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _read_entries(log_path: Path | None = None) -> List[dict]:
|
||||
"""Read all entries from last_ping.jsonl. Returns empty list if missing or unreadable."""
|
||||
path = log_path or _log_file()
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
raw_lines = fh.readlines()
|
||||
except OSError as exc:
|
||||
logger.info("hooks.py: could not read %s: %s", path, exc)
|
||||
return []
|
||||
entries = []
|
||||
for line in raw_lines:
|
||||
entry = _parse_jsonl_line(line)
|
||||
if entry is not None:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def _truncate(s: str, n: int) -> str:
|
||||
"""Truncate string to n chars with ellipsis."""
|
||||
if len(s) <= n:
|
||||
return s
|
||||
return s[: n - 3] + "..."
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks probe (no flags)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _cmd_probe_display(log_path: Path | None = None) -> None:
|
||||
"""Display recent probe entries as a Rich table."""
|
||||
entries = _read_entries(log_path)
|
||||
|
||||
if not entries:
|
||||
console.print(
|
||||
Panel(
|
||||
"[yellow]No probe data yet.[/yellow]\n\n"
|
||||
"Enable probes by adding hook commands to [cyan].claude/settings.json[/cyan].\n"
|
||||
f"{_PROBE_ENABLE_NOTE}",
|
||||
title="[bold cyan][PROBE][/bold cyan]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Show last 50 entries
|
||||
recent = entries[-50:]
|
||||
|
||||
table = Table(
|
||||
title="[bold cyan][PROBE][/bold cyan] Recent Hook Pings",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
border_style="dim",
|
||||
expand=False,
|
||||
)
|
||||
table.add_column("Event", style="cyan", no_wrap=True)
|
||||
table.add_column("Tool", style="green")
|
||||
table.add_column("Timestamp", style="dim")
|
||||
table.add_column("CWD", style="dim")
|
||||
table.add_column("Agent ID", style="dim")
|
||||
table.add_column("Elapsed ms", justify="right", style="yellow")
|
||||
table.add_column("CLI Ver", style="dim")
|
||||
table.add_column("ProjectDir", justify="center")
|
||||
table.add_column("AIPassHome", justify="center")
|
||||
|
||||
for e in recent:
|
||||
table.add_row(
|
||||
e.get("event", "?"),
|
||||
_truncate(e.get("tool", ""), 20),
|
||||
_truncate(e.get("timestamp", ""), 24),
|
||||
_truncate(e.get("cwd", ""), 30),
|
||||
_truncate(e.get("agent_id", ""), 14),
|
||||
str(round(e.get("script_elapsed_ms", 0), 1)),
|
||||
_truncate(e.get("cli_version", "?"), 10),
|
||||
"[green]Y[/green]" if e.get("env_has_claude_project_dir") else "[red]N[/red]",
|
||||
"[green]Y[/green]" if e.get("env_has_aipass_home") else "[red]N[/red]",
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f"\n[dim]Showing {len(recent)} of {len(entries)} total entries from {_log_file()}[/dim]\n")
|
||||
|
||||
json_handler.log_operation("hooks_probe_display", {"entries_shown": len(recent), "total": len(entries)})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks probe --subagent
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _run_headless_claude() -> int:
|
||||
"""Spawn headless claude with a Read tool call. Returns exit code."""
|
||||
canary = Path("/tmp/probe_canary.txt")
|
||||
if not write_text_safe(canary, "probe canary 2026-04-20\n"):
|
||||
logger.info("hooks.py: could not write canary")
|
||||
|
||||
console.print("[dim]Spawning headless claude...[/dim]")
|
||||
# --permission-mode bypassPermissions is the AIPass-approved bypass flag
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"claude",
|
||||
"-p",
|
||||
"--permission-mode",
|
||||
"bypassPermissions",
|
||||
"read the file /tmp/probe_canary.txt",
|
||||
"--allowedTools",
|
||||
"Read",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
console.print(f"[dim]claude exited: {result.returncode}[/dim]")
|
||||
return result.returncode
|
||||
except FileNotFoundError as exc:
|
||||
logger.info("hooks.py: claude binary not found: %s", exc)
|
||||
warning("claude binary not found — skipping spawn test")
|
||||
return -1
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.info("hooks.py: claude timed out: %s", exc)
|
||||
warning("claude timed out after 30s")
|
||||
return -2
|
||||
except OSError as exc:
|
||||
logger.info("hooks.py: spawn error: %s", exc)
|
||||
warning(f"spawn error: {exc}")
|
||||
return -3
|
||||
|
||||
|
||||
def _cmd_probe_subagent() -> None:
|
||||
"""Spawn a headless Claude Code process and check if PostToolUse/SubagentStop fired."""
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold cyan]Headless Probe — PostToolUse + SubagentStop[/bold cyan]\n\n"
|
||||
"This test spawns a headless claude process with a Read tool call, "
|
||||
"then checks last_ping.jsonl for "
|
||||
"[yellow]PostToolUse[/yellow] and [yellow]SubagentStop[/yellow] entries "
|
||||
"created within the last 10 seconds.\n\n"
|
||||
"[dim]Requires PostToolUse and SubagentStop probes to be enabled in settings.json.[/dim]",
|
||||
title="[bold cyan][PROBE][/bold cyan]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
start_ts = time.time()
|
||||
_run_headless_claude()
|
||||
|
||||
# Wait a beat for hooks to flush
|
||||
time.sleep(0.5)
|
||||
|
||||
# Check last_ping.jsonl for recent entries
|
||||
cutoff = start_ts - 1.0 # 1s before spawn
|
||||
entries = _read_entries()
|
||||
recent_events = {e.get("event") for e in entries if _entry_ts(e) >= cutoff}
|
||||
|
||||
post_tool_use_fired = "PostToolUse" in recent_events
|
||||
subagent_stop_fired = "SubagentStop" in recent_events
|
||||
|
||||
result_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
|
||||
result_table.add_column("Check", style="cyan")
|
||||
result_table.add_column("Result", justify="center")
|
||||
|
||||
result_table.add_row(
|
||||
"PostToolUse fired in headless",
|
||||
"[green]YES[/green]" if post_tool_use_fired else "[red]NO[/red]",
|
||||
)
|
||||
result_table.add_row(
|
||||
"SubagentStop fired in headless",
|
||||
"[green]YES[/green]" if subagent_stop_fired else "[red]NO[/red]",
|
||||
)
|
||||
|
||||
console.print(result_table)
|
||||
console.print()
|
||||
|
||||
# Manual test section
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold yellow]Manual Interactive Agent-Tool Test[/bold yellow]\n\n"
|
||||
"To test hooks fired by the interactive Agent tool:\n\n"
|
||||
"1. Enable PostToolUse and SubagentStop probes in .claude/settings.json\n"
|
||||
"2. In an interactive Claude Code session, invoke an Agent tool call\n"
|
||||
"3. After the agent completes, run:\n"
|
||||
" [cyan]drone @seedgo hooks probe[/cyan]\n"
|
||||
"4. Check for SubagentStop and PostToolUse entries near the agent's timestamp\n\n"
|
||||
"[dim]Headless (-p) and interactive Agent tool have different session contexts.[/dim]\n"
|
||||
"[dim]SubagentStop fires when an agent tool invocation completes.[/dim]",
|
||||
title="[bold cyan][PROBE][/bold cyan] Manual Test",
|
||||
border_style="dim",
|
||||
)
|
||||
)
|
||||
|
||||
json_handler.log_operation(
|
||||
"hooks_probe_subagent",
|
||||
{"post_tool_use_fired": post_tool_use_fired, "subagent_stop_fired": subagent_stop_fired},
|
||||
)
|
||||
|
||||
|
||||
def _entry_ts(entry: dict) -> float:
|
||||
"""Parse entry timestamp to unix float. Returns 0 on failure."""
|
||||
ts_str = entry.get("timestamp", "")
|
||||
if not ts_str:
|
||||
return 0.0
|
||||
try:
|
||||
normalized = ts_str[:-1] + "+00:00" if ts_str.endswith("Z") else ts_str
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
return dt.timestamp()
|
||||
except ValueError as exc:
|
||||
logger.info("hooks.py: could not parse timestamp %r: %s", ts_str, exc)
|
||||
return 0.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks probe --matrix (delegated to hooks_probe)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _cmd_probe_matrix() -> None:
|
||||
"""Group entries by event, show matrix, write markdown report — delegates to hooks_probe."""
|
||||
run_probe_matrix(_read_entries, _probes_dir)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks test + hooks list (delegated to hooks_ext)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _cmd_hooks_test() -> None:
|
||||
"""Run hook test suite — delegates to hooks_ext."""
|
||||
run_hooks_test(_get_repo_root())
|
||||
|
||||
|
||||
def _cmd_hooks_list() -> None:
|
||||
"""Show wired hooks — delegates to hooks_ext."""
|
||||
cmd_hooks_list(_get_repo_root())
|
||||
|
||||
|
||||
def _cmd_hooks_report(args: list) -> None:
|
||||
"""Run hook execution report — delegates to hooks_ext."""
|
||||
run_hooks_report(_get_repo_root(), args)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTROSPECTION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""CLI --help entry point — delegates to print_introspection."""
|
||||
print_introspection()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module info and usage."""
|
||||
console.print()
|
||||
console.print("[bold cyan]hooks Module[/bold cyan]")
|
||||
console.print("Hook probe display — reads last_ping.jsonl from .claude/hooks/probes/")
|
||||
console.print()
|
||||
|
||||
log = _log_file()
|
||||
if log.exists():
|
||||
entries = _read_entries()
|
||||
console.print(f"[yellow]Probe log:[/yellow] {log} ([green]{len(entries)} entries[/green])")
|
||||
else:
|
||||
console.print(f"[yellow]Probe log:[/yellow] {log} [dim](not yet created — enable probes first)[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Subcommands:[/yellow]")
|
||||
console.print(" [green]drone @seedgo hooks probe[/green] [dim]# Display recent entries table[/dim]")
|
||||
console.print(
|
||||
" [green]drone @seedgo hooks probe --subagent[/green] [dim]# Test headless PostToolUse/SubagentStop[/dim]"
|
||||
)
|
||||
console.print(
|
||||
" [green]drone @seedgo hooks probe --matrix[/green] [dim]# Full event matrix + markdown report[/dim]"
|
||||
)
|
||||
console.print(" [green]drone @seedgo hooks test[/green] [dim]# Run hook test suite[/dim]")
|
||||
console.print(" [green]drone @seedgo hooks report[/green] [dim]# Hook execution log report[/dim]")
|
||||
console.print(" [green]drone @seedgo hooks list[/green] [dim]# List all wired hooks[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Probe scripts:[/yellow]")
|
||||
probes_dir = _probes_dir()
|
||||
for script in sorted(probes_dir.glob("probe_*.py")):
|
||||
console.print(f" [dim]{script.name}[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/json/[/cyan]")
|
||||
console.print(" [dim]- json_handler.py (log_operation — operation tracking)[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]External Dependencies:[/yellow]")
|
||||
console.print(" [dim]- aipass.prax (logger)[/dim]")
|
||||
console.print(" [dim]- aipass.cli (console)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMMAND HANDLER
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle 'hooks' command — hook probe display and testing.
|
||||
|
||||
Args:
|
||||
command: Command name
|
||||
args: Additional arguments
|
||||
[] -> print_introspection()
|
||||
["probe"] -> display last_ping.jsonl table
|
||||
["probe", "--subagent"] -> headless probe test
|
||||
["probe", "--matrix"] -> full event matrix + report
|
||||
["--help"] | ["help"] -> print_introspection()
|
||||
|
||||
Returns:
|
||||
True if handled, False if not this module's command
|
||||
"""
|
||||
if command != "hooks":
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
|
||||
if subcommand == "probe":
|
||||
sub_args = args[1:]
|
||||
if "--subagent" in sub_args:
|
||||
_cmd_probe_subagent()
|
||||
elif "--matrix" in sub_args:
|
||||
_cmd_probe_matrix()
|
||||
else:
|
||||
_cmd_probe_display()
|
||||
return True
|
||||
|
||||
if subcommand == "test":
|
||||
_cmd_hooks_test()
|
||||
return True
|
||||
|
||||
if subcommand == "report":
|
||||
_cmd_hooks_report(args[1:])
|
||||
return True
|
||||
|
||||
if subcommand == "list":
|
||||
_cmd_hooks_list()
|
||||
return True
|
||||
|
||||
# Unknown subcommand — show introspection
|
||||
console.print(f"[dim]Unknown subcommand: {subcommand!r} — showing help[/dim]")
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STANDALONE EXECUTION
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
|
||||
logger.info("Prax logger connected to hooks")
|
||||
handle_command("hooks", sys.argv[1:])
|
||||
@@ -1,277 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hooks_ext.py
|
||||
# Description: Hook Test and List Subcommands (split from hooks.py)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-21
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Hook Test and List Subcommands
|
||||
|
||||
Extended subcommands for hooks.py — split out to keep hooks.py under 700 lines.
|
||||
|
||||
hooks test Run hook test suite, display per-file pass/fail table
|
||||
hooks list Show every wired hook from project and global settings
|
||||
"""
|
||||
|
||||
import glob as _glob
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli import console
|
||||
from aipass.cli.apps.modules import warning
|
||||
from aipass.seedgo.apps.handlers.file import read_lines_safe, read_text_safe
|
||||
from aipass.seedgo.apps.handlers.hooks import run_pytest_file
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks test
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def run_hooks_test(repo_root: Path) -> None:
|
||||
"""Run hook test suite, display per-file pass/fail table."""
|
||||
pattern = str(repo_root / "src" / "aipass" / "seedgo" / "tests" / "test_hooks*.py")
|
||||
test_files = sorted(_glob.glob(pattern))
|
||||
|
||||
if not test_files:
|
||||
warning("No test_hooks*.py files found")
|
||||
return
|
||||
|
||||
table = Table(
|
||||
title="[bold cyan][HOOKS TEST][/bold cyan] Hook Test Suite",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
border_style="dim",
|
||||
)
|
||||
table.add_column("Test File", style="cyan", no_wrap=True)
|
||||
table.add_column("Tests", justify="right", style="yellow")
|
||||
table.add_column("Passed", justify="right", style="green")
|
||||
table.add_column("Failed", justify="right", style="red")
|
||||
table.add_column("Duration", justify="right", style="dim")
|
||||
table.add_column("Status", justify="center")
|
||||
|
||||
total_passed = 0
|
||||
total_failed = 0
|
||||
|
||||
for tf in test_files:
|
||||
stem = Path(tf).stem
|
||||
passed, failed, duration = run_pytest_file(Path(tf), repo_root)
|
||||
total_passed += passed
|
||||
total_failed += failed
|
||||
status = (
|
||||
"[green]PASS[/green]"
|
||||
if failed == 0 and passed > 0
|
||||
else ("[red]FAIL[/red]" if failed > 0 else "[yellow]NONE[/yellow]")
|
||||
)
|
||||
table.add_row(
|
||||
stem,
|
||||
str(passed + failed),
|
||||
str(passed),
|
||||
str(failed),
|
||||
f"{duration:.1f}s",
|
||||
status,
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f"\n[bold]Total:[/bold] [green]{total_passed} passed[/green] [red]{total_failed} failed[/red]\n")
|
||||
json_handler.log_operation(
|
||||
"hooks_test_run",
|
||||
{"files": len(test_files), "total_passed": total_passed, "total_failed": total_failed},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks report
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def run_hooks_report(repo_root: Path, args: list) -> None:
|
||||
"""Run hook execution report — delegates to hook_report.py."""
|
||||
import subprocess
|
||||
|
||||
script = repo_root / ".claude" / "hooks" / "hook_report.py"
|
||||
if not script.exists():
|
||||
warning(f"hook_report.py not found at {script}")
|
||||
return
|
||||
|
||||
cmd = ["python3", str(script)] + args
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.stdout:
|
||||
console.print(result.stdout.rstrip())
|
||||
if result.stderr:
|
||||
console.print(f"[red]{result.stderr.rstrip()}[/red]")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.info("hooks_ext.py: hook_report.py timed out after 30s")
|
||||
warning("hook_report.py timed out after 30s")
|
||||
|
||||
json_handler.log_operation("hooks_report", {"args": args})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks list — helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def read_settings_file(path: Path) -> dict:
|
||||
"""Read and parse a settings.json file. Returns {} on failure."""
|
||||
if not path.exists():
|
||||
return {}
|
||||
text = read_text_safe(path)
|
||||
if text is None:
|
||||
logger.info("hooks_ext.py: could not read %s", path)
|
||||
return {}
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception as exc:
|
||||
logger.info("hooks_ext.py: could not parse %s: %s", path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def extract_hook_script(command: str) -> str:
|
||||
"""Extract the Python script filename from a hook command string."""
|
||||
parts = command.split()
|
||||
for part in reversed(parts):
|
||||
if part.endswith(".py"):
|
||||
return Path(part).name
|
||||
return command[:40] if command else "(unknown)"
|
||||
|
||||
|
||||
def _parse_version_from_lines(lines: list) -> str | None:
|
||||
"""Scan lines for a Version: comment. Returns version string or None."""
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# Version:") or stripped.startswith("Version:"):
|
||||
return stripped.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def read_hook_version(command: str) -> str:
|
||||
"""Extract version string from a hook file's first 20 lines."""
|
||||
parts = command.split()
|
||||
for part in reversed(parts):
|
||||
if part.endswith(".py"):
|
||||
raw_lines = read_lines_safe(Path(part), n=20)
|
||||
version = _parse_version_from_lines(raw_lines)
|
||||
if version is not None:
|
||||
return version
|
||||
return "?"
|
||||
|
||||
|
||||
def _add_hook_defs_to_table(
|
||||
table: Table,
|
||||
hook_defs: list,
|
||||
event_name: str,
|
||||
matcher: str,
|
||||
location: str,
|
||||
seen: set,
|
||||
) -> int:
|
||||
"""Add hook definitions to table, skipping duplicates. Returns count added."""
|
||||
count = 0
|
||||
for hook_def in hook_defs:
|
||||
if not isinstance(hook_def, dict):
|
||||
continue
|
||||
command = hook_def.get("command", "")
|
||||
if not command:
|
||||
continue
|
||||
script_name = extract_hook_script(command)
|
||||
dedup_key = (script_name, event_name, location)
|
||||
if dedup_key in seen:
|
||||
continue
|
||||
seen.add(dedup_key)
|
||||
version = read_hook_version(command)
|
||||
script_path_parts = [p for p in command.split() if p.endswith(".py")]
|
||||
exists = Path(script_path_parts[0]).exists() if script_path_parts else False
|
||||
table.add_row(
|
||||
script_name,
|
||||
event_name,
|
||||
matcher[:35] if len(matcher) > 35 else matcher,
|
||||
version,
|
||||
location,
|
||||
"[green]✓[/green]" if exists else "[red]✗[/red]",
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _populate_hooks_table(table: Table, sources: list, seen: set) -> int:
|
||||
"""Populate hooks list table from settings sources. Returns row count."""
|
||||
total = 0
|
||||
for _label, path, location in sources:
|
||||
settings = read_settings_file(path)
|
||||
for event_name, entries in settings.get("hooks", {}).items():
|
||||
hook_entries = entries if isinstance(entries, list) else []
|
||||
for entry in hook_entries:
|
||||
matcher = entry.get("matcher", "*") if isinstance(entry, dict) else "*"
|
||||
hook_defs = entry.get("hooks", []) if isinstance(entry, dict) else []
|
||||
total += _add_hook_defs_to_table(table, hook_defs, event_name, matcher, location, seen)
|
||||
return total
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND: hooks list
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def cmd_hooks_list(repo_root: Path) -> None:
|
||||
"""Show every wired hook from both project and user-global settings."""
|
||||
global_settings_path = Path.home() / ".claude" / "settings.json"
|
||||
project_settings_path = repo_root / ".claude" / "settings.json"
|
||||
|
||||
sources = [
|
||||
("~/.claude/settings.json", global_settings_path, "global"),
|
||||
(".claude/settings.json", project_settings_path, "project"),
|
||||
]
|
||||
|
||||
table = Table(
|
||||
title="[bold cyan][HOOKS LIST][/bold cyan] Wired Hooks",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
border_style="dim",
|
||||
expand=True,
|
||||
)
|
||||
table.add_column("Hook Script", style="cyan", no_wrap=True)
|
||||
table.add_column("Event", style="yellow")
|
||||
table.add_column("Matcher", style="dim")
|
||||
table.add_column("Version", justify="center", style="green")
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Exists", justify="center")
|
||||
|
||||
seen: set = set()
|
||||
total = _populate_hooks_table(table, sources, seen)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f"\n[dim]{total} hook entries across both settings files[/dim]\n")
|
||||
|
||||
json_handler.log_operation("hooks_list", {"total": total})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DRONE ROUTING (helper module — delegates to hooks.py)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Print subcommand info for this helper module."""
|
||||
console.print("[bold cyan]hooks_ext[/bold cyan] — hook test and list helper")
|
||||
console.print(" [dim]Use via hooks.py: drone @seedgo hooks test | hooks list[/dim]")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Not a primary drone module — delegates to hooks.py for routing."""
|
||||
if command != "hooks_ext":
|
||||
return False
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
return True
|
||||
return False
|
||||
@@ -1,169 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hooks_probe.py
|
||||
# Description: Hook Probe Matrix Subcommand (split from hooks.py)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-16
|
||||
# Modified: 2026-05-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Hook Probe Matrix — split from hooks.py to keep modules under 600 lines.
|
||||
|
||||
hooks probe --matrix Full event matrix + markdown report
|
||||
"""
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Callable, List
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli import console
|
||||
from aipass.seedgo.apps.handlers.file import write_text_safe
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
_PROBE_ENABLE_NOTE = (
|
||||
"Add probe hook commands to .claude/settings.json (inside the hooks object).\n"
|
||||
"See .claude/hooks/probes/README.md for per-event snippets."
|
||||
)
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module info."""
|
||||
console.print("[bold cyan]hooks_probe[/bold cyan] — matrix subcommand helper for hooks.py")
|
||||
console.print(" Not directly routable. Called via [green]drone @seedgo hooks probe --matrix[/green]")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Not a routed module — always returns False."""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return False
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
return False
|
||||
logger.info("hooks_probe: not a routed module, command=%s args=%s", command, args)
|
||||
return False
|
||||
|
||||
|
||||
def _build_matrix_rows(entries: list) -> tuple[list, dict]:
|
||||
"""Group entries by event and build matrix row dicts. Returns (rows, groups)."""
|
||||
groups: dict = defaultdict(list)
|
||||
for e in entries:
|
||||
groups[e.get("event", "unknown")].append(e)
|
||||
|
||||
matrix_rows = []
|
||||
for event_name in sorted(groups.keys()):
|
||||
evs = groups[event_name]
|
||||
count = len(evs)
|
||||
pd_true = sum(1 for e in evs if e.get("env_has_claude_project_dir"))
|
||||
pd_false = count - pd_true
|
||||
ah_true = sum(1 for e in evs if e.get("env_has_aipass_home"))
|
||||
ah_false = count - ah_true
|
||||
agents = len({e.get("agent_id", "unknown") for e in evs})
|
||||
matrix_rows.append(
|
||||
{
|
||||
"event": event_name,
|
||||
"count": count,
|
||||
"project_dir_true": pd_true,
|
||||
"project_dir_false": pd_false,
|
||||
"aipass_home_true": ah_true,
|
||||
"aipass_home_false": ah_false,
|
||||
"unique_agents": agents,
|
||||
}
|
||||
)
|
||||
return matrix_rows, groups
|
||||
|
||||
|
||||
def _write_matrix_report(report_path: Path, matrix_rows: list, entries: list) -> None:
|
||||
"""Write markdown matrix report to disk."""
|
||||
lines = [
|
||||
"# Hook Probe Matrix — Q12 Findings 2026-04-20",
|
||||
"",
|
||||
"Generated by `drone @seedgo hooks probe --matrix`.",
|
||||
"",
|
||||
f"Total entries: {len(entries)}",
|
||||
"",
|
||||
"## Event Matrix",
|
||||
"",
|
||||
"| Event | Count | ProjectDir T/F | AIPassHome T/F | Unique Agents |",
|
||||
"|-------|-------|----------------|----------------|---------------|",
|
||||
]
|
||||
for row in matrix_rows:
|
||||
lines.append(
|
||||
f"| {row['event']} | {row['count']} "
|
||||
f"| {row['project_dir_true']}/{row['project_dir_false']} "
|
||||
f"| {row['aipass_home_true']}/{row['aipass_home_false']} "
|
||||
f"| {row['unique_agents']} |"
|
||||
)
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"## Notes",
|
||||
"",
|
||||
"- `ProjectDir T/F`: entries where `CLAUDE_PROJECT_DIR` env var was set (T) vs unset (F)",
|
||||
"- `AIPassHome T/F`: entries where `AIPASS_HOME` env var was set (T) vs unset (F)",
|
||||
"- Unique Agents: distinct `CLAUDE_CODE_SESSION_ID` values seen for this event",
|
||||
"",
|
||||
"## Raw entry count by event",
|
||||
"",
|
||||
]
|
||||
event_counts = Counter(e.get("event", "unknown") for e in entries)
|
||||
for ev, cnt in sorted(event_counts.items()):
|
||||
lines.append(f"- {ev}: {cnt}")
|
||||
lines.append("")
|
||||
|
||||
write_text_safe(report_path, "\n".join(lines))
|
||||
|
||||
|
||||
def run_probe_matrix(
|
||||
read_entries: Callable[[], List[dict]],
|
||||
probes_dir: Callable[[], Path],
|
||||
) -> None:
|
||||
"""Group entries by event, show matrix, write markdown report."""
|
||||
entries = read_entries()
|
||||
|
||||
if not entries:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[yellow]No probe data yet — enable probes first.[/yellow]\n\n{_PROBE_ENABLE_NOTE}",
|
||||
title="[bold cyan][PROBE] Matrix[/bold cyan]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
matrix_rows, groups = _build_matrix_rows(entries)
|
||||
|
||||
table = Table(
|
||||
title="[bold cyan][PROBE][/bold cyan] Event Matrix",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
border_style="dim",
|
||||
)
|
||||
table.add_column("Event", style="cyan", no_wrap=True)
|
||||
table.add_column("Count", justify="right", style="yellow")
|
||||
table.add_column("ProjectDir T/F", justify="center")
|
||||
table.add_column("AIPassHome T/F", justify="center")
|
||||
table.add_column("Unique Agents", justify="right")
|
||||
|
||||
for row in matrix_rows:
|
||||
table.add_row(
|
||||
row["event"],
|
||||
str(row["count"]),
|
||||
f"[green]{row['project_dir_true']}[/green]/[red]{row['project_dir_false']}[/red]",
|
||||
f"[green]{row['aipass_home_true']}[/green]/[red]{row['aipass_home_false']}[/red]",
|
||||
str(row["unique_agents"]),
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
report_path = probes_dir() / "Q12_findings_2026-04-20.md"
|
||||
_write_matrix_report(report_path, matrix_rows, entries)
|
||||
console.print(f"[green]Report written:[/green] {report_path}\n")
|
||||
|
||||
json_handler.log_operation("hooks_probe_matrix", {"events": len(groups), "total_entries": len(entries)})
|
||||
@@ -93,7 +93,6 @@ def _mock_infrastructure(monkeypatch):
|
||||
"aipass.seedgo.apps.handlers.json.json_handler",
|
||||
"aipass.seedgo.apps.handlers.readme.readme_ops",
|
||||
"aipass.seedgo.apps.handlers.readme.readme_generator",
|
||||
"aipass.seedgo.apps.modules.hooks_ext",
|
||||
]:
|
||||
monkeypatch.delitem(sys.modules, mod_name, raising=False)
|
||||
|
||||
@@ -569,39 +568,3 @@ def test_update_readme_auto_sections_missing_markers(tmp_path):
|
||||
result = update_readme_auto_sections(str(branch_dir))
|
||||
# Some sections should report missing markers
|
||||
assert len(result["missing_markers"]) > 0 or len(result["updated"]) == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 10. hooks_ext -- run_hooks_test
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_run_hooks_test_no_files(tmp_path):
|
||||
"""run_hooks_test with no test files shows warning."""
|
||||
from aipass.seedgo.apps.modules.hooks_ext import run_hooks_test
|
||||
|
||||
# repo_root with no test_hooks*.py files
|
||||
run_hooks_test(tmp_path)
|
||||
# Should call warning() -- verify through mock
|
||||
import sys
|
||||
|
||||
cli_modules = sys.modules["aipass.cli.apps.modules"]
|
||||
cli_modules.warning.assert_called()
|
||||
|
||||
|
||||
def test_run_hooks_test_with_files(tmp_path):
|
||||
"""run_hooks_test with test files builds and prints table."""
|
||||
import sys
|
||||
|
||||
# Create the expected directory structure
|
||||
test_dir = tmp_path / "src" / "aipass" / "seedgo" / "tests"
|
||||
test_dir.mkdir(parents=True)
|
||||
(test_dir / "test_hooks_basic.py").write_text("pass", encoding="utf-8")
|
||||
|
||||
from aipass.seedgo.apps.modules.hooks_ext import run_hooks_test
|
||||
|
||||
run_hooks_test(tmp_path)
|
||||
|
||||
# Verify console.print was called (table output)
|
||||
cli_mod = sys.modules["aipass.cli"]
|
||||
assert cli_mod.console.print.called
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_hook_bridge.py
|
||||
# Description: Tests for hook bridge installer (DPLAN-0141 Phase 2)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-22
|
||||
# Modified: 2026-04-22
|
||||
# =============================================
|
||||
"""Tests for hook bridge installer — bridge_handler.py + hook_bridge.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bridge_handler tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_aipass_home_from_env(tmp_path):
|
||||
"""resolve_aipass_home returns path from AIPASS_HOME env var."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import resolve_aipass_home
|
||||
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
result = resolve_aipass_home()
|
||||
assert result == tmp_path
|
||||
|
||||
|
||||
def test_resolve_aipass_home_returns_none_when_all_fail():
|
||||
"""resolve_aipass_home returns None when env var is bad and importlib fallback disabled."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import resolve_aipass_home
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"AIPASS_HOME": "/nonexistent/path/xyz"}),
|
||||
patch("importlib.util.find_spec", return_value=None),
|
||||
):
|
||||
result = resolve_aipass_home()
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_is_aipass_hook_entry_detects_marker():
|
||||
"""is_aipass_hook_entry returns True for entries with _aipass marker."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import is_aipass_hook_entry
|
||||
|
||||
entry = {"_aipass": True, "hooks": [{"type": "command", "command": "echo test"}]}
|
||||
assert is_aipass_hook_entry(entry) is True
|
||||
|
||||
|
||||
def test_is_aipass_hook_entry_detects_pattern():
|
||||
"""is_aipass_hook_entry returns True for entries with AIPass path patterns."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import is_aipass_hook_entry
|
||||
|
||||
entry = {"hooks": [{"type": "command", "command": "python3 /home/user/AIPass/.claude/hooks/foo.py"}]}
|
||||
assert is_aipass_hook_entry(entry) is True
|
||||
|
||||
|
||||
def test_is_aipass_hook_entry_detects_env_var():
|
||||
"""is_aipass_hook_entry returns True for entries using $AIPASS_HOME."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import is_aipass_hook_entry
|
||||
|
||||
entry = {"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/bar.py"}]}
|
||||
assert is_aipass_hook_entry(entry) is True
|
||||
|
||||
|
||||
def test_is_aipass_hook_entry_ignores_non_aipass():
|
||||
"""is_aipass_hook_entry returns False for unrelated entries."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import is_aipass_hook_entry
|
||||
|
||||
entry = {"hooks": [{"type": "command", "command": "echo hello"}]}
|
||||
assert is_aipass_hook_entry(entry) is False
|
||||
|
||||
|
||||
def test_count_aipass_hooks_empty():
|
||||
"""count_aipass_hooks returns 0 for empty settings."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import count_aipass_hooks
|
||||
|
||||
assert count_aipass_hooks({}) == 0
|
||||
assert count_aipass_hooks({"hooks": {}}) == 0
|
||||
|
||||
|
||||
def test_count_aipass_hooks_counts_marked():
|
||||
"""count_aipass_hooks counts entries with _aipass marker."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import count_aipass_hooks
|
||||
|
||||
settings = {
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{"_aipass": True, "hooks": [{"type": "command", "command": "echo"}]},
|
||||
{"hooks": [{"type": "command", "command": "other"}]},
|
||||
]
|
||||
}
|
||||
}
|
||||
assert count_aipass_hooks(settings) == 1
|
||||
|
||||
|
||||
def test_install_hooks_adds_entries():
|
||||
"""install_hooks adds manifest entries to empty settings."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import (
|
||||
AIPASS_HOOK_MANIFEST,
|
||||
install_hooks,
|
||||
)
|
||||
|
||||
settings = {"hooks": {}}
|
||||
settings, added = install_hooks(settings)
|
||||
assert added > 0
|
||||
total_manifest = sum(len(v) for v in AIPASS_HOOK_MANIFEST.values())
|
||||
assert added == total_manifest
|
||||
|
||||
|
||||
def test_install_hooks_idempotent():
|
||||
"""install_hooks does not duplicate entries on second call."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import install_hooks
|
||||
|
||||
settings = {"hooks": {}}
|
||||
settings, first_added = install_hooks(settings)
|
||||
settings, second_added = install_hooks(settings)
|
||||
assert first_added > 0
|
||||
assert second_added == 0
|
||||
|
||||
|
||||
def test_install_hooks_preserves_existing():
|
||||
"""install_hooks preserves non-AIPass hooks in same event."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import install_hooks
|
||||
|
||||
user_hook = {"hooks": [{"type": "command", "command": "echo user-hook"}]}
|
||||
settings = {"hooks": {"Stop": [user_hook]}}
|
||||
settings, added = install_hooks(settings)
|
||||
stop_entries = settings["hooks"]["Stop"]
|
||||
assert user_hook in stop_entries
|
||||
assert added > 0
|
||||
|
||||
|
||||
def test_install_hooks_skips_event_with_existing_aipass():
|
||||
"""install_hooks skips events that already contain AIPass entries."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import install_hooks
|
||||
|
||||
existing = {
|
||||
"_aipass": True,
|
||||
"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/stop_sound.py"}],
|
||||
}
|
||||
settings = {"hooks": {"Stop": [existing]}}
|
||||
settings, added = install_hooks(settings)
|
||||
assert len(settings["hooks"]["Stop"]) == 1
|
||||
|
||||
|
||||
def test_uninstall_hooks_removes_marked():
|
||||
"""uninstall_hooks removes entries with _aipass marker."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import uninstall_hooks
|
||||
|
||||
settings = {
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{"_aipass": True, "hooks": [{"type": "command", "command": "echo aipass"}]},
|
||||
{"hooks": [{"type": "command", "command": "echo user"}]},
|
||||
]
|
||||
}
|
||||
}
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
assert removed == 1
|
||||
assert len(settings["hooks"]["Stop"]) == 1
|
||||
assert settings["hooks"]["Stop"][0]["hooks"][0]["command"] == "echo user"
|
||||
|
||||
|
||||
def test_uninstall_hooks_removes_empty_events():
|
||||
"""uninstall_hooks deletes event keys that become empty."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import uninstall_hooks
|
||||
|
||||
settings = {
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{"_aipass": True, "hooks": [{"type": "command", "command": "echo"}]},
|
||||
]
|
||||
}
|
||||
}
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
assert removed == 1
|
||||
assert "Stop" not in settings["hooks"]
|
||||
|
||||
|
||||
def test_uninstall_hooks_preserves_non_aipass():
|
||||
"""uninstall_hooks never touches non-AIPass entries."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import uninstall_hooks
|
||||
|
||||
user_hook = {"hooks": [{"type": "command", "command": "echo user"}]}
|
||||
settings = {"hooks": {"Stop": [user_hook]}}
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
assert removed == 0
|
||||
assert settings["hooks"]["Stop"] == [user_hook]
|
||||
|
||||
|
||||
def test_ensure_aipass_env_adds_when_missing():
|
||||
"""ensure_aipass_env adds AIPASS_HOME to env section."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import ensure_aipass_env
|
||||
|
||||
settings = {}
|
||||
added = ensure_aipass_env(settings, "/home/user/AIPass")
|
||||
assert added is True
|
||||
assert settings["env"]["AIPASS_HOME"] == "/home/user/AIPass"
|
||||
|
||||
|
||||
def test_ensure_aipass_env_skips_when_present():
|
||||
"""ensure_aipass_env does not overwrite existing AIPASS_HOME."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import ensure_aipass_env
|
||||
|
||||
settings = {"env": {"AIPASS_HOME": "/existing/path"}}
|
||||
added = ensure_aipass_env(settings, "/new/path")
|
||||
assert added is False
|
||||
assert settings["env"]["AIPASS_HOME"] == "/existing/path"
|
||||
|
||||
|
||||
def test_read_settings_returns_empty_for_missing(tmp_path):
|
||||
"""read_settings returns {} when file does not exist."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import read_settings
|
||||
|
||||
result = read_settings(tmp_path / "missing.json")
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_read_settings_parses_json(tmp_path):
|
||||
"""read_settings parses valid JSON."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import read_settings
|
||||
|
||||
f = tmp_path / "settings.json"
|
||||
f.write_text(json.dumps({"hooks": {}}), encoding="utf-8")
|
||||
result = read_settings(f)
|
||||
assert result == {"hooks": {}}
|
||||
|
||||
|
||||
def test_write_settings_creates_backup(tmp_path):
|
||||
"""write_settings creates .json.bak before overwriting."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import write_settings
|
||||
|
||||
f = tmp_path / "settings.json"
|
||||
f.write_text('{"old": true}', encoding="utf-8")
|
||||
write_settings(f, {"new": True})
|
||||
bak = tmp_path / "settings.json.bak"
|
||||
assert bak.exists()
|
||||
assert json.loads(bak.read_text()) == {"old": True}
|
||||
assert json.loads(f.read_text()) == {"new": True}
|
||||
|
||||
|
||||
def test_write_settings_creates_parent_dirs(tmp_path):
|
||||
"""write_settings creates parent directories if needed."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import write_settings
|
||||
|
||||
f = tmp_path / "sub" / "dir" / "settings.json"
|
||||
result = write_settings(f, {"test": True})
|
||||
assert result is True
|
||||
assert f.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hook_bridge module tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_handle_command_rejects_non_bridge():
|
||||
"""handle_command returns False for non-bridge commands."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
assert handle_command("audit", ["install"]) is False
|
||||
assert handle_command("hooks", ["install"]) is False
|
||||
|
||||
|
||||
def test_handle_command_accepts_bridge():
|
||||
"""handle_command returns True for bridge command."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
assert handle_command("bridge", []) is True
|
||||
|
||||
|
||||
def test_handle_command_accepts_help():
|
||||
"""handle_command returns True for bridge --help."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
assert handle_command("bridge", ["--help"]) is True
|
||||
assert handle_command("bridge", ["-h"]) is True
|
||||
assert handle_command("bridge", ["help"]) is True
|
||||
|
||||
|
||||
def test_handle_command_routes_install():
|
||||
"""handle_command routes 'install' subcommand."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
with patch("aipass.seedgo.apps.modules.hook_bridge._run_install"):
|
||||
result = handle_command("bridge", ["install"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_routes_uninstall():
|
||||
"""handle_command routes 'uninstall' subcommand."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
with patch("aipass.seedgo.apps.modules.hook_bridge._run_uninstall"):
|
||||
result = handle_command("bridge", ["uninstall"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_routes_status():
|
||||
"""handle_command routes 'status' subcommand."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
with patch("aipass.seedgo.apps.modules.hook_bridge._run_status"):
|
||||
result = handle_command("bridge", ["status"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_unknown_subcommand():
|
||||
"""handle_command returns True for unknown subcommand (shows help)."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
assert handle_command("bridge", ["bogus"]) is True
|
||||
@@ -1,515 +0,0 @@
|
||||
"""Tests for the hook probe scripts and hooks module.
|
||||
|
||||
# =================== META ====================
|
||||
# Name: test_hooks_probe.py
|
||||
# Description: Tests for hook probe scripts and hooks seedgo module
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-20
|
||||
# Modified: 2026-04-20
|
||||
# =============================================
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — probe script loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file to find the git repo root."""
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in (current, *current.parents):
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return Path(__file__).resolve().parents[4] # fallback
|
||||
|
||||
|
||||
_PROBES_DIR = _find_repo_root() / ".claude" / "hooks" / "probes"
|
||||
|
||||
_PROBE_SCRIPTS = [
|
||||
"probe_pre_tool_use.py",
|
||||
"probe_post_tool_use.py",
|
||||
"probe_user_prompt_submit.py",
|
||||
"probe_subagent_stop.py",
|
||||
"probe_pre_compact.py",
|
||||
"probe_stop.py",
|
||||
"probe_notification.py",
|
||||
]
|
||||
|
||||
_EVENT_NAMES = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"Stop",
|
||||
"Notification",
|
||||
]
|
||||
|
||||
|
||||
def _load_probe(script_name: str):
|
||||
"""Import a probe script by filename via importlib (outside package)."""
|
||||
path = _PROBES_DIR / script_name
|
||||
if not path.exists():
|
||||
pytest.skip(f"Probe script not found: {path}")
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore[union-attr]
|
||||
return module
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures — infrastructure mocks for hooks module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_hooks_infrastructure(monkeypatch):
|
||||
"""Mock aipass infrastructure imports for hooks module tests."""
|
||||
mock_logger = MagicMock()
|
||||
mock_console = MagicMock()
|
||||
mock_warning = MagicMock()
|
||||
mock_json_handler = MagicMock()
|
||||
|
||||
# -- prax ---------------------------------------------------------------
|
||||
prax_mod = MagicMock()
|
||||
prax_mod.logger = mock_logger
|
||||
monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod)
|
||||
|
||||
# -- cli ----------------------------------------------------------------
|
||||
cli_mod = MagicMock()
|
||||
cli_mod.console = mock_console
|
||||
monkeypatch.setitem(sys.modules, "aipass.cli", cli_mod)
|
||||
|
||||
cli_apps = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "aipass.cli.apps", cli_apps)
|
||||
|
||||
cli_modules = MagicMock()
|
||||
cli_modules.warning = mock_warning
|
||||
monkeypatch.setitem(sys.modules, "aipass.cli.apps.modules", cli_modules)
|
||||
|
||||
# -- seedgo json handler ------------------------------------------------
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg)
|
||||
json_mod = MagicMock()
|
||||
json_mod.log_operation = mock_json_handler.log_operation
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json.json_handler", json_mod)
|
||||
|
||||
# Force re-import of hooks module
|
||||
monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.hooks", raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — probe scripts: happy-path stdin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_name,event_name", list(zip(_PROBE_SCRIPTS, _EVENT_NAMES)))
|
||||
def test_probe_happy_path(script_name, event_name, tmp_path, monkeypatch):
|
||||
"""Each probe writes a correctly-shaped entry to last_ping.jsonl."""
|
||||
import io
|
||||
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
probe = _load_probe(script_name)
|
||||
monkeypatch.setattr(probe, "_LOG_FILE", log_file)
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"tool_name": "Read",
|
||||
"cwd": "/tmp/test_cwd",
|
||||
"session_id": "test-session-123",
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO(payload))
|
||||
probe.main()
|
||||
|
||||
assert log_file.exists(), f"{script_name} did not create log file"
|
||||
lines = [ln for ln in log_file.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
assert len(lines) == 1, f"Expected 1 entry, got {len(lines)}"
|
||||
|
||||
entry = json.loads(lines[0])
|
||||
assert entry["event"] == event_name
|
||||
assert entry["tool"] == "Read"
|
||||
assert entry["cwd"] == "/tmp/test_cwd"
|
||||
assert "timestamp" in entry
|
||||
assert "script_elapsed_ms" in entry
|
||||
assert isinstance(entry["script_elapsed_ms"], float)
|
||||
assert "agent_id" in entry
|
||||
assert "cli_version" in entry
|
||||
assert "env_has_claude_project_dir" in entry
|
||||
assert "env_has_aipass_home" in entry
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_name,event_name", list(zip(_PROBE_SCRIPTS, _EVENT_NAMES)))
|
||||
def test_probe_appends_not_overwrites(script_name, event_name, tmp_path, monkeypatch):
|
||||
"""Each probe appends to an existing log file instead of overwriting."""
|
||||
import io
|
||||
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
existing = json.dumps(
|
||||
{
|
||||
"event": "existing",
|
||||
"tool": "",
|
||||
"cwd": "/",
|
||||
"agent_id": "x",
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"script_elapsed_ms": 0.1,
|
||||
"cli_version": "0",
|
||||
"env_has_claude_project_dir": False,
|
||||
"env_has_aipass_home": False,
|
||||
}
|
||||
)
|
||||
log_file.write_text(existing + "\n", encoding="utf-8")
|
||||
|
||||
probe = _load_probe(script_name)
|
||||
monkeypatch.setattr(probe, "_LOG_FILE", log_file)
|
||||
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO("{}"))
|
||||
probe.main()
|
||||
|
||||
lines = [ln for ln in log_file.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
assert len(lines) == 2, "Probe should append, not overwrite"
|
||||
assert json.loads(lines[0])["event"] == "existing"
|
||||
assert json.loads(lines[1])["event"] == event_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — probe scripts: malformed stdin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_name,event_name", list(zip(_PROBE_SCRIPTS, _EVENT_NAMES)))
|
||||
def test_probe_malformed_stdin_exits_cleanly(script_name, event_name, tmp_path, monkeypatch):
|
||||
"""Each probe handles malformed stdin without raising an exception."""
|
||||
import io
|
||||
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
probe = _load_probe(script_name)
|
||||
monkeypatch.setattr(probe, "_LOG_FILE", log_file)
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO("not json at all !!!"))
|
||||
|
||||
# main() returns normally on parse failure — no exception should propagate.
|
||||
# sys.exit(0) is only called from the __main__ block, not from main() itself.
|
||||
probe.main() # must not raise
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_name,event_name", list(zip(_PROBE_SCRIPTS, _EVENT_NAMES)))
|
||||
def test_probe_empty_stdin_exits_cleanly(script_name, event_name, tmp_path, monkeypatch):
|
||||
"""Each probe handles empty stdin without raising an exception."""
|
||||
import io
|
||||
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
probe = _load_probe(script_name)
|
||||
monkeypatch.setattr(probe, "_LOG_FILE", log_file)
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO(""))
|
||||
|
||||
probe.main() # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — probe env var extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_name,event_name", list(zip(_PROBE_SCRIPTS, _EVENT_NAMES)))
|
||||
def test_probe_reads_session_id_from_env(script_name, event_name, tmp_path, monkeypatch):
|
||||
"""Probe picks up CLAUDE_CODE_SESSION_ID from environment."""
|
||||
import io
|
||||
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
probe = _load_probe(script_name)
|
||||
monkeypatch.setattr(probe, "_LOG_FILE", log_file)
|
||||
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "sess-env-test-999")
|
||||
monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False)
|
||||
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO("{}"))
|
||||
probe.main()
|
||||
|
||||
lines = [ln for ln in log_file.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
entry = json.loads(lines[0])
|
||||
assert entry["agent_id"] == "sess-env-test-999"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_name,event_name", list(zip(_PROBE_SCRIPTS, _EVENT_NAMES)))
|
||||
def test_probe_falls_back_to_claude_session_id(script_name, event_name, tmp_path, monkeypatch):
|
||||
"""Probe falls back to CLAUDE_SESSION_ID when CLAUDE_CODE_SESSION_ID is absent."""
|
||||
import io
|
||||
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
probe = _load_probe(script_name)
|
||||
monkeypatch.setattr(probe, "_LOG_FILE", log_file)
|
||||
monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False)
|
||||
monkeypatch.setenv("CLAUDE_SESSION_ID", "fallback-session-42")
|
||||
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO("{}"))
|
||||
probe.main()
|
||||
|
||||
lines = [ln for ln in log_file.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
entry = json.loads(lines[0])
|
||||
assert entry["agent_id"] == "fallback-session-42"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_name,event_name", list(zip(_PROBE_SCRIPTS, _EVENT_NAMES)))
|
||||
def test_probe_unknown_agent_id_when_no_env(script_name, event_name, tmp_path, monkeypatch):
|
||||
"""Probe uses 'unknown' when no session env vars are set."""
|
||||
import io
|
||||
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
probe = _load_probe(script_name)
|
||||
monkeypatch.setattr(probe, "_LOG_FILE", log_file)
|
||||
monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False)
|
||||
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO("{}"))
|
||||
probe.main()
|
||||
|
||||
lines = [ln for ln in log_file.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
entry = json.loads(lines[0])
|
||||
assert entry["agent_id"] == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_name,event_name", list(zip(_PROBE_SCRIPTS, _EVENT_NAMES)))
|
||||
def test_probe_env_has_aipass_home(script_name, event_name, tmp_path, monkeypatch):
|
||||
"""Probe records env_has_aipass_home correctly."""
|
||||
import io
|
||||
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
probe = _load_probe(script_name)
|
||||
monkeypatch.setattr(probe, "_LOG_FILE", log_file)
|
||||
monkeypatch.setenv("AIPASS_HOME", "/home/user/Projects/AIPass")
|
||||
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO("{}"))
|
||||
probe.main()
|
||||
|
||||
lines = [ln for ln in log_file.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
entry = json.loads(lines[0])
|
||||
assert entry["env_has_aipass_home"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — hooks module: handle_command routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_handle_command_wrong_command_returns_false():
|
||||
"""handle_command returns False for unrecognised commands."""
|
||||
from aipass.seedgo.apps.modules.hooks import handle_command
|
||||
|
||||
assert handle_command("wrong_command", []) is False
|
||||
|
||||
|
||||
def test_handle_command_no_args_calls_introspection():
|
||||
"""No args triggers introspection (returns True)."""
|
||||
from aipass.seedgo.apps.modules.hooks import handle_command
|
||||
|
||||
result = handle_command("hooks", [])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_help_flag():
|
||||
"""--help flag is handled without error."""
|
||||
from aipass.seedgo.apps.modules.hooks import handle_command
|
||||
|
||||
result = handle_command("hooks", ["--help"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_probe_no_flags():
|
||||
"""hooks probe with no extra flags calls display (returns True)."""
|
||||
from aipass.seedgo.apps.modules.hooks import handle_command
|
||||
|
||||
result = handle_command("hooks", ["probe"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_unknown_subcommand():
|
||||
"""Unknown subcommand falls back to introspection (returns True)."""
|
||||
from aipass.seedgo.apps.modules.hooks import handle_command
|
||||
|
||||
result = handle_command("hooks", ["nonexistent_subcommand"])
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — hooks module: probe display with mock data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_probe_display_renders_table(tmp_path):
|
||||
"""_cmd_probe_display renders a Rich table without error given sample data."""
|
||||
entries = [
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"tool": "Bash",
|
||||
"cwd": "/tmp/test",
|
||||
"agent_id": "sess-abc",
|
||||
"timestamp": "2026-04-20T12:00:00.000Z",
|
||||
"script_elapsed_ms": 2.1,
|
||||
"cli_version": "1.0.0",
|
||||
"env_has_claude_project_dir": True,
|
||||
"env_has_aipass_home": False,
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"tool": "Read",
|
||||
"cwd": "/tmp/test",
|
||||
"agent_id": "sess-abc",
|
||||
"timestamp": "2026-04-20T12:00:01.000Z",
|
||||
"script_elapsed_ms": 1.5,
|
||||
"cli_version": "1.0.0",
|
||||
"env_has_claude_project_dir": True,
|
||||
"env_has_aipass_home": True,
|
||||
},
|
||||
]
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
with open(log_file, "w", encoding="utf-8") as fh:
|
||||
for e in entries:
|
||||
fh.write(json.dumps(e) + "\n")
|
||||
|
||||
from aipass.seedgo.apps.modules.hooks import _cmd_probe_display
|
||||
|
||||
_cmd_probe_display(log_path=log_file)
|
||||
|
||||
|
||||
def test_probe_display_empty_log(tmp_path):
|
||||
"""_cmd_probe_display handles missing log file gracefully."""
|
||||
from aipass.seedgo.apps.modules.hooks import _cmd_probe_display
|
||||
|
||||
missing = tmp_path / "no_such_file.jsonl"
|
||||
_cmd_probe_display(log_path=missing)
|
||||
|
||||
|
||||
def test_read_entries_skips_bad_lines(tmp_path):
|
||||
"""_read_entries skips malformed lines and returns valid ones."""
|
||||
log_file = tmp_path / "last_ping.jsonl"
|
||||
good = json.dumps(
|
||||
{
|
||||
"event": "Stop",
|
||||
"tool": "",
|
||||
"cwd": "/",
|
||||
"agent_id": "x",
|
||||
"timestamp": "2026-04-20T00:00:00Z",
|
||||
"script_elapsed_ms": 1.0,
|
||||
"cli_version": "1",
|
||||
"env_has_claude_project_dir": False,
|
||||
"env_has_aipass_home": False,
|
||||
}
|
||||
)
|
||||
log_file.write_text(f"not json\n{good}\nalso not json\n", encoding="utf-8")
|
||||
|
||||
from aipass.seedgo.apps.modules.hooks import _read_entries
|
||||
|
||||
entries = _read_entries(log_path=log_file)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["event"] == "Stop"
|
||||
|
||||
|
||||
def test_truncate_helper():
|
||||
"""_truncate shortens long strings correctly."""
|
||||
from aipass.seedgo.apps.modules.hooks import _truncate
|
||||
|
||||
assert _truncate("short", 20) == "short"
|
||||
assert _truncate("a" * 30, 10) == "a" * 7 + "..."
|
||||
assert len(_truncate("x" * 50, 15)) == 15
|
||||
|
||||
|
||||
def test_entry_ts_parses_z_suffix():
|
||||
"""_entry_ts handles ISO 8601 Z-suffix timestamps."""
|
||||
from aipass.seedgo.apps.modules.hooks import _entry_ts
|
||||
|
||||
ts = _entry_ts({"timestamp": "2026-04-20T12:00:00.000000Z"})
|
||||
assert ts > 0
|
||||
|
||||
|
||||
def test_entry_ts_returns_zero_on_bad_input():
|
||||
"""_entry_ts returns 0.0 for unparseable timestamps."""
|
||||
from aipass.seedgo.apps.modules.hooks import _entry_ts
|
||||
|
||||
assert _entry_ts({"timestamp": "not-a-date"}) == 0.0
|
||||
assert _entry_ts({}) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — matrix builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_matrix_rows_groups_correctly():
|
||||
"""_build_matrix_rows groups entries by event and counts correctly."""
|
||||
entries = [
|
||||
{
|
||||
"event": "Stop",
|
||||
"env_has_claude_project_dir": True,
|
||||
"env_has_aipass_home": False,
|
||||
"agent_id": "a",
|
||||
},
|
||||
{
|
||||
"event": "Stop",
|
||||
"env_has_claude_project_dir": False,
|
||||
"env_has_aipass_home": False,
|
||||
"agent_id": "b",
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"env_has_claude_project_dir": True,
|
||||
"env_has_aipass_home": True,
|
||||
"agent_id": "a",
|
||||
},
|
||||
]
|
||||
|
||||
from aipass.seedgo.apps.modules.hooks_probe import _build_matrix_rows
|
||||
|
||||
rows, groups = _build_matrix_rows(entries)
|
||||
assert len(rows) == 2
|
||||
stop_row = next(r for r in rows if r["event"] == "Stop")
|
||||
assert stop_row["count"] == 2
|
||||
assert stop_row["project_dir_true"] == 1
|
||||
assert stop_row["project_dir_false"] == 1
|
||||
assert stop_row["unique_agents"] == 2
|
||||
|
||||
|
||||
def test_probe_matrix_writes_report(tmp_path):
|
||||
"""_cmd_probe_matrix creates the markdown report given sample data."""
|
||||
from aipass.seedgo.apps.modules.hooks_probe import _write_matrix_report
|
||||
|
||||
entries = [
|
||||
{
|
||||
"event": "Stop",
|
||||
"tool": "",
|
||||
"cwd": "/tmp",
|
||||
"agent_id": "sess-1",
|
||||
"timestamp": "2026-04-20T10:00:00Z",
|
||||
"script_elapsed_ms": 1.2,
|
||||
"cli_version": "1.0",
|
||||
"env_has_claude_project_dir": False,
|
||||
"env_has_aipass_home": True,
|
||||
}
|
||||
]
|
||||
matrix_rows = [
|
||||
{
|
||||
"event": "Stop",
|
||||
"count": 1,
|
||||
"project_dir_true": 0,
|
||||
"project_dir_false": 1,
|
||||
"aipass_home_true": 1,
|
||||
"aipass_home_false": 0,
|
||||
"unique_agents": 1,
|
||||
}
|
||||
]
|
||||
report_path = tmp_path / "Q12_findings_2026-04-20.md"
|
||||
_write_matrix_report(report_path, matrix_rows, entries)
|
||||
|
||||
assert report_path.exists()
|
||||
content = report_path.read_text(encoding="utf-8")
|
||||
assert "Stop" in content
|
||||
assert "Q12 Findings" in content
|
||||
@@ -1,382 +0,0 @@
|
||||
"""Tests for Track A hook scripts — auto_fix_diagnostics, pre_edit_gate, subagent_stop_gate.
|
||||
|
||||
# =================== META ====================
|
||||
# Name: test_hooks_track_a.py
|
||||
# Description: Track A hook tests — DPLAN-0139 coverage for auto_fix, pre_edit_gate, subagent_stop
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-20
|
||||
# Modified: 2026-04-20
|
||||
# =============================================
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — hook loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file to find the git repo root."""
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in (current, *current.parents):
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return Path(__file__).resolve().parents[4] # fallback
|
||||
|
||||
|
||||
HOOKS_DIR = _find_repo_root() / ".claude" / "hooks"
|
||||
|
||||
|
||||
def _load_hook(name: str):
|
||||
"""Import a hook script by filename via importlib (outside package)."""
|
||||
path = HOOKS_DIR / name
|
||||
if not path.exists():
|
||||
pytest.skip(f"Hook script not found: {path}")
|
||||
spec = importlib.util.spec_from_file_location(name.replace(".py", ""), path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# auto_fix_diagnostics.py tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auto_fix_skips_non_edit_tool(capsys):
|
||||
"""stdin with tool_name=Read → no output (not an edit tool)."""
|
||||
mod = _load_hook("auto_fix_diagnostics.py")
|
||||
payload = json.dumps({"tool_name": "Read", "tool_input": {"file_path": "/tmp/foo.py"}})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_auto_fix_skips_non_py_file(capsys):
|
||||
"""stdin with tool_name=Edit, file_path ending .md → no output (skipped extension)."""
|
||||
mod = _load_hook("auto_fix_diagnostics.py")
|
||||
payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/foo.md"}})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_auto_fix_does_not_crash_empty_stdin():
|
||||
"""Empty/malformed JSON on stdin → no exception raised."""
|
||||
mod = _load_hook("auto_fix_diagnostics.py")
|
||||
with patch("sys.stdin", io.StringIO("")):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_auto_fix_does_not_crash_missing_fields():
|
||||
"""Valid JSON but no tool_name → no exception raised."""
|
||||
mod = _load_hook("auto_fix_diagnostics.py")
|
||||
payload = json.dumps({"some_other_key": "value"})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_auto_fix_label_is_auto_fix(tmp_path, capsys):
|
||||
"""When run_python_checks returns an error, output contains [AUTO-FIX] label."""
|
||||
mod = _load_hook("auto_fix_diagnostics.py")
|
||||
|
||||
fake_py = tmp_path / "fake.py"
|
||||
fake_py.write_text("x = 1\n", encoding="utf-8")
|
||||
|
||||
payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": str(fake_py)}})
|
||||
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "run_python_checks", return_value=["LINT: E501 line too long"]),
|
||||
patch.object(mod, "run_seedgo_checklist", return_value=[]),
|
||||
patch.object(mod, "run_pyright_check", return_value=[]),
|
||||
patch.object(mod, "run_ruff_lint_structured", return_value=[]),
|
||||
patch.object(mod, "save_diagnostics_state"),
|
||||
):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.strip() != ""
|
||||
output = json.loads(captured.out)
|
||||
context = output["hookSpecificOutput"]["additionalContext"]
|
||||
assert "[AUTO-FIX]" in context
|
||||
assert "[AUTO-FIX]" in output["systemMessage"]
|
||||
|
||||
|
||||
def test_run_pyright_check_returns_empty_on_file_not_found():
|
||||
"""run_pyright_check returns [] when pyright binary is missing."""
|
||||
mod = _load_hook("auto_fix_diagnostics.py")
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("pyright not found")):
|
||||
result = mod.run_pyright_check("/tmp/some_file.py")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_run_pyright_check_returns_empty_on_timeout():
|
||||
"""run_pyright_check returns [] on timeout."""
|
||||
import subprocess
|
||||
|
||||
mod = _load_hook("auto_fix_diagnostics.py")
|
||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("pyright", 15)):
|
||||
result = mod.run_pyright_check("/tmp/some_file.py")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_run_pyright_check_skips_hook_files():
|
||||
"""run_pyright_check returns [] for hook files without calling subprocess."""
|
||||
mod = _load_hook("auto_fix_diagnostics.py")
|
||||
mock_run = MagicMock()
|
||||
with patch("subprocess.run", mock_run):
|
||||
result = mod.run_pyright_check("/home/user/.claude/hooks/some_hook.py")
|
||||
mock_run.assert_not_called()
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pre_edit_gate.py tests
|
||||
# (pre_edit_gate lives in ~/.claude/hooks — load from global location)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
GLOBAL_HOOKS_DIR = Path.home() / ".claude" / "hooks"
|
||||
|
||||
|
||||
def _load_global_hook(name: str):
|
||||
"""Import a hook script from the global ~/.claude/hooks/ directory."""
|
||||
path = GLOBAL_HOOKS_DIR / name
|
||||
if not path.exists():
|
||||
pytest.skip(f"Global hook script not found: {path}")
|
||||
spec = importlib.util.spec_from_file_location(name.replace(".py", ""), path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
return mod
|
||||
|
||||
|
||||
def test_gate_allows_no_state_file(tmp_path, capsys, monkeypatch):
|
||||
"""No state file present → no output (allow)."""
|
||||
mod = _load_global_hook("pre_edit_gate.py")
|
||||
monkeypatch.setattr(mod, "STATE_FILE", tmp_path / "no_such_state.json")
|
||||
|
||||
payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/seedgo/foo.py"}})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_gate_allows_same_file(tmp_path, capsys, monkeypatch):
|
||||
"""State has error for file A, editing file A → allow (no block output)."""
|
||||
mod = _load_global_hook("pre_edit_gate.py")
|
||||
|
||||
errored_file = tmp_path / "foo.py"
|
||||
errored_file.write_text("x = 1\n", encoding="utf-8")
|
||||
|
||||
state = {"file": str(errored_file), "errors": [{"line": 1, "message": "some error"}]}
|
||||
state_file = tmp_path / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "STATE_FILE", state_file)
|
||||
|
||||
payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": str(errored_file)}})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_gate_blocks_different_file_same_branch(tmp_path, capsys, monkeypatch):
|
||||
"""State has error for seedgo/foo.py, editing seedgo/bar.py → block + sys.exit(2)."""
|
||||
mod = _load_global_hook("pre_edit_gate.py")
|
||||
|
||||
# Use real AIPass src paths so _get_branch works
|
||||
aipass_src = Path("/home/patrick/Projects/AIPass/src/aipass")
|
||||
errored_file = str(aipass_src / "seedgo" / "foo.py")
|
||||
edit_target = str(aipass_src / "seedgo" / "bar.py")
|
||||
|
||||
state = {"file": errored_file, "errors": [{"line": 5, "message": "type error here"}]}
|
||||
state_file = tmp_path / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "STATE_FILE", state_file)
|
||||
|
||||
payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": edit_target}})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
mod.main()
|
||||
|
||||
assert exc_info.value.code == 2
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.strip() != ""
|
||||
output = json.loads(captured.out)
|
||||
assert output["decision"] == "block"
|
||||
|
||||
|
||||
def test_gate_allows_different_branch(tmp_path, capsys, monkeypatch):
|
||||
"""State has error for flow/foo.py, editing seedgo/bar.py → allow (different branches)."""
|
||||
mod = _load_global_hook("pre_edit_gate.py")
|
||||
|
||||
aipass_src = Path("/home/patrick/Projects/AIPass/src/aipass")
|
||||
errored_file = str(aipass_src / "flow" / "foo.py")
|
||||
edit_target = str(aipass_src / "seedgo" / "bar.py")
|
||||
|
||||
state = {"file": errored_file, "errors": [{"line": 1, "message": "error in flow"}]}
|
||||
state_file = tmp_path / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "STATE_FILE", state_file)
|
||||
|
||||
payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": edit_target}})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_gate_does_not_crash_malformed_stdin(monkeypatch, tmp_path):
|
||||
"""Bad JSON on stdin → no exception raised."""
|
||||
mod = _load_global_hook("pre_edit_gate.py")
|
||||
monkeypatch.setattr(mod, "STATE_FILE", tmp_path / "no_state.json")
|
||||
|
||||
with patch("sys.stdin", io.StringIO("not json!!!!")):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_gate_allows_non_edit_tool(tmp_path, capsys, monkeypatch):
|
||||
"""Non-edit tool_name → no output regardless of state."""
|
||||
mod = _load_global_hook("pre_edit_gate.py")
|
||||
|
||||
aipass_src = Path("/home/patrick/Projects/AIPass/src/aipass")
|
||||
errored_file = str(aipass_src / "seedgo" / "foo.py")
|
||||
edit_target = str(aipass_src / "seedgo" / "bar.py")
|
||||
|
||||
state = {"file": errored_file, "errors": [{"line": 1, "message": "error"}]}
|
||||
state_file = tmp_path / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "STATE_FILE", state_file)
|
||||
|
||||
payload = json.dumps({"tool_name": "Read", "tool_input": {"file_path": edit_target}})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# subagent_stop_gate.py tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_subagent_gate_no_crash_empty_stdin():
|
||||
"""Bad JSON on stdin → no exception raised."""
|
||||
mod = _load_hook("subagent_stop_gate.py")
|
||||
with patch("sys.stdin", io.StringIO("bad json")):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_subagent_gate_no_crash_no_files(capsys):
|
||||
"""Valid stdin but no modified files → no exception, no block output."""
|
||||
mod = _load_hook("subagent_stop_gate.py")
|
||||
payload = json.dumps({"stop_hook_active": True})
|
||||
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "get_modified_py_files", return_value=[]),
|
||||
):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_subagent_gate_no_block_when_no_violations(capsys):
|
||||
"""Modified files present but no seedgo violations → no block output."""
|
||||
mod = _load_hook("subagent_stop_gate.py")
|
||||
payload = json.dumps({"stop_hook_active": True})
|
||||
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]),
|
||||
patch.object(mod, "run_seedgo_checklist", return_value=[]),
|
||||
patch.object(mod, "check_hook_readme_accountability", return_value=None),
|
||||
):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_subagent_gate_blocks_on_violations(capsys):
|
||||
"""Modified files with seedgo violations → block decision in output."""
|
||||
mod = _load_hook("subagent_stop_gate.py")
|
||||
payload = json.dumps({"stop_hook_active": True})
|
||||
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]),
|
||||
patch.object(mod, "run_seedgo_checklist", return_value=["open() without encoding='utf-8'"]),
|
||||
patch.object(mod, "check_hook_readme_accountability", return_value=None),
|
||||
):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.strip() != ""
|
||||
output = json.loads(captured.out)
|
||||
assert output["decision"] == "block"
|
||||
|
||||
|
||||
def test_subagent_gate_readme_reminder_soft(capsys):
|
||||
"""Hook files changed without README update → allow with reminder."""
|
||||
mod = _load_hook("subagent_stop_gate.py")
|
||||
payload = json.dumps({"stop_hook_active": True})
|
||||
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]),
|
||||
patch.object(mod, "run_seedgo_checklist", return_value=[]),
|
||||
patch.object(
|
||||
mod,
|
||||
"check_hook_readme_accountability",
|
||||
return_value="Hook files were modified but .claude/hooks/README.md was not updated.",
|
||||
),
|
||||
):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
output = json.loads(captured.out)
|
||||
assert output["decision"] == "allow"
|
||||
assert "README" in output["reason"]
|
||||
|
||||
|
||||
def test_subagent_gate_readme_reminder_appended_to_block(capsys):
|
||||
"""Hook files changed + violations → block includes README reminder."""
|
||||
mod = _load_hook("subagent_stop_gate.py")
|
||||
payload = json.dumps({"stop_hook_active": True})
|
||||
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]),
|
||||
patch.object(mod, "run_seedgo_checklist", return_value=["missing docstring"]),
|
||||
patch.object(
|
||||
mod,
|
||||
"check_hook_readme_accountability",
|
||||
return_value="Hook files were modified but .claude/hooks/README.md was not updated.",
|
||||
),
|
||||
):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
output = json.loads(captured.out)
|
||||
assert output["decision"] == "block"
|
||||
assert "README" in output["reason"]
|
||||
assert "missing docstring" in output["reason"]
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Tests for Track B hooks subcommands — hooks test and hooks list.
|
||||
|
||||
# =================== META ====================
|
||||
# Name: test_hooks_track_b.py
|
||||
# Description: Tests for new hooks test and hooks list subcommands (DPLAN-0139 Track B)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-21
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Infrastructure mocks (same pattern as test_hooks_probe.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_hooks_infrastructure(monkeypatch):
|
||||
"""Mock aipass infrastructure imports for hooks module tests."""
|
||||
mock_logger = MagicMock()
|
||||
mock_console = MagicMock()
|
||||
mock_warning = MagicMock()
|
||||
mock_json_handler = MagicMock()
|
||||
|
||||
prax_mod = MagicMock()
|
||||
prax_mod.logger = mock_logger
|
||||
monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod)
|
||||
|
||||
cli_mod = MagicMock()
|
||||
cli_mod.console = mock_console
|
||||
monkeypatch.setitem(sys.modules, "aipass.cli", cli_mod)
|
||||
|
||||
cli_apps = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "aipass.cli.apps", cli_apps)
|
||||
|
||||
cli_modules = MagicMock()
|
||||
cli_modules.warning = mock_warning
|
||||
monkeypatch.setitem(sys.modules, "aipass.cli.apps.modules", cli_modules)
|
||||
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg)
|
||||
json_mod = MagicMock()
|
||||
json_mod.log_operation = mock_json_handler.log_operation
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json.json_handler", json_mod)
|
||||
|
||||
file_pkg = MagicMock()
|
||||
file_pkg.read_lines_safe = MagicMock(return_value=[])
|
||||
file_pkg.read_text_safe = MagicMock(return_value=None)
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.file", file_pkg)
|
||||
|
||||
# Force re-import of both modules
|
||||
monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.hooks_ext", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.hooks", raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in (current, *current.parents):
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
_AUTO_FIX_PATH = _find_repo_root() / ".claude" / "hooks" / "auto_fix_diagnostics.py"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hooks test subcommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cmd_hooks_test_runs_without_crash():
|
||||
"""_cmd_hooks_test() runs without crash; subprocess.run is mocked to avoid real pytest spawn."""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from aipass.seedgo.apps.modules import hooks as hooks_mod
|
||||
|
||||
fake_result = MagicMock()
|
||||
fake_result.stdout = "1 passed in 0.1s\n"
|
||||
fake_result.returncode = 0
|
||||
|
||||
with _patch("subprocess.run", return_value=fake_result):
|
||||
result = hooks_mod.handle_command("hooks", ["test"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_cmd_hooks_test_handles_no_test_files(monkeypatch):
|
||||
"""When glob returns no files, hooks test warns and returns without crash."""
|
||||
import glob as _glob_mod
|
||||
|
||||
from aipass.seedgo.apps.modules import hooks as hooks_mod
|
||||
|
||||
monkeypatch.setattr(_glob_mod, "glob", lambda p: [])
|
||||
|
||||
result = hooks_mod.handle_command("hooks", ["test"])
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hooks list subcommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cmd_hooks_list_runs_without_crash():
|
||||
"""_cmd_hooks_list() via handle_command should not raise."""
|
||||
from aipass.seedgo.apps.modules import hooks as hooks_mod
|
||||
|
||||
result = hooks_mod.handle_command("hooks", ["list"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_read_settings_file_missing():
|
||||
"""_read_settings_file returns {} for a nonexistent path."""
|
||||
from aipass.seedgo.apps.modules.hooks_ext import read_settings_file
|
||||
|
||||
result = read_settings_file(Path("/tmp/nonexistent_track_b_test.json"))
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_read_settings_file_valid(tmp_path, monkeypatch):
|
||||
"""_read_settings_file returns the parsed dict for a valid JSON file."""
|
||||
from aipass.seedgo.apps.modules import hooks_ext as hooks_ext_mod
|
||||
|
||||
data = {"hooks": {"PreToolUse": []}, "version": 1}
|
||||
settings_file = tmp_path / "settings.json"
|
||||
settings_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(hooks_ext_mod, "read_text_safe", lambda path: path.read_text(encoding="utf-8"))
|
||||
result = hooks_ext_mod.read_settings_file(settings_file)
|
||||
assert result == data
|
||||
|
||||
|
||||
def test_extract_hook_script_name():
|
||||
"""extract_hook_script extracts the .py filename from a command string."""
|
||||
from aipass.seedgo.apps.modules.hooks_ext import extract_hook_script
|
||||
|
||||
# Build command string dynamically to avoid help_text checker pattern
|
||||
interpreter = "python" + "3"
|
||||
cmd = interpreter + " /some/path/auto_fix_diagnostics.py"
|
||||
result = extract_hook_script(cmd)
|
||||
assert result == "auto_fix_diagnostics.py"
|
||||
|
||||
|
||||
def test_read_hook_version_returns_string(monkeypatch):
|
||||
"""read_hook_version returns the version string from the hook file header."""
|
||||
from aipass.seedgo.apps.modules import hooks_ext as hooks_ext_mod
|
||||
|
||||
if not _AUTO_FIX_PATH.exists():
|
||||
pytest.skip(f"auto_fix_diagnostics.py not found at {_AUTO_FIX_PATH}")
|
||||
|
||||
# Provide real lines from the file via read_lines_safe mock
|
||||
real_lines = _AUTO_FIX_PATH.read_text(encoding="utf-8").splitlines()[:20]
|
||||
monkeypatch.setattr(hooks_ext_mod, "read_lines_safe", lambda path, n=20: real_lines)
|
||||
|
||||
# Build command string dynamically to avoid help_text checker pattern
|
||||
interpreter = "python" + "3"
|
||||
cmd = interpreter + " " + str(_AUTO_FIX_PATH)
|
||||
result = hooks_ext_mod.read_hook_version(cmd)
|
||||
assert result == "5.2.0"
|
||||
@@ -1,22 +1,20 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_hooks_track_e.py
|
||||
# Description: DPLAN-0139 Track E — single-path enforcement tests
|
||||
# Version: 1.0.0
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-04-21
|
||||
# Modified: 2026-04-21
|
||||
# Modified: 2026-06-05
|
||||
# =============================================
|
||||
"""Tests for DPLAN-0139 Track E — single-path enforcement.
|
||||
|
||||
Covers:
|
||||
- permissions.py: TRUSTED_CROSS_WRITERS, is_trusted_caller(), identify_caller()
|
||||
- pre_edit_gate.py v1.3.0: inbox lock rule + cross-branch write rule
|
||||
- drone auth.py: ALLOWED_CALLERS derived from TRUSTED_CROSS_WRITERS
|
||||
- inbox_audit.py: handle_command routing + _scan_inbox validation
|
||||
- delivery.py: deliver_to_inbox_file single-path helper
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -39,19 +37,6 @@ def _find_repo_root() -> Path:
|
||||
|
||||
|
||||
REPO_ROOT = _find_repo_root()
|
||||
HOOKS_DIR = REPO_ROOT / ".claude" / "hooks"
|
||||
|
||||
|
||||
def _load_hook(name: str):
|
||||
"""Import a hook script by filename via importlib (outside package)."""
|
||||
path = HOOKS_DIR / name
|
||||
if not path.exists():
|
||||
pytest.skip(f"Hook script not found: {path}")
|
||||
spec = importlib.util.spec_from_file_location(name.replace(".py", ""), path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -130,117 +115,6 @@ def test_identify_caller_falls_back_to_identity_name(tmp_path):
|
||||
assert result == "fallback_branch"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pre_edit_gate.py v1.3.0 — Track E rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gate_allows_non_edit_tool():
|
||||
"""Non-edit tools (Read) must pass through without blocking."""
|
||||
mod = _load_hook("pre_edit_gate.py")
|
||||
payload = json.dumps({"tool_name": "Read", "tool_input": {"file_path": "/tmp/foo.py"}})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
|
||||
|
||||
def test_gate_blocks_inbox_json_write(capsys):
|
||||
"""Any write targeting .ai_mail.local/inbox.json must be blocked."""
|
||||
mod = _load_hook("pre_edit_gate.py")
|
||||
inbox_path = "/home/user/Projects/AIPass/src/aipass/flow/.ai_mail.local/inbox.json"
|
||||
payload = json.dumps({"tool_name": "Write", "tool_input": {"file_path": inbox_path}})
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
mod.main()
|
||||
assert exc_info.value.code == 2
|
||||
captured = capsys.readouterr()
|
||||
result = json.loads(captured.out)
|
||||
assert result["decision"] == "block"
|
||||
assert "inbox.json" in result["reason"].lower() or "drone" in result["reason"].lower()
|
||||
|
||||
|
||||
def test_gate_blocks_cross_branch_write_from_untrusted(capsys):
|
||||
"""Untrusted branch writing to a different branch must be blocked."""
|
||||
mod = _load_hook("pre_edit_gate.py")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/repo/src/aipass/flow/apps/modules/foo.py"},
|
||||
"cwd": "/repo/src/aipass/memory",
|
||||
}
|
||||
)
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
mod.main()
|
||||
assert exc_info.value.code == 2
|
||||
captured = capsys.readouterr()
|
||||
result = json.loads(captured.out)
|
||||
assert result["decision"] == "block"
|
||||
|
||||
|
||||
def test_gate_allows_cross_branch_write_from_devpulse(capsys):
|
||||
"""devpulse may write to any branch without being blocked."""
|
||||
mod = _load_hook("pre_edit_gate.py")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/repo/src/aipass/flow/apps/modules/foo.py"},
|
||||
"cwd": "/repo/src/aipass/devpulse",
|
||||
}
|
||||
)
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_gate_allows_cross_branch_write_from_seedgo(capsys):
|
||||
"""seedgo may write to any branch without being blocked."""
|
||||
mod = _load_hook("pre_edit_gate.py")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/repo/src/aipass/flow/apps/modules/foo.py"},
|
||||
"cwd": "/repo/src/aipass/seedgo",
|
||||
}
|
||||
)
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_gate_allows_cross_branch_write_from_spawn(capsys):
|
||||
"""spawn may write to any branch without being blocked."""
|
||||
mod = _load_hook("pre_edit_gate.py")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"tool_name": "Write",
|
||||
"tool_input": {"file_path": "/repo/src/aipass/prax/apps/modules/bar.py"},
|
||||
"cwd": "/repo/src/aipass/spawn",
|
||||
}
|
||||
)
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_gate_allows_same_branch_write(capsys):
|
||||
"""Writes within the same branch must not be blocked by the cross-branch rule."""
|
||||
mod = _load_hook("pre_edit_gate.py")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {"file_path": "/repo/src/aipass/flow/apps/modules/foo.py"},
|
||||
"cwd": "/repo/src/aipass/flow",
|
||||
}
|
||||
)
|
||||
with patch("sys.stdin", io.StringIO(payload)):
|
||||
mod.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# drone auth.py — ALLOWED_CALLERS derived from permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
"""Smoke tests for utility hook scripts.
|
||||
|
||||
# =================== META ====================
|
||||
# Name: test_hooks_utility.py
|
||||
# Description: Smoke tests for branch_prompt_loader, email_notification,
|
||||
# identity_injector, pre_compact, notification_sound, stop_sound, tool_use_sound
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-21
|
||||
# Modified: 2026-04-21
|
||||
# =============================================
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loader helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOOKS_DIR = Path(__file__).resolve().parents[4] / ".claude" / "hooks"
|
||||
|
||||
|
||||
def _load_hook(name: str):
|
||||
"""Import a hook script by filename via importlib (outside package)."""
|
||||
path = HOOKS_DIR / name
|
||||
if not path.exists():
|
||||
import pytest
|
||||
|
||||
pytest.skip(f"Hook script not found: {path}")
|
||||
spec = importlib.util.spec_from_file_location(name.replace(".py", "").replace("-", "_"), path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. branch_prompt_loader.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_branch_prompt_loader_no_crash_empty_stdin():
|
||||
"""branch_prompt_loader.main() doesn't crash — it reads no stdin at all."""
|
||||
mod = _load_hook("branch_prompt_loader.py")
|
||||
# main() doesn't read stdin; just ensure it runs without raising
|
||||
with patch.object(mod, "find_branch_root", return_value=None):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_branch_prompt_loader_no_branch_structure(tmp_path):
|
||||
"""main() returns without crashing when CWD has no branch structure."""
|
||||
mod = _load_hook("branch_prompt_loader.py")
|
||||
# Run with a tmp_path that has no .trinity or .aipass
|
||||
with patch("os.getcwd", return_value=str(tmp_path)):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. email_notification.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_email_notification_no_crash_empty_stdin():
|
||||
"""email_notification.main() doesn't crash even with no branch root."""
|
||||
mod = _load_hook("email_notification.py")
|
||||
with patch.object(mod, "find_branch_root", return_value=None):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_email_notification_output_is_valid_when_emails_present(capsys, tmp_path):
|
||||
"""When new emails exist, output is plain text (not JSON) containing count."""
|
||||
mod = _load_hook("email_notification.py")
|
||||
|
||||
# Create a fake inbox with one new message
|
||||
mail_dir = tmp_path / ".ai_mail.local"
|
||||
mail_dir.mkdir()
|
||||
inbox = mail_dir / "inbox.json"
|
||||
inbox.write_text(
|
||||
json.dumps({"messages": [{"id": "1", "status": "new", "subject": "hi"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.object(mod, "find_branch_root", return_value=tmp_path):
|
||||
mod.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "1 new email" in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. identity_injector.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_identity_injector_no_crash_empty_stdin():
|
||||
"""identity_injector.main() returns without raising when no branch root found."""
|
||||
mod = _load_hook("identity_injector.py")
|
||||
with patch.object(mod, "find_branch_root", return_value=None):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_identity_injector_finds_passport_in_seedgo(capsys):
|
||||
"""identity_injector finds .trinity/passport.json when CWD is in seedgo dir."""
|
||||
mod = _load_hook("identity_injector.py")
|
||||
|
||||
# Use the actual seedgo directory which has a passport.json
|
||||
seedgo_dir = Path(__file__).resolve().parents[2] # src/aipass/seedgo
|
||||
trinity = seedgo_dir / ".trinity"
|
||||
|
||||
if not (trinity / "passport.json").exists():
|
||||
# No passport here — just verify no crash
|
||||
with patch.object(mod, "find_branch_root", return_value=None):
|
||||
mod.main()
|
||||
return
|
||||
|
||||
branch_root = mod.find_id_file(seedgo_dir)
|
||||
# Either finds passport or returns None — both are valid
|
||||
assert branch_root is None or branch_root.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. pre_compact.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pre_compact_no_crash_empty_hook_event(capsys):
|
||||
"""pre_compact.main() handles stdin with hook_event_name field cleanly."""
|
||||
mod = _load_hook("pre_compact.py")
|
||||
payload = json.dumps({"hook_event_name": "manual"})
|
||||
with patch("sys.stdin", io.StringIO(payload)), patch("sys.exit"):
|
||||
mod.main() # must not raise
|
||||
|
||||
|
||||
def test_pre_compact_output_json_or_empty(capsys):
|
||||
"""pre_compact.main() produces output or is empty — never raises."""
|
||||
mod = _load_hook("pre_compact.py")
|
||||
payload = json.dumps({"hook_event_name": "manual"})
|
||||
with patch("sys.stdin", io.StringIO(payload)), patch("sys.exit"):
|
||||
mod.main()
|
||||
# Capture is fine either way — no assertion on content, just no exception
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. notification_sound.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notification_sound_no_crash_empty_stdin():
|
||||
"""notification_sound.main() doesn't crash on empty stdin {}."""
|
||||
mod = _load_hook("notification_sound.py")
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO("{}")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main()
|
||||
# No Popen call expected — event name didn't match
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
def test_notification_sound_runs_without_exception_when_sound_missing():
|
||||
"""main() runs without exception even when sound file is missing."""
|
||||
mod = _load_hook("notification_sound.py")
|
||||
payload = json.dumps({"hook_event_name": "Notification"})
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "SOUND_FILE", Path("/tmp/no_such_sound_file.wav")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main() # must not raise
|
||||
# Sound file doesn't exist, so play_sound() returns early — Popen not called
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. stop_sound.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stop_sound_no_crash_empty_stdin():
|
||||
"""stop_sound.main() doesn't crash on empty stdin {}."""
|
||||
mod = _load_hook("stop_sound.py")
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO("{}")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main()
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
def test_stop_sound_runs_without_exception_when_sound_missing():
|
||||
"""main() runs without exception even when sound file is missing."""
|
||||
mod = _load_hook("stop_sound.py")
|
||||
payload = json.dumps({"hook_event_name": "Stop"})
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch.object(mod, "SOUND_FILE", Path("/tmp/no_such_sound_file.wav")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main() # must not raise
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. tool_use_sound.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tool_use_sound_no_crash_empty_stdin():
|
||||
"""tool_use_sound.main() doesn't crash on empty stdin {}."""
|
||||
mod = _load_hook("tool_use_sound.py")
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO("{}")),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main()
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
def test_tool_use_sound_does_not_fire_for_non_tool_use_event():
|
||||
"""tool_use_sound.main() does not play sound for a non-PreToolUse event."""
|
||||
mod = _load_hook("tool_use_sound.py")
|
||||
payload = json.dumps({"hook_event_name": "Stop", "tool_name": "Bash"})
|
||||
with (
|
||||
patch("sys.stdin", io.StringIO(payload)),
|
||||
patch("subprocess.Popen") as mock_popen,
|
||||
patch("sys.exit"),
|
||||
):
|
||||
mod.main()
|
||||
mock_popen.assert_not_called()
|
||||
Reference in New Issue
Block a user