Skip to content
Ben Peetermans

Ben Peetermans

Builder. 20 years shipping for the web.

Let's talk
Claude Code
Claude Code

A Zero-Dependency Pre-Commit Hook That Blocks AI Code Drift

Build a pre-commit hook that stops AI code drift before it commits. Zero dependencies, copy-pasteable, works on any stack. The full walkthrough.

A Zero-Dependency Pre-Commit Hook That Blocks AI Code Drift
A CLAUDE.md file is a polite request. A pre-commit hook is a locked door.

You can tell an AI not to hardcode values, not to duplicate helpers, not to bypass your services. It will agree. Then it will do it anyway, on file 40 of an 85-file change, when nobody is reading every diff. I know because I dropped a checker into a Laravel codebase I’d been building for months and it found 104 violations of rules I’d written down myself.

This post is the fix, in files you can copy into a repo today. No framework to install. No dependency to add. One Python script with zero imports beyond the standard library, one JSON file, one git hook. The whole thing is the hard-enforcement layer of the AI Change Control framework. If the framework is the why, this is the how.

The fastest path is to clone the repo and copy two folders. I’ll show that first, then walk through every file so you understand what you just installed.

The fast path: clone and copy

The canonical code lives in one place: github.com/spp-ben/ai-change-control. Clone it, copy the two folders into your repo, run the installer.

git clone https://github.com/spp-ben/ai-change-control /tmp/acc
cp -r /tmp/acc/.ai /tmp/acc/scripts your-repo/
cd your-repo
sh setup.sh

setup.sh is idempotent. It makes the scripts executable, symlinks the hook into git, and runs the first audit so you see your starting drift number. Re-run it any time. That’s the entire install.

The rest of this post is what those files contain and how to tune them for your stack.

Four walls, and where the hook sits

Enforcement runs on four mechanisms. You don’t need all four on day one. Knowing where each fits stops you from building the expensive one first.

Pre-commit (local, fast)CI policy checks (central, same scans)Static analysis / AST rules (semantic)Architecture tests (boundaries)

Pre-commit checks. Fast, cheap, local. The first wall. A git hook running pattern scans against changed files. Catches banned strings, banned imports, debug leftovers, edits to generated files. This is the one you build first, and it’s the whole subject of this post.

CI policy checks. The same scan, run centrally on every push. The second wall. Prevents the “I skipped the hook locally” problem. Same script, different entry point.

Static analysis. Semantic, not string-based. PHPStan and Larastan for PHP, ESLint for JS and TypeScript. Catches things grep can’t see. This comes later.

Architecture tests. Boundary enforcement. Controllers can’t touch infra, views can’t query the DB. The fourth wall, built last.

Grep-based checks give you 80% of the value at 5% of the effort. AST-based checks give you the last 20% at ten times the cost. Don’t invert that order. It’s the same “cheap scan first, expensive scan second” discipline that makes the dead code cleanup loop work: narrow the search space cheaply before you spend money on precision.

The eight rules that matter most

Before any code, here’s what you’re enforcing. These apply to every project regardless of stack. If I could ship one list across every repo I own, it’s this.

The Universal 8

