Skip to content

Commit 4945d8b

Browse files
authored
fix(install): register every hook as a matcher group, not just SessionStart (#167)
Claude Code expects each hook event to be a list of matcher groups holding a nested `hooks` array. `Stop` was written as a flat list of entries, on the premise that the two events genuinely differed in shape — and the comment stating that premise predicted its own failure mode exactly: the config parses and the hook never fires. It never fired. Every event is now written grouped; mergeFlat is gone. mergeMatcher treats an empty matcher as "the group with no matcher key", found again by that same absence, so repeated installs stay idempotent and uninstall still removes exactly enola's entry (comparing group["matcher"] to "" matches no group at all — the value is nil). An existing installation has to migrate. A flat entry is a map with no `hooks` key; fed to the group merger unguarded it would have a hooks array grafted onto a command entry, producing a hybrid that parses and does nothing — the defect surviving its own fix. enola's own legacy entry is dropped, anyone else's is preserved verbatim, and uninstall removes both shapes. The blast radius recorded in DEFECTS_FOUND.md was wrong, and the truth is worse: a malformed entry invalidates the whole hooks block, so the correctly-shaped SessionStart beside it did not fire either. `install --hooks` configured nothing at all — no baseline pinned, no grading — while reporting success. Verified across three configurations, one variable each; the correction is recorded with the defect. Covered by TestStopHook_FiresInARealSession (opt-in, ENOLA_E2E=1): installs the hooks as a user does, pins a baseline, closes a real dependency cycle, ends a headless session, and asserts the hook both ran and produced the verdict. It fails on the code it replaces. The unit tests around it assert mechanics only — grouping as an invariant across every event, idempotency, migration, clean uninstall, foreign entries untouched — because a unit test can only check the shape against the belief that produced it, which is how this survived the first time.
1 parent 5370777 commit 4945d8b

4 files changed

Lines changed: 482 additions & 46 deletions

File tree

pkg/install/hooks.go

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ const enolaHookMarker = "enola"
2323
type hookSpec struct {
2424
Event string
2525
Subcommand string
26-
// Matcher, when set, means this event takes matcher-GROUPED entries rather than a
27-
// flat list. The two shapes are not interchangeable: writing one where the other is
28-
// expected produces a config that parses cleanly and never fires.
26+
// Matcher narrows an event to particular occasions of it — SessionStart fires on
27+
// startup, resume and clear, and enola wants the first two. An empty Matcher means
28+
// the event applies unconditionally, NOT that the entry takes a different shape:
29+
// every event is written as a list of matcher groups, and a group with no matcher
30+
// simply omits the key. See writeHooks.
2931
Matcher string
3032
Description string
3133
}
@@ -73,26 +75,29 @@ func writeHooks(o Options, remove bool) ([]Result, error) {
7375
hooks = map[string]any{}
7476
}
7577

76-
// Stop takes a flat list of entries; SessionStart takes matcher-grouped ones.
77-
// The events genuinely differ in shape, and getting it wrong is the silent kind
78-
// of failure: the config parses and the hook simply never fires.
78+
// EVERY event is a list of matcher groups, each holding a nested `hooks` array.
79+
// Events differ in whether they carry a matcher, not in their shape.
80+
//
81+
// This code used to write Stop as a flat list of entries, on the stated premise
82+
// that the two events genuinely differed — and the comment above it predicted
83+
// its own failure mode exactly: the config parses and the hook simply never
84+
// fires. It never fired. `SessionStart` pinned a baseline before editing while
85+
// `Stop` silently graded nothing, which is the worst of the three possible
86+
// states: everything looks configured and the half that produces the value is
87+
// absent. Verified against a real session, both shapes, one variable.
7988
for _, h := range installedHooks {
8089
entry := map[string]any{
8190
"type": "command",
8291
"command": o.hookCommand() + " " + h.Subcommand,
8392
"timeout": hookTimeoutSeconds,
8493
"source": enolaHookMarker,
8594
}
86-
if h.Matcher != "" {
87-
hooks[h.Event] = mergeMatcher(hooks[h.Event], h.Matcher, entry, remove)
88-
} else {
89-
hooks[h.Event] = mergeFlat(hooks[h.Event], entry, remove)
90-
}
95+
hooks[h.Event] = mergeMatcher(hooks[h.Event], h.Matcher, entry, remove)
9196
}
9297

93-
for _, k := range []string{"Stop", "SessionStart"} {
94-
if lst, ok := hooks[k].([]any); ok && len(lst) == 0 {
95-
delete(hooks, k)
98+
for _, h := range installedHooks {
99+
if lst, ok := hooks[h.Event].([]any); ok && len(lst) == 0 {
100+
delete(hooks, h.Event)
96101
}
97102
}
98103
if len(hooks) == 0 {
@@ -107,24 +112,14 @@ func writeHooks(o Options, remove bool) ([]Result, error) {
107112
return []Result{r}, nil
108113
}
109114

110-
// mergeFlat adds or removes one entry in a flat hook list, preserving every other entry.
111-
func mergeFlat(existing any, entry map[string]any, remove bool) any {
112-
list, _ := existing.([]any)
113-
out := make([]any, 0, len(list)+1)
114-
for _, e := range list {
115-
if !isEnolaEntry(e) {
116-
out = append(out, e)
117-
}
118-
}
119-
if !remove {
120-
out = append(out, entry)
121-
}
122-
return out
123-
}
124-
125-
// mergeMatcher adds or removes one entry inside a matcher-grouped hook list. An existing
126-
// group with the same matcher is reused so the user does not accumulate duplicate groups;
127-
// groups belonging to anything else are left exactly as they were.
115+
// mergeMatcher adds or removes one entry inside an event's list of matcher groups. An
116+
// existing group with the same matcher is reused so the user does not accumulate
117+
// duplicate groups; groups belonging to anything else are left exactly as they were.
118+
//
119+
// An empty matcher means the group that carries no matcher key — the event applying
120+
// unconditionally. Such a group is written without the key and recognised again by the
121+
// same absence, so a second install updates it in place rather than appending a
122+
// duplicate, and uninstall still finds it.
128123
func mergeMatcher(existing any, matcher string, entry map[string]any, remove bool) any {
129124
groups, _ := existing.([]any)
130125
out := make([]any, 0, len(groups)+1)
@@ -136,14 +131,25 @@ func mergeMatcher(existing any, matcher string, entry map[string]any, remove boo
136131
out = append(out, g)
137132
continue
138133
}
134+
// A map with no `hooks` key is a bare command entry from a flat list — the
135+
// shape enola wrote for Stop before this was fixed, which parses and never
136+
// fires. Ours is dropped, which is how an existing installation migrates.
137+
// Anyone else's is preserved verbatim: it is their file, and a hook that does
138+
// not fire is still not ours to delete.
139+
if _, grouped := group["hooks"]; !grouped {
140+
if !isEnolaEntry(group) {
141+
out = append(out, g)
142+
}
143+
continue
144+
}
139145
inner, _ := group["hooks"].([]any)
140146
kept := make([]any, 0, len(inner))
141147
for _, e := range inner {
142148
if !isEnolaEntry(e) {
143149
kept = append(kept, e)
144150
}
145151
}
146-
if !remove && group["matcher"] == matcher {
152+
if !remove && matcherOf(group) == matcher {
147153
kept = append(kept, entry)
148154
placed = true
149155
}
@@ -157,14 +163,26 @@ func mergeMatcher(existing any, matcher string, entry map[string]any, remove boo
157163
}
158164

159165
if !remove && !placed {
160-
out = append(out, map[string]any{
161-
"matcher": matcher,
162-
"hooks": []any{entry},
163-
})
166+
group := map[string]any{"hooks": []any{entry}}
167+
if matcher != "" {
168+
group["matcher"] = matcher
169+
}
170+
out = append(out, group)
164171
}
165172
return out
166173
}
167174

175+
// matcherOf returns a group's matcher, treating an absent or non-string value as the
176+
// empty matcher — the group that applies unconditionally.
177+
//
178+
// Comparing group["matcher"] to a string directly cannot see that group: the value is
179+
// nil, and nil never equals "". Every install would then append another group instead
180+
// of updating the one already there, and uninstall would leave it behind.
181+
func matcherOf(group map[string]any) string {
182+
s, _ := group["matcher"].(string)
183+
return s
184+
}
185+
168186
// isEnolaEntry reports whether a hook entry is one enola installed. Identified by an
169187
// explicit marker rather than by matching the command string, so a user who edits the
170188
// command — adding a flag, pointing at a different binary — still gets a clean uninstall.

pkg/install/hooks_e2e_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package install
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"runtime"
9+
"strings"
10+
"testing"
11+
"time"
12+
)
13+
14+
// This is the check that would have caught the Stop hook never firing, and the only
15+
// kind that could have.
16+
//
17+
// The failure mode is a configuration that parses, reports success, and does
18+
// nothing. Every cheaper check passed while it was broken: `enola hook stop`
19+
// produced the right verdict when invoked by hand, the installer wrote the file it
20+
// meant to write, and a unit test asserted the shape against the same belief that
21+
// produced it. What settled it was ending a real session and looking at whether the
22+
// hook ran. So this test installs the hooks the way a user does, ends a session with
23+
// a real regression present, and asserts the verdict came out — the same discipline
24+
// TestPublishedExample_StillDemonstratesWhatItClaims applies to examples/cross-repo.
25+
//
26+
// It is opt-in because it spawns a real agent session. ENOLA_E2E=1 runs it. The skip
27+
// names its reason rather than passing silently: a test that quietly does nothing is
28+
// the thing being tested here.
29+
func TestStopHook_FiresInARealSession(t *testing.T) {
30+
if os.Getenv("ENOLA_E2E") != "1" {
31+
t.Skip("set ENOLA_E2E=1 to run: this spawns a real Claude Code session")
32+
}
33+
if runtime.GOOS == "windows" {
34+
t.Skip("the hook wrapper is a POSIX shell script")
35+
}
36+
claude, err := exec.LookPath("claude")
37+
if err != nil {
38+
t.Skip("claude not on PATH: the Stop hook cannot be exercised end to end")
39+
}
40+
41+
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
42+
defer cancel()
43+
44+
work := t.TempDir()
45+
enola := buildEnola(ctx, t, work)
46+
repo := writeCyclePendingRepo(t, work)
47+
48+
// Pin the baseline BEFORE the regression exists — this is the "before" the Stop
49+
// hook grades against. Pinned deliberately (no auto-pin marker), so the
50+
// SessionStart hook leaves it alone and the session cannot re-baseline the
51+
// regression away.
52+
runCLI(ctx, t, repo, enola, "baseline", "pin", repo)
53+
54+
// Close the cycle. b already imports a; now a imports b.
55+
writeFile(t, filepath.Join(repo, "a", "a.go"), `package a
56+
57+
import "example.com/e2e/b"
58+
59+
// A now calls back into b, closing a cycle.
60+
func A() string { return "a" + b.B() }
61+
`)
62+
63+
// The hook command is a wrapper around the real binary: it records that it ran
64+
// at all, and tees the hook's stdout. Those are two different questions — the
65+
// defect was that the hook never ran, not that it answered wrongly — and only
66+
// the first is visible from outside the session.
67+
log := filepath.Join(work, "hooks")
68+
if err := os.MkdirAll(log, 0o755); err != nil {
69+
t.Fatal(err)
70+
}
71+
wrapper := filepath.Join(work, "enola-hook-wrapper")
72+
writeFile(t, wrapper, "#!/bin/sh\n"+
73+
"printf '%s\\n' \"$*\" >> '"+filepath.Join(log, "fired.log")+"'\n"+
74+
"'"+enola+"' \"$@\" 2>> '"+filepath.Join(log, "stderr.log")+"'"+
75+
" | tee -a '"+filepath.Join(log, "stdout.log")+"'\n")
76+
if err := os.Chmod(wrapper, 0o755); err != nil {
77+
t.Fatal(err)
78+
}
79+
80+
if _, err := Install(Options{
81+
Scope: ScopeLocal,
82+
RepoDir: repo,
83+
HomeDir: t.TempDir(),
84+
Hooks: true,
85+
HookCommand: wrapper,
86+
Targets: []string{"claude"},
87+
}); err != nil {
88+
t.Fatalf("Install: %v", err)
89+
}
90+
91+
settings := filepath.Join(repo, ".claude", "settings.json")
92+
session := exec.CommandContext(ctx, claude, "-p", "Say OK", "--settings", settings)
93+
session.Dir = repo
94+
if out, err := session.CombinedOutput(); err != nil {
95+
t.Fatalf("claude session failed: %v\n%s", err, out)
96+
}
97+
98+
fired := readIfPresent(filepath.Join(log, "fired.log"))
99+
stdout := readIfPresent(filepath.Join(log, "stdout.log"))
100+
101+
if !strings.Contains(fired, "hook stop") {
102+
t.Errorf("the Stop hook never ran.\nhooks that did run:\n%s\nsettings.json:\n%s",
103+
fired, readIfPresent(settings))
104+
}
105+
// Firing is necessary but not sufficient: a hook that runs and stays silent
106+
// leaves the session ungraded just as completely.
107+
if !strings.Contains(stdout, "structural regression") {
108+
t.Errorf("the Stop hook produced no regression verdict for a repo with a real cycle.\n"+
109+
"stdout:\n%s\nstderr:\n%s", stdout, readIfPresent(filepath.Join(log, "stderr.log")))
110+
}
111+
// SessionStart was already correct, and is the control: if neither fired, the
112+
// session itself did not run the way this test assumes.
113+
if !strings.Contains(fired, "hook session-start") {
114+
t.Errorf("SessionStart did not fire either — the session did not run as assumed:\n%s", fired)
115+
}
116+
}
117+
118+
// buildEnola compiles the binary under test. The hook invokes enola as a
119+
// subprocess, so an e2e run has to exercise a real build rather than this package.
120+
func buildEnola(ctx context.Context, t *testing.T, work string) string {
121+
t.Helper()
122+
bin := filepath.Join(work, "enola")
123+
cmd := exec.CommandContext(ctx, "go", "build", "-o", bin, "./cmd/enola")
124+
cmd.Dir = filepath.Join("..", "..")
125+
if out, err := cmd.CombinedOutput(); err != nil {
126+
t.Fatalf("building enola: %v\n%s", err, out)
127+
}
128+
return bin
129+
}
130+
131+
// writeCyclePendingRepo creates a two-package Go module with a single edge b -> a.
132+
// Adding the opposite edge closes a cycle, which is what the default policy fails on.
133+
func writeCyclePendingRepo(t *testing.T, work string) string {
134+
t.Helper()
135+
repo := filepath.Join(work, "repo")
136+
writeFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2e\n\ngo 1.25\n")
137+
writeFile(t, filepath.Join(repo, "a", "a.go"), `package a
138+
139+
// A is the leaf.
140+
func A() string { return "a" }
141+
`)
142+
writeFile(t, filepath.Join(repo, "b", "b.go"), `package b
143+
144+
import "example.com/e2e/a"
145+
146+
// B depends on a.
147+
func B() string { return a.A() }
148+
`)
149+
return repo
150+
}
151+
152+
func writeFile(t *testing.T, path, content string) {
153+
t.Helper()
154+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
155+
t.Fatal(err)
156+
}
157+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
158+
t.Fatal(err)
159+
}
160+
}
161+
162+
func runCLI(ctx context.Context, t *testing.T, dir, bin string, args ...string) {
163+
t.Helper()
164+
cmd := exec.CommandContext(ctx, bin, args...)
165+
cmd.Dir = dir
166+
if out, err := cmd.CombinedOutput(); err != nil {
167+
t.Fatalf("%s %s: %v\n%s", filepath.Base(bin), strings.Join(args, " "), err, out)
168+
}
169+
}
170+
171+
func readIfPresent(path string) string {
172+
b, err := os.ReadFile(path)
173+
if err != nil {
174+
return "<file not written>"
175+
}
176+
return string(b)
177+
}

0 commit comments

Comments
 (0)