If your lint step printed 0 problems and you assumed the code was clean, run this first:
const { ESLint } = await import("eslint"); const eslint = new ESLint({ cwd: process.cwd() }); const results = await eslint.lintFiles(["src"]); console.log( "findings:", results.reduce((n, r) => n + r.messages.length, 0), "files linted:", results.map((r) => r.filePath) ); Read the second number, not the first. "0 findings" is a statement about the files you read. If you read none, it is not a statement about your code. I built a throwaway repo where both numbers line up to prove it, on ESLint 9.39.2 with a flat config.
Mechanism 1: the default file list never included your files
A flat config object without a files key matches JavaScript by default. In a codebase that is mostly TypeScript, that default quietly excludes the code you actually own.
Reproduce it in ninety seconds. Make a folder, drop in two files and a config:
// eslint.config.mjs (note: no `files` key) export default [ { rules: { "no-eval": "error" } }, ]; // a.ts eval("alert(1)"); // good.js const x = 1; console.log(x); Point ESLint at the folder:
const results = await eslint.lintFiles(["."]); Result: findings: 0, and files linted: ["eslint.config.mjs", "good.js"]. The a.ts file contains a live eval call. It was not in the list, so it was not read, so it produced nothing. From the harness's point of view the run was clean. From the code's point of view nothing happened.
The CLI behaves the same and is even quieter. Running the lint over that folder exits 0 with an empty stderr. A green checkmark, a zero, and nothing to read. The fix is one line: name the files you mean to check.
export default [ { files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], rules: { "no-eval": "error" } }, ]; Rerun the same lint and the eval surfaces: findings: 1. Same code, same rule, one line of config is the difference between "clean" and "checked".
The partial corpus is the nastier version
The all-or-nothing case above is easy to catch. It produced a run with literally nothing to show for it. The version that slips past review is the one that lints some of your code and calls the result a result.
Say the repo is eighty percent TypeScript and the config default still only covers the JavaScript. The lint runs, it reads the twenty percent that happens to be .js, and it reports back a number. That number is not wrong. It is honest about the files it touched. It is wrong only in the way your brain reads it, as "the codebase is clean" instead of "the files I was pointed at are clean". A partial corpus produces plausible numbers, and plausible numbers are exactly what you do not expect to question.
You can see the file list in the API, which is the whole point of the pre-flight. The same trap exists on the command line, and it is quieter there. Point the ESLint binary at the folder with a config that has no files key and it exits 0 with an empty stderr. There is no "0 of 50 files matched" line, no warning, nothing to read. The exit code says the job succeeded, and in the narrow sense that it did: it completed a run over the files it was allowed to see.
Mechanism 2: a file outside the working directory is ignored, not an error
The second silent path is a base-path rule. ESLint resolves what it is allowed to lint relative to the working directory you give it. Ask it to lint a file that lives outside that directory and it does not fail. It returns one message with a ruleId of null:
File ignored because outside of base path. That sentence is the only explanation you are ever given, and it is easy to lose, which brings us to the bug.
The counting trap
Most harnesses count findings by filtering for messages that have a ruleId. That is a reasonable habit for real rule violations. It is exactly the wrong habit here, because the ignore message has no ruleId. I ran the naive filter over the out-of-directory case:
const all = results.flatMap((r) => r.messages); const counted = all.filter((m) => m.ruleId); console.log(counted.length); // 0 Zero. The one and only message in the output was dropped on the way through the filter, and the harness reported a clean run for a file it never opened. A green result and a file nobody read produce the same number.
The fix is to stop deriving "clean" from the absence of findings. Derive it from what you read. Count the linted files and compare that count against a number you got from somewhere ESLint cannot lie to you, like a glob you ran yourself or the file count your build system reports. If the two disagree, the run is void, not clean.
A pre-flight that catches both
Paste this at the top of the lint step you run in CI or in the loop. It fails loudly when the run touched nothing:
const { ESLint } = await import("eslint"); async function lintWithFloor({ cwd, targets, minFiles }) { const eslint = new ESLint({ cwd }); const results = await eslint.lintFiles(targets); const findings = results.reduce((n, r) => n + r.messages.length, 0); const linted = results.map((r) => r.filePath); const ignored = results.flatMap((r) => r.messages.filter((m) => m.ruleId === null).map((m) => m.message) ); if (linted.length < minFiles) { throw new Error( `lint floor failed: read ${linted.length}/${minFiles} files. ` + `If ${ignored.length} were ignored, the run did not check what you think. ` + `Check the config file list and the working directory.` ); } if (ignored.length) console.warn("ignored:", ignored); return { findings, linted }; } // lintWithFloor({ cwd: repoRoot, targets: ["src"], minFiles: 40 }); The important line is the throw, not the finding count. A linter is a claim about a set of files. The set is defined by your config's files patterns and by the working directory, both of which you control, and both of which can silently shrink to almost nothing. minFiles turns "did it run" into a number you can trip on, so a config that matches the wrong extension, or a path that is one .. off, fails the build instead of passing it.
Audit your own repo in one command
Before you trust the pre-flight, check whether the gap already exists in your tree. The question to ask is: how many files match the rules your config actually applies, and how many did a real run touch? Compare a glob you run yourself against the linted count. On the throwaway repo, that comparison is the whole finding:
import { ESLint } from "eslint"; import { globSync } from "node:fs"; const want = new Set(globSync("src/**/*.ts", { absolute: true })); const eslint = new ESLint({ cwd: process.cwd() }); const results = await eslint.lintFiles(["src"]); const got = new Set( results.filter((r) => r.filePath.endsWith(".ts")).map((r) => r.filePath) ); const missed = [...want].filter((f) => !got.has(f)); console.log("ts files you own:", want.size); console.log("ts files actually linted:", got.size); console.log(missed.length ? "MISSED:" : "coverage ok"); On the throwaway repo the printout is:
ts files you own: 2 ts files actually linted: 0 MISSED: b.ts, c.ts findings in run: 0 Two TypeScript files you own, zero actually linted, zero findings. That last line is the one your CI log shows. The first three are the one it hides. If the audit prints a list, your config is not checking the code you think it is checking, and no number of findings will tell you that. Run it once, read the list, and add a files entry for whatever it names. The two mechanisms from the top of this post are the only two places this list comes from: the file list in your config, and the working directory the run was started from.
What this is not
This is not a claim that flat config is broken, and it is not a claim that your linter is lying. It ran exactly as configured. The trap is that the most common reading of the output, "0 means clean", is only true when you have already checked that the run covered the files you care about. Two settings and one counting habit make the coverage check optional, and an optional check is not a check.
The whole change is three moves: name the files in your config, set the working directory to the tree you are measuring, and assert a floor on how many files a run may read. None of them touch the rules, so the linter is still doing the same job. You have just stopped accepting a clean bill of health for a file nobody opened.
This post was written with AI assistance. The author is responsible for its content.

