🐧

lintnet - General purpose linter powered by Jsonnet

2024/06/08に公開

lintnet という、汎用的な linter を OSS で開発しているので紹介します。
本記事執筆時の 1 週間くらい前 (2024-05-30) に正式にリリース (GA ではない) したばかりの新しい OSS です。

https://lintnet.github.io/

要点まとめ

  • lintnet という OSS の紹介
  • JSON, YAML, HCL といったファイルの汎用的な linter
  • Conftest のようなもの
  • Jsonnet を使って lint rule や 設定、テストなどを記述
  • Jsonnet は動的に設定を生成するための JSON を拡張した言語
    • セキュアでパワフルでありながらシンプルで学習コストが低い
    • lintnet では Native function で Jsonnet を拡張している
  • Go で書かれたシングルバイナリなのでインストールは容易
  • 設定ファイルやコマンドによって洗練・標準化された UX を提供
  • lintnet は lint rule を Module として簡単に配布・再利用できる
    • Module の Update も Renovate で自動化出来る
    • 理想的には GitHub Actions の action のようなエコシステムが構築できると良い

Lint Night #3 で登壇しました

先日行われた Lint Night #3 というイベントで lintnet について登壇しました。
資料も公開しているので良ければ御覧ください。内容はこの記事ともかなり重複していますが、時間の都合上登壇時には説明できなかったことなども説明しています。

https://lintnight.connpass.com/event/319202/

https://speakerdeck.com/szksh/lintnet-general-purpose-linter-powered-by-jsonnet

lintnet とは

lintnet は JSON や YAML などの汎用的な linter です。
Conftest をご存じの方は、似たようなものと思ってくれて良いでしょう。
Go で実装されており、シングルバイナリを PATH 配下に置くだけで簡単にインストール出来ます。

lintnet 自体は何の lint rule も持ちませんが、ユーザーが定義した lint rule に従って lint を実行するフレームワークとして機能します。
ユーザーは lint rule の実装など本質的な部分に専念し、それ以外の部分は lintnet に任せることができます。
k8s manifest や Terraform の設定ファイル, GitHub Actions Workflow, CircleCI の設定ファイルといった様々なファイルの lint に lintnet という一つのツールで対応することができます。
ファイルの種類ごとにツールを使い分けたり新たにツールを一から開発したりする必要がありません。

lintnet は JSON や YAML だけでなく、様々なフォーマットの lint に対応しています。

ちなみに未対応のフォーマットのファイルに関しても構造化されていないテキストとして lint は可能です。
用途は限定的だとは思いますが、ファイル名のフォーマットやファイル末尾の改行の有無、 NG word が使われていないかなどのチェックは出来ます。

現在プログラミング言語の lint には対応していませんし対応する予定もありません(詳細は後述)。

lintnet では lint rule を Jsonnet で記述します。
Jsonnet は設定を動的に生成するために JSON を拡張した言語です。

Jsonnet に馴染みのない人も多いかもしれませんが、上記の公式サイトを読めば大体分かります。

シンプルな言語であり、学習コストは高くありません。
シンプルでありながら if や for 文、関数定義、標準ライブラリ、外部ファイルの import 等など lint rule を記述するには十分な機能を有しています。
lintnet では Jsonnet の Go 実装である go-jsonnet の Native function という仕組みを使って独自の関数を追加し、 Jsonnet 標準の機能では難しい lint rule の実装も可能にしています。

https://lintnet.github.io/docs/lint-rule/native-function/

またファイルシステムやネットワークへのアクセス、 OS コマンドの実行が制限されているためセキュアに lint を実行できます。

lintnet で lint rule を書く際はパラメータを lint rule に渡すために Top-level arguments を使い、決まったフォーマットに従って lint の結果を返す関数を定義します。

lintnet ではサンプルも提供しているので、それを見ればだいたい書き方がつかめるかと思います。

lintnet では Module という仕組みを使って lint rule を簡単に配布・再利用する事ができます。

https://lintnet.github.io/docs/module/

理想的には GitHub Actions の action のように多くの便利な Module がコミュニティから生まれ、それらを活用することで簡単に高度な lint を実現できるようにしたいです。
同じような lint rule を色々な現場で一から再実装したり、複数のリポジトリで lint rule を共有するのに苦労したりする必要はありません。
Module の配布は GitHub に Jsonnet を公開するだけですし、 Module 利用も設定ファイルや lint rule で Module のアドレスを参照するだけですので非常に簡単です。
Module はバージョニングして管理することができ、 lintnet 公式の Renovate Preset を使って更新を自動化できます。

