02Critério5 min

Cinco migrações de ESLint para Biome: a decisão já está tomada

Quando você faz a mesma migração em cinco projetos sem parar para pensar, aquilo deixa de ser preferência e vira critério. O que quebra pelo caminho e o que se perde.

Por Salva Castells

Imagem de capa: Aris riyanto, CC BY-SA 4.0.
Neste post

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ôr aria-hidden="true" explícito.
  • Colisão de nomes. Um tipo TaskList importado 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.