BiomeのLintの違反箇所全てにbiome-ignoreを自動挿入するスクリプトを作った
背景
最近職場で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