Open source, MIT

Let anyone ship.
Let nothing dangerous through.

Bouncer is a small set of CI gates. They sit at the door of your main branch and check the handful of mistakes that are cheap to catch now and expensive to find later. Everyone passes through the same ones: senior engineers, the person in marketing who edited a page, and the AI agent that opened the pull request at two in the morning.

The problem

More people can write code now. Review did not get faster.

Every company is discovering the same thing at once. A designer can produce a working page. A support lead can fix their own copy. An agent can open twenty pull requests before lunch. The bottleneck moved: it is no longer writing the change, it is being confident the change is safe.

The usual answer is to review harder. That does not scale, and it puts the whole safety system inside one tired person's attention. The other answer is to lock contribution down to the people who already know where the mines are, which wastes most of what just became possible.

Gates are the third answer. Write the expensive mistakes down once, as code, and let the machine check every change forever. Then a contributor does not need to know that orders are tenant-scoped, or that a float cannot hold money. They will be told, by name, on the line, with what to do instead.

Where a rule belongs

Kind of ruleWhere it goesWhy
Formatting, import orderFormatterDeterministic, already solved
Types, null safetyCompiler, strict modeFree, and it runs in the editor
Taste, context, intentAGENTS.mdA machine cannot check taste
Wrong is expensiveA gateOnly this category actually holds

If a rule lives in your instructions file and you would be upset to find it broken in production, it is in the wrong row.

Try it

Break something on purpose.

This runs the real gate modules, not a demo copy of them. Pick an example or paste your own code.

service.ts
result
No gate objects. This would merge.
 

What it checks

Five gates.

Each one exists because of a specific bug that is quiet, plausible, and costly. None of them is a style opinion.

Secrets and dangerous shape

secrets

Credentials, private keys, and code that reads like an attack even when it is not.

Tenant scope

scope

Database access that reaches around the tenant-scoped client, via the raw client, an alias, or raw SQL.

Money arithmetic

money

Float maths on currency, and the hardcoded x100 that breaks every zero- and three-decimal currency.

Migration safety

migration-safety

Schema changes that break the previous version of the app during the deploy window.

Documentation links

doc-links

Links in markdown that point at files which no longer exist, relative or back into this repository.

Measured, not claimed

Run against a real production monorepo.

1,209 files, 7.6 MB of source. Every finding was read by hand, which is the only way to know whether a tool like this is worth running.

45 to 13

Blocking findings, after fixing the false positives. All 13 that remain are real: four money conversions that break for zero-decimal currencies, four float parses of currency, five dead documentation links.

291 to 74ms

Total scan time, median of seven runs. The scope gate alone went 11x faster once its alias matcher stopped being rebuilt on every line.

The 32 false positives came in three families, and every one is now a test asserting the gate stays quiet. Credential-shaped strings in spec files were 35% of all findings and every one was deliberate test data, so those downgrade to warnings rather than being excluded, and a live Stripe key still blocks even in a test. Percentages were being reported as currency bugs, because Math.round(x * 100) is both. And a postgres://postgres:postgres@localhost in a setup script's help text is not a secret.

Adopting it

Two hundred existing problems is the normal case.

Switching on a new gate in a mature repository surfaces findings nobody is going to fix before merging anything else. So the realistic choices become not adding the gate, or adding it as a warning everyone learns to scroll past. Both mean the rule is not enforced.

npx bouncer-gates --baseline-write

That records what is already wrong. Those stop blocking; anything new blocks immediately. The debt stays visible and countable, and the bleeding stops the same afternoon.

The fingerprint is content-based rather than line-based, which is the detail that decides whether a baseline survives. Keyed on line numbers, adding one import at the top of a file re-reports every grandfathered finding at once, in a pull request that had nothing to do with any of them, and the team stops trusting the tool that same day. Keyed on the line's content, a finding survives moving around and starts blocking again the moment somebody edits that line, which is exactly when it deserves another look.

How it is built

Four decisions that make gates survivable.

A gate never fails open.

If a gate cannot do its job, because there is no git history or a config is missing, it reports skipped and says why. It never returns nothing and lets the build go green. "We looked and it was fine" and "we could not look" are different answers, and only one of them is safe to merge on.

The escape hatch requires a reason.

Every gate honours a comment on the line, or the line above it:

// bouncer-ok(scope): finance dashboard, spans all tenants by design

A bare bouncer-ok(scope): with nothing after it suppresses nothing. That single requirement is what stops the hatch decaying into a blanket ignore, and it puts the justification in the file, where the next reader finds it instead of in a pull request nobody will open again.

Findings never quote what they found.

A secret scanner that prints the matched text writes the secret into the CI log, which is usually more public than the file it came from. So a finding carries a location and a rule id, and a human opens the file. That is a deliberate cost.

Gates are pure functions.

A gate takes files and returns findings. No filesystem, no git, no printing. All the input gathering lives in the runner. That is why the same modules run in four places with no second implementation to drift, and all four are live so you can check rather than take my word for it: the CLI on npm, the Cloudflare Worker behind the playground above, a Node service on Railway, and your own browser tab whenever that Worker cannot be reached.

And one thing that is not a decision, but is the point

Half the test suite asserts that gates stay quiet. That is the half that matters. Catching the bad case is easy. Not firing on the twenty near-misses around it is the difference between a gate people keep and a gate that gets switched off in a month, leaving everyone feeling covered while nothing is checked.

Use it

Two files and a workflow step.

npx bouncer-gates                        # every gate, whole repository
npx bouncer-gates --changed              # only what this branch touched
npx bouncer-gates --only scope,money     # some of them
npx bouncer-gates --explain scope        # what a gate checks, and how to excuse a case
npx bouncer-gates --baseline-write       # grandfather what is already wrong
npx bouncer-gates --sarif                # GitHub code scanning

Configure the tenant-scoped models in bouncer.config.json, add one step to your workflow, and set fetch-depth: 0 so the migration gate can tell which files are new. That last line is the difference between a real check and one that quietly reports skipped forever.

Adding your own gate is a new file in gates/, an entry in the registry, and a test that it stays quiet. The .claude/skills/new-gate skill walks an agent through it, including the part where it tells you the rule you asked for should not be a gate at all.