📌

eslint-plugin-import-accessのpackageDirectoryオプション

に公開

皆さんこんにちは。今回は、筆者が公開しているESlintプラグインeslint-plugin-import-accessの新バージョン (v3.1.0) で追加された新しいオプション packageDirectory を紹介します。

このオプションにより、プラグインの活用の幅がかなり広がります。ぜひ試してみてください。

復習: eslint-plugin-import-accessとは

まず、このプラグイン自体をご存じない方のために、基本的なところを解説します。次の画像はeslint-plugin-import-accessの機能を端的に説明するものです。

eslint-plugin-import-accessの機能を画像(このあと本文で説明されます)

eslint-plugin-import-accessが無い状態では、プロジェクト内のどこからどこへでも自由にimportすることができます。しかし、実際にはインポートに制限をかけたいことが多くあります。例えば、いわゆるpackage by featureでディレクトリを構成している場合、あるfeatureの中から、他のfeatureの内部の関数を自由に呼び出したりするのは避けたいです。

このような場合にeslint-plugin-import-accessを使うことで、インポートに制限をかけることができます。

上の画像では、sub/foo.tsからconst fooがエクスポートされています。このfoosub/bar.tsからインポートするのはOKですが、subの外にあるmain.tsからインポートするのはエラーとなります。

つまり、この例ではsubディレクトリを「パッケージ」に見立てており、パッケージの中でのインポートは自由だが、パッケージの外(main.ts)からパッケージの中をインポートするのはだめということです。

このように、「パッケージ」の概念を採り入れることで自由なインポートを制限するのがeslint-plugin-import-accessの基本的な機能です。

他のimport系ルールとの違い

自由なインポートを制限するESLintルールは他にもありますが、それらとeslint-plugin-import-accessの大きな違いはJSDocを用いた制御にあります。

上の画像の例では、よく見ると以下のようにJSDocで@packageが指定されていますね。

/**
 * @package
 */
export const foo = 3;

このように@packageを指定することで、このexportは「package-private」だよという意味になります。package-privateというのは「パッケージ外からインポートしてはいけない」という意味です。

つまり、eslint-plugin-import-accessでは、設定ファイルでルールを全部指定するのでなく、JSDocを用いることで一つ一つのexport単位で「これはパッケージ外からインポートしてもいいのかどうか」を指定できるのです。

デフォルトでは、@packageを指定したものだけがeslint-plugin-import-accessによる制限の対象となります。@packageを指定していないものに関しては、外から自由にインポートできます。

しかし、defaultImportability: "package"オプションを指定することでデフォルトを@packageにできます。この場合、JSDocでいちいち指示しなくても、全てのexportが全部パッケージ外からインポート不可になります。このとき例外的にパッケージ外からインポート可能にするためには、@publicをJSDocで指定します。

eslint-plugin-import-accessでプロジェクト全体を統制したい場合にdefaultImportability: "package"が活用されています。

新オプション packageDirectory

これまで「パッケージ」「パッケージ外」といった説明をしてきましたが、パッケージとは何を指すのでしょうか。

実は、これまでのeslint-plugin-import-accessではパッケージ=ディレクトリであり、全てのディレクトリがeslint-plugin-import-accessからはパッケージとして扱われていました。

つまり、@package扱いになっているファイルは、そのファイルがあるディレクトリの外からインポート不可ということです。

これはシンプルなルールではありますが、あまり融通がきかなくて不便でした。とにかくディレクトリの外から中の何かをインポートすることが不可能となっています。index.tsは1つ親のディレクトリからインポートできるというindexLoopholeオプションがあるため、これを活用してディレクトリの中のものをindex.tsからまとめてエクスポートする運用が実用上必須となっており、むやみに大量のindex.tsファイルが生まれていました。

この状況を改善すべく、今回登場したオプションがpackageDirectoryです。これはglobパターンを使って、どのディレクトリをパッケージとして扱うか指定できるというものです。以下のような指定ができます。

// デフォルト(全てのディレクトリがパッケージ)
packageDirecotry: ["**"] 
// _internalという名前のディレクトリはパッケージとして扱わない
packageDirectory: ["**", "!**/_internal"]
// src/packages直下のディレクトリがパッケージとなる
packageDirectory: ["src/packages/*"]

例えば、_internalというディレクトリ名をパッケージとして扱わないようにしたとします(このユースケースは@honey32さんに提供いただきました)。

この場合、以下のような挙動になります。

// ----- src/feature1/_internal/helpers.ts
/**
 * @package
 */
export const internalHelper = () => { /* ... */ };

