A codebase an AI wrote can ship with a linter installed and nothing enforcing it: the ESLint config is the framework default, warnings scroll past in CI, and every rule the agent tripped is silenced with an eslint-disable comment. Linters protect a codebase only when three things are true: one config, a short list of rules that fail the build, and a suppression count someone watches.
What linters are, and what a formatter is
Linters are static analysis tools that read source code without running it and report problems, from potential runtime bugs to styling issues, each tied to a named rule. A formatter only rewrites layout. Of the three kinds of rule ESLint names (problems, suggestions and layout), only the first belongs in a build gate.
Linting is one control among the engineering standards for AI-assisted teams; this page sets its rules.
ESLint’s core concepts describe it as “a configurable JavaScript linter” that “helps you find and fix problems in your JavaScript code,” where “Problems can be anything from potential runtime bugs, to not following best practices, to styling issues.” An ESLint rule’s type is one of three. A “problem” rule flags code that “either will cause an error or may cause a confusing behavior,” a “suggestion” rule flags something that “could be done in a better way but no errors will occur,” and a “layout” rule cares about “whitespace, semicolons, commas, and parentheses.” My working rule is that only the problem kind belongs in a build gate.
The meaning of linters gets blurred because four kinds of tool check code, and in my reading an AI-built repository often has one of them while being treated as if it had all four. A formatter rewrites layout and has no opinion about correctness. A type checker such as TypeScript “checks a program for errors before execution, and does so based on the kinds of values.” A security scanner looks for vulnerable patterns and the paths data takes through the code. The last column below is my reading.
| Tool type | What it checks | Can it auto-fix | Should it fail the build (my reading) |
|---|---|---|---|
Formatter (Prettier, ruff format) | Layout only: line breaks, quotes, indentation | Yes, it rewrites the file; ruff format --check only reports | Yes, as a check that files are already formatted |
| Linter (ESLint, Ruff, RuboCop) | Rule violations, from potential runtime bugs to styling issues | Some rules: ESLint applies fixes with --fix, and its docs say fixes don’t change application logic | Yes, for problem rules only |
| Type checker (TypeScript) | That values match the kinds of values the code declares | Some errors, through editor quick fixes that TypeScript powers; tsc has no fix option | Yes |
| Security scanner | Vulnerable patterns and data flows | Depends on the scanner | Yes, for findings you have triaged |
A linter is not a security scanner, and the difference matters when someone says the code was checked: what is a code scan, and what it finds and misses covers that side.
Why it matters for a small SaaS: what a linting issue hides when an agent writes the code
A linting issue is one reported violation of one rule at one place in the code. ESLint’s own report for each one carries the rule name, a severity where “1 means warning and 2 means error,” the line, and a fix when the rule can produce one. The severity decides everything that follows: a rule set to “warn” is reported but “doesn’t affect exit code,” while “error” makes ESLint exit with code 1, which is what fails a CI job.
On a codebase people wrote, the author reads the issue and fixes the line. An AI agent told to make the build pass has three cheaper moves than fixing the code, and each one turns the check green while the bug stays. The table is my reading of what each move looks like.
| What the agent does when lint fails | What it looks like in the diff | Why it matters |
|---|---|---|
| Adds an inline suppression | A new // eslint-disable-next-line, # noqa or //nolint comment above or beside the flagged line | The rule still runs everywhere else, so nothing looks wrong, and this one bug ships |
| Downgrades or turns off the rule | A config line changing "error" to "warn" or "off" | Every future violation of that rule passes too, across the whole repository |
| Excludes the file or folder | A new glob under ignores or exclude | Nothing in that path is checked again, including code written later |
Each move is visible in a diff if someone knows to look, and that matters more here than on a team of engineers because the other gates are often missing. I audited AI-built apps in June and July 2026, and at least 17 of the 21 third-party apps had no deploy gate: every push ships straight to production with nothing checking it first. At least 23 of the 26 audited apps had zero working automated tests. Those apps are a set I chose to audit, not a random sample, so the counts describe them and are no rate for AI-built apps in general. In a repository like that, the linter and the type checker may be the only automated reviewers a change ever meets.
Warnings are where issues go to be ignored. My working rule: a rule is either worth failing the build or worth turning off, and nothing sits at “warn” for long. If you can’t open the repository yourself, the checks you can run without reading code are the place to start.
How it works: one config, one formatter, and the rules worth failing a build over
The work runs in the order below: split the two jobs, pick the tool, pick the rules, count the suppressions, then repeat the setup for every other language in the repository.
Prettier vs ESLint: two jobs, and how to stop them fighting
Prettier vs ESLint is a split of two jobs: Prettier formats and ESLint finds problems. They can clash when ESLint’s stylistic rules are switched on. The documented fix is to let Prettier own layout and switch the conflicting lint rules off with eslint-config-prettier.
Prettier’s page on integrating with linters says it directly: “Use Prettier for code formatting concerns, and linters for code-quality concerns,” because most stylistic lint rules are unnecessary next to Prettier and “might conflict with Prettier.” In a repository, the clash shows up as files that flip between two styles from one commit to the next, depending on which tool touched them last. That is my reading of the symptom, and it is easy to spot in a history.
The same page covers running Prettier as an ESLint rule through eslint-plugin-prettier, and calls those plugins “generally not recommended, but can be useful in certain circumstances,” listing that they are “slower than running Prettier directly” and are “yet one layer of indirection where things may break.” ESLint moved the same way: ESLint’s note deprecating its formatting rules, published in October 2023, deprecated them in v8.53.0 and recommends “a source code formatter instead.”
My working rule for the setup: one config file per tool at the repository root, one format script and one lint script in package.json, and the same two commands locally and in CI. It’s the first cleanup I’d make on an AI-built JavaScript project, since a generated project can arrive with both tools and overlapping defaults.
ESLint alternatives: Oxlint vs ESLint, and Biome for JavaScript and TypeScript
ESLint alternatives worth knowing are two tools written in Rust. Oxlint is a dedicated linter with more than 870 rules that can replace ESLint or run first with ESLint behind it. Biome formats and lints JavaScript and TypeScript from one config file. Both base many of their rules on ESLint and its plugins.
ESLint is the incumbent. The newest version in its docs on October 3, 2026 was v10.11.0; flat config (eslint.config.js) became the default in v9.0.0 in April 2024, and from v10.0.0 the old .eslintrc format “is no longer supported.” Its plugin ecosystem carries framework rules and typescript-eslint’s type-aware rules, which read TypeScript’s type information to catch things like an un-awaited promise.
For Oxlint vs ESLint, the Oxlint docs describe “a high-performance linter for JavaScript and TypeScript built on the Oxc compiler stack.” Its type-aware linting “currently supports 59 out of 61 type-aware rules from typescript-eslint” through a separate oxlint-tsgolint package, and its JS plugins, which load existing ESLint plugins, “are currently in alpha.” The docs give two adoption paths: replace ESLint, “recommended for most projects,” or “Run Oxlint first, then run ESLint with overlapping rules disabled” using eslint-plugin-oxlint. On speed, Oxc’s own benchmarks page, as of October 3, 2026, says Oxlint is “50x - 100x faster than ESLint depending on the number of CPU cores”; the page itself prints no date.
Biome is one tool for both jobs in JavaScript and TypeScript. Version 2.5 lints with “561 rules from ESLint, TypeScript ESLint, and other sources,” formats JavaScript, TypeScript, JSX, TSX, JSON, HTML, CSS and GraphQL, and reads one biome.json. Its own docs mark the gaps: Vue, Svelte and Astro support is experimental, and its floating-promises rule sits in the nursery group, which “means that it is experimental and the behavior can change at any time.”
The “when to pick it” column is my reading; every other cell comes from each project’s docs.
| Tool | What it replaces | Written in | Type-aware rules | Plugin support | When to pick it (my reading) |
|---|---|---|---|---|---|
| ESLint | Nothing; it is the reference the others port rules from | JavaScript (an npm package with a Node.js API) | Through typescript-eslint | Plugins are npm modules with rules, configs and processors | You depend on framework plugins or many type-aware rules |
| Oxlint | ESLint, fully or for the rules it covers | Rust, with type-aware rules run by tsgolint in Go | 59 of 61 typescript-eslint type-aware rules | JS plugins on ESLint’s v9+ plugin API, in alpha | A large ESLint setup where lint time slows CI |
| Biome | Prettier plus much of ESLint | Rust | Its own type inference; noFloatingPromises is experimental | GritQL plugins | A small app with no custom plugins that wants one tool |
My working rule for the decision fits in three lines. A small app with no custom plugins can move to Biome and delete two tools. A large ESLint setup can put Oxlint in front for speed and keep ESLint for whatever Oxlint does not cover yet. An app whose value sits in type-aware or framework rules stays on ESLint until the alternative covers them. On an inherited codebase, switching tools is never the first job; enforcing whichever one is already there is.
The rules worth failing a build over on AI-written code
Lint rules worth failing a build over on AI-written code fall into seven classes: un-awaited promises, unused code, loose equality, wrong hook dependencies, swallowed errors, unchecked types or non-null assertions at a boundary, and dynamic code or HTML injection sinks. Everything stylistic belongs to the formatter. A rule fails the build or is off.
The seven classes are my working rule for AI-written code. Each rule id below is listed in its project’s docs as of October 3, 2026, and a cell with no matching rule says so.
| Failure class | What AI-written code does (my reading) | ESLint or typescript-eslint rule | Ruff, RuboCop, golangci-lint or Clippy equivalent |
|---|---|---|---|
| A promise nobody awaits | Calls a payment or email function without await, so its failure never reaches the caller | @typescript-eslint/no-floating-promises, @typescript-eslint/no-misused-promises | Ruff RUF006 (an asyncio task with no stored reference); Clippy let_underscore_future (a future dropped with let _) |
| Unused variable, import or assignment | Leaves dead branches behind when it regenerates a file | no-unused-vars (@typescript-eslint/no-unused-vars in TypeScript) | Ruff F401, F841; RuboCop Lint/UselessAssignment; golangci-lint unused, ineffassign |
| Loose equality | Compares with == and lets JavaScript coerce the types | eqeqeq | Not stated in those tools’ docs |
| Wrong hook dependencies | Leaves a value out of a useEffect list, so the screen shows stale data | react-hooks/exhaustive-deps | Not stated in those tools’ docs |
| Swallowed error | Writes an empty catch or a catch-all that drops the error | no-empty (it skips a block that holds only a comment) | Ruff E722, BLE001, S110; RuboCop Lint/SuppressedException; golangci-lint errcheck |
any, unchecked cast or non-null assertion at a boundary | Reads config or API data as any or with ! | @typescript-eslint/no-explicit-any, no-unsafe-assignment, no-unsafe-type-assertion, no-non-null-assertion | Ruff ANN401; Clippy unwrap_used (restriction group, off by default) |
eval, dynamic code and HTML injection sinks | Builds code from strings or injects raw HTML | no-eval, no-new-func, @typescript-eslint/no-implied-eval; react/no-danger from eslint-plugin-react | Ruff S307, S102; RuboCop Security/Eval |
The boundary row is where linting meets the compiler, and knowing how to enable TypeScript strict mode closes the same gap from the type side. Every ESLint and typescript-eslint rule id in the table is listed in typescript-eslint’s rules or ESLint’s own rules index, apart from the two React plugins’ rules.
In one of my own apps, eighteen required settings were read with a TypeScript non-null assertion, which is erased at compile time. A missing value became undefined inside request URLs: the app booted clean and failed feature by feature. The lesson I take from it: an assertion that tells the compiler to stop checking is exactly what a lint rule can refuse, one line at a time, and @typescript-eslint/no-non-null-assertion reports each ! postfix.
The seven as errors, in an ESLint flat config for a TypeScript and React project:
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
export default defineConfig({
files: ["**/*.{ts,tsx}"], extends: [tseslint.configs.recommendedTypeChecked],
plugins: { "react-hooks": reactHooks }, languageOptions: { parserOptions: { projectService: true } },
linterOptions: { reportUnusedDisableDirectives: "error" },
rules: { "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-unused-vars": "error",
eqeqeq: "error", "react-hooks/exhaustive-deps": "error", "no-empty": "error", "no-eval": "error", "no-new-func": "error",
"@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-non-null-assertion": "error", "@typescript-eslint/no-implied-eval": "error" },
});
A repository with hundreds of existing violations needs an order. My working rule: turn the seven on as errors, run the auto-fixer once in its own commit, record what remains as a baseline count, and fail the build on any increase; anything stylistic goes to the formatter or gets switched off, and nothing is left at “warn”. ESLint has the baseline built in. Its bulk suppressions (eslint --fix --suppress-all) record existing violations of rules set to “error” in an eslint-suppressions.json file at the root, so the rule is “enforced for new code” while old violations are not reported, and ESLint exits non-zero once a suppressed violation is fixed but its entry is still there.
The suppressions to watch: eslint-disable, ts-ignore, noqa, nolint and allow
Lint suppressions are the comments and config entries that silence a rule: eslint-disable, @ts-ignore, noqa, rubocop:disable, nolint and allow. Count them with one search that tallies each syntax, print the total in CI, and require that each names a rule and gives a reason. An agent told to make lint pass can reach for them first.
Every ecosystem has an inline form, a file or folder form, and usually a setting that reports unused or unexplained ones. The table lists each from the tool’s own docs; the census pattern is the part of each syntax the command below matches.
| Ecosystem | Inline syntax | File or folder syntax | Census pattern | Setting that reports unused or unexplained ones |
|---|---|---|---|---|
| ESLint | // eslint-disable-next-line rule-name, // eslint-disable-line, with a reason after -- | /* eslint-disable */ at the top of a file; ignores in the config; eslint-suppressions.json | eslint-disable | linterOptions.reportUnusedDisableDirectives (default “warn”); noInlineConfig turns all inline config off; the eslint-comments plugin’s require-description asks for reasons |
| TypeScript | // @ts-ignore, // @ts-expect-error | // @ts-nocheck | @ts- | typescript-eslint ban-ts-comment, with allow-with-description |
| Ruff | # noqa: F841; a # ruff: disable[...] range | # ruff: noqa in the file; per-file-ignores, exclude | noqa | RUF100 (unused-noqa); PGH004 (blanket-noqa) |
| RuboCop | # rubocop:disable Cop/Name, rubocop:todo, rubocop:disable-next, with a reason after -- | .rubocop_todo.yml from --auto-gen-config; Exclude under a cop | rubocop: | Lint/RedundantCopDisableDirective; --report-unused-todo-entries; Style/DisableCopsWithinSourceCodeDirective flags every directive |
| golangci-lint | //nolint:errcheck // reason | linters.exclusions.paths and rules | nolint | nolintlint with require-explanation and require-specific (both default false) |
| Clippy | #[allow(clippy::lint_name)], #[expect(...)] | #![allow(...)] at the top of a module or crate; [lints.clippy] in Cargo.toml | [allow(, [expect( | allow_attributes_without_reason (restriction group); #[expect] warns when the lint never fires |
| PHP_CodeSniffer | // phpcs:ignore Sniff.Name, with a note after -- | // phpcs:ignoreFile, // phpcs:disable; <exclude-pattern> in phpcs.xml | phpcs: | Not stated in PHP_CodeSniffer’s docs |
Here TypeScript’s suppressions are only counted; removing them belongs with strict mode. The census is one search, run at the repository root, with the output written down and dated. The flags come from GNU grep’s and uniq’s manuals: -r recurses, -h drops file names, -o prints only each match, -E takes the extended pattern, --exclude-dir skips dependency and build folders, and uniq -c counts each distinct match after sort.
grep -rhoE --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=vendor --exclude-dir=target \
'eslint-disable(-next-line|-line)?|@ts-(ignore|expect-error|nocheck)|noqa|rubocop:(disable|todo)|nolint|#!?\[(allow|expect)\(|phpcs:(ignore|disable)' . \
| sort | uniq -c
The policy is three rules, and they are my working rule. Every suppression names one rule and carries a reason on the same line. The count is printed in CI and may not rise without a reviewer’s approval. Config-level rule changes and ignore paths are reviewed like code. If you want GitHub to enforce that review, check your plan first: protected branches are available in public repositories with GitHub Free, and in public and private repositories with GitHub Pro, GitHub Team, GitHub Enterprise Cloud or GitHub Enterprise Server. The agent-side half, telling the agent it may not add suppressions and catching it when it does, belongs to the guardrails for AI coding agents.
Per-language config: Ruff ignore rules, RuboCop Rails, golangci-lint config, clippy.toml and PHPCS
Each language keeps its lint config in one main file: Ruff in pyproject.toml, RuboCop in .rubocop.yml, golangci-lint in .golangci.yml, PHP_CodeSniffer in phpcs.xml. Rust splits it: lint levels go in Cargo.toml or source attributes, and clippy.toml holds settings for single lints. Ignoring a rule for one file and excluding the file are different settings.
| Language | Linter | Config file | Ignore one rule for one file | Formatter that pairs with it |
|---|---|---|---|---|
| Python | Ruff | pyproject.toml, ruff.toml or .ruff.toml | per-file-ignores, a glob mapped to rule codes | ruff format |
| Ruby | RuboCop with rubocop-rails | .rubocop.yml | Exclude: under the cop’s name | RuboCop’s own layout fixes (--fix-layout) |
| Go | golangci-lint | .golangci.yml (also .yaml, .toml, .json) | linters.exclusions.rules with a path and the linter’s name | The formatters section (gofmt, goimports, gofumpt and others) |
| Rust | Clippy | Levels in Cargo.toml [lints.clippy]; lint settings in clippy.toml | #![allow(clippy::lint_name)] at the top of that module | rustfmt |
| PHP | PHP_CodeSniffer (phpcs) | phpcs.xml (also .phpcs.xml, phpcs.xml.dist) | <exclude-pattern> inside the sniff’s <rule> | phpcbf, from the same project |
Python. Ruff reads its settings from pyproject.toml, ruff.toml or .ruff.toml, and Ruff’s configuration docs show the same schema in all three. To make Ruff ignore a rule for one file or folder, map a glob to rule codes under per-file-ignores; to stop Ruff reading a file at all, list it under exclude or extend-exclude. The first keeps every other rule running on that file, and the second checks nothing there. Ruff also formats, through ruff format, which its docs call “a drop-in replacement for Black,” so one tool covers both jobs.
Ruby. RuboCop Rails is “A RuboCop extension focused on enforcing Rails best practices and coding conventions,” installed as the rubocop-rails gem. From RuboCop 1.72 it loads with plugins: rubocop-rails in .rubocop.yml; earlier versions use require instead, as the rubocop-rails docs note. The rubocop-rails-omakase gem describes itself as “Omakase Ruby styling for Rails” for teams that “haven’t committed to any specific dialect already,” and new Rails 7.2+ apps include it automatically. Other extensions exist for performance and test frameworks; load only the ones you will enforce.
Go. A golangci-lint config in the v2 format starts with version: "2", the only value the reference allows, followed by top-level linters, formatters, issues, output, run and severity sections. golangci-lint’s configuration file reference shows every key, and golangci-lint migrate converts a v1 file. The default standard set enables five linters: errcheck, govet, ineffassign, staticcheck and unused. My working rule is to start from those defaults and add linters one at a time, never from a 600-line golden config nobody on the team can explain.
Rust. The Clippy book’s configuration page puts lint levels in attributes such as #[allow(...)], in command-line flags, or in the [lints.clippy] table of Cargo.toml. The clippy.toml file is different: it holds a “variable = value mapping” that some individual lints read, such as disallowed-names or msrv, and the book notes “The configuration file is unstable and may be deprecated in the future.” In my reading, those two places are the ones people mix up.
PHP. PHPCS is two scripts: phpcs finds “violations of a defined coding standard” and phpcbf will “automatically correct coding standard violations.” PHP_CodeSniffer now lives under the PHPCSStandards organization as “the official continuation of the now abandoned PHP_CodeSniffer package which was created by Squizlabs,” its default standard is PSR12, and it reads a phpcs.xml ruleset from the project root.
A repository with two languages gets two configs and one CI job that runs both; that is my working rule, and it keeps one red check per pull request.
How to check your own app: prove the lint job gates a merge
A linter gates a merge when five checks pass: the documented command fails on a checkout with a planted bug, a pull request with that bug fails CI, the suppression count matches the recorded one, config changes were reviewed, and the agent fixes the code instead of silencing the rule.
Each check below can fail, and each leaves evidence to keep.
- 01 On a clean checkout, add one violation from the fail list on a scratch branch (an un-awaited promise in TypeScript, or any row of the table in another language) and run the lint command the README documents. Fail: there is no such script, or it exits 0. Evidence: the output and the exit code.
- 02 Open a pull request with that same violation. Fail: the CI lint job passes. Where the repository is on a GitHub plan that offers protected branches for it, also fail if the pull request can still be merged. Evidence: the failed check, dated.
- 03 Run the suppression census and compare it with the last recorded count. Fail: there is no recorded count, or the count rose and nobody approved it. Evidence: the two counts with their dates.
- 04 Read the last ten commits that touched the lint or formatter config. Fail: a rule was switched off in the same commit as the code that broke it. Evidence: the commit list.
- 05 Ask the coding agent to make a failing lint pass, and read the diff before accepting it. Fail: a suppression or a config change instead of a code fix. Evidence: the diff.
The plan condition in check 2 is GitHub’s, as given in the suppressions section above. The pipeline that runs the lint job is its own subject, set out in CI/CD best practices. An AI review bot can be a second reader on those config commits in check 4, and choosing a CodeRabbit alternative is a separate decision from the lint rules themselves.
The Production Hardening Sprint verifies two of its deliverables with a check of this kind. Deliverable 7.3, Pull-request CI, is verified this way: “Submit failing and passing changes and retain the CI results.” Deliverable 10.8, AI repository guardrails, is verified this way: “Review the guidance and demonstrate CI catching a representative forbidden regression.”
Where the sprint fits
No deliverable is named for linting, but three sit closest to it. In deliverable 7.3 we run linting, type checks, builds, and tests on every pull request. Deliverable 10.6 enables TypeScript strict mode and resolves errors in TypeScript applications, with an equivalent strict-checking approach for other supported stacks. Deliverable 10.8 provides CLAUDE.md, AGENTS.md, Cursor rules, or equivalents describing conventions and protected patterns, and adds CI checks for enforceable rules. Outside the sprint: building new product features or modules, completing unfinished core features or business workflows, and rebuilding core functionality that does not yet perform its intended job. The full list of deliverables is in the published scope.
Common questions about linting
What is a linter in AI?
A linter in AI usually means one of two things: a linter run on code an AI assistant wrote, which is what this page covers, or an AI tool that reviews code. I don’t count the second as a linter, because its output is not a fixed rule applied the same way on every run. AI review bots can sit next to a linter; they don’t replace one.
What does “lint” mean in slang?
In everyday English, lint is the fluff and tiny fibers that clothing sheds, and cotton linters are the fine fibers left on cotton seeds after ginning. The programming sense comes from Lint, a C checker Stephen C. Johnson wrote at Bell Labs in 1978, named after the lint trap in a clothes dryer.
Does Ruff enforce PEP8?
Partly, and only a little by default. Ruff implements pycodestyle rules (the E and W codes), but its default set omits “any stylistic rules that overlap with the use of a formatter,” and only three pycodestyle codes (E722, E902 and W605) are in Ruff’s default rule list as of October 3, 2026. Layout is left to ruff format, whose settings cite PEP 8 for defaults such as four-space indentation.
Does golangci-lint include go vet?
Yes. golangci-lint ships a govet linter that its docs describe as “roughly the same as ‘go vet’” and that “uses its passes,” and in v2 it is one of the five linters in the default standard set.
Does Rust Analyzer use Clippy?
Not by default. rust-analyzer’s diagnostics come mostly from its cargo check integration, and its rust-analyzer.check.command setting defaults to “check”; set it to “clippy” and it runs cargo clippy instead.
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase