Oxlintの内側:リンターのアーキテクチャとルールシステム
前提知識
- ›第1〜4回:アーキテクチャ、AST、パーサー/セマンティック、Visitor/Traverse
- ›リンティングツールの基本的な理解(ESLintの概念)
Oxlintの内側:リンターのアーキテクチャとルールシステム
OxlintはOxcの基盤クレートの上に構築されたフラッグシップアプリケーションです。ESLintのドロップイン代替として機能し、50〜100倍の高速化を実現しながら、15のプラグインカテゴリにわたる730以上のルールをサポートしています。そのアーキテクチャを理解することで、これまでの記事で紹介してきた各要素(アリーナアロケーター、AST、パーサー、セマンティック解析、そしてvisitorシステム)がひとつのプロダクションツールとしてどのように統合されるかが見えてきます。この記事では、CLIのエントリーポイントから並列ファイル処理、診断出力に至るまで、一連のlint実行の流れを追っていきます。
CLIからLintRunnerへ:オーケストレーション層
処理の起点となるのは apps/oxlint/src/main.rs で、その内容は驚くほどシンプルです:
#[tokio::main]
async fn main() -> CliRunResult {
let command = lint_command().run();
init_tracing();
if command.lsp {
run_lsp(None).await;
return CliRunResult::LintSucceeded;
}
init_miette();
command.handle_threads();
let mut stdout = BufWriter::new(std::io::stdout());
CliRunner::new(command, None).run(&mut stdout)
}
このバイナリが行うことは4つです。bpafを使ったCLI引数のパース、LSPモードとlintモードの切り替え、スレッドの設定、そしてCliRunnerへの委譲です。stdoutをBufWriterでラップしているのはパフォーマンス上の工夫で、書き込みをまとめることでsyscallの回数を減らしています。
apps/oxlint/src/lint.rs#L30-L38 のCliRunnerは、設定の読み込み、ファイルの検索、フィルターの構築、出力フォーマットといった高レベルな処理を担当します。実際のlint処理はLintRunnerに委譲します。
sequenceDiagram
participant CLI as main.rs
participant CR as CliRunner
participant LR as LintRunner
participant LS as LintService
participant DS as DiagnosticService
CLI->>CR: new(command)
CR->>CR: load config, discover files
CR->>LR: new(lint_service, directives_store)
LR->>LS: process files in parallel (rayon)
LS-->>DS: send diagnostics
DS-->>CLI: format and output
crates/oxc_linter/src/lint_runner.rs#L19-L28 のLintRunnerは、通常のoxc lintingとオプションの型情報を活用したlintingの両方を調整します:
pub struct LintRunner {
lint_service: LintService,
type_aware_linter: Option<TsGoLintState>,
directives_store: DirectivesStore,
cwd: PathBuf,
}
Rule traitとプラグインシステム
oxlintの拡張性の核となるのが、crates/oxc_linter/src/rule.rs#L16-L73 にあるRule traitです。このtraitは3つのフックを定義しています:
pub trait Rule: Sized + Default + fmt::Debug {
/// Visit each AST Node
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {}
/// Run only once. Useful for inspecting scopes and trivias etc.
fn run_once(&self, ctx: &LintContext) {}
/// Run on each Jest node
fn run_on_jest_node<'a, 'c>(
&self,
jest_node: &PossibleJestNode<'a, 'c>,
ctx: &'c LintContext<'a>,
) {}
}
ほとんどのルールはrun()を実装します。これはすべてのASTノードに対して呼び出されます。no-duplicate-importsのようにファイル全体を対象とするルールは、代わりにrun_once()を実装します。run_on_jest_nodeフックはJest/Vitestのテストルール専用です。
ルールは設定用のメソッドも提供します:
from_configuration()— ESLintスタイルのJSON設定をパースshould_run()— ファイルレベルの実行条件(TypeScript専用ルールなど)
classDiagram
class Rule {
<<trait>>
+run(node, ctx)
+run_once(ctx)
+run_on_jest_node(jest_node, ctx)
+should_run(host) bool
+from_configuration(value) Result~Self~
}
class NoUnusedVars {
-options: NoUnusedVarsOptions
+run(node, ctx)
}
class NoDebugger {
+run(node, ctx)
}
Rule <|.. NoUnusedVars
Rule <|.. NoDebugger
ルールの整理
ルールはプラグインカテゴリごとに整理されています:
| Plugin | Example Rules | Count |
|---|---|---|
eslint |
no-unused-vars, no-debugger |
~100+ |
typescript |
no-explicit-any, consistent-type-imports |
~60+ |
react |
jsx-no-target-blank, no-direct-mutation-state |
~30+ |
unicorn |
prefer-array-flat-map, no-null |
~80+ |
import |
no-default-export, no-cycle |
~20+ |
jsx-a11y |
alt-text, anchor-is-valid |
~30+ |
新しいルールはjust new-rule <name> <plugin>でひな形を生成できます。このコマンドでボイラープレートファイルの生成、ルールの登録、コードのフォーマットまで一括して行われます。
LintContextとセマンティックデータへのアクセス
crates/oxc_linter/src/context/mod.rs#L33-L62 のLintContextは、ルールがlintingインフラと対話するための主要なインターフェースです。共有のContextHostをラップし、ルールごとのメタデータを追加します:
pub struct LintContext<'a> {
parent: Rc<ContextHost<'a>>,
current_plugin_name: &'static str,
current_rule_name: &'static str,
severity: Severity,
}
Derefを通じて、LintContextはSemantic構造体への直接アクセスを提供します。つまり、ルールからは以下にアクセスできます:
- AST —
ctx.nodes()経由 - スコープデータ —
Scoping構造体を通じたスコープ、シンボル、参照 - モジュールレコード — import/exportの情報
- コメント — ディレクティブの処理用
- ソーステキスト — コンテキストを含むエラーメッセージ用
impl<'a> Deref for LintContext<'a> {
type Target = Semantic<'a>;
fn deref(&self) -> &Self::Target {
self.parent.semantic()
}
}
この設計により、no-unused-varsのようなlintルールはシンボルテーブルを直接参照できます:
// From no_unused_vars - checking if a symbol is used
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
// Access symbols and references through ctx (Deref to Semantic)
// ...
}
ヒント: カスタムlintルールを書く際は、ASTを再解析するよりも
ctx.scoping().symbol_flags(symbol_id)を優先して使いましょう。セマンティック解析はすでにすべてを解決済みです。その結果を活用することで、無駄な処理を避けられます。
診断、自動修正、無効化ディレクティブ
ルールが違反を検出すると、LintContextを通じて診断を報告します。診断システムはmiette(フォークされたoxc-mietteクレート経由)をベースに構築されており、ソースコードのコンテキストを含むリッチなエラーメッセージを提供します:
flowchart TD
Rule -->|"ctx.diagnostic()"| LC[LintContext]
LC -->|"Message"| DS[DiagnosticService]
DS --> Graphical[Graphical Output]
DS --> JSON[JSON Output]
DS --> GitHub[GitHub Actions Format]
DS --> Unix[Unix Format]
自動修正
ルールはRuleFixerを通じて自動修正を提供できます。修正はFixKindによって分類されます:
- Fix:安全な自動修正
- Suggestion:ユーザーの確認が必要
- Dangerous:プログラムの動作を変える可能性あり
無効化ディレクティブ
lint_runner.rs#L36-L39 のDirectivesStoreはeslint-disableコメントを管理します:
pub struct DirectivesStore {
map: Arc<Mutex<FxHashMap<PathBuf, DisableDirectives>>>,
}
複数のスレッドがファイルを並列処理しながらディレクティブの状態を共有する必要があるため、Arc<Mutex<...>>を使用しています。このストアは複数のルール名形式(例:typescript-eslint/no-explicit-anyと@typescript-eslint/no-explicit-any)でディレクティブを確認します。
パフォーマンス最適化
OxlintがESLintと比べて50〜100倍の高速化を実現できるのは、第1〜4回で紹介した基盤の上に構築された複数の技術によるものです。
アリーナプーリング
ファイルごとにAllocatorを生成・破棄するのではなく、linterはアロケーターのプールを維持します。ファイルを処理する際、ワーカースレッドはプールからアロケーターを借り出し、リセットして、パース・セマンティック解析・lintに使用した後、プールに返却します。これにより、アロケーションのオーバーヘッドを排除し、CPUキャッシュ上でメモリをウォームな状態に保てます——第2回のアロケーターの解説で説明した再利用パターンそのものです。
Rayonによるファイルレベルの並列処理
ファイルはrayonのwork-stealingスレッドプールを使って並列処理されます。各ファイルは独立したパイプライン(parse → semantic → lint)で処理されます。共有状態はConfigStore(初期化後は読み取り専用のルール設定)とDirectivesStore(Arc<Mutex<>>でガード)のみです。
flowchart TB
subgraph "Rayon Thread Pool"
T1[Thread 1] --> F1[file1.ts: Parse → Semantic → Lint]
T2[Thread 2] --> F2[file2.ts: Parse → Semantic → Lint]
T3[Thread 3] --> F3[file3.ts: Parse → Semantic → Lint]
T4[Thread N] --> F4[fileN.ts: Parse → Semantic → Lint]
end
CS[ConfigStore - read only] -.->|Arc| T1
CS -.->|Arc| T2
CS -.->|Arc| T3
CS -.->|Arc| T4
F1 -->|diagnostics| DS[DiagnosticService]
F2 --> DS
F3 --> DS
F4 --> DS
AstTypesBitset
すべてのルールがすべてのASTノード型を対象とするわけではありません。debugger文をチェックするルールはDebuggerStatementノードだけを見れば十分です。すぐに処理を返すだけのルールへのディスパッチを避けるため、OxcはAstTypesBitsetを使用します——ルールが対象とするASTノード型をコンパクトなbitsetで表現したものです。
このbitsetはルールの初期化時に一度だけ計算されます。トラバーサル中、linterは各ルールのrun()メソッドを呼び出す前にbitsetを確認し、現在のノード型に対応しないルールをスキップします。
線形メモリスキャン
ASTはアリーナアロケートされているため(第2回)、そのトラバーサルはほぼ連続したメモリの走査になります。ポインターで連結された個別アロケートされたノードをたどる場合と比べて、キャッシュ効率が格段に優れています。アリーナから読み込まれる各キャッシュラインには複数の小さなASTノードが含まれる可能性が高く、メモリアクセスのコストを効果的に分散できます。
ヒント: oxlintをプロファイリングすると、ほとんどのファイルでlintルールの実行よりもparse + semanticの時間が支配的であることがわかります。各
run()呼び出しはASTを再解析するのではなく事前計算済みのセマンティックデータを操作するため、ルールごとのオーバーヘッドは非常に小さくなっています。
次回予告
最終回では、パイプラインの出力側を取り上げます。Babel互換プリセットを持つtransformer、minifierの不動点最適化ループ、識別子のマングリング、ソースマップ付きのコード生成、Prettier互換フォーマッター、そしてすべてをNode.jsに公開するNAPIバインディングを解説します。