// ----- src/feature1/foo.ts
import { internalHelper } from "./_internal/helpers"; // ✓ インポート可能

// ----- src/main.ts
import { internalHelper } from "./feature1/_internal/helpers"; // ✖ インポート不可

まず、internalHelper@packageでエクスポートされていますが、これが属するパッケージはsrc/feature1になります。直接の親ディレクトリはsrc/feature1/_internalですが、これはパッケージとして扱わないため、さらにその親が所属パッケージになります。

そうなると、src/feature1/foo.tsからinternalHelperをインポートすることが可能になります。このファイルもsrc/feature1に属しており、同じパッケージ内でのインポートになるからです。

一方、src/main.tsからインポートすることはできません。なぜなら、main.tssrc/feature1パッケージの外にあり、外から中をインポートすることは禁止だからです。

このように、packageDirectoryがあると、パッケージ内でのファイルの整理の自由度が上がります。

おすすめの使用法

筆者は、packageDirectoryの登場により、上で言及したdefaultImportability: "package"の実用性が向上したと考えています。従来は厳しすぎて使うのに苦労するオプションでしたが、どの単位を「パッケージ」として扱うのか設定できるようになったことで、過度な制限をかけることなくプロジェクトの秩序を保つことができます。

packageDirectoryを用いて適当な大きさにパッケージを切って、defaultImportability: "package"を用いてパッケージ間のインポートを制限しましょう。

そして、特別に他のパッケージへの提供を意図しているexportにのみ@publicをつけましょう。

こうすることで、そのパッケージのインターフェース(パッケージ外に対してどのような機能を提供しているのか)が明確になります。

例えば、フロントエンドのアプリケーションで画面単位のpackageとした場合、画面全体のコンポーネントだけ@publicにする運用が考えられます。

// src/features/foo

/**
 * @public
 */
export const FooPage: React.FC = ...

“メタ設定”のすすめ

このように便利なpackageDirectoryですが、設定の書き方にはコツがあります。それは、個別のディレクトリを列挙するのではなく、“ディレクトリ名のルール”をpackageDirectoryに記載することです。より端的に言えば、ワイルドカード(***)を活用するということです。

例えば、いわゆるpackage by featureを採用しておりsrc/packages/foosrc/packages/barをパッケージとして扱いたいとしましょう。そのときは、foobarを個別に設定ファイルに明記するのは避け、代わりに*を使います。

// 避けるべき設定方法
packageDirectory: ["src/packages/foo", "src/packages/bar"],
// 望ましい設定方法
packageDirectory: ["src/packages/*"],

前者のようなやり方は、パッケージが増えるたびにESLintの設定ファイルを書き換える必要がある点が特によくありません。より抽象度の高いルール設定にすることで、このような事態を避けられます。

他に、以下のような設定にしてみるのも面白いでしょう。

// foo.package のような名前のディレクトリはパッケージとして扱う
packageDirectory: ["**/*.package"],
// bar.internal のような名前のディレクトリはパッケージとして扱わない
packageDirectory: ["**", "!**/*.internal"],

上記のような設定の場合は、一度ルールを定義してしまえば、あとは個々のディレクトリがパッケージかどうかは、ディレクトリの命名で決めることができます。つまり、ディレクトリを作る人が、そのディレクトリに関するルールも決めることができます。

このやり方では、ESLint設定 (packageDirectory) は言わば「ルールを決めるルール」になっていますね。これを筆者はメタ設定と呼んでいます。Next.jsのフレームワークにファイル・ディレクトリの命名ルールがあるのと同じようなものですね。eslint-plugin-import-accessを使うことで自分で命名ルールを定義できるのです。

まとめ

eslint-plugin-import-accessに新しいオプションpackageDirectoryが追加されたことで、eslint-plugin-import-accessの利便性と活用の幅が広がりました。

特に、このオプションがあることでdefaultImportability: "package"での運用が現実的なものになります。自分のプロジェクトにの状況や構成にあわせてpackageDirectoryのルールを定義しましょう。これにより、望ましくないインポートを効率よく検知できるようになります。

また、“メタ設定”の考え方は、eslint-plugin-import-accessがもともと持っていた「JSDocを用いて、設定ファイルではなく現場でルールを指定する」という特徴をさらに推し進めるものとなっています。JSDocに加えて、ディレクトリ名という道具が新たに加わったわけですね。

ちなみに、今回のオプションの実装は、Claude Code on the webのクレジットが何か付与されていたので使ってみる目的で全てClaude Codeにやってもらいました。多分大丈夫だと思いますが、使ってみて問題があればissueなどでの共有をぜひよろしくお願いいたします。

GitHubで編集を提案

Discussion