All Field Notes

Linting Is Not Pedantry When an AI Is Writing Your Code

Why formatting, import rules, naming conventions, and static checks become practical safety systems in AI-heavy workflows.

LintingStatic analysisAgent workflows

Lint rules can feel fussy when a human team already shares context. Everyone knows the style, the boundaries, and the handful of patterns that should not be used unless there is a very good reason.

AI-heavy development changes that assumption. Agents produce code that looks plausible—clean formatting, sensible names, reasonable structure—and is still wrong in ways that matter. The codebase needs a mechanical way to say no.

Forget the "style police" framing. A lint setup is a fast rejection system. It catches the patch that starts a background write and never awaits it, the route handler that reaches straight into a database client, the accidental Promise[] returned by map(async () => ...), the import from a folder that was meant to be private. We have seen every one of those in generated code that sailed through a quick visual review.

Reviewer attention is expensive. It should go toward product behavior, architecture, and risk—not toward rediscovering the same missing await or cross-layer import every afternoon.

The Real Smell Is Unbounded Permission

We reviewed a codebase last year where a dashboard server had grown into a single Express file of more than four thousand lines — static assets, CORS, rate limiting, access checks, SQL, admin endpoints, and page routing in one place, next to a flat directory of one-off scripts. Nothing there was a moral failure; the file had simply become the easiest place to put every new idea, and each addition made the next one look normal. Taking that server apart safely is its own discipline, which we cover in Stop Asking the Agent to Refactor Everything at Once. The linting question is different: what mechanical pressure stops the repo from growing that shape in the first place?

Linting cannot design that architecture for you. It can make the architecture harder to ignore.

Formatting Is the Smallest Part

Formatting still matters. Consistent diffs reduce review noise. A generated patch that reformats a whole file around a two-line behavior change is harder to review, harder to revert, and easier to accidentally approve. Prettier or an equivalent formatter should take that argument off the table.

But formatting is the easy part. The important rules encode decisions:

  • Promises must be awaited, returned, or deliberately marked as detached.
  • Boundary inputs start as unknown until they are validated.
  • Route handlers do not import persistence clients directly.
  • UI components do not reach into server-only modules.
  • Generated scripts do not import application internals that are not part of a stable maintenance API.
  • A function or file that keeps growing past an agreed threshold gets flagged before it ships.

Those are not preferences. They are executable memory — the same job types do for your data shapes.

The TypeScript strict flag is the foundation. It turns on a family of checks—strictNullChecks, noImplicitAny, and several others—that get stricter as TypeScript evolves. ESLint then covers the patterns TypeScript allows but your project should not.

@typescript-eslint/no-floating-promises is the single rule we would enable first in any AI-heavy codebase. An unawaited promise means work that can silently fail, run out of order, or reject after the caller has moved on. The rule's own documentation walks through exactly these failure modes.

For a Next.js project, the framework's own lint rules add another layer. The Next.js ESLint configuration includes core-web-vitals and TypeScript presets that understand which files are routes, which are client components, and where the framework's conventions override general TypeScript advice. Without these, generic rules will flag patterns that Next.js intentionally uses.

The AI-Plausible Bug

Here is the kind of generated route handler that looks harmless in review:

type EventPayload = {
  name: string;
  properties: Record<string, string | number | boolean>;
};
 
async function saveEvent(payload: EventPayload): Promise<void> {
  await eventStore.insert(payload);
}
 
export async function POST(request: Request) {
  const payload = (await request.json()) as EventPayload;
 
  saveEvent(payload);
 
  return Response.json({ ok: true });
}

The code has a type, a helper, and a clean response. It also lies to the user. The request can return ok: true before the write finishes. If saveEvent rejects, the route has already replied. The cast also tells TypeScript to trust the request body without proving the shape.

The better version makes both decisions explicit:

type EventPayload = {
  name: string;
  properties: Record<string, string | number | boolean>;
};
 
function isEventPayload(value: unknown): value is EventPayload {
  if (typeof value !== 'object' || value === null) return false;
 
  const candidate = value as Partial<EventPayload>;
 
  return (
    typeof candidate.name === 'string' &&
    typeof candidate.properties === 'object' &&
    candidate.properties !== null
  );
}
 
async function saveEvent(payload: EventPayload): Promise<void> {
  await eventStore.insert(payload);
}
 
export async function POST(request: Request) {
  const body: unknown = await request.json();
 
  if (!isEventPayload(body)) {
    return Response.json({ error: 'Invalid event payload' }, { status: 400 });
  }
 
  await saveEvent(body);
 
  return Response.json({ ok: true });
}

In production, we would normally reach for Zod or a similar schema library rather than hand-writing every guard. But the validation approach is not the point here. The workflow is. The first version should fail lint before review. no-floating-promises rejects the detached write. no-unsafe-assignment and related rules can make untrusted JSON handling visible. no-explicit-any blocks the fastest escape hatch. A custom project rule can reject direct access to eventStore from route files if persistence is supposed to go through a service layer.

The agent can still make the mistake. The repo does not have to accept it.

A Rule Escalation Ladder

Not every repeated problem deserves a custom lint rule on day one. The useful question is how far the rule needs to move before it reliably changes generated patches.

LayerUse whenExample ruleFails where
FormatterWhitespace, import ordering, and diff noise — the agent should never spend a review cycle on line wrapsPrettierEditor save / pre-commit
TypeScriptThe shape of the value is the issue: nullable records, unchecked returns, widened genericsstrict, strictNullCheckstsc --noEmit / build
Type-aware lintTypeScript allows the code but the project shouldn't: floating promises, unsafe unknown assignments, accidental anyno-floating-promises, no-unsafe-assignment, no-explicit-anyeslint / CI
Import-boundary lintArchitecture is drifting one plausible import at a timeno-restricted-importseslint / CI
Custom ruleThe same project-specific review comment has appeared three timesa hand-written rule, e.g. no-route-database-importeslint / CI

The rule does not need to be clever at the custom-rule layer. It needs to catch the pattern that keeps wasting reviewer attention.

That ladder keeps the linting work pragmatic. Start with the cheapest mechanical feedback that would have rejected the bad patch, then move the rule down into CI so the next agent sees the failure before a human does.

A Practical Config Shape

A useful lint setup for an AI-heavy TypeScript app normally has three layers:

import nextVitals from 'eslint-config-next/core-web-vitals';
import prettierConfig from 'eslint-config-prettier/flat';
import { defineConfig, globalIgnores } from 'eslint/config';
import tseslint from 'typescript-eslint';
 
export default defineConfig([
  globalIgnores(['.next/**', 'dist/**', 'build/**']),
  ...nextVitals,
  ...tseslint.configs.strictTypeChecked,
  {
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      '@typescript-eslint/no-explicit-any': 'error',
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/no-unsafe-assignment': 'error',
      '@typescript-eslint/consistent-type-imports': 'error',
      'prefer-const': 'error',
    },
  },
  prettierConfig,
]);

The first layer is framework behavior; the second is type-aware TypeScript analysis; the third is project policy. The exact import paths will depend on your framework's ESLint package—the structure matters more than the specific names. Prettier sits at the end so formatting rules do not conflict with correctness rules.

Run this alongside tsc --noEmit and the test suite in CI, using the same commands developers and agents run locally — the merge-blocking loop we cover in CI/CD Is the Adult Supervision Your Vibe-Coded App Needs. When an agent generates a route handler with a floating promise, the lint output says exactly what is wrong and which rule caught it. The agent can fix the problem in the next iteration without anyone explaining the issue—you can even put "fix the lint failure without weakening the rule" in the agent's instructions. That feedback loop is the real payoff: a consistent first-pass reviewer that gives precise feedback before a human looks at the diff.

Boundaries Need Rules, Not Just Diagrams

