Most codebases have two kinds of rules. The first kind lives inside one file: this variable is unused, this type does not fit, this function returns the wrong thing. A type checker, a linter and a test suite catch those, every time, without anyone asking.
The second kind is architecture, and architecture is a relationship between two files rather than a mistake inside one. A use case must not import from the action above it. Two database writes in one request must share a transaction. A file must be reachable from somewhere. Each file involved can be perfectly correct on its own, so every tool that reads one file at a time passes it.
Those rules end up enforced by a person, in code review, on every pull request, forever. The reviewer is the enforcement mechanism, and a reviewer gets tired, gets busy, and misses the one import in a forty-file diff that points the wrong way.
That got worse once an AI assistant started writing much of my code. There is now more to review than I can read carefully, and asking another AI to review it only swaps one guess for another. I was inspired by Uncle Bob (Robert C. Martin) to check AI's work with deterministic tools instead: same repo in, same verdict out, every time. Quality gates are that idea applied to architecture.
Rule: if a reviewer keeps checking for it, write it down as a script that reads the repo and prints the lines it disagrees with.
That script is a quality gate: a rule the build enforces. It is not a test — it does not run your code, it reads it — and it is not a lint rule, because it is allowed to look at the whole tree at once and ask questions about how files relate.
The payoff is less about catching bugs and more about who stops checking. Once a rule is a gate, nobody reviews for it again. The review gets to be about whether the change is a good idea, which is the part only a person can do.
Three examples, each chosen because the failure it catches looks fine in every file it touches.
The app is layered, and each layer may only import the layers below it:
UI → action → use case → gateway → entity → lib
An action is the server function a component calls; a use case holds the business logic; a gateway is the data access layer that talks to the database; an entity is a plain domain type; a lib is a shared utility.
The failure: someone needs a helper that happens to live next to an action, and imports it into a use case. It compiles. The lint rule import/no-cycle stays quiet, because the arrow points the wrong way without closing a ring — which is every real case. The symptom arrives weeks later, as a use case you cannot unit test without dragging in a server-only module, a session check and a browser.
The gate reads every file, gives it a layer, and reports each import that runs back up the chain:
const LAYERS = ['lib', 'entity', 'gateway', 'usecase', 'action', 'ui'] as const;
type Layer = (typeof LAYERS)[number];
// Classifies a file by its name and path: `*UseCase.ts` is a use case, and so on.
declare function layerOf(path: string): Layer | undefined;
type SourceFile = { path: string; imports: string[] };
function findDependencyDirectionProblems(files: SourceFile[]): string[] {
return files.flatMap((file) => {
const from = layerOf(file.path);
if (from === undefined) return [];
return file.imports.flatMap((target) => {
const to = layerOf(target);
if (to === undefined || LAYERS.indexOf(to) <= LAYERS.indexOf(from)) {
return [];
}
return [
`${file.path} (${from}) imports ${target} (${to}) — never back up`,
];
});
});
}
Sideways and downhill are fine. Only the reverse is reported. The fix is to move the shared thing down a layer, not to exempt the file.
Rule: the action opens the transaction. A use case never opens one, and a gateway never opens one.
The failure is the whole argument for gates. An action makes two gateway writes. The first commits, the second throws. Both calls are correct — the right table, the right columns, the right types, a passing test on each. The only thing wrong is that they did not share a connection, and now the database holds half of an operation that nobody can put back by editing code.
There is nothing in either file for a type checker to reject and nothing for a test to assert, because the bug is not in a file at all.
The shape the gate holds every action to:
export async function CreateIncomeAction(
input: CreateIncomeInput,
): Promise<void> {
await executeInTransaction(POSTGRES_URL, async (client) => {
const useCase = new CreateIncomeUseCase({
incomeGateway: new IncomeGatewayImpl(client),
ledgerGateway: new LedgerGatewayImpl(client),
});
await useCase.execute(input);
});
}
Every gateway is built on the one client the transaction handed out, so every write inside the use case commits or rolls back together. The gate asks three questions:
executeInTransaction?BEGIN, COMMIT or ROLLBACK?None of that needs to understand the code deeply. It needs the imports and the text, across the whole tree — exactly what a reviewer would otherwise check by hand on every new action.
Rule: every source file is reachable from an entry point.
An unreachable file is not malformed. It type-checks, it lints, it is formatted, and its imports all resolve. tsc has nothing to say because every type in it is sound. The linter has nothing to say because every rule is followed. The test suite never loads it because nothing a test imports leads there. It is correct code that runs nowhere, and correctness is all the other checks measure.
The gate walks the import graph breadth-first from the entry points — pages, layouts, route handlers, specs, config files, scripts a package.json runs — and reports every file the walk never reached. Reachability matters more than "nobody imports it": two dead files that import each other each have an importer.
When this gate first ran it found two files, both of which had been type-checking, linting and formatting cleanly for months: a card component left behind when its section was rewritten, and a generated UI primitive that was never rendered. Both were deleted.
A gate is a name and a function that reads the repo:
/** One problem, in a line that names the file and says what is wrong with it. */
export type GateProblem = string;
/** Everything a gate is handed. Gates read the repo; none of them changes it. */
export type GateContext = {
repoRoot: string;
log: (line: string) => void;
};
export type Gate = {
name: string;
run: (context: GateContext) => GateProblem[] | Promise<GateProblem[]>;
};
run returns problems instead of throwing. A gate that throws stops at the first violation; one that returns a list lets a single run report all of them. The rule itself is a plain function over data — files in, lines out — so it can be tested against a small fake repo the spec builds, while the gate hands it the real one.
One runner loops over every gate, prints a section for each one that found something, and exits non-zero if anything was found:
const problems: GateProblem[] = [];
for (const gate of gates) {
const lines = await gate.run(context);
if (lines.length === 0) continue;
console.log(`\n${gate.name}:`);
for (const line of lines) console.log(` ${line}`);
problems.push(...lines);
}
if (problems.length > 0) {
console.log(`\n${problems.length} problem(s).`);
process.exitCode = 1;
} else {
console.log('\nEverything agrees.');
}
A failing run reads like this:
Dependency direction:
src/income/CreateIncomeUseCase.ts (use case) imports src/income/CreateIncomeAction.ts (action) — never back up
Dead files:
src/components/ServiceCard.tsx — no entry point reaches this file
2 problem(s).
Each line names the file and says what to do about it. A clean run prints one sentence. Gates read only the repo — no database, no network, no credentials — so the command is safe to run at any point in a change, and it runs as the last step of the full check, after the tests.
The repo this comes from has 31 of them by now, and each one is a rule no reviewer checks by hand any more.
No allow-list. A gate carries no list of permitted violations. When a rule arrives after the code it governs, fix the existing crossings first and land the gate at zero. It is tempting to ship the gate with a list of "known" files to skip and promise to clean them up later — but a list of exempt files is a list of files nobody has to read, and it only ever grows. A gate at zero fails on the very next violation, which is the only moment the fix is cheap.
A false line is fatal. The first time a gate reports something that is not actually a problem, people learn to skim it. A gate nobody reads is worse than no gate: it costs a CI minute and buys nothing, and it teaches the team that red output is noise. When a gate is wrong, the gate gets fixed — usually by teaching it an entry point or a file shape it did not know about — never the code bent around it.
Hold to both, and the rules you used to repeat in review become rules nobody has to repeat at all. They hold the same way whether a person or an AI wrote the change.