https://github.com/lintnet/renovate-config

Official Module も幾つか存在するので、 Module を開発する際の参考にしてください。
自分が開発している linter である ghalintnllint をそれぞれ lintnet の Module に移植しています。

また Terraform に関する Module も幾つかあります。

いかに Jsonnet で lint を実現しているのか

以下の図は lintnet ではいかに Jsonnet で lint を実現しているのかを示しています。

image

  1. lint 対象のファイルを parse してデータに変換
  2. lint 対象のファイルからファイルのパスやフォーマットなどのメタデータを抽出
  3. lint 対象のファイルのデータやメタデータ、 lint rule のパラメータを lint rule を記述した Jsonnet に 渡して Jsonnet を評価し JSON を生成(生成された JSON や lint error のリスト)
  4. lint error のリストが空であれば lint 対象のファイルは valid, そうでなければ invalid とする

つまり lint rule は lint 対象のファイルのデータやメタデータなどを input として受け取り lint error のリストを出力する Jsonnet の関数です。

lintnet の使い方

まずは Tutorial で最低限の基本的な使い方は理解できます。
この Tutorial 以外にも様々なサンプルドキュメントを提供しているので参考にしてください。

細かな機能

細かな機能はここでは説明しませんので軽く紹介するにとどめます。

lintnet で実現が難しいこと

lintnet で実現が難しいことが幾つかあります。
ちなみにこれらのことは Conftest でも出来ないはずですし、汎用的な linter としてこれらを実現するのは中々難しいと思います。

  • lint error の位置情報の活用(パース時に情報が失われるため)
    • reviewdog との連携
    • editor と連携し、エラーが起こっている箇所に警告を出すこと
    • エラーが起こっている箇所を抜粋して分かりやすく出力すること
  • コードの自動修正
  • コード中にアノテーションを書くこと
    • アノテーションを書いて特定の箇所の lint を ignore するとか

特定箇所の lint の無効化に関して言うと、アノテーションの代わりに lint rule の parameter で実現することができます。
その場合、 lint rule 側で無効化を実装する必要があります。

なぜ lintnet を作ったのか

なぜ lintnet を作ったのか、一言でいうと「自分にとって理想の汎用的な lint ツールが欲しかったから」です。
この願望は何年も前から抱いていましたが、うまい方法を中々思いつけずにいました。

世の中には様々なツールとその設定ファイルが存在し、それらの設定ファイルに適用したい様々な lint ルールがあります。
そして対象ツールごとに様々な lint ツールが存在します。
しかし、 lint ツールがやることは lint ルールこそ違えど共通する部分も多いです。
lint ツールごとにこれらを一から作るのはあるべき姿ではありません。
ツールの利用者側からしても、色々なツールをインストールして使いわけるよりも、一つのツールに統一されていたほうが楽でしょう。
個々の lint ルール以外の共通部分を実装した汎用的なツールが一つあり、個々の lint ルールを plugin として実装するのが理想でしょう。
ユーザーは一つのツールだけ使えば良く、開発者は lint rule の開発に専念することが出来ますし、簡単に拡張することが出来ます。

そして GitHub Actions の action のように多くの便利な plugin がコミュニティから生まれ、それらを活用して高度な lint を簡単に実現できるようになっているような状態が理想です。

この理想を実現する一つのツールとして、 Conftest があります。
ご存じの方も多いとは思いますが、 Conftest は lintnet 同様、様々なファイルの lint に対応できる汎用的な linter です。

Rego という Policy を記述するのに特化した言語で Policy を記述します。
Conftest を使えば先述の lint ルール以外の共通部分を実装した汎用的なツールが一つあり、個々の lint ルールを plugin として実装するのが理想 は実現できます。

しかし自分は Conftest では満足できませんでした。
不満は幾つかありますが、個人的には 再利用性が低い のが一番問題だと思っています。
lint ルールというのは、勿論特定の組織固有のものもありますが、多くの組織に適用できる汎用的なルールも多いでしょう。
にも関わらず、それらが簡単に再利用可能な形で公開されておらず、各組織が独自に同じような Conftest Policy を実装していたりします。
また、複数リポジトリで同じ Policy を共有したいという場合でも統一的な方法がなく、各々で共有方法を考え実装していたりします。
Conftest にも Policy を共有する仕組みはありますが、これは広く普及させるには低レイヤー過ぎる API だと自分は思います。

