Published on

> Clean Code Is the Best Documentation — Especially for AI Agents

Authors

The thesis (recap)

Traditional docs explain code from the outside; clean code explains itself from the inside:

  • Names express intent
  • Types encode contracts & invariants
  • Small functions isolate behavior
  • Tests read like examples
  • Comments explain why, not what

For AI agents, this means:

  • Smaller, denser context windows (less room to guess)
  • Higher retrieval precision (clear, consistent identifiers)
  • Fewer hallucinations (less ambiguity)
  • Tests as guardrails (source of behavioral truth)

You still keep a thin layer of external docs: Runbook, Glossary, ADRs, and generated API docs. Everything else lives in the source.

Updated quick checklist — and what enforces it

Print this and stick it in your PR template.

  • Names reveal intent Enforced by: unicorn/prevent-abbreviations, @typescript-eslint/naming-convention
  • Types/guards encode invariants Enforced by: tsconfig strict, @typescript-eslint/no-explicit-any, @typescript-eslint/strict-boolean-expressions
  • Small, single-purpose functions Enforced by: max-params, max-lines-per-function, complexity, sonarjs/cognitive-complexity
  • Tests read like examples (and exist) Enforced by: Vitest + a tiny "ensure test siblings" script
  • Comments explain why (not what) Enforced by: jsdoc/require-jsdoc, jsdoc/require-description (exported APIs)
  • Predictable imports & structure Enforced by: simple-import-sort, import/no-default-export (Next.js overrides where required)
  • CI blocks merges if anything fails Enforced by: GitHub Actions + tsc --noEmit

The linter that encodes the checklist (Next.js + TypeScript + tRPC + Drizzle)

1) tsconfig.json — make types the contract

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "exactOptionalPropertyTypes": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "useUnknownInCatchVariables": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "types": ["vitest/globals"]
  },
  "include": ["src", "next-env.d.ts"]
}

2) .eslintrc.cjs — encode intent, limit complexity, require "why"

/* eslint-env node */
module.exports = {
  root: true,
  parser: "@typescript-eslint/parser",
  plugins: [
    "@typescript-eslint",
    "import",
    "unicorn",
    "sonarjs",
    "jsdoc",
    "simple-import-sort",
    "vitest"
  ],
  extends: [
    "next/core-web-vitals",
    "plugin:@typescript-eslint/recommended",
    "plugin:import/recommended",
    "plugin:import/typescript",
    "plugin:unicorn/recommended",
    "plugin:sonarjs/recommended",
    "plugin:jsdoc/recommended",
    "plugin:vitest/recommended"
  ],
  settings: {
    "import/resolver": { typescript: true }
  },
  rules: {
    /* Intent in names */
    "unicorn/prevent-abbreviations": ["error", {
      allowList: {
        args: true, env: true, db: true, api: true, id: true, prop: true, params: true, ctx: true,
        req: true, res: true
      }
    }],
    "@typescript-eslint/naming-convention": [
      "error",
      { "selector": "typeLike", "format": ["PascalCase"] },
      { "selector": "variable", "types": ["boolean"], "format": ["camelCase"], "prefix": ["is","has","can","should"] }
    ],
    "import/no-default-export": "error",

    /* Types/guards encode invariants */
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/strict-boolean-expressions": ["error", { allowNullableBoolean: false }],
    "@typescript-eslint/no-non-null-assertion": "error",

    /* Small, single-purpose functions */
    "max-params": ["error", 3],
    "max-lines-per-function": ["error", { max: 60, skipComments: true, skipBlankLines: true }],
    "complexity": ["error", 10],
    "sonarjs/cognitive-complexity": ["error", 15],

    /* Comments explain WHY (not WHAT) on exported APIs */
    "jsdoc/require-jsdoc": ["error", {
      publicOnly: { esm: true },
      require: { FunctionDeclaration: true, MethodDefinition: true, ClassDeclaration: true },
      contexts: [
        "ExportNamedDeclaration > FunctionDeclaration",
        "TSTypeAliasDeclaration",
        "TSInterfaceDeclaration"
      ]
    }],
    "jsdoc/require-description": "error",

    /* Predictable imports/structure */
    "simple-import-sort/imports": "error",
    "simple-import-sort/exports": "error",
    "import/order": "off",

    /* File naming: Components or utilities can be kebab- or Pascal-case.
       Next special files are exempted below. */
    "unicorn/filename-case": ["error", {
      cases: { kebabCase: true, pascalCase: true },
      ignore: ["^page$", "^layout$", "^error$", "^loading$", "^not-found$"]
    }],

    /* Pragmatic defaults */
    "unicorn/no-null": "off" // allow `null` to model absence
  },
  overrides: [
    /* Next.js requires default exports for pages/app special files */
    {
      files: [
        "src/pages/**/*.{ts,tsx}",
        "src/app/**/page.tsx",
        "src/app/**/layout.tsx",
        "src/app/**/error.tsx",
        "src/app/**/loading.tsx",
        "src/app/**/not-found.tsx",
        "next.config.*"
      ],
      rules: { "import/no-default-export": "off" }
    },
    /* Test files: relax function-size for describe/it blocks */
    {
      files: ["**/*.test.{ts,tsx}"],
      rules: {
        "max-lines-per-function": ["error", { max: 120, skipComments: true, skipBlankLines: true }]
      }
    }
  ]
};

