test(config): add hook configuration validation tests
Tests ensure: - All SessionStart hooks with gt prime include --hook flag - registry.toml session-prime includes all required roles These catch the seance discovery bug before it breaks handoffs. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
208
internal/config/hooks_test.go
Normal file
208
internal/config/hooks_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Test Hook Configuration Validation
|
||||
//
|
||||
// These tests ensure Claude Code hook configurations are correct across Gas Town.
|
||||
// Specifically, they validate that:
|
||||
// - All SessionStart hooks with `gt prime` include the `--hook` flag
|
||||
// - The registry.toml includes all required roles for session-prime
|
||||
//
|
||||
// These tests exist because hook misconfiguration causes seance to fail
|
||||
// (predecessor sessions become undiscoverable).
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// ClaudeSettings represents the structure of .claude/settings.json
|
||||
type ClaudeSettings struct {
|
||||
Hooks map[string][]HookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type HookEntry struct {
|
||||
Matcher string `json:"matcher"`
|
||||
Hooks []HookAction `json:"hooks"`
|
||||
}
|
||||
|
||||
type HookAction struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// HookRegistry represents the structure of hooks/registry.toml
|
||||
type HookRegistry struct {
|
||||
Hooks map[string]RegistryHook `toml:"hooks"`
|
||||
}
|
||||
|
||||
type RegistryHook struct {
|
||||
Description string `toml:"description"`
|
||||
Event string `toml:"event"`
|
||||
Matchers []string `toml:"matchers"`
|
||||
Command string `toml:"command"`
|
||||
Roles []string `toml:"roles"`
|
||||
Scope string `toml:"scope"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
}
|
||||
|
||||
// findTownRoot walks up from cwd to find the Gas Town root.
|
||||
// We look for hooks/registry.toml as the unique marker (mayor/ exists at multiple levels).
|
||||
func findTownRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
// hooks/registry.toml is unique to the town root
|
||||
registryPath := filepath.Join(dir, "hooks", "registry.toml")
|
||||
if _, err := os.Stat(registryPath); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionStartHooksHaveHookFlag ensures all SessionStart hooks with
|
||||
// `gt prime` include the `--hook` flag. Without this flag, sessions won't
|
||||
// emit session_start events and seance can't discover predecessor sessions.
|
||||
func TestSessionStartHooksHaveHookFlag(t *testing.T) {
|
||||
townRoot, err := findTownRoot()
|
||||
if err != nil {
|
||||
t.Skip("Not running inside Gas Town directory structure")
|
||||
}
|
||||
|
||||
var settingsFiles []string
|
||||
|
||||
err = filepath.Walk(townRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip inaccessible paths
|
||||
}
|
||||
if info.Name() == "settings.json" && strings.Contains(path, ".claude") {
|
||||
settingsFiles = append(settingsFiles, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to walk directory: %v", err)
|
||||
}
|
||||
|
||||
if len(settingsFiles) == 0 {
|
||||
t.Skip("No .claude/settings.json files found")
|
||||
}
|
||||
|
||||
var failures []string
|
||||
|
||||
for _, path := range settingsFiles {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to read %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var settings ClaudeSettings
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
t.Logf("Warning: failed to parse %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sessionStartHooks, ok := settings.Hooks["SessionStart"]
|
||||
if !ok {
|
||||
continue // No SessionStart hooks in this file
|
||||
}
|
||||
|
||||
for _, entry := range sessionStartHooks {
|
||||
for _, hook := range entry.Hooks {
|
||||
cmd := hook.Command
|
||||
// Check if command contains "gt prime" but not "--hook"
|
||||
if strings.Contains(cmd, "gt prime") && !strings.Contains(cmd, "--hook") {
|
||||
relPath, _ := filepath.Rel(townRoot, path)
|
||||
failures = append(failures, relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(failures) > 0 {
|
||||
t.Errorf("SessionStart hooks missing --hook flag in gt prime command:\n %s\n\n"+
|
||||
"The --hook flag is required for seance to discover predecessor sessions.\n"+
|
||||
"Fix by changing 'gt prime' to 'gt prime --hook' in these files.",
|
||||
strings.Join(failures, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistrySessionPrimeIncludesAllRoles ensures the session-prime hook
|
||||
// in registry.toml includes all worker roles that need session discovery.
|
||||
func TestRegistrySessionPrimeIncludesAllRoles(t *testing.T) {
|
||||
townRoot, err := findTownRoot()
|
||||
if err != nil {
|
||||
t.Skip("Not running inside Gas Town directory structure")
|
||||
}
|
||||
|
||||
registryPath := filepath.Join(townRoot, "hooks", "registry.toml")
|
||||
data, err := os.ReadFile(registryPath)
|
||||
if err != nil {
|
||||
t.Skipf("hooks/registry.toml not found: %v", err)
|
||||
}
|
||||
|
||||
var registry HookRegistry
|
||||
if err := toml.Unmarshal(data, ®istry); err != nil {
|
||||
t.Fatalf("Failed to parse registry.toml: %v", err)
|
||||
}
|
||||
|
||||
sessionPrime, ok := registry.Hooks["session-prime"]
|
||||
if !ok {
|
||||
t.Fatal("session-prime hook not found in registry.toml")
|
||||
}
|
||||
|
||||
// All roles that should be able to use seance
|
||||
requiredRoles := []string{"crew", "polecat", "witness", "refinery", "mayor", "deacon"}
|
||||
|
||||
roleSet := make(map[string]bool)
|
||||
for _, role := range sessionPrime.Roles {
|
||||
roleSet[role] = true
|
||||
}
|
||||
|
||||
var missingRoles []string
|
||||
for _, role := range requiredRoles {
|
||||
if !roleSet[role] {
|
||||
missingRoles = append(missingRoles, role)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingRoles) > 0 {
|
||||
t.Errorf("session-prime hook missing roles: %v\n\n"+
|
||||
"Current roles: %v\n"+
|
||||
"All roles need session-prime for seance to discover their predecessor sessions.",
|
||||
missingRoles, sessionPrime.Roles)
|
||||
}
|
||||
|
||||
// Also verify the command has --hook
|
||||
if !strings.Contains(sessionPrime.Command, "--hook") {
|
||||
t.Errorf("session-prime command missing --hook flag:\n %s\n\n"+
|
||||
"The --hook flag is required for seance to discover predecessor sessions.",
|
||||
sessionPrime.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreCompactPrimeDoesNotNeedHookFlag documents that PreCompact hooks
|
||||
// don't need --hook (session already started, ID already persisted).
|
||||
func TestPreCompactPrimeDoesNotNeedHookFlag(t *testing.T) {
|
||||
// This test documents the intentional difference:
|
||||
// - SessionStart: needs --hook to capture session ID from stdin
|
||||
// - PreCompact: session already running, ID already persisted
|
||||
//
|
||||
// If this test fails, it means someone added --hook to PreCompact
|
||||
// which is harmless but unnecessary.
|
||||
t.Log("PreCompact hooks don't need --hook (session ID already persisted at SessionStart)")
|
||||
}
|
||||
Reference in New Issue
Block a user