https://www.conftest.dev/sharing/

conftest pull は単にファイルをダウンロードしてくるだけのコマンドであり、ダウンロード先のパスの衝突などについて全く無頓着です。
次のコマンドを実行すると policy というディレクトリが作られ、 policy/policy.rego にファイルがダウンロードされます。

conftest pull https://raw.githubusercontent.com/open-policy-agent/conftest/master/examples/spdx/policy/policy.rego
$ ls policy 
policy.rego

別の policy を pull しましょう。

conftest pull https://raw.githubusercontent.com/open-policy-agent/conftest/master/examples/builtin-errors/invalid-dns.rego
$ ls policy 
invalid-dns.rego  policy.rego

同じく policy ディレクトリにダウンロードされました。
この 2 つはファイル名が異なるので別のパスにダウンロードされています。では同じ名前のファイルを pull した場合どうなるのでしょうか?

conftest pull https://raw.githubusercontent.com/open-policy-agent/conftest/master/examples/cyclonedx/policy/policy.rego

何のエラーも吐かずに正常終了しますが、なんとファイルが壊れています。

$ echo $?
0

$ cat policy/policy.rego
package main

deny[msg] {
	expected_data_license := "conftest-demo"
	input.CreationInfo.DataLicense != expected_data_license
	msg := sprintf("DataLicense should be %d, but found %d", [expected_data_license, input.CreationInfo.DataLicense])
}
o expected SHA256 %s", [input.metadata.component.version, expected_shas256]
    )
}%

まさか壊れるとは思ってなかったので、流石にこれはバグとして報告しました。

https://github.com/open-policy-agent/conftest/issues/957

$ conftest -v
Conftest: 0.53.0
OPA: 0.65.0

ただ、壊れるのがなんとかなったとしても、そもそも conftest pull コマンドはダウンロード先が衝突した場合のことや名前空間、 isolation といったことを全く考慮していません。
上記の問題は conftest pull をする際にダウンロード先を明示的に指定してあげれば回避できますが、ユーザー側が意識的にそれをしなければなりません。

また別の問題としてバージョニングが強制されておらず master branch からダウンロードできてしまっています。これは policy の変更によって突然 test が壊れる可能性があります。

contest は policy を実行する、テストする、再利用するための最低限の機能だけを提供していて、それをワークフローに組み込むためのそれ以外の部分はユーザー側に委ねられているという印象があります。
故に自分はこれを低レイヤーな APIだと思っています。
低レイヤーな API だけを提供するというアプローチはツールの開発者としてはコアとなる部分だけの開発に専念できますし、ユーザーも工夫次第で自由に使えるという良い側面もあります。
しかし多くのユーザーはそこに自由はあまり求めてなく、細かな部分は意識せずにいい感じに使えるより高レイヤーな APIが必要だと思っています。

もし Conftest の policy を共有する仕組みが上手くいっているのだとしたら、 Conftest ほど知名度の高いツールであれば先述のような 多くの便利な plugin がコミュニティから生まれ、それらを活用して高度な lint を簡単に実現できるようになっているような状態 になっているはずですが、実際そうはなっていません。

このような考えを何年も前から抱いていて必要であれば自分でこの問題を解決したいと思っていましたが、実現方法が中々思いつきませんでした。
自分は Go が好きなので Go がサポートしている組み込みのスクリプト言語を使うという案もありましたが、今回の用途に適した言語はありませんでしたし、あまり筋が良いとは思えませんでした。

https://awesome-go.com/embeddable-scripting-languages/

また lint ルールや Framework を Go の package として実装するという方法もありました。
Go の interface を満たす形で lint ルールを実装するというやり方です。
知名度の低い言語よりは Go のほうが遥かに良いですし、 Go のエコシステムを活用できますし、 lint ルールの再利用にも Go の Module の仕組みがそのまま使えます。
ですがこれにも色々問題があり、筋が良いとは思えませんでした。
まず Go で lint ルールを書くのが結構面倒であるということ、 Go だと何でも出来すぎてしまい特にサードパーティの Module を利用する場合セキュリティ的に注意が必要 (新しい Module を導入したり Module を update したりする際、セキュリティに問題がないかレビューが必要) であるということ、 lint 実行時に Go が必要になることなどが挙げられます。
これは Go 以外の多くの言語にも言えることだと思います。
Deno など一部の言語ではファイルシステムやネットワークへのアクセスが禁止できて安全にできますが、自分が Deno などにはあまり詳しくないのと、 Jsonnet を使った仕組みのほうがよりやりたいことに適していると思っているため、あまり検討はしていません。

