02Criterio5 min

Cinque migrazioni da ESLint a Biome: la decisione è già presa

Quando fai la stessa migrazione in cinque progetti senza fermarti a pensarci, smette di essere una preferenza e diventa un criterio. Cosa si rompe per strada e cosa si perde.

Di Salva Castells

Immagine di copertina: Aris riyanto, CC BY-SA 4.0.
In questo post

In un SaaS di generazione di immagini ho cancellato .prettierrc, eslint.config.mjs e sette dipendenze da package.json nello stesso commit. Il lockfile ha perso 545 righe. Il giorno dopo ho fatto lo stesso in un altro progetto, e poi in un altro ancora. Alla quinta volta non stavo più valutando niente: aprivo il repo, toglievo ESLint e Prettier e scrivevo biome.json.

Quella non è più una preferenza. È un criterio, ed è meglio saperlo difendere.

Cosa mi ha spinto la prima volta

Non è stata la velocità. È stato l'albero delle dipendenze. La configurazione che ho tolto era questa:

const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];

Un FlatCompat perché la flat config capisse le configurazioni vecchie. Un eslint-config-prettier il cui unico lavoro è spegnere le regole di ESLint che vanno in conflitto con Prettier. Un eslint-plugin-tailwindcss e un prettier-plugin-tailwindcss che fanno cose simili da due lati diversi. Sei pacchetti la cui ragione di esistere è che gli altri strumenti non si pestino i piedi.

Biome fa formattazione e lint nello stesso binario. Quel problema sparisce perché non ci sono più due strumenti da coordinare.

Cosa si rompe senza che nessuno ti avvisi

La migrazione non è cancellare file e tirare dritto. È la passata di formattazione subito dopo: 78 file toccati in un commit, in cui non puoi rivedere niente perché sono tutte virgolette e indentazioni. Si fa e si accetta.

Il problema vero arriva quando accendi il linter davvero. In un bot di promemoria su Telegram, la prima passata con le regole raccomandate ha dato 60 errori nel sito. Quasi nessuno era rumore:

  • Label non associate. Campi del login senza htmlFor/id. ESLint con la config di Next non me l'aveva mai detto.
  • SVG decorativi. La regola non riconosce la forma abbreviata di aria-hidden; serve un aria-hidden="true" esplicito.
  • Collisione di nomi. Un tipo TaskList importato che andava in conflitto con un componente dallo stesso nome. Compilava perfettamente ed era sbagliato.
  • Key basate sull'indice. In un componente di testo rotante, la key di ogni carattere era l'indice del map. La soluzione non è stata silenziare la regola: è stata precalcolare l'offset assoluto nel memo per avere una key stabile.

È questo il punto interessante. Dei 60, la maggior parte erano errori veri che stavano lì da mesi. Il resto si è risolto con soppressioni documentate: un effetto a esecuzione singola, loop di scheletri a dimensione fissa, un !important che sovrascrive react-remove-scroll-bar, <img> di avatar da OAuth.

Un biome-ignore senza commento che spiega il perché è debito. Con il commento è una decisione.

La velocità conta per dove ti permette di mettere il lint

È la cosa che si ripete di più nei confronti e quella spiegata peggio. Il numero non conta. Conta dove puoi attaccare il comando quando ci mette poco.

Con ESLint il lint viveva in CI, perché metterlo nella build locale voleva dire condannarti ad aspettare. Con Biome lo script è diventato così:

"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."

Il lint davanti alla build. Se è rosso, non compili. Quella riga è tutta la differenza: l'errore smette di scoprirsi quindici minuti dopo su un runner e si scopre prima che la build parta. Con uno strumento lento quella riga è insopportabile e finisci per toglierla.

Cosa perdi

Due cose, e vale la pena nominarle.

La prima è l'ecosistema di plugin. Se dipendi da un plugin di ESLint molto specifico, non c'è e probabilmente non ci sarà. L'ho verificato togliendo eslint-plugin-tailwindcss: l'ordine delle classi lo risolve il formattatore di Tailwind, non il linter, quindi non mi è mancato. Ma è un caso fortunato, non una regola generale.

La seconda è più stupida e dà più fastidio: la configurazione di default non è la stessa ovunque. Un progetto è uscito con "indentStyle": "tab" e un altro con "indentStyle": "space" e "indentWidth": 2. Settimane dopo ho dovuto fare un commit il cui unico contenuto era cambiare tab in spazi in biome.json perché tutto fosse uguale. Fissa lo $schema con la versione esatta e copia lo stesso biome.json tra i progetti. La comodità di questo strumento è che non ha quasi configurazione; approfittane e tienine una sola.

Il mio stack di qualità oggi

Biome, TypeScript in modalità strict, test e knip per il codice morto. Poco altro.

Knip è il pezzo che usano in meno e quello che sorprende di più. Una passata sul bot di Telegram ha cancellato 19 file morti e 21 dipendenze inutilizzate: sottopacchetti @radix-ui/* ridondanti con il meta-pacchetto, @react-three/fiber e three interi, date-fns e eslint, che era ancora installato mesi dopo averlo tolto. Salta fuori anche un intero cluster di azioni e casi d'uso che non chiamava più nessuno.

Ma knip resta fuori dalla CI, di proposito. In un repo grande la prima passata ha dato 107 rilevamenti preesistenti: agganciarlo al gate lo mette in rosso dal giorno uno e finisci per ignorare il rosso. Va usato come strumento manuale, ogni qualche mese, con la potatura in un commit a sé e verificata con tsc e i test prima del merge.

La configurazione deve essere esplicita sugli entrypoint o ti cancella mezza applicazione. In un monorepo con Next bisogna dichiarargli a mano quali file sono punti d'ingresso:

"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]

I componenti generati da shadcn vanno in ignore perché ce ne sono sempre più di quanti ne usi ed è normale. sharp e tailwindcss vanno in ignoreDependencies perché non li importa nessuno, si usano dalla build.

Un linter lento è un linter che finisce in un job a parte che non guarda nessuno. Un linter veloce entra nella build e diventa il pavimento del progetto.