World’s largest virtual agentic engineering & quality conference
A linter reads code without running it. See a real ESLint run, the six errors it caught, the bug it did not, and where static analysis stops being enough.

Shravan Mahajan
Author
Last Updated on: August 8, 2026
A shopping cart file was run through ESLint. It reported six errors. Every one was fixed, the linter went green, and the cart still undercharged every customer who used a coupon.
That run is reproduced in full below, because it explains what a linter is far better than a definition does. This guide covers how linting works, what it reliably catches, the exact category it cannot see, and where to put the check that catches the rest.
Overview
A linter is a static analysis tool that reads source code without running it and reports patterns that break a configured rule set. It catches likely bugs, correctness risks, and inconsistencies in seconds, and it is limited to what can be decided from the text alone, so logic that is well written and still wrong passes cleanly.
What Does a Linter Catch?
Where Does Linting Stop?
At the boundary between text and execution. Linting proves properties of the source; tests prove properties of the running program. Both are needed, and confusing one for the other is how a green pipeline ships a defect that a single click would have exposed.
A linter is a program that reads your source code as text, builds a structural model of it, and reports places where that structure matches a pattern you have declared unwanted. It never executes the code it examines.
The name is inherited. The original lint was written by Stephen C. Johnson at Bell Labs in 1978 to catch problems in C programs that the compiler of the day let through, and it was named for the fibres a dryer trap collects. The metaphor has held up better than most: a linter collects small debris that individually looks harmless.
What makes it useful is speed and placement. A linter runs in seconds, inside the editor, on code that has not been committed, let alone deployed. That means the feedback arrives while the author still has the reasoning in their head, which is the cheapest moment any defect can be found.
It is also advisory rather than mandatory. A compiler must reject what it cannot translate. A linter reports on code that is entirely valid, against rules your team chose, which is why two projects in the same language can produce completely different lint output from identical code.
Four steps, and understanding them explains both the strengths and the hard limit.
Step three is where the ceiling sits. A rule can see that a comparison exists and that it uses loose equality. It cannot see what the two sides will hold when the program runs, because that information does not exist until it does. Every limitation in the next section follows from that one fact.
Here is the cart file from the opening, with six deliberate problems planted in it. Read it before the output and see how many you find.
const TAX_RATE = 0.2;
const CURRENCY = 'GBP';
export function total(items, coupon) {
let sum = 0;
for (const item of items) {
sum += item.price * item.qty;
}
if (coupon == null) {
sum = sum * (1 + TAX_RATE);
}
return sum;
console.log('never runs');
}
export const config = { retries: 2, timeout: 5000, retries: 3 };
export function applyDiscount(sum) {
if (sum > 100) {
return sum - discountAmount;
}
return sum;
}Run against ESLint 9.39.5 with six correctness rules enabled, the output is:
2:7 error 'CURRENCY' is assigned a value but never used no-unused-vars
10:14 error Expected '===' and instead saw '==' eqeqeq
15:3 error Unreachable code no-unreachable
15:3 error 'console' is not defined no-undef
18:52 error Duplicate key 'retries' no-dupe-keys
22:18 error 'discountAmount' is not defined no-undef
✖ 6 problems (6 errors, 0 warnings)Every finding is correct and useful. Two of them are outright bugs that would throw at runtime: discountAmount does not exist, and console is undeclared in a module with no environment configured. The duplicate key silently discards the first value, so a retry setting somebody deliberately chose is thrown away without warning. This is the case for linting, made concretely.
Notice what each of these has in common. Every one is visible in the text. No value had to be computed to find it.
Now the same file with all six findings fixed. Loose equality tightened, unused constant removed, unreachable line deleted, duplicate key resolved, undefined reference dropped.
const TAX_RATE = 0.2;
export function total(items, coupon) {
let sum = 0;
for (const item of items) {
sum += item.price * item.qty;
}
if (coupon === null) {
sum = sum * (1 + TAX_RATE);
}
return sum;
}
export const config = { retries: 3, timeout: 5000 };ESLint on this file reports nothing and exits zero. A pipeline gating on lint would go green. Here is what the function actually returns for a single £100 item:
no coupon -> 120.00
with coupon -> 100.00Tax is applied only when there is no coupon. Every discounted order loses twenty percent of its tax, silently, forever. The linter saw that condition on line nine and had an opinion about it, but the opinion was that == should be ===. The comparison operator was the only thing about that line it could evaluate.
This is not a shortcoming to be fixed by a better rule set. No lint rule can exist for it, because the code is not malformed. It is well structured, consistently written, correctly typed if you added types, and wrong. The three categories a linter is structurally unable to reach:
| Category | Example | Why static analysis cannot see it |
|---|---|---|
| Wrong logic, right form | The tax branch above. | The code expresses an intention. The tool has no access to the intention, only to the expression. |
| Wrong assumptions about data | Reading a field an API stopped returning last month. | The shape of the response exists only at runtime, in another system. |
| Wrong behaviour when assembled | A button that renders and posts to the wrong endpoint. | Every file is individually valid. The defect only exists once they run together in a browser. |
The practical consequence is a sequencing rule. Linting is the cheapest gate and belongs first, but a green lint result is evidence about the text and nothing else. Treating it as evidence about behaviour is how a clean pipeline ships an undercharging cart.
Three tools read your code before it runs, and conflating them causes real friction, usually in the form of a linter and a formatter fighting over the same file.
| Question | Linter | Formatter | Compiler |
|---|---|---|---|
| Answers | Does this code do something it should not? | Does this code look the way we agreed? | Can this code be translated at all? |
| Changes behaviour | Only if you apply a fix. | Never. | Not applicable; it produces output. |
| Configurable | Heavily, per project. | Lightly, and deliberately so. | Barely. The language decides. |
| Can be ignored | Yes, that is the design. | Yes, but there is no reason to. | No. It stops the build. |
| Typical tool | ESLint, Ruff, RuboCop | Prettier, Black, gofmt | tsc, javac, rustc |
The workable arrangement is to let the formatter own everything about appearance and let the linter own only correctness. Style rules inside a linter generate findings that need a human to act on something a machine could have fixed, which is the fastest way to teach a team that lint output is noise.
| Language | Linter | Worth knowing |
|---|---|---|
| JavaScript, TypeScript | ESLint | Rules are plugins, so the ecosystem covers frameworks and accessibility as well as the core language. |
| Python | Ruff, Pylint | Ruff reimplements much of the older toolchain and is fast enough to run on every keystroke. |
| Ruby | RuboCop | Ships opinionated defaults, which is why most teams start by disabling a portion of them. |
| Go | golangci-lint | An aggregator that runs many analysers in one pass rather than a single rule set. |
| Rust | Clippy | Ships with the toolchain and leans toward idiom, not just correctness. |
| Java | Checkstyle, SpotBugs | Checkstyle covers convention, SpotBugs analyses bytecode for likely defects. They are complementary. |
| CSS | Stylelint | Catches invalid properties and duplicate selectors that browsers silently ignore at runtime. |
Whichever you use, start from the recommended set. A hand-built rule list assembled before the project has produced any defects tends to encode preferences rather than lessons, and preferences are what developers disable inline.
The recommended set is the right default, and a handful of rules beyond it repay their noise immediately. These are ordered by the severity of the defect they prevent rather than by how often they fire.
| Rule | What it stops | Why it earns a place |
|---|---|---|
| no-floating-promises | An async call whose result is never awaited. | The most damaging silent failure in modern JavaScript. The operation appears to succeed, errors vanish, and the bug surfaces as missing data much later. |
| no-await-in-loop | Sequential awaits over a collection. | Functionally correct and quietly slow. Ten records take ten round trips where one batched call would do. |
| eqeqeq | Loose equality comparisons. | Type coercion produces comparisons that are true for values a reader would not expect to match. |
| no-shadow | An inner binding reusing an outer name. | The code reads as if it operates on the outer value. Reviews miss it because both lines look correct in isolation. |
| consistent-return | A function returning a value on some paths only. | Callers receive undefined from a branch nobody thought about, usually the error path. |
| jsx-a11y/alt-text | Images with no text alternative. | An accessibility failure that is cheap to catch here and expensive to retrofit across a UI later. |
The accessibility rules deserve a note on scope. A linter can confirm an image declares alt text; it cannot judge whether the text describes the image usefully, and it cannot see contrast, focus order, or whether a screen reader announces a component sensibly, because all three depend on the rendered page. Static rules catch the omissions, and accessibility testing covers everything they cannot reach.
That pattern repeats across every rule in the table. Each one catches an omission or a shape in the text, and each has a matching class of defect that only shows itself when the code runs.
Linters return a non-zero exit code when they find errors, which is all a pipeline needs.
# Fail the job on errors, allow warnings through
npx eslint . --max-warnings=0
# Only lint what this change touched, which keeps
# a legacy codebase from blocking every merge
npx eslint $(git diff --name-only origin/main --diff-filter=d | grep -E '\.[jt]sx?$')Three decisions matter more than the command. Run it before your test suite, since it takes seconds and the tests take minutes. Separate error severity from warning severity so correctness blocks a merge and preference does not. And on an existing codebase, lint the diff rather than the repository, so the backlog is cleared as files are touched instead of in one commit nobody can review.
Once linting is green on every merge, the next gate is the one that actually runs the code. That ordering matters for the same reason the sections above did: each stage catches a class the previous one could not see. The relationship between checking the artifact and checking the behaviour is covered in verification vs validation.
The undercharging cart needed one thing to be caught: something that added an item, applied a coupon, and read the total. No amount of static analysis substitutes for that, and this gap has widened rather than narrowed as AI coding agents write more of the code.
The reason is structural and worth stating plainly. An AI agent's own quality gates are linters, type checkers, and unit tests, and every one of those reads source code. The agent can run all three, see them pass, and report success on a checkout flow that charges the wrong amount, because nothing in its loop ever rendered the page.
Kane CLI closes that specific gap from the terminal. It takes a plain-English objective and drives real Chrome to complete it, and it is constrained to actions a real user could perform, so it cannot force a state the interface never reached in order to report a pass.
kane-cli run "add one item priced 100 to the cart, apply coupon SAVE10, \
assert the order total includes tax" --url http://localhost:3000A pass is granted only when the expected state is confirmed through evidence such as DOM state, a URL change, or a screenshot, and each run leaves that evidence behind. In CI it returns standard POSIX exit codes, so the same command that answers the local question can gate a merge alongside the lint step rather than replacing it.
The two checks are complementary and belong in that order. Linting is faster, cheaper, and catches the six errors in the first run above, none of which need a browser. Running the flow catches the seventh, which none of the six rules could ever have found.
Note: Static analysis proves the code is well formed. Driving the flow proves it charges the right amount. Kane CLI does the second from your terminal, with a pass granted only on evidence. Read the Kane CLI docs
Turn on the recommended rule set today and lint the diff rather than the repository. That gets you the six-error class of finding on every merge without a cleanup commit nobody can review, and it costs an afternoon.
Then hold the boundary in your head. A green linter means the text is well formed. It is not a statement about what the program does, and the cart above is what that distinction costs when it gets blurred: six errors found, one bug shipped, twenty percent of tax quietly missing from every discounted order.
Put a check that executes the code behind the one that reads it. Running those flows across real browsers is what TestMu AI's automation cloud is built for, and the automation documentation covers wiring an existing suite into a pipeline that already lints. For what belongs in those tests, see the test script guide.
Author
Shravan Mahajan is a Software Engineer at TestMu AI building Kane CLI, the command-line tool that runs browser automation from the terminal, describing flows in natural language that execute in a real Chrome browser and return pass or fail with shareable proof. He has an experience of 6 years in the Technical industry. His top skills are JavaScript, React.js, and full-stack development. At Fractal he built automated data pipelines with T-SQL, SSIS, Python, and Azure. He is also a Microsoft Certified Azure Data Engineer Associate.
Did you find this page helpful?
More Related Blogs
TestMu AI forEnterprise
Get access to solutions built on Enterprise
grade security, privacy, & compliance