3) Minimal "tests must exist" check (fast & simple)

Ensures every non-test module has a sibling *.test.ts(x).

scripts/ensure-test-siblings.mjs

import { globby } from "globby";
import { relative } from "node:path";

const files = await globby(["src/**/*.ts", "src/**/*.tsx", "!src/**/*.test.*", "!src/**/_*.ts"]);
const missing = [];

for (const f of files) {
  const guesses = [f.replace(/\.tsx?$/, ".test.ts"), f.replace(/\.tsx?$/, ".test.tsx")];
  const found = await globby(guesses);
  if (found.length === 0) missing.push(relative(process.cwd(), f));
}

if (missing.length) {
  console.error("❌ Missing tests for:");
  for (const m of missing) console.error(" -", m);
  process.exit(1);
} else {
  console.log("✅ Tests exist for all modules.");
}

4) package.json scripts

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "lint": "eslint . --ext .ts,.tsx",
    "lint:fix": "eslint . --ext .ts,.tsx --fix",
    "verify:tests-exist": "node scripts/ensure-test-siblings.mjs",
    "test": "vitest run",
    "validate": "pnpm typecheck && pnpm lint && pnpm verify:tests-exist && pnpm test"
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^8",
    "@typescript-eslint/parser": "^8",
    "eslint": "^9",
    "eslint-plugin-import": "^2.29.1",
    "eslint-plugin-jsdoc": "^48",
    "eslint-plugin-simple-import-sort": "^12",
    "eslint-plugin-sonarjs": "^0.25",
    "eslint-plugin-unicorn": "^55",
    "eslint-plugin-vitest": "^0.4",
    "globby": "^14",
    "typescript": "^5",
    "vitest": "^2"
  }
}

5) CI that blocks merges if anything slips

.github/workflows/ci.yml

name: CI
on: [push, pull_request]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm validate

Here's an example in action

Before (vague, complex, under-tested)

// src/billing/calc.ts
export function calc(a: number, b: number, c: number, d: boolean, e?: any) {
  let r = Math.round((a / b) * c);
  if (d && e) r += e;
  return r;
}
// (no test file)

What the tooling flags:

  • unicorn/prevent-abbreviations: a, b, c, d, e convey no intent
  • @typescript-eslint/no-explicit-any: forbids any
  • max-params: 5 > 3
  • jsdoc/require-jsdoc: exported API lacks a description of why
  • verify:tests-exist: no calc.test.ts(x)

After (the code is the documentation)

// src/billing/proration.ts

/**
 * Prorates a cycle charge by remaining days.
 * Floors daily rate to avoid over-crediting in variable-length months (28–31 days).
 */
export function prorateSubscription(
  amountCents: number,
  cycleDays: number,
  remainingDays: number
): number {
  if (cycleDays <= 0) throw new Error("cycleDays must be > 0");
  const safeRemaining = Math.min(Math.max(remainingDays, 0), cycleDays);
  const daily = Math.floor(amountCents / cycleDays);
  return daily * safeRemaining;
}
// src/billing/proration.test.ts
import { expect, it, describe } from "vitest";
import { prorateSubscription } from "./proration";

describe("prorateSubscription", () => {
  it("prorates by remaining days", () => {
    expect(prorateSubscription(3000, 30, 10)).toBe(1000);
  });
  it("never credits more than charged", () => {
    expect(prorateSubscription(3000, 30, 40)).toBe(3000);
  });
  it("handles zero or negative remaining days", () => {
    expect(prorateSubscription(3000, 30, -2)).toBe(0);
  });
});

Result: names carry intent, invariants are guarded, function is small and linear, exported API explains why, and tests read like examples. An agent can infer behavior from a tiny, high-signal context—no extra docs needed.

Why this reduces hallucinations (context-budget intuition)

A "docs-first" change often forces an agent to ingest wiki pages + PR threads + diffs (~5–7k tokens). The "code-as-doc" version is small functions + JSDoc + tests (~700–1,400 tokens). Less ambiguity in fewer tokens reduces the need for the model to guess.

Adoption plan (tighten gradually)

  1. Start gentle: keep the rules but loosen thresholds (e.g., max-lines-per-function: 100, complexity: 15). Land the linter.
  2. Gate in CI: require pnpm validate to pass on PRs. Add the test-sibling check.
  3. Tighten over time: nudge complexity/size thresholds downward as code gets cleaner.
  4. Keep docs thin: maintain Runbook, Glossary, and ADRs; migrate everything else into code, types, and tests.

With this setup, your intent is literally encoded in your code. The linter + types + tests turn your repo into machine- and human-readable documentation.

Copy/paste summary

  • Checklist (printable): in this post
  • Configs: tsconfig.json, .eslintrc.cjs, package.json scripts
  • Script: scripts/ensure-test-siblings.mjs
  • CI: .github/workflows/ci.yml
  • Example: proration.ts + proration.test.ts

If you want a repo-ready PR that drops these files into a Next.js/TypeScript project (with thresholds tuned to your codebase), say the word and I'll generate it.