Go で plugin 機構を実現する方法として hashicorp/go-pluginknqyf263/go-plugin のようなライブラリもあります。
特に knqyf263/go-plugin は WASM を活用していてセキュリティ的に安全に plugin を実行でき、技術的にも非常に興味深いです。
しかし、多くの人に plugin を作って配布してもらうのはハードルが高く、また自分は WASM にはあまり詳しくありませんが結構制約が厳しいはずです。
今回はより簡単に lint ルールを開発、配布出来るようにしたいと思いました。

あとは Conftest 同様 Rego を使うという手もありますが、そこまで Rego が好きでもないのでやる気にはなれませんでした。

という感じで長らく良い案が思い浮かばなかったのですが、あるときふと Jsonnet を使って lint ルールを記述できるのではないかと思いつきました。

Jsonnet 自体は aqua-renovate-configsuzuki-shunsuke/renovate-config で Renovate Config Preset を生成するのに以前から便利に使っていました。
Jsonnet は lint ルールを記述するのに必要な機能を備えつつ、 Go や Python のような汎用の言語のように何でも出来すぎず、ちょうどよい言語でした。
本当にうまくいくかは半信半疑でしたが、思いついたからには試さずにはいられませんでした。
軽い気持ちで作り始めたものの、少し作り始めてからこれはいけるのではないかと思うようになりました。

Module の開発には aqua の Registry の開発経験が活きました。
aqua 同様、 Renovate でアップデートを自動化できるようにしたり、中々良い感じに出来たと思います。
Module の配布は Jsonnet を GitHub でホストするだけで非常に簡単であり、何かをビルドしたり CI でどこかしらにアップロードしたりする必要がなく、 Module の配布・再利用のハードルを非常に低く出来ました。
パラメータの渡し方や対象ファイルの指定・除外方法も設定ファイルで宣言的に記述するようになっていて、統一的な方法で実現できました。
エコシステムと呼べるほどのものができるかはこれから次第ですが、その土台となる仕組みが出来たことにまずは満足しています。

Lint Night #3 の懇親会で頂いた質問への回答

質問を頂いた際にも回答はしましたが、改めてここでも回答をしておきたいと思います。

Q. なぜ Jsonnet なのか。 JavaScript とかではだめなのか?

先述の通り、Jsonnet ではファイルシステムやネットワークへのアクセス、 OS コマンドの実行が制限されているためセキュアに lint を実行できます。
サードパーティの Module も比較的安心して使うことが出来ます。
JavaScript などでは何でも出来すぎてしまい特にサードパーティの Module を利用する場合セキュリティ的に注意が必要 (新しい Module を導入したり Module を update したりする際、セキュリティに問題がないかレビューが必要) です。

ただし、一般的なソフトウェア開発でサードパーティのライブラリは日常的に使われており、そんなことをいったらまともに開発なんかできないじゃないかと思う方もいるかもしれませんが、ほとんどの lint ではファイルシステムやネットワークへのアクセス、 OS コマンドの実行は必要ありません。
必要なリスクは背負うしかありませんが、不要なリスクは回避すべきです。

また JavaScript などは自由度が高い分、人によってコードの書き方に差異が生じ、 style や lint などをどうするかなど考えることが増えます。
それぞれの言語に対し知見や思い入れを持っている人が多い分、 lint rule のロジックという本筋からずれた議論が起こりやすいです。
Jsonnet ではそこまでの自由度がないためコードの差異も生じづらく、 lint rule のロジックに専念しやすいと思います。

セキュリティを担保する方法としては Deno や WASM, コンテナなど幾つかありますが、 Jsonnet を使った仕組みのほうがシンプルですし、あえて複雑な仕組みを選ぶ理由もありません。
自分の経験 (Go と Jsonnet は知っているものの、 Deno や WASM はよく知らない) と合わせて考えても、 Jsonnet を選ぶのが最も自然でした。
Deno や WASM については本記事で言及しているので、そちらも参照してください。

Q. Jsonnet 以外の Configuration Language は考えなかったのか?

Jsonnet 以外にも様々な configuration Language があります。

https://github.com/apple/pkl/discussions/7

https://github.com/google/jsonnet
https://github.com/apple/pkl
https://github.com/cue-lang/cue
https://github.com/dhall-lang/dhall-lang
https://github.com/bazelbuild/starlark
https://github.com/tweag/nickel
https://github.com/kcl-lang/kcl

