DevOps
Build Gates: Architecture Rules a README Cannot Enforce
September 202611 min read

A build gate is a script your repository owns that asserts something about your specific architecture and exits non-zero when the assertion fails. A linter rule encodes general language knowledge and applies to any project. The difference matters because most architecture rules are facts about one codebase, such as which directory a heavy dependency belongs to, and no off-the-shelf linter can know them.
ESLint reasons about one file at a time and knows the language, not your design. It cannot tell that a component three imports away pulls a 3D engine into a route that should stay light. Enforcing that requires walking the import graph across files, which is a different kind of check and usually a short script of your own.
Leave them to review only when the rule needs judgement about intent. Mechanise the rule when breaking it is expensive and silent, because a reviewer cannot reliably notice an import chain that adds weight to a route without reading every file in it. Review time is the most expensive checking layer you have, so spend it on decisions rather than on invariants a script can hold.
Walk the static import graph from each route entry file and report any path that reaches the dependency. Follow only static imports with a from-clause, because a dynamic import is the mechanism that keeps the dependency in a separate lazy chunk, so it should be treated as a cut point exactly as the bundler treats it.
When the rule is a matter of taste, when breaking it is loud enough to notice immediately, or when deciding compliance needs judgement about intent. A gate that produces false positives gets bypassed and then deleted, which is worse than no gate, because the team also loses trust in the gates that were working.

