The Build Enforces the Conventions So the Agent Can't Forget Them
AI fixes a violation once I point at it, but drifts on conventions across a large change. So the build checks them, the agent fixes what fails, and I review the rest.
AI is good at fixing a problem once I point at it. Show an agent a badly named method or a function that has grown too long, ask for a fix, and it usually gets it right.
The trouble starts when the agent writes something large: a new feature, a new module, a whole project. Then most of the time it doesn't follow the conventions, at least not when there are a lot of them. Method length, variable names, consistent names for folders, modules and classes, the shape of loops, the style of comments. The conventions were in the instructions, and an agent would still drift on some of them in a large change.
I could keep catching those things in review, but that depends on someone noticing every violation, and nobody does. So I moved the conventions out of the instructions and into the build.
If a convention can be checked by a program, the build checks it, and the agent fixes what fails.
This article covers the toolkit that makes that work in a .NET codebase: a spell checker used as a naming rule, Sonar's analyzers, the ReSharper formatter, a few custom checks built on Roslyn, the .NET compiler platform, cognitive complexity, and tests for dependency injection. The code samples are simplified illustrations with made-up names.
How a failure turns into a fix
All of the checks run through one verification command that builds the code, runs the tests and runs the quality checks. Agents run it locally, and CI runs it again.
The loop is simple. When an error-level check fails, the build doesn't pass, so the agent has to fix it. Some checks are only warnings. The agent can leave a warning in place, but it has to explain in the pull request why. Justifying a warning is meant to be more work than fixing it, which is the point.
For that loop to work, a failure has to say what's wrong and where. New violations come back in a fixed shape (wrapped here to fit the page):
new code must follow the rule
"{rule}" ({n} new):
<one line per new violation>A run also has to prove it checked something. If the verification step finds that zero tests ran, it fails. A green result from a broken filter isn't evidence that anything was checked.

