NeverThrowを触ろうと思ったらESLintのFlat Configを触っていた(後編)

の続き。eslint-plugin-neverthrow に手を入れて動くようにしてみるぞー!そして記事を書き終わるぞー!

eslint-plugin-neverthrow に手を入れてみる

そのままでは動かなさそうだから手を入れて動くようにしてみたい。パッケージを作ればできそうではあるけど、もうちょっと簡単にできないかな?と思って探してみたら、こんな記事を見つけた。ありがたい。

srcのとなりにコードを置いて、それを使うようにできるのかー。これなら簡単に eslint-plugin-neverthrow に手を入れられそう。この記事や、ESLintのプラグイン自作ページを見ながら試してみたら、サンプルのプラグインが動いた。この仕組みで eslint-plugin-neverthrow に対して手を入れてみよう。

my-plugin という名前でプラグインを作る

# プラグイン用のフォルダを作る
❯ mkdir my-plugin

index.js はこんな感じ。

import {rule} from './must-use-result.js'

const plugin = {
  meta: {
    name: 'my-plugin',
    version: '1.0.0',
  },
  rules: { 'must-use-result': rule },
}

export default plugin

そして、must-use-result ルールのファイルをダウンロードしてくる。TypeScriptのファイルだから、tscでjsにコンパイルすればいいか。

❯ cd my-plugin
❯ curl https://raw.githubusercontent.com/mdbetancourt/eslint-plugin-neverthrow/refs/heads/master/src/rules/must-use-result.ts -O
❯ ls
index.js           must-use-result.ts

で、コンパイルエラーが出てるからまずはそれを解消しよう。

import部分のエラー

import部分では↓の3ヶ所がエラーになる。

(1) tsutils がない:

import { unionTypeParts } from 'tsutils';

(2) @typescript-eslint/experimental-utils がない:

import type {
  TSESLint,
  ParserServices,
} from '@typescript-eslint/experimental-utils';

(3) ../utils がない

import { MessageIds } from '../utils';

import部分のエラーを解消する

(1) tsutils は追加したらよさそう。

❯ pnpm add -D tsutils

はい、消えたー。

(2) @typescript-eslint/experimental-utils は、たぶん最新版ではもう experimental ではなくなってそう。と思って探してみたら @typescript-eslint/utils に入ってた。

 import type {
   TSESLint,
   ParserServices,
-} from '@typescript-eslint/experimental-utils';
+} from '@typescript-eslint/utils';

はい、消えたー。

(3) ../utils は↓のファイルで3行だけなのでコピペしたらよさそう。

https://github.com/mdbetancourt/eslint-plugin-neverthrow/blob/master/src/utils.ts

-import { MessageIds } from '../utils';
+
+export enum MessageIds {
+  MUST_USE = 'mustUseResult'
+}

はい、消えたー。

rule定義部分のエラーを解消する

meta情報が違うよとか、defaultOptionsがないよとか、export のやり方がダメだよとか怒られるので、それを修正。

@@ -186,14 +186,11 @@ function processSelector(
   return true;
 }