Key Takeaway
A written convention is not a constraint. Converting an architecture rule into a script that fails the build is what makes it real, and the conversion only pays for rules that are load-bearing, frequently violated and mechanically checkable. Three prebuild gates on this site cover 49 route entries in under three seconds.
I added a walkable 3D world to a portfolio site whose other pages are measured in kilobytes. The rule was easy to state, and I wrote it into the repository's own instruction file: the game costs nothing on the pages that are not it. Stating it was the easy part. A sentence in a markdown file has no way to fail, and three.js is exactly the kind of dependency that arrives through an import nobody inspected.
This post is about converting that sentence into a script that exits non-zero, using the three gates that now run before every build of this site. It covers what each one proves, what each one cost to write, and the one that shipped in a weak form, passed cleanly for months, and was hiding 168 wrong dates the whole time.
BairesDev's guide to maintainable code lays out the enforcement stack in the order most teams build it, and the ordering is right. Each layer makes the next one cheaper:
The gap sits underneath all three. Every one of those layers inspects the diff in front of a human. None of them knows anything about your architecture. ESLint knows JavaScript; it does not know that three.js belongs to one route on this site and that an import from the nav bar would undo the entire arrangement. That rule is not a code smell. It is a fact about this repository, and the only place it can live is in a script this repository owns.
The version of the rule that a grep can enforce is the weak one. Checking that three.js is imported only under the game directories is a search for a string, roughly twenty lines, and it catches the obvious mistake. It does not catch the one that actually costs you the page weight.
The rule that matters is that no route entry may reach the engine, and reach is a graph question, not a text question. A page importing one innocent-looking game component, which imports the world, which imports the engine, ships three.js in that route's first load. Nothing in the page file says so. The gate has to walk the import graph, and it has to walk only the static edges, because a dynamic import is precisely what keeps the engine out of the bundle:
// Static import/export with a from-clause only.
// A bare import("x") has no from-clause, so dynamic imports are cut
// points here by construction — exactly as they are in the bundler.
const STATIC_IMPORT =
/(?:^|\n)\s*(?:import|export)\b([\s\S]*?)from\s*["']([^"']+)["']/g;
function staticImports(file) {
const source = stripComments(fs.readFileSync(file, "utf8"));
const specifiers = [];
for (const [, clause, specifier] of source.matchAll(STATIC_IMPORT)) {
// A type-only import is erased by the compiler and carries no weight.
if (/^\s*type\b/.test(clause)) continue;
specifiers.push(specifier);
}
return specifiers;
}That last detail is what makes the check honest rather than approximate. A bare import call has no from-clause, so it never matches, which means dynamic imports are cut points in the walk by construction — the same place the bundler cuts. The gate walks from all 49 route entry files on this site and reports the offending chain, not just the offending file, so the fix is obvious. Type-only imports are skipped for the same reason the compiler erases them: they carry no weight.
The blog registry on this site is split across two modules: one holds the card data a reader sees, the other holds the SEO metadata Google sees. They must agree. The first version of the parity gate proved they agreed by comparing the two sets of keys, which is the obvious check and the cheap one. It shipped, it passed on every build, and it caught nothing.
It caught nothing because key sets were never the thing that drifted. The values were. The date a reader sees on a card comes from a month key and a year; the date Google sees comes from a separate published date feeding the sitemap and the structured data. When I finally compared the values instead of the keys, 168 of 386 posts disagreed, several by ten months. A card read May 2025 while its own structured data said July 2024. Nothing had ever surfaced it, because both values were individually valid.
// Version 1: the two key sets agree. Shipped, passed, caught nothing.
const missingMeta = [...staticIds].filter((id) => !metaIds.has(id));
// Version 2: the values agree too. The date a reader sees comes from
// monthKey + year; the date Google sees comes from datePublished.
// Both were individually valid, which is why nothing ever surfaced it.
const dateMismatches = BLOG_POSTS_STATIC.filter((post) => {
const meta = BLOG_META[post.id];
if (!meta) return false;
const [year, month] = meta.datePublished.split("-");
return post.year !== year || post.monthKey !== monthKeyFor(month);
});A passing gate is not evidence of a healthy invariant
A green check tells you the assertion you wrote is true. It says nothing about whether you asserted the right thing. The parity gate was green for its entire weak life. When you write a gate, ask what the failure it is meant to prevent would actually look like in the data, then confirm the gate would catch that specific shape — ideally by breaking something on purpose and watching it fail.
Enforcement is not free, and the honest way to decide is to price it. Here is every rule this site mechanises, what each one proves, and what it took:
| Gate | What it proves | What it cost |
|---|---|---|
| Engine location | three.js is imported only under the game's own directories | A string search, about 20 lines |
| Route reachability | No route entry reaches the engine through static imports | A depth-first graph walk, the bulk of a 282-line file |
| Link prefetch | Every link to the game opts out of viewport prefetching | A brace-aware tag scan, about 40 lines |
| Registry parity | Every post's displayed date matches its own structured data | About 40 lines, living inside the module it guards |
The whole set runs in about two and a half seconds on every build. That number matters more than the line count: a gate that adds a minute to the build gets disabled during the first incident, and it never comes back. The prefetch check is the one I would defend least on its own merits — but the nav and footer render on every page, so a single missed opt-out would pull the game's route chunk site-wide, and the check is forty lines. Cheap enforcement of a rule with a wide blast radius is an easy trade.
Most conventions should stay conventions. Writing a gate for a rule that breaks twice a decade is a maintenance liability wearing a safety costume. Four questions decide it, and a rule needs all four:
The reachability rule scores on all four, which is why it justified a graph walk. A rule about import ordering would score on none of them, which is why it stays in the linter's hands or nowhere at all.
BairesDev's piece argues that automation cannot enforce architectural intent or naming that communicates business meaning, and that human judgement is required for both. On naming, that matches my experience exactly. On architectural intent, I would put the line somewhere else: intent that has been reduced to a structural claim is mechanisable, and reducing it is most of the work. The claim that the engine must not reach a route entry is architectural intent, and it is also a graph property a script can decide.
What stays human is everything upstream of that reduction — deciding the game deserves its own route, that a lazy boundary is the right shape, that the trade is worth making at all. A script cannot tell you a module boundary follows the wrong seam. It can only hold a boundary you already chose. So the honest division is not automation versus judgement; it is judgement first, then automation to stop the judgement quietly eroding.
A gate is only as good as its weakest invocation path. These run from the prebuild hook rather than a dedicated CI job, which means they run on every build, on my machine and in the pipeline, without anyone needing to remember a step:
// package.json — prebuild runs on every "npm run build",
// locally and in CI, with no separate workflow step to forget.
"scripts": {
"prebuild": "node scripts/generate-blog-sources.mjs --check && node scripts/game/check-placements.mjs && node scripts/game/check-imports.mjs",
"build": "next build"
}
// A gate nobody can skip beats a thorough one wired to a job
// that a reviewer is allowed to mark "not required".This matters more than thoroughness. A separate CI job can be marked non-required, skipped on a hotfix, or quietly removed when it goes red on an unrelated Friday. A prebuild step fails the build itself, so the only way past it is to fix the thing or delete the check deliberately — and deleting it is a visible diff that someone reviews. The failure messages are part of the design too: each one names the offending file, the import chain that got there, and what to do about it, because a gate that only says no teaches nobody anything.
The catalogue on this site now runs past 590 posts and one 3D route, and I have stopped trusting myself to remember any invariant that matters. The test is simple: if a rule would cost something real when broken, and breaking it would be silent, it does not belong in a markdown file where it can only be read. It belongs in a script that exits non-zero. Write the rule down once for the humans, then write it again for the machine, and let the machine be the one that never gets tired.
Sources and further reading