Num SaaS de geração de imagens apaguei .prettierrc, eslint.config.mjs e sete dependências do package.json no mesmo commit. O lockfile perdeu 545 linhas. No dia seguinte fiz o mesmo em outro projeto, e em outro. Na quinta vez eu já não estava avaliando nada: abria o repositório, tirava ESLint e Prettier, e escrevia biome.json.
Isso já não é uma preferência. É um critério, e convém saber defendê-lo.
O que me empurrou na primeira vez
Não foi a velocidade. Foi a árvore de dependências. A configuração que tirei era esta:
const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];
Um FlatCompat para que a flat config entendesse configurações antigas. Um eslint-config-prettier cujo único trabalho é desligar as regras do ESLint que colidem com o Prettier. Um eslint-plugin-tailwindcss e um prettier-plugin-tailwindcss que fazem coisas parecidas a partir de dois lados diferentes. Seis pacotes cuja razão de existir é que as outras ferramentas não pisem umas nas outras.
O Biome faz formatação e lint no mesmo binário. Esse problema desaparece porque deixa de haver duas ferramentas para coordenar.
O que quebra e ninguém avisa
A migração não é apagar arquivos e seguir. É a varredura de formatação logo depois: 78 arquivos tocados num commit em que você não consegue revisar nada, porque é tudo aspas e indentação. Faz-se e aceita-se.
O problema de verdade chega quando você liga o linter para valer. Num bot de lembretes no Telegram, a primeira passada com as regras recomendadas deu 60 erros na web. Quase nenhum era ruído:
- Labels sem associação. Campos do login sem
htmlFor/id. O ESLint com a config do Next nunca tinha me avisado. - SVG decorativos. A regra não reconhece a forma abreviada de
aria-hidden; é preciso pôraria-hidden="true"explícito. - Colisão de nomes. Um tipo
TaskListimportado que colidia com um componente de mesmo nome. Compilava perfeitamente e estava errado. - Keys por índice. Num componente de texto rotativo, a key de cada caractere era o índice do
map. A solução não foi silenciar a regra: foi pré-calcular o offset absoluto no memo para ter uma key estável.
Esse é o ponto interessante. Dos 60, a maioria eram falhas reais que estavam ali havia meses. O resto se resolveu com supressões documentadas: um efeito de execução única, loops de esqueletos de tamanho fixo, um !important que sobrescreve o react-remove-scroll-bar, <img> de avatares de OAuth.
Um biome-ignore sem comentário explicando o porquê é dívida. Com comentário é uma decisão.
A velocidade importa pelo lugar onde ela deixa você colocar o lint
É o que mais se repete nas comparações e o que pior se explica. O número não importa. Importa onde você pode plugar o comando quando ele demora pouco.
Com o ESLint o lint vivia no CI, porque colocá-lo no build local era se condenar a esperar. Com o Biome o script ficou assim:
"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."
O lint na frente do build. Se está vermelho, você não compila. Essa linha é toda a diferença: o erro deixa de ser descoberto quinze minutos depois num runner e passa a ser descoberto antes de o build começar. Com uma ferramenta lenta essa linha é insuportável e você acaba tirando.
O que você perde
Duas coisas, e vale a pena nomeá-las.
A primeira é o ecossistema de plugins. Se você depende de um plugin de ESLint muito específico, ele não existe e provavelmente não vai existir. Comprovei isso ao tirar o eslint-plugin-tailwindcss: a ordem das classes é resolvida pelo formatador do Tailwind, não pelo linter, então não senti falta. Mas é um caso de sorte, não uma regra geral.
A segunda é mais boba e dá mais trabalho: a configuração padrão não é a mesma em todo lugar. Um projeto saiu com "indentStyle": "tab" e outro com "indentStyle": "space" e "indentWidth": 2. Semanas depois tive que fazer um commit cujo único conteúdo era trocar tabs por espaços no biome.json para que tudo ficasse igual. Fixe o $schema com a versão exata e copie o mesmo biome.json entre projetos. A comodidade dessa ferramenta é que ela quase não tem configuração; aproveite e tenha uma só.
Meu stack de qualidade hoje
Biome, TypeScript em modo estrito, testes, e knip para código morto. Pouco mais.
Knip é a peça que menos gente usa e a que mais surpreende. Uma passada no bot do Telegram apagou 19 arquivos mortos e 21 dependências sem uso: subpacotes @radix-ui/* redundantes com o metapacote, @react-three/fiber e three inteiros, date-fns, e eslint, que continuava instalado meses depois de eu tê-lo tirado. Também aparece um cluster inteiro de ações e casos de uso que ninguém mais chamava.
Mas o knip fica fora do CI, de propósito. Num repositório grande a primeira passada deu 107 achados preexistentes: pendurá-lo no gate deixa tudo vermelho no dia um e você acaba ignorando o vermelho. Vai como ferramenta manual, a cada vários meses, com a poda em seu próprio commit e verificada com tsc e com os testes antes do merge.
A configuração precisa ser explícita com os entrypoints, ou ele apaga metade da aplicação. Num monorepo com Next é preciso declarar na mão quais arquivos são pontos de entrada:
"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]
Os componentes gerados pelo shadcn vão em ignore porque sempre há mais do que você usa, e isso é normal. sharp e tailwindcss vão em ignoreDependencies porque ninguém os importa, são usados a partir do build.
Um linter lento é um linter que acaba num job separado que ninguém olha. Um linter rápido entra no build e vira o chão do projeto.
