---
title: "Terser, node -c, and the 18-Line Installer Behind Our Commit Gate"
canonical: https://dxdev.com/blog/guardrails-that-install-themselves/
datePublished: 2026-04-25
---
One [broken minified JavaScript file was committed and deployed](/blog/bug-into-guardrail/) before anyone noticed it. Our informal check was a person looking at the output, and no machine had proved the result still parsed. We were the check. We got it wrong, and nothing caught it until after it had shipped.

That showed us a design problem. A rule we have to remember never becomes a repository rule. A rule an AI agent has to rediscover at the start of every task is hardly better. We work from four clones, and each one is another place where a good convention can turn into an absent control.

So we stopped trying to get the team, or an agent, to remember the check. We made the repository install it.

## The gap between two steps

We had a minification path and we had a way to test JavaScript syntax. Nothing bound the two together. A compressed asset can look complete, especially when review means reading one long line of generated code. What we actually needed was stricter: after minification, Node must be able to parse the result.

Our first fix was a small wrapper. The new `ai/scripts/compile-js.sh` is 61 lines. It runs `terser`, then has `node -c` verify the resulting JavaScript. The two commands became one named operation, so if either fails there is no successful compile to pretend was complete.

That fixed the failure mode, but only for someone who chose to run the wrapper. Anyone, human or agent, could take the familiar path, skip it, and commit a broken file again. A check that can be bypassed by accident still relies on memory.

## Why the other options lost

Documentation can tell a developer or agent to syntax-check dated JavaScript before commit. It cannot make that check happen when the commit is made.

Remote CI alone would catch the bad artifact, but later is the wrong side of a commit boundary for this problem. The useful failure is local, specific and immediate. Whoever makes the commit should see that a dated JS file is syntactically broken before it enters history.

A conventional Git hook installed by hand into `.git/hooks` lost for the same reason. It only works on the checkout where someone remembered the install step. With four active clones, the next clone is not an edge case. It is normal work.

So the control needed two layers: a pre-commit gate that could reject the offending change, and an installation path that ran without anyone remembering it.

## The hook that installs the hook

On April 25, we closed the ticket with 137 lines across four files. The 48-line `ai/scripts/git-hooks/pre-commit` script blocks any commit that contains a syntactically broken dated JS file. That is the enforcement point, where the policy becomes executable at the moment an invalid change would otherwise become a commit.

The other half is `.claude/hooks/install-pre-commit.sh`, an 18-line installer. A SessionStart entry in `.claude/settings.json` runs it for the developer role. The installer sets Git's `core.hooksPath` for that clone, pointing Git at the repository-managed hook path instead of a hand-populated local directory.

That configuration detail is the whole design. The pre-commit script is checked into the repository, but checked-in code does nothing if Git has no reason to call it. `core.hooksPath` makes the hook discoverable. SessionStart makes that configuration happen again wherever the repository is opened in a developer session. A new clone is no longer a blank spot in the safety model.

The sequence is short. A session begins. Git is configured with the hook path for this clone. A commit is attempted. The hook checks the dated JS file. A broken file is rejected.

Each part has its own job. `terser` creates the minified output. `node -c` verifies syntax. The pre-commit hook makes that verification mandatory when relevant files are committed. SessionStart carries the mandatory check across the four clones we actually use.

## What changed in how we judge a guardrail

We used to think a good guardrail was a carefully written instruction. This incident changed the test. A guardrail is real only when a fresh working copy receives it before the next safety-sensitive action.

That matters even more with AI agents. An agent can read a runbook, follow a checklist, and still take a path that skips the check. We do not want a safety property to depend on whether an agent happened to load the right context, or whether we remembered to mention an old failure. The repository can carry the condition forward instead.

The pre-commit hook is 48 lines. The installer that lets a fourth clone inherit it without anyone remembering is 18. Neither number is impressive. Together they are the difference between a rule we have to remember and one we don't.

For any safety check in your own process, ask whether a machine runs it or a person has to remember it. In our case a person was the check, and a broken file shipped. A control that installs itself in every new working copy and blocks the bad change at commit time depends on nobody's memory.
