Replacing ESLint with oxlint

Jesse James Richard
|
Sep 6, 2026
|
7 min read
#Method
#CI/CD
#AI & Agents
#Architecture

On Giant Context an agent writes most of the code and no second engineer reads it before it ships. The lint gate is the code review. It ran on every push for nine months and it was green on every one of them, and two facts about it decided this week's work. It was running a preset with the rules that mattered switched off. And it took 46 seconds, so it only ever ran in CI, after the code was written, pushed and merged.

Both are fixed. The linter is now oxlint. It runs the same rules at the same severity with nothing dropped, it takes 9 seconds in CI and one second locally, and it enforces the coding standards that had lived in a document since January.

What the old gate was being asked

ESLint was configured on the non-type-checked recommended set, with no-explicit-any off and the public website's frontend excluded. A gate set up that way is green because it is never asked the questions that would make it red. It measures its own configuration and reports that the configuration held.

I also had its cost wrong. The CI job is called Lint & Format and took about two minutes, and I read that as what linting cost. The breakdown on a current run is 34 seconds of checkout and install, 25 seconds of a circular-dependency check, and 9 seconds of lint. I had been reading an install time as a lint time for nine months and treating the job as too expensive to touch.

What the swap cost

Nothing was dropped. The old configuration named eleven rules explicitly, and every one of them has an oxlint equivalent at the same severity. ban-ts-comment, no-empty-object-type, no-explicit-any, no-inferrable-types, no-non-null-assertion, no-require-imports, no-unsafe-function-type, no-unused-vars with the same underscore ignore patterns, no-wrapper-object-types and prefer-as-const are all errors on both sides. The React hooks rules survived. rules-of-hooks is an error.

Two rules were softened rather than lost. exhaustive-deps and the fast-refresh rule only-export-components were errors under ESLint's recommended hooks config and are warnings now. That is the entire honest cost of the migration, and a repository that treats either of those as blocking should know it before switching.

The larger change is the posture. oxlint ships a default-on correctness set, and here it is turned off. categories: {"correctness": "off"}, and 136 rules listed by name, 121 at error, 10 at warn, 5 deliberately off. The five are import/no-cycle, nextjs/no-img-element, node/no-process-env, unicorn/filename-case and unicorn/no-null, each with a reason. The gate is an allowlist rather than a preset with overrides, and every rule in it was chosen. On a codebase where the linter is the reviewer, that is the difference between a reviewer with a checklist and one with a mood. A standard of my own goes into the same list, by name, next to no-explicit-any, and the gate does not know the difference.

Nine seconds in CI, one second on save

oxlint is a linter written in Rust that runs the common ESLint rule set with no JavaScript runtime in the loop. The Lint check step is the same step on both sides of the switch, same runner class, same repository. Three ESLint samples on 3 September were 45, 46 and 46 seconds. Three oxlint samples on 6 September were 8, 9 and 10. Locally, with both configurations restored to the same tree at the same moment, ESLint took 16 seconds and oxlint took one. The gap between five times in CI and sixteen times locally is runner and cold-cache overhead.

The number that matters is the one second, and not because it is small. A 46-second gate runs in CI. A violation comes back as a red job twenty minutes after the push, by which point the agent that wrote it has written three more files in the same pattern. A one-second gate runs on save, in the editor, before the file is closed. A prohibition enforced there is enforced at the point of writing, and the agent's next file is written against a codebase that does not contain the violation. When there is no human reviewer, that is where the review has to happen.

Coding standards as lint rules

The standards this codebase was built from were written before the first feature, and the first of the six non-negotiables was no SCREAMING_SNAKE_CASE. For nine months that rule lived in a document. It held about as well as a documented rule holds, which is well in the files an agent copies from and less well at the edges. A run of the linter with the rule switched on found a fair amount of it.

The linter that could have enforced it was in the lockfile the same week the rule was written. Both linters take rules written by the person using them, and I had never written one for either, because I had not thought of the coding standards as something a linter could check. That is the part I got wrong. Not the drift, which is what a document does, but nine months of owning the tool and never pointing it at the standards.

oxlint takes custom rules through jsPlugins, a plain JavaScript file, no build step, listed in the configuration beside the built-in rules. The rule for non-negotiable number one is a regular expression and a report.

.oxlint/plugins/giantcontext.js, the no-screaming-snake-case rule

// Two-or-more uppercase segments joined by underscores: FOO_BAR, A_B_C, _PRIVATE_X.// Single all-caps words (URL, ID, PORT) are a separate question, not matched.const screamingSnakeCase = /^_*[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+_*$/;
const checkBinding = (context, id) => {  if (id?.type === "Identifier" && screamingSnakeCase.test(id.name)) {    context.report({      message: `"${id.name}" is SCREAMING_SNAKE_CASE — owned TypeScript identifiers use camelCase. (Env vars, SQL, and external protocol constants are reads/strings/keys, not owned declarations, and are not flagged.)`,      node: id,    });  }};

The rest of the file is the list of places a name is declared, variable declarators, function declarations and the other binding sites, each calling checkBinding on the identifier. The comments carry the judgment. A single all-caps word is a different question and is not matched. An environment variable or a SQL column is read, not declared, so it is never flagged. Everything the rule needed to know was already written down in January. It took until September to write it down somewhere that fails.

Three standards are enforced this way today, this one, a rule that rejects immediately-invoked function expressions, and a rule that rejects a function whose name contains And, because a function that does two things has a name that says so. Each is a short file, and each took less time to write than the document that described it.

What it took and what it is for

oxlint went into the tree on 22 August beside ESLint, the retirement was one commit on 5 September, and the three custom rules were written in between. Since June this backend has been one language, so there is one linter, one rule file and one gate that every package passes through on every save. Before this week the coding standards were enforced by the agent having read them. Now they are enforced by the agent being unable to save a file that breaks them, and any standard I write next is a short file away from the same treatment. The agent does the typing, the gate does the review, and I can add to the gate in an afternoon.

I built a marketing platform artificial intelligence runs

#Keystone
#APIs & MCP
#Growth

Giant Context is a marketing platform where the intelligence is the operator. A model reads what a business is, works out what its marketing is missin...

Jesse James Richard

|

Sep 2, 2026
Read previous

Building something like this

If this is the kind of engineering you need, get in touch.

Contact Jesse
Home
About
Contact
Sitemap
Privacy Policy
Terms of Service
Cookie Policy