Classi開発者ブログ

教育プラットフォーム「Classi」を開発・運営するClassi株式会社の開発者ブログです。

ライブラリメンテナンスのための自作ESLintルール作成

こんにちは、プラットフォーム部の id:lacolacoです。

Classi社内では、複数のWebアプリケーションで共通のUIコンポーネントを社内ライブラリ化しています。ライブラリを長くメンテナンスしていると、過去に提供していたAPIを廃止して、新しいAPIに置き換えたいことが多々あります。いきなり古いAPIを消して新しいAPIを提供する破壊的変更のアプローチは、社内ライブラリであれば利用者とコミュニケーションしやすいため妥協してしまいそうになります。しかし、破壊的変更への追従が利用者側で負担となって棚上げされた結果、後になって急いで適用して欲しいセキュリティパッチのバージョンアップがなかなか適用できない、なんてことになるのは避けたいところです。

そんな流れで、今回はライブラリメンテナンスの上でAPIの非推奨化から廃止までのプロセスを支援するためのESLintルールを自作した話をします。AI要素もちょっとあります。

社内ライブラリと破壊的変更

Classiで採用しているAngularフレームワークを例にあげると、Angularの非推奨化ポリシーでは廃止予定のAPIはまず非推奨APIとしてマークされ、最低でも2メジャーバージョン(1年間)の猶予期間が与えられます。その猶予期間のあとのメジャーバージョンにて、非推奨APIは破壊的変更として廃止されます。社内ライブラリは広く使われるOSSではないのでここまでの厳格なポリシーは必要ありませんが、少なくとも非推奨化の段階を踏み、既存の利用箇所を新しいAPIに移行したうえで安全に廃止できるようなプロセスを取りたいと考えています。

そういうモチベーションで、社内ライブラリのTypeScriptコードにはいくつもJSDocの @deprecated アノテーションが付けられたAPIがありましたが、いざ廃止しようとすると困ることがありました。まず、現状、どのAPIに非推奨マークが付けられているのかの一覧もなく、毎回コード検索をするしかありません。また、一度にすべての非推奨APIを廃止するわけではなく、残すものもあるため、 @deprecated アノテーションの有無だけでは一律に処理ができません。

やりたいことは以下の2点です:

  • ライブラリ中で非推奨化されているAPIが一覧できる
  • 削除されるべき非推奨APIと、まだ残しておきたい非推奨APIが区別できるようになる

これを実現するにあたって考えたのが、自作ESLintルールでした。

APIの非推奨アノテーションを検出するESLintルール

TypeScript ESLintにはno-deprecatedルールがありますが、これは非推奨マークされたAPIを「参照している」コードを検出するものです。今回欲しいのは、ライブラリ開発者側の立場で、非推奨マークされたAPIの「宣言」を検出するものです。これはある程度探した限りでは見つかりませんでしたので、自作することにしました。

シンプルなルール適用例として、次のような設定をすればライブラリ中のすべての @deprecated アノテーションがついた宣言をエラーとして検出します。

// eslint.config.js  
import customRules from './tools/eslint-rules';

export default [  
  {  
    plugins: {  
      custom: customRules,  
    },  
    rules: {  
      'custom/deprecated': 'error',   
    },  
  },  
];  

これだけだと残しておきたい非推奨APIを検出してしまうので、つぎのように allow オプションによって明示的に許可された名前のシンボルは対象外とできるようにしました。

// eslint.config.js  
export default [  
  {  
    rules: {  
      'custom/deprecated': [  
        'error',  
        {  
          allow: ['allowedFunction', 'AllowedClass'], // 許可する名前のリスト  
        },  
      ],  
    },  
  },  
];  

クラスの一部のメソッドだけ非推奨化することもあるため、 ClassName.memberName というパターンもサポートしています。

{  
  allow: [  
    'MyClass.deprecatedMethod',   
    'MyClass.deprecatedProp',  
  ];  
}  

このようなルールを作成してESLintを実行すると、既存の非推奨APIがエラーとして可視化されます。最初は仕方ないのでひとつひとつ手で allow 配列に入れていくと、 allow 配列はまさに「ライブラリ中で非推奨化されているAPIが一覧できる」場所になりました。

