I wrote a small lint rule — a check that scans the codebase and yells when it finds a pattern we’d agreed to stop writing — to ban hardcoded config arrays. The kind where someone drops ["us-east", "us-west", "eu-central"] straight into the source instead of reading it from one config file. Ban the literal, force the lookup, done.
The first thing it flagged was my own test.
To make sure the rule actually fired, I’d written a fixture — a deliberately bad file, the textbook example of the thing I was banning, sitting in the test folder so I could assert “yes, the rule complains about this.” That’s the whole job of a fixture: be wrong on purpose so you can prove the detector notices.
Then I ran the linter across the repo and it found the fixture. Of course it did. The bad example was, by design, the bad pattern. The rule worked perfectly. It just didn’t know the difference between code I wanted it to police and the scaffolding I’d built to test the policing.
For a second I felt clever-then-stupid in the same breath. The tool passed its own exam by failing me.
Here’s the thing I actually didn’t think through: a guardrail doesn’t scan the code you point it at. It scans everything in its path. Including the trap you laid for it. A smoke detector you’re testing with a real match will, correctly, go off — the question was never whether it works, it’s whether you stood it next to the stove.
My first instinct was to add an ignore comment to the fixture. Suppress the warning right there on the bad line. That works, but it’s a lie you have to maintain — every fixture needs the magic comment, and the day someone forgets, the build breaks for a reason that looks like a real violation. Worse, you’ve now taught the rule to trust inline overrides, which is exactly the escape hatch people abuse to keep writing the banned pattern in real code.
The fix was boring and correct: move the fixtures out of the scanned path entirely. The linter only looks at src. The bad examples live somewhere the linter never walks. Now the fixture can be as wrong as it needs to be, and the rule can be as strict as it needs to be, and they never collide.
Keeping fixtures out of the lint path give me the detail
This was an ESLint rule with Vitest tests. The mistake was putting the intentionally-bad fixtures inside src/, where the lint config globs.
Two clean options. Scope the lint target so it never sees test material:
eslint "src/**/*.ts" --ignore-pattern "**/__fixtures__/**"Or, better, keep fixtures as strings inside the test file using RuleTester, so the “bad code” is data, not a file on disk:
import { RuleTester } from "eslint";
import rule from "../no-hardcoded-config";
new RuleTester().run("no-hardcoded-config", rule, {
invalid: [{
code: 'const regions = ["us-east", "us-west"];',
errors: [{ messageId: "hardcoded" }],
}],
});The bad pattern now exists only as a test input — never as a real file a repo-wide scan can stumble onto.
The general lesson I keep relearning: any tool that inspects your whole system will inspect the parts you built to inspect it. The test rig, the example, the mock, the seed data — they all live inside the blast radius unless you deliberately move them out. When you build a guardrail, the next question isn’t “does it catch the bad thing.” It’s “what else is standing in its path that looks bad on purpose?”