🍣

BiomeのLintの違反箇所全てにbiome-ignoreを自動挿入するスクリプトを作った

2024/06/06に公開

背景

最近職場でESLintとPrettierの代わりにBiomeを導入しました。基本的にBiomeのRecommend設定を利用するつもりですが、違反箇所が多すぎるため最初は多くのルールを無効にして導入しました。
違反箇所が多すぎるため、今後ルールを有効化する際に都度手動で修正することは時間がかかりすぎます。このためbiome lint --writeなどを使って自動で修正することになりますが、どのルールでも安全に修正できるわけではありません。しかし、BiomeのLintルール違反箇所にbiome-ignoreコメントを入れてエラーを無視することにすれば挙動を変えずにルールを有効化できます。そしてルールを有効化して新たな違反が新規で追加されなくなった後、ゆっくりとbiome-ignoreで無視したエラーを修正していけば良いのです。
今回このbiome-ignoreコメントの挿入を自動で実行するスクリプトを作成しました。

以前にESLintの違反をCIでチェックするようにした際、eslint-interactiveを利用させて頂き、違反箇所全てにeslint-disableを挿入しました。また、typescriptの設定をstrictにする際にはTypeScript Compiler API を使って ts-expect-error を一括挿入するの記事内のスクリプトを利用させて頂き、違反箇所全てにts-expect-errorを挿入しました。

このため、今回私が作成したスクリプトも誰かの役に立つかもしれないと思ったので公開しようと思います。

補足

このDiscussionをみるに、どうやら近いうちにBiome公式でこのスクリプトの機能がサポートされるようです。楽しみですね。

スクリプトの概要

Biomeは --reporter=jsonオプションをつけることで診断結果をJSON形式で出力することができます。JSONで出力された結果をもとに、エラーが出ている箇所の上の行にbiome-ignoreを入れた後、Biomeでフォーマットを実行するというのがこのスクリプトの概要です。
TSXを解析していないので{/* biome-ignore lint: xxx */}の挿入には対応していません。

作成時にはまったポイント

エラーの位置はファイルの先頭からのバイト数を示している

Biomeが出力する診断結果にはエラーが発生して警告が表示される場所の開始と終了位置が診断結果のオブジェクトのlocation.spanに整数で保存されています。この値はUTF-8のファイル上の先頭からのバイト数を示しているようです。全角文字などはUTF-8では3バイト以上なのでlocation.spanでは3つ以上の数値としてカウントされますが、Javascriptの文字列上では1文字扱いであるため、String.slice()を使うと期待した位置で分割できません。このためBufferを使って文字列をUTF-8のバイト列に戻した上で分割する必要があります。

同じ箇所、同じルールでも複数のエラーが存在しうる

BiomeのLintのエラーをJSONで出力する場合、同じ箇所同じルールでも複数のエラー出力される場合があります。例えばlint/correctness/useExhaustiveDependenciesはuseEffectなどにおいて、コールバック内で利用している変数のうち、dependenciesに列挙されていない全ての変数1つずつにエラーが出力されます。次のようなコードの場合、lint/correctness/useExhaustiveDependenciesのエラーは同じ位置、同じルールで変数a,b,cの3つ分診断結果にエラーが出力されます。
診断結果に1対1対応でbiome-ignoreを入れると同じ位置、同じルールで重複してbiome-ignoreが出力されてしまいます。
このため、同じ行に挿入される同じルールの警告はひとまとめにしてbiome-ignoreにする必要があります。

import { useEffect } from "react";

function component() {
    let a = 1;
    let b = 1;
    let c = 1;
    useEffect(() => {
    console.log(a)
    console.log(b)
    console.log(c)
    }, []);
}

--colors=forceをつけないと一部の絵文字が変換させられてしまう

こちらのPRを見て頂くと分かりますが、いくつかの絵文字がbiome lintで標準出力にした際に文字情報の一部が欠落させられてしまう場合があります。PRのコメントをみると --colors=forceをつけると回避することができるようなのでこれをつけてBiomeのコマンドを実行したあと、出力された文字列からANSIエスケープシーケンスを全部取り除くことで絵文字が変換されていない診断結果のJSONを利用することができます。

スクリプト

このスクリプトはBiome 1.7.3を前提として作成しました。動作させることだけを念頭に作成したので可読性に難がある点はご容赦ください。

// insert-biome-ignore.mjs
import { execSync, spawn } from 'node:child_process'
import { writeFileSync } from 'node:fs'

const data = await getDiagnostics()
const diagnosticsByFile = groupByFile(data)
fix(diagnosticsByFile)
format()

async function getDiagnostics () {
  let data = "";
  // https://github.com/biomejs/biome/issues/2988
  // avoid the PR error, we need to use '--colors=force' as a workaround
  const child = spawn('npx', ['@biomejs/biome', 'lint', '--colors=force', '--reporter=json', '--max-diagnostics=1000', '.'])
  for await (const chunk of child.stdout) {
    data += chunk;
  }
  // https://stackoverflow.com/questions/25245716/remove-all-ansi-colors-styles-from-strings
  const text = data.replaceAll(
    // cspell:disable-next-line
    /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');

  return JSON.parse(text)
}

function groupByFile(diagnostics) {
  const diagnosticsByFile = new Map()
  for (const diagnostic of data.diagnostics) {
    const { path, span, sourceCode } = diagnostic.location
    const { category, description } = diagnostic
    const nearestLineBreakIndex = getNearestLineBreakIndex(sourceCode, span[0])
    const file = path.file
    const simpleDiagnostic = { category, nearestLineBreakIndex, file, span, description }
    const current = diagnosticsByFile.get(file)
    if (current) {
      // to avoid inserting same category biome-ignore multiple times in the same line
      if (
        current.diagnostics.some(d => d.nearestLineBreakIndex === nearestLineBreakIndex) &&
        current.diagnostics.some(d => d.category === category)
      ) continue;

      diagnosticsByFile.set(file, {sourceCode, diagnostics: [...current.diagnostics, simpleDiagnostic]})
    } else {
      diagnosticsByFile.set(file, {sourceCode, diagnostics: [simpleDiagnostic]})
    }
  }
  return diagnosticsByFile
}

// This function is necessary for files that contain multibyte characters.
function getNearestLineBreakIndex(sourceCode, binaryIndex) {
  const buffer  = Buffer.from(sourceCode, 'utf-8')
  const sliced = Uint8Array.prototype.slice.call(buffer, 0, binaryIndex).toString();
  return sliced.lastIndexOf('\n')
}

function fix(diagnosticsByFile) {
  for(const [file, data] of diagnosticsByFile) {
    const { sourceCode, diagnostics } = data
    const sorted = [...diagnostics].sort((a, b) => b.nearestLineBreakIndex - a.nearestLineBreakIndex)

    let ignored = sourceCode
    for (const diagnostic of sorted) {
      const { nearestLineBreakIndex, category, description } = diagnostic
      if (nearestLineBreakIndex === -1) {
        ignored = `// biome-ignore ${category} :[AUTOMATICALLY-INSERTED] ${description}\n${ignored}`
      } else {
        ignored =
          ignored.slice(0, nearestLineBreakIndex) +
          `\n// biome-ignore ${category} :[AUTOMATICALLY-INSERTED] ${description.replace(/\r?\n/g, '')}` +
          ignored.slice(nearestLineBreakIndex)
      }
    }
    writeFileSync(file, ignored)
  }
}

function format(target) {
  try {
    execSync('npx @biomejs/biome format --write .')
  } catch (e) {
    console.error(e)
  }
}
コミューン株式会社

Discussion