この状態ができてしまえばあとは簡単です。廃止したいAPIだけを allow 配列から取り除き、ESLintを実行すると対応すべきコードがわかります。直近のアップデートではそこそこの数の非推奨APIを削除したのですが、 allow 配列をいじったあとのコード変更作業はAI(今回はGitHub Copilot ChatのAgentモード)だけで完結しました。

プロンプト:

TDDを開始

- 目的: 非推奨マークされたAPIのうち、eslint.config.ts で許可されていないものだけを廃止する  
- テストコマンド: pnpm lint  
- 完了条件: ESLintエラーから custom/deprecated ルールのエラーがなくなる  
- 禁止事項  
  - ESLintエラーと直接関係のないコードの変更

完了条件が明確で、ESLintのエラー出力からどのファイルのどの宣言が処理対象か迷うこともないので、残っていた非推奨APIへの参照を置き換えつつAPIの削除までスムーズに進行しました。「TDDを開始」と言ってテストの仕方と完了条件を教えておけばだいたいうまくいく。

ESLintルールの実装

今回のESLintルールは TypeScript ESLintの力を借りて、TypeScriptの型情報を使っています。2025年5月に開催されたTSKaigi 2025でも自作ESLintルールの話は多く、それに触発された部分もけっこうあります。

基本的にはそれぞれの宣言についてJSDocが付いているか、付いていれば @deprecated アノテーションが含まれているか、ということを見ていくだけなのですが、一部の構文ではちょっとした工夫が必要だったので書いておきます。とはいえ、実際には自分はテストコードだけを書いていて、ASTを走査する実装コードはほとんどGitHub Copilot Chatに書かせています。

変数宣言のJSDoc

TypeScriptの変数宣言に付いたJSDocは、それぞれの変数の宣言(VariableDeclarator)ではなく const といったレベル(VariableDeclaration)に付きます。これは const a = 1, b = 2 のような構文がありえるためですね。この場合、JSDocが付いているノードと、報告すべきシンボルの名前を取り出すノードがずれるので注意が必要でした。

   /**
     * 変数宣言の@deprecatedチェック
     * JSDocコメントは親のVariableDeclarationに付くため、そちらをチェックする
     */
    const checkVariableDeclaration = (node: TSESTree.VariableDeclarator): void => {
      const name = getIdentifierName(node.id);
      if (!name || isAllowed(name)) {
        return;
      }

      // JSDocコメントは親のVariableDeclarationノードに付いているのでそちらをチェック
      const parentNode = node.parent;
      if (parentNode && parentNode.type === TSESTree.AST_NODE_TYPES.VariableDeclaration) {
        if (hasDeprecatedTag(parentNode)) {
          reportDeprecatedError(node, name);
        }
      }
    };

エクスポート文のJSDoc

エクスポート宣言では、宣言自体とエクスポート文の両方のコメントをチェックします。以下の2つの例は結果的には同じ意味ですが、fooFunction が非推奨APIであるということを検出するには ExportSpecifierFunctionDeclaration の両方を見る必要があります。

/**
 * @deprecated エクスポート文のコメント
 */
export function fooFunction() {}

// または

/**
 * @deprecated 関数自体のコメント
 */
function fooFunction() {}
export { fooFunction };

加えて、APIの命名だけが変更されたケースでは、次のようにエクスポート文でのエイリアスに対する非推奨化もあります。次のようなケースでは ExportSpecifier のノードのJSDocをチェックしつつ、報告するシンボルは ExportSpecifier.name ではなく ExportSpecifier.exported というズレを考慮する必要があります。

export {
   NewExport,
   /**
     * @deprecated Use NewExport instead
     */
   NewExport as DeprecatedExport,
};

まとめ

破壊的変更を安全にリリースしていくための非推奨化プロセスにおいて、当初のねらい通り「ライブラリ中で非推奨化されているAPIが一覧できる」ことと「削除されるべき非推奨APIと、まだ残しておきたい非推奨APIが区別できるようになる」が実現できました。これまではメンテナの脳内にしかなかったライブラリ全体の非推奨化の状況がコードベースに可視化されたことで、忘れても大丈夫になりマインドシェアの解放にも繋がっています。

ESLintに限らず、ASTと仲良くなればソースコードに対する問題解決の幅が大きく広がるので、まだ自作Lintルール未経験の方もぜひ挑戦してみてください。最後に参考リンクを紹介して終わります。

© 2020 Classi Corp.