Spell checking is really a naming check
The least obvious tool in the set is a spell checker. cspell is an open-source spell checker built for code. It splits identifiers into words, so a method name written in PascalCase or camelCase, or a field in snake_case, is checked word by word. It checks comments and documentation summaries too.
Every word has to be known. In my setup, that means it appears in one of the language and software dictionaries cspell provides, or in a controlled project glossary of real domain terms. On top of that there's a list of explicitly forbidden abbreviations. I also set the minimum word length to three so short tokens get checked.
// Before: Get + Msg + Svc
MessageService GetMsgSvc()
=> messageService;
// After: Get + Message + Service
MessageService GetMessageService()
=> messageService;In the first name, two of the three words are abbreviations, so the check fails. The failure points at the file and line. The fix is a rename, done with Roslyn's rename refactoring so every reference moves with it. A genuinely new domain term goes into the glossary. The glossary isn't a way to excuse every poor name. Generated files and build output are excluded.
The forbidden list exists because a dictionary alone isn't enough. Some abbreviations are valid entries in a software dictionary, so the configuration bans the ones I don't want even when a dictionary would accept them. A dictionary also can't tell a good name from a bad one made of real words. That still takes a reviewer.
Other naming rules sit beside it. Separate naming checks reject selected abbreviations and single-letter parameters and fields, so orders.Select(o => o.Id) becomes orders.Select(order => order.Id). They also apply method-name conventions. Names the framework fixes, and generated code, get scoped exceptions.
Sonar analysis: selected rules block, readability waits for judgment
Sonar's C# rules run inside the build as an analyzer package, SonarAnalyzer.CSharp. I don't enable every rule. Selected defect and security diagnostics are configured as errors and fail the build. Selected readability diagnostics become warnings that go into a findings report the agent and reviewers work through. Rules that conflict with conventions we chose on purpose, or that only add noise, are turned off.
Logging templates are one of the areas the analysis covers. Sonar's rule S2629 asks for constant log templates rather than strings built by concatenation:
// Before: built by concatenation
logger.LogInformation(
"Processed order " + order.Id);
// After: a constant template
logger.LogInformation(
"Processed order {OrderId}",
order.Id);The second form keeps the message the same every time and passes the value separately, so a structured logging provider can search and group entries by template. That's one category among several the analysis covers, along with unused private members, exception handling and security-sensitive patterns.
Cognitive complexity is a different signal from length
A short method can still be hard to read. Sonar's rule S3776 measures cognitive complexity: the score goes up each time the code breaks a straight reading flow, with loops, conditionals, catches, switches and conditions that mix operators. Each level of nesting adds more.
The rule is off by default, and I turned it on with its standard thresholds: 15 for a method and 3 for a property accessor. They're a setting to tune, not a law. Above them, the finding is a readability warning. It's worth a second look because ten lines of nested branches can be harder to follow than thirty lines of flat mapping.
That's why I keep line count and complexity as separate checks. They catch different problems.
The formatter ends the whitespace debate
Formatting is the easiest convention to check, and the most pointless one to argue about. The ReSharper command-line formatter applies the team's formatting profile: a 120-character line length, braces on their own lines, file-scoped namespaces, a fixed member order and a few other choices. Our verification runs it in a check mode that fails if formatting would change a file.
// Before
namespace Orders {
public class OrderPrinter {
public void Print(Order order) {
Console.WriteLine(order.Id);
} } }
// After
namespace Orders;
public class OrderPrinter
{
public void Print(Order order)
{
Console.WriteLine(order.Id);
}
}Agents don't fix formatting by hand. They run the formatter. It checks shape, and only shape.
Custom Roslyn checks for size and conditions
Some conventions needed custom code. Roslyn is the .NET compiler platform, and it lets a check read source code as a syntax tree rather than as text. I use it for three size rules:
- A condition has at most two boolean operands.
- A loop or lambda body has at most three code lines.
- A method over 30 code lines gets a warning.
Blank lines, brace-only lines and comments don't count toward those limits.
The condition rule is the one that most improves the code when it's applied well:
// Before: three operands
if (order.IsActive
&& order.Total > 0
&& !order.IsExpired)
{
Submit(order);
}
// After: a named condition
if (IsReadyToSubmit(order))
{
Submit(order);
}The fix only counts if IsReadyToSubmit keeps exactly the original logic and its name says what the condition means. Splitting code into badly named helpers just to get under a threshold misses the point. That's also why the method-length rule is a warning: a long method is sometimes just a straightforward mapping list.
Dependency injection, tested
The last group checks how classes get their dependencies. These are my architecture choices, written as tests.
Constructors take interfaces rather than concrete services from the solution, with documented exceptions:
// Before: a concrete class
public SubmitOrderHandler(
PriceCalculator calculator)
{ /* ... */ }
// After: an interface
public SubmitOrderHandler(
IPriceCalculator calculator)
{ /* ... */ }The interface's implementation is registered by the module that owns it.
A test reads the compiled code to catch services created directly with new, including inside lambdas, where they're easy to miss. Legitimate factories and the composition root, where the app wires its dependencies, get scoped handling.
Each module registers its own types. The composition root calls those module registrations rather than owning everyone else's.
Then a test builds the real dependency-injection container with two options the framework provides. ValidateOnBuild checks that the registered services can actually be created. ValidateScopes checks that a scoped service is never resolved from the root provider. A registration with a missing or invalid dependency fails the test instead of failing at startup. Each application port, the interface the application uses to reach something outside it, must also have exactly one registration.
None of this proves every class needs an interface. A test enforces the shape I chose. I still decide whether a boundary is worth having.

Existing code doesn't have to be fixed on day one
Turning these rules on against code that already exists would fail on day one. So each rule can keep a baseline, an allowlist of the violations that already existed when it was turned on. A run compares current findings with that baseline.
New violations fail. A baseline entry whose violation has since been fixed fails too, until it's removed (message wrapped here to fit):
these entries no longer break
"{rule}"; remove them so the
allowlist only shrinks:
<one line per stale entry>That keeps the baseline honest as the code improves. It isn't tamperproof. If an agent could enlarge a baseline or weaken a rule without anyone noticing, the whole thing would mean nothing. Changes to the rules or the baselines get my review, and agents don't approve their own changes to them.
Where it runs
Locally, the verification command checks changed files where that makes sense. The build and architecture tests still run more broadly. CI widens the checks to the whole solution. Some integration checks are a separate opt-in mode, so not every integration test runs on every pull request.
A pipeline being present isn't the same as a required check. Making the checks required before a merge is a separate step.
What the build still can't tell me
A rule can be enforced exactly as written and still be a bad rule. One check required folder names to share a word with the types inside them. Applied strictly, it could split folders that belonged together or force worse names, so I made it a warning. Following a pattern for the sake of following a pattern is not what I want.
Even when every check passes, I still review architecture, the rules we haven't written yet, the logic and the structure. A green build means the code follows the conventions we encoded. It doesn't mean the design is right.
That's where I want my review time to go. Abbreviations, braces and a third operand in an if are the build's job. The agent fixes those, and I look at what only a person can judge.