Using ESLint "complexity" Rule To Keep Agent Code Simpler
Coding agents write code that works and then keeps growing. One function can quickly expand to ten different branches of logic. ESLint has a built-in rule that puts a hard ceiling on that, and it turns out a lint error is a much better instruction than anything you can write in your AGENTS.md.
I spend a lot of time thinking about ways in which I can get agents to write code that is easy to read. Last week, I asked an agent to add "just one more case" to a validation function, and it did exactly as I instructed. Then I opened the file and found a single function with eleven branches in it, nested three deep, doing four unrelated jobs. Technically, it workd and every test passed. However, I had to read it twice before I understood what it did. ๐
Here's the thing about that code: nothing was wrong with it. It was the natural result of asking a very capable, very literal collaborator to extend something. The path of least resistance for an agent is almost always "add another if to the function I'm already in." I'd be lying if I said that I haven't done the same.
AGENTS.md are a suggestion, a lint error is a fact
I used to try to solve this with words. AGENTS.md had a bullet in it that said something like "prefer small, focused functions." And it kind of worked? Sometimes. When the context window wasn't full of other things.
The problem is that everything in a prompt is advice competing with other advice. "Keep functions small" is next to "make the tests pass" and "match the existing code style" and fifteen other instructions, and when they conflict, the agent picks only one.
A lint error is not simly advice. It's a failed command with a file, a line number, and a clear error message. The agent runs npm run lint, gets a non-zero exit code, reads the message, and fixes it โ not because it agreed with me, but because the loop it's in isn't finished until the command passes. This has changed how I think about agent setup entirely.
The linter is the deterministic part of your prompt that runs.
"Complexity" rule is a bag sizer at the gate
You know that metal frame at the airport gate โ the one you drop your carry-on into to prove it fits? Nobody argues with it. There's no conversation about whether the bag is basically fine. Either it goes in the frame or it doesn't, and if it doesn't, you split your stuff into two bags.
ESLint's complexity rule is the bag sizer for your functions. It measures cyclomatic complexity (roughly), the number of independent paths through a function, and errors when a function goes over your limit. Every if, every else if, every case, every && or ||, every ternary, every ?., every default parameter adds one.
Turning it on is is a single line in your ESLint config file:
// Header: eslint.config.mjs
export default [
{
rules: {
complexity: ['error', { max: 10 }],
},
},
];The default max is 20, which is very generous. A twenty-path function is already quite complex. I like something in the 8โ12 range for my app code.
What it actually looks like
Here's roughly the function I got back, cleaned up a bit:
function validateProfile(input: ProfileInput) {
const errors: string[] = [];
if (!input.name || input.name.trim().length === 0) {
errors.push('Name is required');
} else if (input.name.length > 80) {
errors.push('Name is too long');
}
if (!input.email) {
errors.push('Email is required');
} else if (!input.email.includes('@')) {
errors.push('Email is invalid');
}
if (input.age !== undefined) {
if (input.age < 13) {
errors.push('Must be 13 or older');
} else if (input.age > 120) {
errors.push('Age is invalid');
}
}
if (input.website && !input.website.startsWith('https://')) {
errors.push('Website must use HTTPS');
}
return errors;
}If you count them up, there are eight branches plus two logical operators, on top of the baseline of one. That's a complexity of 11. With max: 10, the agent's next npm run lint prints this:
1:1 error Function 'validateProfile' has a complexity of 11. Maximum allowed is 10 complexityHere's the delightful part: it doesn't need me to explain the fix. There is really only one move available when a function is too branchy, and that's to take some branches out of it. So it comes back with this:
function validateName(name?: string) {
if (!name || name.trim().length === 0) return 'Name is required';
if (name.length > 80) return 'Name is too long';
return null;
}
function validateEmail(email?: string) {
if (!email) return 'Email is required';
if (!email.includes('@')) return 'Email is invalid';
return null;
}
function validateAge(age?: number) {
if (age === undefined) return null;
if (age < 13) return 'Must be 13 or older';
if (age > 120) return 'Age is invalid';
return null;
}
function validateWebsite(website?: string) {
if (website && !website.startsWith('https://')) {
return 'Website must use HTTPS';
}
return null;
}
function validateProfile(input: ProfileInput) {
return [
validateName(input.name),
validateEmail(input.email),
validateAge(input.age),
validateWebsite(input.website),
].filter((error) => error !== null);
}validateProfile is now a complexity of 1. Each helper is 3 or 4. And notice what fell out of the refactor for free: every one of those helpers is independently testable, independently readable, and independently replaceable. The next field someone adds is a new five-line function and one new line in the array instead of twelfth branch in a function nobody wants to open.
I didn't have to ask for modularity in my prompt. Instead, I asked for a number under 10, and modularity is what that number means in practice. Pretty cool, right?
Picking a number that isn't annoying
A couple of caveats, because this isn't magic.
-
Don't turn it on at 8 in an existing codebase. You'll get a wall of red across files nobody was planning to touch, and the temptation to just delete the rule will be strong. Start at
'warn'with the defaultmax: 20, see what lights up, then ratchet down over a few passes. -
Complexity also only measures branching. A function can be under the limit and still be terrible โ badly named, doing three things, reaching into globals. And a determined agent can technically satisfy the rule by shoving branches into a helper that's just as messy. In practice I've seen that happen a handful of times, and each time the split was still an improvement. It pairs well with
max-depthandmax-lines-per-functionif you want more coverage, butcomplexityalone gets you most of the way.
The one thing you do have to do is close the loop: your agent has to actually run the linter. A line in AGENTS.md saying to run npm run lint before finishing, or a hook that runs it automatically.
Let the tools do the nagging
What I like most about this is that it moved a code review comment I was tired of writing into a place where it enforces itself. I'm not the one asking "could this be two functions?" anymore โ a number is, every single time, on every single file, without getting tired or distracted or overruled by the rest of the context window.