Enforces test obligations
TestObligationEnforcer is a Stop-event hook that blocks session end when code files have been modified without tests being written or run. It works in tandem with TestObligationTracker (PostToolUse), which tracks which code files still need testing.
The hook distinguishes between files that need new tests written (no test file exists) and files that already have tests but need them to be run. It uses an escalating block mechanism with a configurable limit, writing a review document and releasing the session after the limit is reached.
Output uses a compact tree format with relative paths grouped by directory, reducing token usage compared to full absolute paths.
Stop — fires when the user attempts to end a Claude Code session, blocking if test obligations remain unfulfilled.
MAX_BLOCKSIt does not fire when:
Reads the pending file list from the session's obligation state file
If no pending file exists or the list is empty, returns silent (session proceeds)
Reads the current block count for this session
If the block limit (MAX_BLOCKS) has been reached, writes a review document and clears the flag files, releasing the session
Categorizes pending files into two groups:
hasTestFile)Builds a block message with a narrative opener and the categorized file lists
Increments the block count and returns a block decision
src/validator.ts but do not write any tests. When you try to end the session, TestObligationEnforcer blocks with a tree-formatted message:src/parser.ts which already has src/parser.test.ts. TestObligationEnforcer detects the test file exists but hasn't been run:src/components/Button.svelte. The hook correctly detects Button.svelte.test.ts as the test file (Svelte convention), not Button.test.svelte.| Dependency | Type | Purpose |
|---|---|---|
narrative-reader | lib | Picks escalating narrative tone for block messages |
TestObligationStateMachine.shared | shared | Provides pendingPath, blockCountPath, MAX_BLOCKS, buildBlockLimitReview, hasTestFile, formatAsTree (tree-formatted output), deriveTestPaths (includes Svelte/Vue patterns) |
result | core | ok wrapper for Result type returns |
process.cwd() | runtime | Gets current working directory to convert absolute paths to relative for compact output |
@anthropic-ai/claude-agent-sdk | SDK types | SyncHookJSONOutput return type. R5 block path uses top-level decision: "block" + reason because Stop is a NonHookSpecificEvent and has no hookSpecificOutput wrapping (contrast with PreToolUse where deny goes through hookSpecificOutput.permissionDecision). R8 silent path is a bare {}. Post-SDK-refactor migration. |