refactor(hooks): drop runtime shadow types and tighten the executor#2534
Merged
dgageot merged 4 commits intodocker:mainfrom Apr 27, 2026
Merged
Conversation
The pkg/hooks Config/Hook/MatcherConfig structs were exact-shape shadow copies of latest.HooksConfig/HookDefinition/HookMatcherConfig, with the same fields and tags. Their only purpose was to attach a typed HookType and a 4-line GetTimeout method — at the cost of a 12-events-listed-in-four-places translation layer that grew on every new event. Replace the shadows with type aliases to the persisted types so the runtime keeps its short names but the schema is the single source of truth: - Delete Hook, MatcherConfig, Config and their dedicated IsEmpty in pkg/hooks/types.go; replace with aliases in pkg/hooks/config.go. - Move Hook.GetTimeout to latest.HookDefinition where it can be shared by both names through the alias. - Delete FromConfig and the convertHooks/convertMatchers helpers; the runtime caller now passes a.Hooks() straight through. - Soften HookType to a string alias so existing test code keeps compiling without conversion churn, and drop the now-redundant string(h.Type) cast in dedupKey. The 12-event listing now lives in exactly two places: the HooksConfig struct itself (which owns IsEmpty and validate) and compileEvents in the executor. Assisted-By: docker-agent
Five small, related cleanups now that Config/Hook/MatcherConfig are
plain aliases for the persisted types:
- Drop matcher.raw. The "" and "*" matchers already short-circuit to a
nil regexp, and so do flat events; matches() collapses to a single
return statement and the struct loses an unused field.
- Merge hookResult and HandlerResult. They were near-duplicates with
parallel field names; hookResult now embeds HandlerResult and only
adds err. runHook stops copying field-by-field, the timeout/exec
error paths return a fresh hookResult{err: ...} instead of zeroing
three fields by hand, and aggregate reads the same Stdout/Stderr/
ExitCode/Output names the rest of the package already uses.
- Pull the JSON-on-stdout fallback out of runHook into
parseStdoutJSON. Three nested ifs for one straight-line decode is
easier to read end-to-end.
- Replace the contextEvents map with EventType.consumesContext().
Behaviour is identical (same four events) but the call site reads
as a question about the event rather than a map lookup, and the
knowledge moves next to the EventType definitions.
- Move HookType + HookTypeCommand/HookTypeBuiltin into config.go,
next to the Hook alias they describe. Types.go is now strictly
about events, decisions, and the Input/Output payloads.
- Compress dispatch_test.go's per-event fixtures into an onlyHooks
map literal, iterated twice. The 12-event listing now lives in
exactly one place in the test file (down from three), matching the
one place it lives in compileEvents.
Assisted-By: docker-agent
When runHook encounters a timeout or handler error, it must preserve
the handler's stderr output and set exitCode to -1 so aggregate can
surface them correctly. The refactoring that embedded HandlerResult
into hookResult inadvertently broke this by returning a zero-valued
HandlerResult (empty stderr, exitCode=0) on error paths.
This is critical for PreToolUse fail-closed semantics: when a hook
fails to execute, aggregate reads r.Stderr to include in the denial
message. Without this fix, the stderr is lost and users see only
"PreToolUse hook failed to execute: <err>" without the underlying
command's diagnostic output.
The fix explicitly constructs HandlerResult{Stderr: res.Stderr,
ExitCode: -1} in both error returns, matching the old behavior where
runHook mutated r.stderr and r.exitCode before returning.
Assisted-By: docker-agent
The previous fix (64a00e7e8) restored Stderr preservation in runHook's err and timeout paths but constructed a fresh HandlerResult with only Stderr + ExitCode — silently dropping any Stdout the handler had captured before failing. The original pre-refactor code preserved both, and a future caller in aggregate consuming r.Stdout on the err branch would silently see "" instead of the captured output. Replace the explicit field listing with a small markFailed closure that mutates the already-constructed r in place, exactly matching the old "set ExitCode = -1, clear Output, attach err" pattern. This: - preserves both Stdout and Stderr (matching pre-refactor behaviour), - is robust to future fields on HandlerResult — they propagate automatically rather than needing each call site updated, - makes the contract explicit in one named place that aggregate's err-branch reads stderr from. Add TestExecutorHandlerErrorPreservesStderr to pin the contract aggregate already relies on. The original regression slipped through because no existing test asserted result.Stderr for the PreToolUse fail-closed path; this test would have failed against the pre-64a00e7e8 code. Assisted-By: docker-agent
rumpl
approved these changes
Apr 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
pkg/hooks/config.gohad grown a 12-events-listed-in-four-places translation layer on top ofpkg/hooks.Config/Hook/MatcherConfig— pure shadow copies oflatest.HooksConfig/HookDefinition/HookMatcherConfigthat existed only to attachHook.GetTimeout()and a typedHookType. This series replaces the shadow with type aliases, deletes the conversion code, and tightens a few executor types along the way.The four commits are independently buildable so they can be reviewed in order.
Commits
refactor(hooks): drop runtime shadow types in favor of latest aliases—pkg/hooks.Config/Hook/MatcherConfigbecome type aliases for the persisted types. DeletesFromConfig, the two converter helpers, and the duplicateConfig.IsEmpty. MovesGetTimeouttolatest.HookDefinitionso both names share it through the alias. SoftensHookTypeto a string alias to keep existing test fixtures compiling without conversion churn. Net −63 lines.refactor(hooks): tighten executor types and dispatch test— five small, related cleanups now that the types are unified:matcher.raw("" and "*" already short-circuit to a nil regexp);matches()collapses to one return statementhookResultandHandlerResult(they had parallel field names);hookResultnow embedsHandlerResultand only addserrparseStdoutJSONso the JSON-on-stdout fallback isn't three nested ifs insiderunHookcontextEventsmap withEventType.consumesContext()(same four events, reads as a question about the event)HookType+ constants fromtypes.gotoconfig.gonext to theHookalias they describedispatch_test.go's per-event fixtures into oneonlyHooksmap literal iterated twicefix(hooks): preserve stderr and exitCode in hookResult error paths— fixes a regression introduced in commit 2: whenrunHookhit a timeout or handler error, returning a freshhookResult{err:...}silently dropped the handler-capturedStderrthataggregatereads to build the PreToolUse fail-closed message. RestoresStderrandExitCode = -1on both error returns.fix(hooks): also preserve stdout in failed-hook results, pin contract— tightens commit 3: replaces the explicitHandlerResult{Stderr: ..., ExitCode: -1}literal with a smallmarkFailedclosure that mutates the already-builtrin place. This preserves bothStdoutandStderr(matching pre-refactor behaviour exactly) and is robust to future fields onHandlerResult. AddsTestExecutorHandlerErrorPreservesStderrto pin the contract — this test fails against commit 2's code, so it would have caught the original regression.Net effect
pkg/hooks/config.goshrinks from a 12-event translation layer to a 44-line file containing only type aliases andHookTypeconstantsHooksConfigstruct itself (which ownsIsEmptyandvalidate) andcompileEventsin the executor — down from fivelatest.HooksConfigplus one line incompileEventshooks.Config,hooks.Hook,hooks.MatcherConfig,hooks.HookType, all the constants) is unchanged — every existing struct literal, YAML key, and runtime path still worksTestExecutorHandlerErrorPreservesStderrpins the PreToolUse fail-closed stderr contract that previously had no coverageVerified
mise lint— 0 issuesmise test— 97 packages greenorigin/main(resolved a conflict with the recentbuildHooksExecutorsextraction; the change there is just dropping the now-unnecessaryhooks.FromConfigwrapper)