Read OSS

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-L38CliRunnerは、設定の読み込み、ファイルの検索、フィルターの構築、出力フォーマットといった高レベルな処理を担当します。実際の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-L28LintRunnerは、通常の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-L62LintContextは、ルールがlintingインフラと対話するための主要なインターフェースです。共有のContextHostをラップし、ルールごとのメタデータを追加します:

pub struct LintContext<'a> {
    parent: Rc<ContextHost<'a>>,
    current_plugin_name: &'static str,
    current_rule_name: &'static str,
    severity: Severity,
}

Derefを通じて、LintContextSemantic構造体への直接アクセスを提供します。つまり、ルールからは以下にアクセスできます:

  • ASTctx.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-L39DirectivesStoreeslint-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(初期化後は読み取り専用のルール設定)とDirectivesStoreArc<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バインディングを解説します。