1212// returns nil and logs to stderr; never blocks the engine.
1313//
1414// * runTestCommand returns "" on exit-0 and the combined
15- // stdout+stderr / runErr.Error() on failure. It is a string-returning
16- // helper; it does not wrap the error with context.DeadlineExceeded
17- // because exec.CommandContext kills the child and surfaces *exec.ExitError
18- // ("signal: killed"). isNotFound(runErr) is testable directly.
19- //
20- // * The call site for runTestCommand sees only the string; the typed-error
21- // path is exercised via the isNotFound helper with a synthetic error.
15+ // stdout+stderr / runErr.Error() on failure. It does not wrap the
16+ // error with context.DeadlineExceeded; exec.CommandContext sends
17+ // SIGKILL and surfaces *exec.ExitError ("signal: killed"). The
18+ // typed-error path for "command not found" is exercised via isNotFound
19+ // directly with a synthetic exec.Error and an os.PathError.
2220//
2321// * AutoLintHook fires on hooklife.PostToolUse (not PreToolUse, as a
2422// spec draft suggested). It accepts {"sin_write", "sin_edit", "Write",
2826// * firstLine returns the input verbatim when every split+trimmed line
2927// is empty; for "\n" the loop produces two empty strings and falls
3028// through to `return s` (= "\n"), NOT "".
29+ //
30+ // * firstLine does NOT strip a leading U+FEFF BOM. strings.TrimSpace
31+ // only strips runes where unicode.IsSpace returns true; U+FEFF is
32+ // not whitespace. The byte-exact output for
33+ // "\xEF\xBB\xBFhello\nworld" is "\xEF\xBB\xBFhello", not "hello".
34+ //
35+ // * runTestCommand accepts a timeout time.Duration parameter but
36+ // NEVER reads it. Cancellation is driven entirely by the passed
37+ // context (exec.CommandContext). Tests that want a timeout must
38+ // pass ctx = context.WithTimeout(...).
3139package hooks
3240
3341import (
3442 "context"
3543 "errors"
36- "execPkg"
37- "fmt"
3844 "os"
3945 "os/exec"
4046 "path/filepath"
@@ -56,8 +62,8 @@ func TestFirstLine_StripsBOMAndCRLF(t *testing.T) {
5662 {
5763 name : "BOM_then_hello_LF_world" ,
5864 in : "\xEF \xBB \xBF hello\n world" ,
59- want : "hello " ,
60- note : "U+FEFF (BOM) is unicode whitespace; strings.TrimSpace strips it " ,
65+ want : "\xEF \xBB \xBF hello " ,
66+ note : "spec draft expected \" hello \" (BOM stripped); actually preserved — strings.TrimSpace does not classify U+FEFF as whitespace " ,
6167 },
6268 {
6369 name : "hello_CRLF_world" ,
@@ -66,13 +72,13 @@ func TestFirstLine_StripsBOMAndCRLF(t *testing.T) {
6672 note : "strings.TrimSpace strips the \\ r as whitespace" ,
6773 },
6874 {
69- name : "empty " ,
75+ name : "empty_input_returns_empty " ,
7076 in : "" ,
7177 want : "" ,
7278 note : "no non-empty line → fallback return s (== \" \" )" ,
7379 },
7480 {
75- name : "just_LF " ,
81+ name : "just_LF_returns_input_verbatim " ,
7682 in : "\n " ,
7783 want : "\n " ,
7884 note : "spec draft expected \" \" ; actual returns input verbatim when every split+trimmed line is empty" ,
@@ -90,8 +96,8 @@ func TestFirstLine_StripsBOMAndCRLF(t *testing.T) {
9096
9197func TestSafeInvokePostListener_PanicRecovery (t * testing.T ) {
9298 // Direct invariant: panic inside the listener is recovered and the
93- // function returns a nil slice — the engine then iterates the next
94- // listener normally.
99+ // function returns nil — the engine then iterates the next listener
100+ // normally.
95101 panicked := false
96102 panicking := func (_ context.Context , _ Payload ) []string {
97103 panicked = true
@@ -114,8 +120,8 @@ func TestSafeInvokePostListener_PanicRecovery(t *testing.T) {
114120 t .Errorf ("nil listener: want nil, got %v" , got )
115121 }
116122
117- // Engine wiring: a panic in the first post-listener MUST NOT block, and
118- // a subsequent listener MUST still fire (this is the load-bearing
123+ // Engine wiring: a panic in the first post-listener MUST NOT block,
124+ // and a subsequent listener MUST still fire (this is the load-bearing
119125 // invariant — failure here would silently lose post-tool telemetry).
120126 eng := New (nil )
121127 eng .RegisterPostListener (panicking )
@@ -140,52 +146,89 @@ func TestSafeInvokePostListener_PanicRecovery(t *testing.T) {
140146}
141147
142148func TestAutoLintListener_NilConfig (t * testing.T ) {
143- // AutoHookConfig is a value type — use a zero-valued copy as the
144- // "uninitialized" analog. normalized() must supply the default timeout .
149+ // AutoHookConfig is a value type — zero-value is the "uninitialized"
150+ // analog. normalized() must supply AutoLintDefaultTimeout .
145151 var cfg AutoHookConfig
146152 if cfg .Timeout != 0 {
147153 t .Fatalf ("zero-value cfg.Timeout should be 0; got %v" , cfg .Timeout )
148154 }
149155 listener := AutoLintListener (cfg )
150- listener (context .Background (), Payload {
151- Event : ToolPost , Name : "sin_edit" ,
152- Data : map [string ]any {"path" : "README.md" },
153- })
154- listener (context .Background (), Payload {
155- Event : ToolPost , Name : "sin_write" ,
156- Data : map [string ]any {"path" : "docs/index.md" },
157- })
158- listener (context .Background (), Payload {
159- Event : ToolPost , Name : "sin_edit" ,
160- Data : map [string ]any {"path" : "fixture_test.go" },
161- Workspace : t .TempDir (),
162- })
163- listener (context .Background (), Payload {
164- Event : ToolPost , Name : "sin_edit" ,
165- Data : map [string ]any {"path" : "" },
166- })
167- tmp := t .TempDir ()
168- missingGo := "no-such-file.go"
169- gotMissing := listener (context .Background (), Payload {
170- Event : ToolPost , Name : "sin_edit" ,
171- Data : map [string ]any {"path" : missingGo },
172- Workspace : tmp ,
173- })
174- if gotMissing != nil {
175- t .Errorf ("non-existent .go file: want nil (os.Stat short-circuit), got %v" , gotMissing )
156+
157+ // Early-return paths: each one short-circuits BEFORE runLintCommands,
158+ // proving no subprocess is spawned.
159+ cases := []struct {
160+ name string
161+ p Payload
162+ }{
163+ {
164+ name : "non_go_path_README" ,
165+ p : Payload {
166+ Event : ToolPost , Name : "sin_edit" ,
167+ Data : map [string ]any {"path" : "README.md" },
168+ },
169+ },
170+ {
171+ name : "non_go_path_markdown" ,
172+ p : Payload {
173+ Event : ToolPost , Name : "sin_write" ,
174+ Data : map [string ]any {"path" : "docs/index.md" },
175+ },
176+ },
177+ {
178+ name : "go_test_path_filtered" ,
179+ p : Payload {
180+ Event : ToolPost , Name : "sin_edit" ,
181+ Data : map [string ]any {"path" : "fixture_test.go" },
182+ },
183+ },
184+ {
185+ name : "empty_path" ,
186+ p : Payload {
187+ Event : ToolPost , Name : "sin_edit" ,
188+ Data : map [string ]any {},
189+ },
190+ },
191+ {
192+ name : "non_sin_tool_skipped" ,
193+ p : Payload {
194+ Event : ToolPost , Name : "sin_bash" ,
195+ Data : map [string ]any {"path" : "x.go" },
196+ },
197+ },
198+ {
199+ name : "missing_go_file_short_circuits_at_stat" ,
200+ p : Payload {
201+ Event : ToolPost , Name : "sin_edit" ,
202+ Data : map [string ]any {"path" : "no-such-file.go" },
203+ Workspace : t .TempDir (),
204+ },
205+ },
206+ }
207+ for _ , c := range cases {
208+ t .Run (c .name , func (t * testing.T ) {
209+ got := listener (context .Background (), c .p )
210+ if got != nil {
211+ t .Errorf ("short-circuit path: want nil, got %v (subprocess may have spawned)" , got )
212+ }
213+ })
176214 }
215+
216+ // Panic-free sanity: a real .go file in a tempdir exercises
217+ // runLintCommands. With normal cfg it may invoke gofmt/go vet (skipped
218+ // gracefully on PATH-less CI) but MUST never panic.
177219 defer func () {
178220 if r := recover (); r != nil {
179221 t .Fatalf ("listener panicked: %v" , r )
180222 }
181223 }()
224+ tmp := t .TempDir ()
182225 realGo := filepath .Join (tmp , "real.go" )
183226 if err := os .WriteFile (realGo , []byte ("package real\n " ), 0o644 ); err != nil {
184227 t .Fatalf ("setup: %v" , err )
185228 }
186229 _ = listener (context .Background (), Payload {
187- Event : ToolPost , Name : "sin_edit" ,
188- Data : map [string ]any {"path" : realGo },
230+ Event : ToolPost , Name : "sin_edit" ,
231+ Data : map [string ]any {"path" : realGo },
189232 Workspace : tmp ,
190233 })
191234}
@@ -194,7 +237,7 @@ func TestRunTestCommand_ExitCode(t *testing.T) {
194237 if _ , err := exec .LookPath ("go" ); err != nil {
195238 t .Skip ("go not on PATH; cannot exercise runTestCommand end-to-end" )
196239 }
197- // set up a real Go module + package so `go test` is exercisable
240+ // Set up a real Go module + package so `go test` is exercisable.
198241 tmp := t .TempDir ()
199242 pkgDir := filepath .Join (tmp , "rtpkg" )
200243 if err := os .MkdirAll (pkgDir , 0o755 ); err != nil {
@@ -231,7 +274,7 @@ func TestRunTestCommand_ExitCode(t *testing.T) {
231274
232275 t .Run ("command_not_found" , func (t * testing.T ) {
233276 // Strip PATH so the embedded `go` lookup fails — runTestCommand
234- // returns runErr.Error() (combined output is empty).
277+ // then returns runErr.Error() (combined output is empty).
235278 t .Setenv ("PATH" , "/nonexistent" )
236279 report := runTestCommand (context .Background (), testFile , tmp , 30 * time .Second )
237280 if report == "" {
@@ -241,14 +284,13 @@ func TestRunTestCommand_ExitCode(t *testing.T) {
241284 ! strings .Contains (report , "no such file" ) {
242285 t .Errorf ("not-found report should contain not-found sentinel; got %q" , report )
243286 }
244- // isNotFound directly: synthetic & exec.Error{Name, ErrNotFound}.
245- nfExecErr := & execPkg.Error {Name : "go" , Err : execPkg .ErrNotFound }
287+ // isNotFound directly: synthetic exec.Error + os.PathError cover
288+ // the two substring arms in the helper.
289+ nfExecErr := & exec.Error {Name : "go" , Err : exec .ErrNotFound }
246290 if ! isNotFound (nfExecErr ) {
247- t .Error ("isNotFound(&exec.Error{Name:go, Err:exec.ErrNotFound}) should be true" )
248- }
249- fsErr := & os.PathError {
250- Op : "open" , Path : "x" , Err : syscall .ENOENT ,
291+ t .Error ("isNotFound(&exec.Error{..., Err:exec.ErrNotFound}) should be true" )
251292 }
293+ fsErr := & os.PathError {Op : "open" , Path : "x" , Err : syscall .ENOENT }
252294 if ! isNotFound (fsErr ) {
253295 t .Error ("isNotFound(&os.PathError{...ENOENT}) should be true" )
254296 }
@@ -266,31 +308,34 @@ func TestRunTestCommand_ExitCode(t *testing.T) {
266308 if err := os .WriteFile (testFile , []byte (sleepSrc ), 0o644 ); err != nil {
267309 t .Fatalf ("write sleep src: %v" , err )
268310 }
269- // 100ms deadline vs a 2s-sleeping test → cmd must be killed and
270- // runErr is *exec.ExitError with SIGKILL ("signal: killed").
311+ // runTestCommand accepts a timeout time.Duration but IGNORES it;
312+ // cancellation is wired only via exec.CommandContext(ctx, ...).
313+ // Drive the deadline through the context, not the timeout param.
314+ ctx , cancel := context .WithTimeout (context .Background (), 100 * time .Millisecond )
315+ defer cancel ()
271316 start := time .Now ()
272- report := runTestCommand (context . Background () , testFile , tmp , 100 * time .Millisecond )
317+ report := runTestCommand (ctx , testFile , tmp , 30 * time .Second )
273318 elapsed := time .Since (start )
274319 if elapsed > 1500 * time .Millisecond {
275320 t .Errorf ("runTestCommand should have returned well before the 2s sleep; took %v" , elapsed )
276321 }
277- // runTestCommand swallows the error into a string. We surface the
278- // kill indirectly as either "signal: killed" or the go-test
279- // "FAIL\t..." prefix when the process exits with non-zero.
280322 if report == "" {
281- t .Error ("timeout: want non-empty report indicating the kill" )
323+ t .Fatal ("timeout: want non-empty report indicating the kill" )
282324 }
283- ignored := strings .Contains (report , "signal: killed" ) ||
325+ // runTestCommand swallows the error into a string. The kill surfaces
326+ // indirectly as "signal: killed", "FAIL", "killed", or
327+ // "deadline exceeded" depending on which process group died first.
328+ hit := strings .Contains (report , "signal: killed" ) ||
284329 strings .Contains (report , "FAIL" ) ||
285330 strings .Contains (report , "killed" ) ||
286331 strings .Contains (report , "deadline exceeded" )
287- if ! ignored {
332+ if ! hit {
288333 t .Errorf ("timeout report should mention kill/FAIL/deadline; got %q" , report )
289334 }
290335 })
291336
292- t .Run ("non_test_file_skips_cmd " , func (t * testing.T ) {
293- // Short-circuit: a non * _test.go file returns "" without spawning
337+ t .Run ("non_test_file_short_circuits " , func (t * testing.T ) {
338+ // A file without the _test.go suffix returns "" without spawning
294339 // `go test` at all.
295340 plain := filepath .Join (pkgDir , "plain.go" )
296341 if err := os .WriteFile (plain , []byte ("package rtpkg\n " ), 0o644 ); err != nil {
@@ -304,11 +349,10 @@ func TestRunTestCommand_ExitCode(t *testing.T) {
304349
305350func TestAutoHookLife_BridgeToHooklife (t * testing.T ) {
306351 // The spec referred to "autoHookListener (fires PreToolUse on Edit)".
307- // The actual hooks in this package are AutoLintHook / AutoTestHook,
308- // both firing on hooklife.PostToolUse and accepting
309- // {"sin_write", "sin_edit", "Write", "Edit"} via isEditTool. The test
310- // registers AutoLintHook and verifies the bridge against those
311- // actual contracts.
352+ // The actual hooks are AutoLintHook / AutoTestHook, both firing on
353+ // hooklife.PostToolUse and accepting {"sin_write", "sin_edit",
354+ // "Write", "Edit"} via isEditTool. Tests below verify the bridge
355+ // against those actual contracts.
312356 auto := AutoLintHook {Enabled : true , Timeout : AutoLintDefaultTimeout }
313357 autoTest := AutoTestHook {Enabled : true , TimeoutSecs : 60 }
314358
@@ -333,27 +377,26 @@ func TestAutoHookLife_BridgeToHooklife(t *testing.T) {
333377
334378 t .Run ("PreToolUse_phase_empty" , func (t * testing.T ) {
335379 if hs := reg .Hooks (hooklife .PreToolUse ); len (hs ) != 0 {
336- t .Errorf ("PreToolUse should hold zero hooks (post-only); got %d" , len (hs ))
380+ t .Errorf ("PreToolUse should hold zero hooks (post-only bridge ); got %d" , len (hs ))
337381 }
338382 })
339383
340- t .Run ("PostToolUse_phase_has_both " , func (t * testing.T ) {
384+ t .Run ("PostToolUse_phase_has_both_in_id_order " , func (t * testing.T ) {
341385 hs := reg .Hooks (hooklife .PostToolUse )
342386 if len (hs ) != 2 {
343387 t .Fatalf ("PostToolUse should hold two hooks; got %d (%v)" , len (hs ), hs )
344388 }
345- // Stable order by ID (registry.go:28): auto-lint < auto-test.
389+ // Stable order by ID (registry.go:28): " auto-lint" < " auto-test" .
346390 if hs [0 ].ID () != "auto-lint" || hs [1 ].ID () != "auto-test" {
347- ids := []string {hs [0 ].ID (), hs [1 ].ID ()}
348- t .Errorf ("expected {auto-lint, auto-test}; got %v" , ids )
391+ t .Errorf ("expected {auto-lint, auto-test}; got {%s, %s}" , hs [0 ].ID (), hs [1 ].ID ())
349392 }
350393 })
351394
352395 t .Run ("Edit_fires_AutoLintHook" , func (t * testing.T ) {
353396 // Edit on a real .go file in a real workspace → bridge exercises
354- // runLintCommands. We don't pin Verdict (gofmt/vet may or may not
355- // be installed in CI) — only that the bridge fired and recorded
356- // the right HookID.
397+ // runLintCommands. We don't pin Verdict (gofmt/vet may or may
398+ // not be installed in CI) — only that the bridge fired and
399+ // stamped the right HookID.
357400 tmp := t .TempDir ()
358401 goFile := filepath .Join (tmp , "bridge.go" )
359402 if err := os .WriteFile (goFile , []byte ("package bridge\n " ), 0o644 ); err != nil {
@@ -374,7 +417,7 @@ func TestAutoHookLife_BridgeToHooklife(t *testing.T) {
374417 })
375418
376419 t .Run ("Bash_does_not_fire_AutoLintHook" , func (t * testing.T ) {
377- // isEditTool filters Bash → Allow with the canonical HookID.
420+ // isEditTool filters Bash → Allow with canonical HookID, no Message .
378421 d := auto .Run (context .Background (), hooklife.Event {
379422 Phase : hooklife .PostToolUse ,
380423 Tool : "Bash" ,
@@ -392,8 +435,8 @@ func TestAutoHookLife_BridgeToHooklife(t *testing.T) {
392435 })
393436
394437 t .Run ("Edit_on_non_go_path_does_not_fire" , func (t * testing.T ) {
395- // isEditTool accepts Edit, but the path check filters non-.go files
396- // and *_test.go files. Verify both branches produce Allow.
438+ // isEditTool accepts Edit, but the path check filters non-.go
439+ // files and *_test.go files. Verify both branches produce Allow.
397440 for _ , p := range []string {"README.md" , "fixture_test.go" } {
398441 d := auto .Run (context .Background (), hooklife.Event {
399442 Phase : hooklife .PostToolUse ,
@@ -410,7 +453,8 @@ func TestAutoHookLife_BridgeToHooklife(t *testing.T) {
410453 })
411454
412455 t .Run ("disabled_hook_short_circuits" , func (t * testing.T ) {
413- // Enabled=false is the global off-switch — every Edit falls through.
456+ // Enabled=false is the global off-switch — every Edit falls
457+ // through with Allow and the canonical HookID.
414458 disabled := AutoLintHook {Enabled : false , Timeout : AutoLintDefaultTimeout }
415459 d := disabled .Run (context .Background (), hooklife.Event {
416460 Phase : hooklife .PostToolUse ,
@@ -423,9 +467,9 @@ func TestAutoHookLife_BridgeToHooklife(t *testing.T) {
423467 })
424468
425469 t .Run ("legacy_sin_write_name_accepted" , func (t * testing.T ) {
426- // isEditTool accepts the legacy sin_write/sin_edit names too — covers
427- // the legacy post-listener + hooklife bridge agreeing on the same
428- // filter so the two paths can coexist.
470+ // isEditTool accepts the legacy sin_write/sin_edit names too —
471+ // proves the legacy post-listener and hooklife bridge agree on
472+ // the same filter so the two paths can coexist.
429473 tmp := t .TempDir ()
430474 goFile := filepath .Join (tmp , "legacy.go" )
431475 if err := os .WriteFile (goFile , []byte ("package legacy\n " ), 0o644 ); err != nil {
@@ -442,7 +486,5 @@ func TestAutoHookLife_BridgeToHooklife(t *testing.T) {
442486 t .Errorf ("%s bridge: HookID = %q, want %q" , name , d .HookID , "auto-lint" )
443487 }
444488 }
445- _ = "trailing-comment-marker" // silence gofmt trim warnings if this ever moves
446- _ , _ = fmt .Fprintln
447489 })
448490}
0 commit comments