Summary
Pre-commit hooks that run integration tests are the sweet spot for preventing LLM-caused regressions from ever being committed. Pair this with linter configs that treat violations as errors (not warnings) to create a hard gate that LLMs cannot bypass. Warnings get ignored. Errors block the commit. This combination turns git commit into a verification checkpoint that catches regressions before they enter history.
The Problem
LLMs are confident committers. They generate code, run it, see output that looks reasonable, and move on. When a linter rule is configured as a warning, the LLM sees the warning, decides it’s non-critical, and commits anyway. When a test suite runs but only includes unit tests, all tests pass while the actual feature is broken at the integration boundary.
The result: regressions get committed, pushed, and discovered later by humans. The LLM has moved on. The context window has rotated. Fixing it now costs 10x what it would have cost at commit time.
The Solution
Two principles working together:
- Errors, not warnings: Configure every linter rule you care about as
"error", never"warn". If a rule matters, it blocks. If it doesn’t matter, remove it entirely. - Integration tests in pre-commit: Run your integration test suite as a pre-commit hook. Not just linting. Not just type checking. The actual integration tests that verify features work end-to-end.
Why Errors Over Warnings
Warnings are suggestions. Errors are gates. LLMs treat them accordingly.
What happens with warnings
$ git commit -m "add user registration"
⚠ warning no-console Unexpected console.log statement
⚠ warning @typescript-eslint/no-explicit-any Unexpected any
✖ 0 errors and 2 warnings
# Commit succeeds. Warnings ignored. Debt accumulates.
The LLM sees zero errors, considers the commit clean, and proceeds. The warnings scroll past. Nobody acts on them.
What happens with errors
$ git commit -m "add user registration"
✖ error no-console Unexpected console.log statement
✖ error @typescript-eslint/no-explicit-any Unexpected any
✖ 2 errors and 0 warnings
# Commit blocked. LLM must fix before proceeding.
The commit fails. The LLM reads the error output, fixes the violations, and tries again. No human intervention needed. The gate did its job.
The Configuration Principle
{
"rules": {
"no-console": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "error",
"no-unused-vars": "error"
}
}
The rule: if you care about it, set it to "error". If you don’t care enough to block a commit, delete the rule. There is no middle ground. "warn" is a decision to ignore something while pretending you haven’t.
Why Integration Tests in Pre-Commit
Unit tests pass. The feature is still broken. This is the most common failure mode in LLM-assisted development.
The Gap Between Unit Tests and Reality
// Unit test passes
test('calculateDiscount applies percentage', () => {
expect(calculateDiscount(100, 10)).toBe(10); // ✅
});
// But the API endpoint is broken because:
// - The route expects `discount_code`, LLM used `discountCode`
// - The database column is INTEGER, code sends DECIMAL
// - The email service import is missing
Unit tests verify isolated logic. LLMs rarely break isolated logic. They break integration points: wrong column names, mismatched API contracts, missing imports across module boundaries.
Pre-Commit Integration Tests Catch This
#!/bin/bash
# .husky/pre-commit
# Step 1: Lint with errors-only config
npx lint-staged
# Step 2: Type check
npx tsc --noEmit
# Step 3: Run integration tests (the key step)
npm run test:integration
When the LLM tries to commit code that breaks an integration test:
$ git commit -m "add discount flow"
Running pre-commit hooks...
✅ Lint passed
✅ Type check passed
❌ Integration test failed:
FAIL tests/integration/checkout.test.ts
● Checkout › applies discount code
expect(received).toBe(expected)
Expected: 200
Received: 400
Response body: { error: "Unknown field: discountCode" }
# Commit blocked. LLM reads error, fixes field name, retries.
The regression never enters git history. The fix happens in the same context window where the LLM still has full understanding of what it just wrote.
Why Pre-Commit is the Sweet Spot
There are multiple places you could run integration tests. Pre-commit is optimal for LLM workflows:
| Gate | When it runs | Feedback delay | Context preserved? |
|---|---|---|---|
| Editor/hook (Claude Code hooks) | On file write | Seconds | Yes |
| Pre-commit | On git commit | Seconds | Yes |
| CI/CD | On push/PR | Minutes | No (context rotated) |
| Staging | On deploy | Hours | No |
Editor hooks catch errors fast but run on individual files. They can’t verify cross-file integration.
Pre-commit is the last checkpoint where the LLM still has full context. It runs all integration tests against all staged changes. If something breaks, the LLM fixes it immediately because it still knows what it just changed.
CI/CD catches errors too late. By the time CI fails, the LLM conversation may have ended. A human has to debug it, or a new LLM session starts without the original context. The cost of fixing is 10x higher.
Implementation
Step 1: Configure Linting as Errors
// .eslintrc.json or eslint.config.js
{
"extends": ["eslint:recommended", "plugin:@typescript-eslint/strict"],
"rules": {
// Every rule that matters is "error"
"no-console": "error",
"no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "error",
// If you don't want to enforce it, delete it entirely
// NEVER use "warn" - it creates a false sense of quality
}
}
Step 2: Set Up Pre-Commit with Integration Tests
# Install husky
npm install --save-dev husky
npx husky init
# Install lint-staged
npm install --save-dev lint-staged
// package.json
{
"scripts": {
"test:integration": "vitest run tests/integration/",
"test:pre-commit": "vitest run tests/integration/ --reporter=verbose"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix --max-warnings=0"
]
}
}
Note --max-warnings=0: even if some rules slip through as warnings, this flag treats any warning as a failure. Belt and suspenders.
# .husky/pre-commit
#!/bin/bash
# Lint staged files (errors only)
npx lint-staged
# Type check entire project
npx tsc --noEmit
# Run integration tests
npm run test:pre-commit
Step 3: Keep Integration Tests Fast
Pre-commit hooks must be fast or developers (and LLMs) will skip them. Target under 30 seconds.
// tests/integration/setup.ts
import { beforeEach, afterEach } from 'vitest';
import { db } from '../../src/db';
// Use in-memory SQLite or test containers for speed
beforeEach(async () => {
await db.migrate.latest();
await db.seed.run();
});
afterEach(async () => {
await db.migrate.rollback();
});
If your integration suite takes over 60 seconds, split it:
- Pre-commit: Core integration tests (critical paths, 10-20 tests, <30s)
- CI/CD: Full integration suite (all paths, 50-100 tests, 2-5 min)
The Compounding Effect
These two principles reinforce each other:
- Errors-only linting catches code quality violations immediately
- Integration tests catch behavioral regressions immediately
- Pre-commit gate ensures neither slips into git history
- LLM self-correction happens in the same context window
The LLM develops a feedback loop: it writes code, the pre-commit gate rejects it, it reads the error, it fixes the code. No human involvement. No context switching. No regressions shipped.
Over time, the LLM also learns from the pattern. It starts writing code that passes the integration tests on the first try because the hook output in prior attempts taught it what the project expects.
Anti-Patterns
Using --no-verify to bypass hooks
# NEVER allow this in LLM workflows
git commit --no-verify -m "skip checks"
If the LLM uses --no-verify, it defeats the entire system. Configure your CLAUDE.md or system prompts to explicitly prohibit this flag.
Warnings as “soft enforcement”
{
"rules": {
"no-console": "warn" // ❌ Will be ignored by LLMs
}
}
There is no such thing as soft enforcement with LLMs. Either the gate blocks or it doesn’t. Warnings are invisible.
Only running unit tests in pre-commit
# .husky/pre-commit
npm run test:unit # ❌ Misses integration failures
Unit tests in pre-commit give false confidence. The commit goes through, CI fails 5 minutes later, and the LLM has moved on.
Related
- Early Linting Prevents Technical Debt Ratcheting – Why linting from day 1 prevents debt accumulation
- Integration Over Unit Tests – Why integration tests provide higher signal-to-noise for LLM verification
- Claude Code Hooks as Automated Quality Gates – Tool-call-level hooks for real-time feedback
- Making Invalid States Impossible – Constraining the LLM computation graph through tooling
- Test-Based Regression Patching – Writing regression tests before fixing bugs
- Verification Sandwich Pattern – Pre/post verification establishes clean baselines
References
- Husky Git Hooks – Modern git hooks for JavaScript projects
- lint-staged – Run linters only on staged files for faster pre-commit checks
- ESLint –max-warnings flag – Treat any warning as a commit-blocking error