Do

  • No hardcoded canonical values. If it exists as an enum, const, or config, raw literals in app code are banned: statuses, event names, route names, option keys, feature flags
  • No duplicate primitives. Flag new helpers with suspicious names (format*, build*, normalize*, get*, helper, util) unless no existing equivalent
  • No bypassing approved layers. If a service or action exists for a concern, reimplementing it inline is banned
  • No local config literals. Base URLs, domain names, API versions, env-driven values must come from config
  • No edits to generated, vendor, or build files. Obvious, worth enforcing at the hook level anyway
  • No debug leftovers. Ban dd(, dump(, var_dump(, console.log(, stray print_r, temp markers
  • No broad refactors unless requested. Detect suspicious blast radius: too many files for a small task, rename waves, unrelated directories touched
  • No legacy path usage where canon exists. Once a replacement is established, ban the old path. The highest-ROI rule in real projects.

Rule 1 and Rule 8 together catch most drift. If you build nothing else, build those two.

Step 1: the policy file

Create .ai/policy.json. This is the machine-readable source of truth the checker reads. The universal block is the same in every repo. The project block is where each repo encodes its own canon, and that block is where the real leverage lives.

{
  "source_paths": ["app", "resources", "routes", "config", "tests", "src"],

  "universal": {
    "banned_patterns": [
      "\\bdd\\(",
      "\\bdump\\(",
      "\\bvar_dump\\(",
      "\\bprint_r\\(",
      "\\bconsole\\.log\\(",
      "\\bdebugger;",
      "\\bFIXME\\b",
      "\\bHACK\\b"
    ],
    "forbidden_paths": [
      "vendor/", "node_modules/", "public/build/",
      "dist/", "build/", ".next/", ".astro/", ".git/"
    ]
  },

  "project": {
    "banned_literals": [],
    "banned_imports": [],
    "banned_usages": []
  }
}

Patterns are Python regexes, so backslashes get doubled in JSON: \\b becomes \b at runtime. source_paths scopes the scan, so the checker never wastes time on folders you don’t own. Start the project block empty. You’ll fill it after you’ve seen real drift, not before.

Step 2: the checker

Create scripts/policy-check.py. Python is the safest cross-platform choice. It runs on every stack I touch (Laravel, WordPress, Astro, custom) without needing a runtime the repo wouldn’t otherwise have. The full script is about 140 lines and lives in the repo: scripts/policy-check.py. You don’t need to type it, you copied it in the fast path above. Here’s what it actually does.

It loads .ai/policy.json, gets the list of files to scan, and reports any line that matches a banned pattern. The one design decision worth understanding is how it picks which files to scan:

# default: staged files if in a git repo, else everything under source_paths
#   --all     scan everything (audit mode, or non-git repos)
#   --staged  force staged-only
#   --file    scan a single path

That’s the whole behavior. In a normal commit it scans only your staged changes, so the hook is fast. Run it with --all and it audits the entire repo, which is how you get your starting drift number. Run it on one file with --file while you’re iterating. Three modes, one script, zero dependencies. The exit code is the contract: 0 means clean, 1 means violations found, 2 means the config is broken.

Step 3: wire it into git

The hook source is version-controlled in the repo, then symlinked into git. Versioning it means the team shares one hook and edits to it are live. Create scripts/git-hooks/pre-commit:

#!/bin/sh
python3 scripts/policy-check.py
if [ $? -ne 0 ]; then
  echo ""
  echo "Commit blocked. Fix the violations above or bypass with --no-verify (don't)."
  exit 1
fi

Then symlink it into git’s hooks directory. This one line is the load-bearing wiring:

ln -sf ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit
chmod +x scripts/git-hooks/pre-commit scripts/policy-check.py

The symlink matters. .git/hooks/ is never committed, so a real file there would drift out of sync per-machine. A symlink to a tracked source file means everyone runs the same hook and you maintain it in one place. setup.sh does exactly this, which is why the fast path is one command.

The bypass is git commit --no-verify. It exists for genuine emergencies. The whole point is that reaching for it should feel wrong, so don’t teach your AI to use it.

Step 4: wire it into CI

Local hooks catch you early. CI catches you when you skipped the hook. Add one job on whatever platform you use that runs the same script in audit mode:

python3 scripts/policy-check.py --all

Same script, second entry point. Neither wall can be bypassed without intent. That’s the design: make drift hard to commit and impossible to push unnoticed.

Step 5: project bans, where it gets real

The universal block is table stakes. The project block is where the script earns its keep, because it encodes what’s load-bearing about this codebase: your canonical services, your legacy bans, your “always use X, never Y” rules. You can’t buy that off the shelf.

The repo ships stack starters in examples/. Here’s the Laravel one. Note the overrides block, which scopes rules to specific folders so controllers and views get tighter rules than the rest of the app:

{
  "source_paths": ["app", "resources", "routes", "config", "tests", "database"],

  "project": {
    "banned_literals": ["'page_load'", "\"page_load\"", "'conversion'", "\"conversion\""],
    "banned_imports": [],
    "banned_usages": []
  },

  "overrides": [
    {
      "paths": ["app/Http/Controllers/"],
      "banned_usages": ["\\bDB::", "\\bCache::", "\\bRedis::", "\\benv\\("]
    },
    {
      "paths": ["resources/views/"],
      "banned_usages": ["::query\\(", "::where\\(", "\\bDB::"]
    },
    {
      "paths": ["app/"],
      "banned_patterns": ["\\benv\\("]
    }
  ]
}

That bans raw event strings everywhere, blocks env() outside config, and stops direct DB access from controllers and views. The WordPress starter bans direct $wpdb access, legacy option keys, and enqueues outside the canonical hook:

{
  "source_paths": ["wp-content/themes", "wp-content/mu-plugins", "wp-content/plugins"],
  "project": {
    "banned_literals": ["https://example\\.com", "/wp-content/themes/old-theme/"],
    "banned_usages": ["\\$wpdb->", "get_option\\('legacy_", "wp_enqueue_script\\("]
  }
}

And the Astro starter bans hardcoded API URLs, legacy utils imports, and the old axios path once fetch is canon:

{
  "source_paths": ["src"],
  "project": {
    "banned_literals": ["https://api\\.example\\.com", "\"draft\"", "'draft'"],
    "banned_imports": ["from ['\"]\\.\\./\\.\\./utils/legacy", "from ['\"]axios['\"]"],
    "banned_usages": ["fetch\\(['\"]https://api\\.example\\.com"]
  }
}

Same shape, three stacks. One checker reads all of them.

Start with 5 to 10 rules, not 50

The ROI on the first ten rules is enormous. The ROI on rules 11 through 100 falls off a cliff. Don’t try to encode everything.

Add these first, in this order

Do

  • Hardcoded canon values, the single biggest source of drift
  • Direct legacy helper usage, banned once a replacement exists
  • Raw DB and query bypasses, forcing everything through canonical layers
  • Env and config literals in app code, moved to config
  • Debug leftovers: dd, dump, console.log, FIXME, HACK

Ship those five. Let your AI trip over them. Refine from what it actually does, not what you imagine it might do. Native analyzers come after the script is catching real violations: PHPStan then Larastan for PHP, ESLint with no-restricted-imports for JS and TypeScript, eslint-plugin-boundaries for cross-layer import rules, szepeviktor/phpstan-wordpress for WordPress. For style and architecture, Laravel Pint and Pest’s arch testing slot in once the grep layer is solid. None of those are day-one work.

The nine steps to make it load-bearing

Here’s the exact path I ran on a real Laravel 13 codebase. It took one focused session, not nine days.

1
Create .ai/policy.json with the universal block and an empty project block
2
Create scripts/policy-check.py (copy it from the repo, zero dependencies)
3
Create scripts/git-hooks/pre-commit with the one-liner, version-controlled in the repo
4
Symlink it into git: ln -sf ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit
5
Run python3 scripts/policy-check.py --all to audit current drift. Whatever number comes back is your starting point
6
Do a canon inventory pass. Grep for your services, enums, named routes, shared components. Find 3 to 5 places where something should use canon but doesn’t. Add bans for those bypasses
7
Remediate the existing drift. Don’t stop at “most of them are fixed.” Get to zero
8
Flip strict mode on. In your deploy script or CI, make any drift block the build by default
9
Commit it. Let the hook scan your commit, let it pass. That’s the loop closing

Step 5 is the moment of truth. When I ran it, the number that came back was the receipt: 104 violations in a codebase I’d built myself, with a tight CLAUDE.md that explicitly forbade the exact patterns being caught. That’s the gap between a documented rule and an enforced one.

The whole framework is five files: .ai/policy.json, scripts/policy-check.py, scripts/git-hooks/pre-commit, an optional setup.sh, and one line in your deploy script. Everything else in the framework post is explaining why and what to put in the rules, not more infrastructure.

Clone it, copy two folders, run the installer. Then watch the first audit tell you how much your AI has been quietly ignoring you.


Related: