Skip to content

refactor(hooks): drop runtime shadow types and tighten the executor#2534

Merged
dgageot merged 4 commits intodocker:mainfrom
dgageot:board/refactoring-boilerplate-in-pkg-hooks-con-00fdb9e5
Apr 27, 2026
Merged

refactor(hooks): drop runtime shadow types and tighten the executor#2534
dgageot merged 4 commits intodocker:mainfrom
dgageot:board/refactoring-boilerplate-in-pkg-hooks-con-00fdb9e5

Conversation

@dgageot
Copy link
Copy Markdown
Member

@dgageot dgageot commented Apr 27, 2026

What

pkg/hooks/config.go had grown a 12-events-listed-in-four-places translation layer on top of pkg/hooks.Config/Hook/MatcherConfig — pure shadow copies of latest.HooksConfig/HookDefinition/HookMatcherConfig that existed only to attach Hook.GetTimeout() and a typed HookType. 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

  1. refactor(hooks): drop runtime shadow types in favor of latest aliasespkg/hooks.Config/Hook/MatcherConfig become type aliases for the persisted types. Deletes FromConfig, the two converter helpers, and the duplicate Config.IsEmpty. Moves GetTimeout to latest.HookDefinition so both names share it through the alias. Softens HookType to a string alias to keep existing test fixtures compiling without conversion churn. Net −63 lines.

  2. refactor(hooks): tighten executor types and dispatch test — five small, related cleanups now that the types are unified:

    • drop matcher.raw ("" and "*" already short-circuit to a nil regexp); matches() collapses to one return statement
    • merge hookResult and HandlerResult (they had parallel field names); hookResult now embeds HandlerResult and only adds err
    • extract parseStdoutJSON so the JSON-on-stdout fallback isn't three nested ifs inside runHook
    • replace the contextEvents map with EventType.consumesContext() (same four events, reads as a question about the event)
    • move HookType + constants from types.go to config.go next to the Hook alias they describe
    • compress dispatch_test.go's per-event fixtures into one onlyHooks map literal iterated twice
  3. fix(hooks): preserve stderr and exitCode in hookResult error paths — fixes a regression introduced in commit 2: when runHook hit a timeout or handler error, returning a fresh hookResult{err:...} silently dropped the handler-captured Stderr that aggregate reads to build the PreToolUse fail-closed message. Restores Stderr and ExitCode = -1 on both error returns.

  4. fix(hooks): also preserve stdout in failed-hook results, pin contract — tightens commit 3: replaces the explicit HandlerResult{Stderr: ..., ExitCode: -1} literal with a small markFailed closure that mutates the already-built r in place. This preserves both Stdout and Stderr (matching pre-refactor behaviour exactly) and is robust to future fields on HandlerResult. Adds TestExecutorHandlerErrorPreservesStderr to pin the contract — this test fails against commit 2's code, so it would have caught the original regression.

Net effect

  • pkg/hooks/config.go shrinks from a 12-event translation layer to a 44-line file containing only type aliases and HookType constants
  • The 12-event listing now exists in exactly two places: the HooksConfig struct itself (which owns IsEmpty and validate) and compileEvents in the executor — down from five
  • Adding a new event is one field on latest.HooksConfig plus one line in compileEvents
  • Public API surface (hooks.Config, hooks.Hook, hooks.MatcherConfig, hooks.HookType, all the constants) is unchanged — every existing struct literal, YAML key, and runtime path still works
  • New test TestExecutorHandlerErrorPreservesStderr pins the PreToolUse fail-closed stderr contract that previously had no coverage

Verified

  • mise lint — 0 issues
  • mise test — 97 packages green
  • Rebased on top of current origin/main (resolved a conflict with the recent buildHooksExecutors extraction; the change there is just dropping the now-unnecessary hooks.FromConfig wrapper)
dgageot added 4 commits April 27, 2026 16:18
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
@dgageot dgageot requested a review from a team as a code owner April 27, 2026 14:22
@dgageot dgageot merged commit 2cda184 into docker:main Apr 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants