Prose Instructs, Tools Enforce: A Complexity Gate for Claude Code
Writing “keep functions short” in your CLAUDE.md is a preference. A hook that runs lizard after every write and blocks if any function exceeds 30 lines is a gate. Only one of those is always enforced.
Yesterday’s post argued that AI agents should use LSP and ast-grep instead of grep and file reads — tools that return precise answers at zero token cost. The same logic extends beyond navigation. You can describe desired behavior in prose and hope it’s followed, or you can measure it with a tool and know.
The rule file problem
My global Claude Code config has a file at ~/.claude/rules/lsp.md. It opens with:
LSP plugins are installed: typescript-lsp, pyright-lsp, rust-analyzer-lsp.
These provide semantic navigation that is faster and more precise than
text search.
Priority order for code search:
1. LSP — symbol is known; use go-to-definition, find-references, hover
2. ast-grep (sg) — pattern is structural; use when shape matters, not just text
3. Grep/Glob — last resort: non-code content, unknown symbol name, unsupported file type
That’s not just preference text. Those plugins are actually configured — typescript-language-server, pyright, and rust-analyzer are all installed and running. The rule describes when to use a capability that exists. Without the tooling, the prose is aspirational. With the tooling, the prose is a usage guide for something real.
The priority order makes the hierarchy explicit: LSP for symbol lookups, ast-grep for structural patterns, grep only when neither applies. This distinction matters because instructions only work if there’s something to point at. The rule tells Claude when to call goToDefinition or sg run instead of falling back to grep — but the tools do the work. The prose reduces token waste by routing correctly; the tools provide precision that grep never could.
The rule continues with a table:
| Task | Use LSP | Avoid |
|---|---|---|
| Find where a function is defined | go to definition | Grep or sg for function name |
| Find all callers of a function | find references | Grep or sg for function name |
| Find all implementations of an interface | find implementations | Grep or sg |
That table replaces a paragraph of prose explaining why grep is imprecise for symbol lookup. The structure communicates faster and takes fewer tokens to attend to than a prose explanation.
For LSP and ad-hoc sg run searches, prose-plus-tool is the ceiling — the agent still chooses whether to call goToDefinition or run a pattern search. But sg has a second mode: sg scan applies named rule files across the codebase and can run as a PostToolUse hook, blocking on structural violations the same way lizard blocks on complexity. Patterns with a structural definition — empty catch blocks, await outside try/catch, deprecated API shapes — can be enforced, not just guided. For numeric properties like function length and cyclomatic complexity, lizard does the same job.
The complexity hook
Every write and edit in Claude Code can trigger a PostToolUse hook. I have one at ~/.claude/hooks/check-complexity.py that runs lizard — a code complexity analyzer — on the modified file.
Lizard measures four things:
- NLOC — non-comment lines of code per function
- CCN — cyclomatic complexity (McCabe): the number of linearly independent execution paths through a function
- Parameters — argument count
- File length — total lines in the file
The hook thresholds:
MAX_FILE_LINES = 500
MAX_FUNC_LINES = 30
MAX_CCN = 10
MAX_PARAMS = 5
When a write violates a threshold, the hook outputs a block decision:
Code complexity violations in .eleventy.js:
.eleventy.js:7: warning: (anonymous) has 51 NLOC, 1 CCN, 301 token,
1 PARAM, 123 length, 0 ND
Refactor before proceeding.
Claude Code sees this as a blocked tool call and must fix the violation before proceeding. Not a warning. Not a soft suggestion. A hard stop.
The hook has one important design choice: it fails open. Any exception exits 0. A bug in the hook must never prevent legitimate writes.
except Exception:
# Fail open — hook bugs must never block writes
sys.exit(0)
Test files (tests/, spec/, __tests__, fixtures, mocks) are skipped. The complexity constraint is for production code — test helpers are legitimately repetitive and verbose by nature.
What it looks like in practice
Yesterday, while adding Mermaid diagram support to this site’s 11ty config, the hook fired on the first attempt. The export default function that configures Eleventy had grown to 51 NLOC and 123 lines. The block message named the function, the location, and the metrics.
I extracted mermaidTransform and buildMarkdownLib into module-level named functions. The hook fired again: the main function was still 34 NLOC across 75 lines.
I extracted registerPlugins and registerFilters. The hook passed.
Three rounds of refactoring, each one locating a specific function that was too long. The end result was a config file where each function does one thing: registerPlugins registers plugins, registerFilters registers filters, mermaidTransform handles one transform. Prose alone might have produced one extraction. The hook found all three.
That’s the operational difference between a rule and a gate.
Why prose degrades
My ~/.claude/rules/prompting-quality.md file has a section on instruction bloat:
Custom instructions (CLAUDE.md, system prompts) are prepended to every request. Keep them under 4KB. Every kilobyte beyond that is dead weight on every token budget in every session.
If I wrote “keep functions under 30 lines” in CLAUDE.md, that instruction would fire on every request — whether the session involves writing code or not. The hook fires only when a file is written, costs zero tokens at any other time, and cannot be ignored.
The same file cites research on constraint compliance:
Per MOSAIC research (arxiv:2601.18554), the number of hard constraints per section directly affects compliance: 1–6 constraints: Reliable compliance. 7–15 constraints: Unpredictable — partial compliance, constraint blending. >15 constraints: Degraded — constraints are ignored or averaged together.
Every CLAUDE.md accumulates rules. A rule for function length, a rule for naming conventions, a rule for error handling, a rule for logging, a rule for test coverage. The list grows. Compliance degrades. The things you care most about start getting lost in the noise.
The answer isn’t to write fewer rules — it’s to stop using prose for properties that tools can measure. Function length is a number. Cyclomatic complexity is a number. File length is a number. Lizard measures all three. Move those out of prose and into a hook; use the prose budget for things a tool can’t express.
Setting it up
The hook requires lizard, which the setup script installs into an isolated venv at ~/.claude/hooks/.venv:
~/.claude/hooks/setup-complexity.sh
This is a one-time setup that doesn’t touch any project dependencies. The venv is self-contained.
The hook itself is registered in Claude Code’s global settings under PostToolUse, targeting Write and Edit tool calls. When those tools complete, the hook runs synchronously — Claude sees the block decision before being allowed to continue.
The pattern
The principle generalizes:
| Property | Prose version | Tool version |
|---|---|---|
| Function length | “keep functions short” in CLAUDE.md | lizard hook, blocks at 30 NLOC |
| Code style | “use consistent formatting” | prettier/ruff, auto-applied |
| Type safety | “be careful with types” | tsc --noEmit, blocks on error |
| Symbol navigation | “use the semantic definition” | LSP configured, returns the actual definition |
| Structural patterns | “don’t swallow errors in catch blocks” | ast-grep rule, finds them structurally |
Prose belongs in the config for things only a human can define: the voice to write in, the architectural decisions to follow, the tradeoffs to favor. For anything that can be expressed as a threshold or a structural query, move it to a tool.
The question to ask about any rule in your CLAUDE.md: could this be a tool instead? If yes, write the tool. The rule will stop burning tokens and start actually enforcing.