Architecture diagrams help, but agents do not reliably follow diagrams unless the repo enforces the implications. If a route file must not import the database client, write that down as a lint rule. If scripts must not reach into web-only modules, enforce it. If server-only helpers must not be imported by client components, make the failure automatic.

Many teams can start with published rules from ESLint, typescript-eslint, the framework, and import-boundary plugins. When the repeated mistake is genuinely project-specific, ESLint supports custom rules. Its custom rule tutorial is explicit about the reason to create one: use a custom rule when built-in or community rules do not meet the need, including preventing a particular bug from recurring or enforcing a project practice.

A project-specific rule can be small:

import type { TSESTree } from '@typescript-eslint/utils';
import { ESLintUtils } from '@typescript-eslint/utils';
 
export const noRouteDatabaseImport = ESLintUtils.RuleCreator(
  (ruleName) => `https://example.com/lint-rules/${ruleName}`,
)({
  name: 'no-route-database-import',
  meta: {
    type: 'problem',
    docs: {
      description:
        'Require route handlers to use service modules instead of importing the database client directly.',
    },
    messages: {
      useService:
        'Route handlers should call a service module instead of importing the database client directly.',
    },
    schema: [],
  },
  defaultOptions: [],
  create(context) {
    return {
      ImportDeclaration(node: TSESTree.ImportDeclaration) {
        if (context.filename.includes('/app/api/') && node.source.value === '@/lib/database') {
          context.report({ node, messageId: 'useService' });
        }
      },
    };
  },
});

That rule is not universal advice. Some apps intentionally keep thin data access inside route handlers. The value is that the decision becomes explicit. If the project says route handlers own HTTP concerns and service modules own persistence, the lint rule turns that sentence into a check.

This is how linting prevents the monolith smell from coming back one generated endpoint at a time. It does not say "large files are always bad." It says "this file is not allowed to absorb another responsibility without a deliberate boundary change."

The Diagnostic Question

In a Post Code Labs diagnostic, linting is not scored by how strict it looks in eslint.config.ts. It is scored as evidence of operational maturity, as part of the full Post Code Labs consultation — which covers linting and static analysis within its twelve-category review.

Score your own repo one point each:

  • Strict TypeScript is enabled — generated code can't widen important values without friction.
  • Type-aware lint rules run; linting is not just formatting.
  • Framework rules run against the folders that actually ship.
  • Import boundaries are enforced, not tribal knowledge.
  • Exceptions are rare, local, and explained.
  • CI runs the same check an agent would see locally — it can't be bypassed by passing local review alone.
  • At least one repeated review comment has been converted into an automated check.

5+: solid. 3–4: typical mid-maturity. ≤2: your reviewer is the lint engine.

Most projects land in the middle. One might have Prettier, basic ESLint, and a passing build, but no type-aware rules. Another has strong TypeScript settings but no ownership rules, so generated patches still reach across layers freely. A third has a strict config locally that CI never enforces. The remediation path is different in each case.

The goal is not to maximize rule count. The goal is to reduce the number of ways plausible code can be wrong silently.

What To Do Next Monday

Start with an inventory, not a crusade.

Run the current lint command and classify the failures into three buckets: formatting noise, correctness issues, and architecture issues. Formatting noise should be automated away. Correctness issues should become non-negotiable. Architecture issues deserve a short design decision before enforcement.

Then make five small moves:

  1. Turn on TypeScript strict in a branch if it is not already enabled. If the blast radius is large, measure it first and phase the fixes by folder.
  2. Add type-aware linting for promises and unsafe values. Start with the paths that handle requests, jobs, payments, auth, or data writes.
  3. Add one import-boundary rule that protects the most abused boundary in the repo.
  4. Pick the review comment your team has written three times and turn it into a lint rule, type, test, or schema.
  5. Require the same check in CI that agents run locally. The command should fail before a human reviewer becomes the lint engine.

That is a realistic week of work, and it meaningfully changes how generated code enters the repo. The agent still writes the first draft. The difference is that the codebase can now push back on the drafts that do not meet the standard—without waiting for a reviewer to notice.

References