Pre-Commit Integration Tests: The LLM Regression Gate

James Phoenix
James Phoenix

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:

  1. 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.
  2. 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.

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course

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:

  1. Errors-only linting catches code quality violations immediately
  2. Integration tests catch behavioral regressions immediately
  3. Pre-commit gate ensures neither slips into git history
  4. 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

References

Topics
Errors Over WarningsGit HooksIntegration TestsLintingLlm VerificationPre CommitQuality GatesRegression PreventionTesting Strategy

More Insights

Frontmatter as Document Schema: Why Your Knowledge Base Needs Type Signatures

Frontmatter is structured metadata at the top of a file that declares what a document is, what it contains, and how it should be discovered. In agent-driven systems, frontmatter serves the same role t

James Phoenix
James Phoenix
Cover Image for AI Leverage Without Skill Atrophy

AI Leverage Without Skill Atrophy

Manual coding keeps the skill alive. Systems thinking is needed. Long term you need to leverage AI and leverage your brain. Not outsource thinking.

James Phoenix
James Phoenix