自分は Jsonnet と CUE 以外は使ったことがありませんが、 Jsonnet には以下のようなアドバンテージがあります。

  • シンプルで学習コストが低い
  • 知名度が高く、 GitHub Star 数的にも人気がある
  • Go のライブラリがあり、簡単に Go から呼び出せる

シンプルで学習コストが低いのは、ユーザーにより簡単に使ってもらう上で非常に重要です。
Jsonnet で十分に要件を満たしており、自分が知らない他の言語をあえて選ぶ理由も特にありませんでした。

Q. Programing 言語の lint も、 Programing 言語をパースしてデータに変換すれば出来るのでは

登壇時に lintnet の制約として Programing 言語の lint が出来ない といいましたが、これは語弊がありました。
汎用の linter を謳っておきながら実は Programing 言語の lint は出来ませんだと嘘っぽいので、現状は出来ないということをはっきり明言しておきたかったという意図がありましたが、語弊があったかなと思います。
Programing 言語であってもパースしてデータに変換できれば恐らくできるでしょう。
ただし Programing 言語の lint は JSON や YAML などの lint とは性質的に異なる部分もある気がしていて、本当に Jsonnet で満足に lint rule を実装できるかはやってみないと分かりません。
また、 Programing 言語の lint rule を実装したいというモチベーションが自分には特にありません。
多くの Programing 言語にはすでに linter が色々あり、独自の lint rule を実装したいというモチベーションが自分にはありません。
一般的にはニーズがあってもおかしくないので、要望があれば対応するかもしれませんが、今のところその予定はありません。

Q. 位置情報が取れないのは実用上厳しくないか?

https://x.com/nsfisis/status/1799024198834372632

登壇時に時間の関係上詳しく説明ができなかったので誤解を招いてしまったかなと反省しています。
まずファイル名は取れますし、 lint rule の開発者が意識しなくても lintnet のエラーメッセージには勝手にファイルパスが含まれます。
たしかに lint error でどこでエラーが起こったのか全くわからないと実用上問題でしょう。
ここでいう位置情報は行や列番号のことです。

image

こちらの図で lint 対象のファイルをパースする際に位置情報は多くの場合失われてしまいます。
例えば JSON のパースには Go の encoding/json#Decoder.Decode が使われていて、位置情報は失われます。
ただし、例えば csv のパースでは行番号は維持されます。このため、位置情報が失われるかはパーサ次第ですが、恐らく最も使われるであろう JSON, YAML, HCL パーサでは失われます。
例えば YAML では ast を使ってパースすれば位置情報を保持してパースすることが出来ます。

https://pkg.go.dev/github.com/goccy/go-yaml/ast

しかしここで得られた位置情報を上手く lintnet に組み込むことは結構難しいと思います。
データを lint して lint error が発生する要素を見つけた後、その要素の位置情報を参照する必要がありますが、これを lint rule の開発者の負担なくどうやって実現するかは色々と考えなければなりません。

ただ、例えば GitHub Actions の job の lint をするといった場合、 lint error が発生した job 名は位置情報がなくても簡単にわかります。
そのため、こういった位置を特定するのに使える情報を lint rule の返り値に含めることでユーザーはどこでエラーが起こったのか知ることが出来ます。
位置情報はわからないため reviewdog などとの連携は難しいですが、どこでエラーが起こったのか知ることは出来ます。
ただしこれも lint rule 次第で、もし lint rule が位置を特定するのに使える情報を返してくれなければ分かりません。
なので皆さんが lint rule を実装する際は、きちんと位置を特定するのに使える情報を返すようにしてください。

位置を特定するのに使える情報は、 lint によって異なります。
GitHub Actions の workflow 名の lint であればファイル名だけで十分ですし、 job の lint であれば job 名も必要です。
そのため、 lint rule では位置を特定するのに使える情報を location というフィールドに含めて返すというルールになっていますが、 location のフォーマットは定まっておらず、任意の情報を含めることが出来ます。

https://github.com/lintnet-modules/ghalint/blob/91d6162812602787b02a651f9e8a69d583ac029c/workflow/job_permissions/main.jsonnet#L6-L8

さいごに

以上、 lintnet という linter を紹介しました。
lintnet はまだ開発が始まったばかりの OSS でこれからより洗練させていきます。
色々可能性のある面白いプロジェクトだと思っているので、よろしければ使ってみてフィードバックをください。

Discussion