-const rule: TSESLint.RuleModule<MessageIds, []> = {
+export const rule: TSESLint.RuleModule<MessageIds, []> = {
   meta: {
     docs: {
       description:
         'Not handling neverthrow result is a possible error because errors could remain unhandleds.',
-      recommended: 'error',
-      category: 'Possible Errors',
-      url: '',
     },
     messages: {
       mustUseResult:
@@ -202,6 +199,7 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
     schema: [],
     type: 'problem',
   },
+  defaultOptions: [],

   create(context) {
     const parserServices = context.parserServices;
@@ -220,5 +218,3 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
     };
   },
 };
-
-export = rule;

tscでJSにトランスパイルしておく

# my-pluginにいる場合はリポジトリルートに戻っておく
❯ cd ..

# tscはファイルを指定する場合はtsconfigを見てくれないので自分でオプションを指定する
❯ pnpm tsc my-plugin/must-use-result.ts --module NodeNext --moduleResolution NodeNext

❯ ls my-plugin/must-use-result.js
my-plugin/must-use-result.js

これでプラグインはできたかな。まだ手をいれてないから、同じエラーになるはずなので、確認してみよう。

my-pluginを使うように設定

こういう設定にしてみた。色々調べているうちにFlat Configに少し慣れてきて、eslint-plugin-neverthrow の旧フォーマットの設定は、新フォーマットでこう書けばいいじゃんって分かってきた。

import myPlugin from "./my-plugin/index.js";

export default [
  ...
  {
    plugins: {
      myPlugin,
    },
    languageOptions: {
      parser: tseslint.parser,
      parserOptions: {
        ecmaVersion: 2021,
        sourceType: 'module',
        project: ['./tsconfig.json'],
        tsconfigRootDir: __dirname,
      }
    },
    rules: {
      'myPlugin/must-use-result': 'error',
    },
  },
];

からの実行!

❯ pnpm eslint

Oops! Something went wrong! :(

ESLint: 9.15.0

Error: Error while loading rule 'myPlugin/must-use-result': types not available, maybe you need set the parser to @typescript-eslint/parser

やったー!ローカルでも同じエラーになったー。

手を入れる

これでやっと手を入れられる。↓この部分が取れてないから

const parserServices = context.parserServices;

↓を参考に修正してみる。

@typescript-eslint/utilsESLintUtils を使って parserServices を取得してみる。

@@ -1,9 +1,10 @@
 import { TypeChecker } from 'typescript';
 import { unionTypeParts } from 'tsutils';
 import { TSESTree } from '@typescript-eslint/types';
-import type {
-  TSESLint,
-  ParserServices,
+import {
+  type TSESLint,
+  type ParserServices,
+  ESLintUtils,
 } from '@typescript-eslint/utils';

 export enum MessageIds {
@@ -202,7 +203,7 @@ export const rule: TSESLint.RuleModule<MessageIds, []> = {
   defaultOptions: [],

   create(context) {
-    const parserServices = context.parserServices;
+    const parserServices = ESLintUtils.getParserServices(context);
     const checker = parserServices?.program?.getTypeChecker();

     if (!checker || !parserServices) {

からの、実行。

❯ pnpm tsc my-plugin/must-use-result.ts --module NodeNext --moduleResolution NodeNext \
∙ && pnpm eslint

Oops! Something went wrong! :(

ESLint: 9.15.0

TypeError: context.getScope is not a function

お、違うエラーになった。一歩前進!

Rules APIに変更が入ってた

新しく出てきたエラーメッセージ:

context.getScope is not a function

これは、探したらすぐに見つかった。

あぁ、ESLintのv9で設定だけじゃなくてRules APIにも変更が入ったのか。納得。

ESLint v9.0.0 introduces changes to the rules API that plugin rules use, which included moving some methods from the context object to the sourceCode object. If you’re seeing one of these errors, that means the plugin has not yet been updated to use the new rules API.

からの、compatibility utilitiesがあるのか。

Use the compatibility utilities to patch the plugin in your config file

compatibility utilitiesを使ってみる

終わりが見えてきたぞー!

# @eslint/compat を追加
❯ pnpm install @eslint/compat -D

fixupPluginRules を使うように eslint.config.js を修正

@@ -7,6 +7,7 @@ import path from "path";
 import { fileURLToPath } from "url";

 import myPlugin from "./my-plugin/index.js";
+import { fixupPluginRules } from "@eslint/compat";

 // mimic CommonJS variables -- not needed if using CommonJS
 const __filename = fileURLToPath(import.meta.url);
@@ -49,7 +50,7 @@ export default [

   {
     plugins: {
-      myPlugin,
+      myPlugin: fixupPluginRules(myPlugin),
     },
     languageOptions: {
       parser: tseslint.parser,

からの実行!!!

❯ pnpm tsc my-plugin/must-use-result.ts --module NodeNext --moduleResolution NodeNext \
  && pnpm eslint src/hello-neverthrow.ts

(Redacted)/hello-result/src/hello-neverthrow.ts
  10:16  error  Result must be handled with either of match, unwrapOr or _unsafeUnwrap  myPlugin/must-use-result

✖ 1 problem (1 error, 0 warnings)

きた〜!「neverthrowのResult型をちゃんと処理してないよ」ってエラーになった。

一旦立ち止まる

eslint-plugin-neverthrow が動かない理由は2つあった

  • ESLint v9でFlatConfigに変わったけど eslint-plugin-neverthrow の設定が旧形式だった
    • 僕がそれを新形式に変換する知識がなくて、最初はFlatCompatを使ってみた
    • けど、結局色々調べてるうちに新形式の書き方が分かったからFlatCompatいらなくなった
  • ESLint v9でRules APIに変更が入った
    • から実行するとエラーになった
    • ローカルで触っているあいだに、本当の原因のエラーメッセージがでてきてESLintのヘルプページにたどり着けた
    • fixupPluginRules ユーティリティを使ったら動いた

ということは?

eslint-plugin-neverthrow そのままで、FlatConfig形式で設定を書いて、 fixupPluginRules でRules APIを旧形式に変換してやれば動くのでは?

やってみる

eslint-plugin-neverthrow をインポートして、fixupPluginRules をかけて、FlatConfigで設定を書いてあげるとこうなる。

...

import { fixupPluginRules } from "@eslint/compat";
import eslintPluginNeverthrow from "eslint-plugin-neverthrow";

...

export default [
...

  {
    plugins: {
      eslintPluginNeverthrow: fixupPluginRules(eslintPluginNeverthrow),
    },
    languageOptions: {
      parser: tseslint.parser,
      parserOptions: {
        ecmaVersion: 2021,
        sourceType: 'module',
        project: ['./tsconfig.json'],
        tsconfigRootDir: __dirname,
      }
    },
    rules: {
      'eslintPluginNeverthrow/must-use-result': 'error',
    },
  }
 ];

で、実行すると、実行エラーにならずにちゃんと怒ってくれるー!

❯ pnpm eslint src/hello-neverthrow.ts

(Redacted)/hello-result/src/hello-neverthrow.ts
  10:16  error  Result must be handled with either of match, unwrapOr or _unsafeUnwrap  eslintPluginNeverthrow/must-use-result

✖ 1 problem (1 error, 0 warnings)

やったー!!!

おしまい

NeverThrowを触ろうと思ったらESLintのFlat Configを触っていて、ずいぶん遠回りしてESLintが提供してくれている変換ツールにたどり着いてNeverThrowのESLintが動くようになった!道中で、Flat Config, FlatComat, ローカルプラグイン, fixupPluginRules を触って楽しんだ!面白かったー!

最終的なコードはここに置いといた。