AIにガイドラインを渡すだけでなく、lintでチェックする

Massivでは、コードをClaude CodeやCodexのようなAIエージェントが書いています。開発の工程を定義し、複数のアプリで共通の契約を守れるようにライブラリを用意しています。コードの書き方や判断基準は、ガイドラインやSkillで伝えています。ただ、指示だけで変更のすべてに反映されるとは限りません。そこで、ガイドラインに書いた内容のうち機械的に判定できるものはlintや生成物の一致チェックにして、コミットフックやCIで確かめています。

たとえば、フロントエンドでは、フォームの送信時に入力値を検証する処理を共有のFormコンポーネントに持たせています。ただ、画面ごとに<form>を直接書けるままでは、その検証を通らない実装も作れてしまいます。そこで、ガイドラインでFormを使う書き方を伝え、lintで<form>を直接書いていないかをチェックしています。ライブラリで必要な処理を担い、ガイドラインで使い方を伝え、lintでその使い方から外れた実装を見つける、という役割分担です。

この記事では、lintに加えて、生成物の一致やAPIの互換性などを確かめる仕組みも含めて「チェック」と呼びます。ガイドラインや実際に起きた問題からチェックを作る考え方と、コード・生成物・文章のチェックに使っているツールや自作ルールを紹介します。

書き方を決めるときに、チェックの仕方も考える

Massivでは、コードの書き方を決めたときに、その書き方をチェックするルールも追加しています。先ほどの共有のFormコンポーネントを使う書き方は、<form>を直接書くことを止めるmassiv/no-form-elementというoxlintの自作ルールでチェックしています。書き方をガイドラインに載せるのと同時にルールを入れておくと、新しく書くコードだけでなく、既存のコードで<form>を直接書いている箇所も見つかります。

新しい仕組みを用意したときも、チェックを追加します。型付きのURLを生成する仕組みを作った際には、内部リンクを文字列で書くことを止めるmassiv/no-string-internal-hrefを入れました。型付きURLを用意しても、内部リンクを文字列で直接書けば、型によるチェックを受けられません。そこで、文字列で書いた内部リンクをlintで見つけ、型付きURLを使うように促しています。

ガイドラインを書いたときに、その内容からまとめてチェックを作ることもあります。Webアプリのガイドラインでは、各項目を1つずつ見て、機械的に判定できるものを候補として書き出しました。候補ごとに、既存のlintの設定で済むか自作が必要か、いまのコードに違反がいくつあるかを書き、独立して作れる単位に分けて入れています。

書き方を決める際には、チェックの対象を機械的に判別できるかも考えています。Playwrightのテストを*.play.ts、Playwrightのテストを支えるコードをplaywright/に置くと決めれば、ファイル名や配置から@playwright/testのimportを許可する対象を判別できます。同じことを実現する書き方が複数あるなら、機械的に判別しやすい方を選んでいます。

チェックから漏れる書き方にも対応する

既存のチェックで見つけられない書き方に対応することもあります。たとえば、ログは共有ライブラリのロガーから出すようにしているため、no-consoleでconsole.logなどの使用を禁止しています。ただ、process.stdout.writeでも標準出力に直接書き込めるので、no-consoleだけではロガーを通さない出力を防げません。massiv/no-processでは、こうしたprocessへの直接アクセスもチェックしています。

チェックが通ったことと、ガイドラインで定めた書き方を守れていることは、必ずしも同じではありません。チェックから漏れる書き方が見つかったら、その書き方も検出できるようにルールを見直しています。

Massivで何をチェックしているか

Massivでは、フロントエンドとバックエンドの両方で主にTypeScriptを使っているため、ここではTypeScript向けのツールや自作ルールを中心に紹介します。

コードの書き方から、ファイルの構成、生成物、文章まで、守りたいことごとにチェックを紹介します。同じツールでも、目的が異なるものは項目を分けています。

書式をそろえる:oxfmtなどのフォーマッター

書式の違いはレビューで指摘するものではないので、フォーマッターでそろえています。TypeScript・JSON・Markdownにはoxfmt、Goにはgofumptとimportを並べるgci、PythonにはRuff formatを使います。シェルにはshfmt、YAMLにはyamlfmt、TOMLにはTaplo、SQLにはsqlfluffを使い、Pkl、Terraform、Protobufにもそれぞれの整形コマンドを用意しています。

コードの誤りを見つける:oxlintなどのlinter

言語ごとのlinterで、バグにつながる書き方を調べています。TypeScriptではoxlintを使い、バグや誤りにつながるルールをまとめたcorrectnessとsuspiciousのカテゴリを.oxlintrc.jsonでerrorにしています。

{
  "categories": {
    "correctness": "error",
    "suspicious": "error"
  }
}

設定項目はoxlintの公式資料で確認できます。Goにはgolangci-lint、PythonにはRuff、シェルにはShellCheck、DockerfileにはHadolintを使います。型の不一致は、tscやtyでもチェックしています。

チームで決めた書き方を守る:oxlintの自作ルール

ガイドラインで定めた書き方のうち、既存のlintルールでは表せないものは、oxlintのJSプラグインとして作ったmassiv/*のルールでチェックしています。いまは19個あります。既存のルールで表せるものは設定で済ませ、独自の条件や置き換え先の案内が必要なものを自作しています。

自作のルールは、ルートの.oxlintrc.jsonのjsPluginsで読み込みます。ルールはcreateRuleという小さな関数で組み立て、「どのファイルに効くか」をappliesToとしてチェックの本体と分けて宣言しています。

export const requireBrowserOnlyPage = createRule({
  appliesTo: [appRouterPage, staticExportApp],
  messages: {
    notWrapped: "Wrap the whole page in `<BrowserOnly>` ...",
  },
  check(context) {
    // ...
  },
});

必要なファイルの作り忘れを見つける:oxlintの自作ルールなど

ファイルの中身だけでなく、必要なファイルがそろっているかも調べます。massiv/require-page-testは、page.tsxの隣にページのテストpage.play.tsがあるかをチェックします。Fakeのページ (massiv/require-fake-page) やアプリのエラー境界 (massiv/require-app-error) も、対象となるアプリの構成に応じてチェックしています。GitHub Actionsでは、すべてのjobの最後に失敗の調べ方を示すPlaybookのstepがあるかを、自作のシェルスクリプトで確かめています。

依存の向きを守る:fallow

フロントエンドとバックエンドでは実行環境や役割が異なるため、共有ライブラリやアプリの間で許可する依存の向きを決め、fallowでチェックしています。fallowはRust製の静的解析ツールで、importをたどってリポジトリ全体の依存のグラフを作ります。以前はknipとdependency-cruiserの2つで行っていたチェックをfallowにまとめました。

ディレクトリーの間の依存の向きに加えて、決まった場所からだけimportしてよいものをfallow-rule-pack.jsoncに書いています。禁止するimportと、その禁止から除くファイルを指定する形です。たとえば、Playwright向けの設定では、@playwright/testを対象にし、*.play.tsやplaywright/配下などを除外しています。

{
  "id": "playwright-import-only-in-tests",
  "kind": "banned-import",
  "specifiers": ["@playwright/test"],
  "exclude": [
    "**/*.play.ts",
    "**/playwright/**/*.ts",
    "**/playwright.config.ts",
  ],
  "message": "@playwright/test は Playwright のテスト (*.play.ts) と、テストを支えるコード (playwright/ など) からだけ import してください。",
}

使われないコードや依存を残さない:fallow・go mod tidy

リファクタリングで不要になったファイルや依存もチェックします。fallowでは、依存の向きと同じfallow dead-codeというコマンドで、使われていないファイル・依存・exportや、importの循環を調べます。エージェントにリファクタリングを任せると古い関数や依存が残りがちなので、消し忘れに気付けます。Goでは、go mod tidyの結果とgo.mod・go.sumが一致するかを確かめています。

テストの書き方をそろえる:oxlint

テストコードにも、チームで決めた書き方があります。Unit TestのVitestにはoxlint組み込みのvitestプラグイン、Integration TestのPlaywrightにはeslint-plugin-playwrightのルールを、oxlintで有効にしています。

依存するバージョンを固定する:pnpm catalog・ロックファイル・dockerfile-pin

依存するパッケージやツールは、使うバージョンが一意に決まるように固定しています。各package.jsonがpnpmのcatalogを参照し、catalogがバージョンの範囲ではなく固定のバージョンを使っているかをチェックします。mise.lockとmise.toml、uv.lockとpyproject.tomlの一致も確かめています。

Dockerfileのベースイメージも、タグだけでは参照先が変わりうるため、digestで固定します。dockerfile-pinでdigestが指定されているかと、そのdigestが実在するかをチェックしています。レジストリへの問い合わせが必要なため、このチェックはコミット前には入れずCIで行っています。

GitHub Actionsで使うactionも、タグではなくコミットSHAで固定し、jactionlintでチェックしています。CIでは--onlineを付け、固定したSHAと横に書いたバージョンのコメントが一致するかも確かめています。

生成物のチェック

生成し忘れや手作業の編集で、生成物と元の定義にずれが起きないようにチェックしています。たとえば、Protobufの定義から生成するコードは、定義だけを変更して生成し忘れると古いままになります。元の定義から生成し直し、現在のファイルとの差分を確認することで、生成物の更新漏れや手作業の編集を見つけられます。

APIの書き方と互換性を調べる:buf・api-linter

Protobufのスキーマでは、書き方と、変更による互換性をチェックします。buf lintとapi-linterで書き方を調べ、buf breakingでmainからの変更を比較します。互換性を壊す変更が必要な場合は、Pull Requestにproto-breaking-changeのラベルを付けて明示的に許可する運用です。

SQLの書き方をそろえる:sqlfluff

PostgreSQLのスキーマとクエリーも、書き方を決めてチェックしています。命名・書式・スキーマ設計・クエリーの書き方はガイドライン「DBスキーマのスタイルガイド」にまとめ、そのうち機械的に判定できるものをsqlfluffでチェックします。hkのfixではsqlfluff fixを呼ぶので、書式の指摘はコミット前に直ります。

ガイドラインで定めた書き方に合わせて、sqlfluffの設定ファイル.sqlfluffでルールごとの設定を指定しています。たとえば、キーワードを大文字に、識別子を小文字にそろえ、最後の文にも;を求めています。

[sqlfluff:rules:capitalisation.keywords]
capitalisation_policy = upper

[sqlfluff:rules:capitalisation.identifiers]
extended_capitalisation_policy = lower

[sqlfluff:rules:convention.terminator]
require_final_semicolon = True

クエリーにはsqlcのパラメーター (sqlc.arg(x)や@x) が入るので、そのままでは解析できません。sqlfluffのplaceholderのtemplaterで、チェックの間だけパラメーターを値に置き換えています。

CI・インフラの設定を調べる:jactionlint・TFLintなど

開発に使うワークフローやインフラの設定もチェックの対象です。GitHub Actionsにはjactionlint、TerraformにはTFLintを使います。外部に問い合わせるチェックは結果が揺れるので、コミット前には入れずCIだけで行っています。

文章とリンクを調べる:markdownlint・textlint・lychee

Massivでは、ガイドラインだけでなく、機能の要件や設計を記述した仕様書など、多くのドキュメントを管理しています。コーポレートサイトの作り方を紹介した記事でも書いたように、AIエージェントに実装を任せる際には、こうしたドキュメントで前提を伝えています。そのため、コードだけでなく、ドキュメントの記法や表記もチェックしています。

Markdownの記法はmarkdownlint、日本語の文章はtextlintでチェックします。textlintでは、です・ます調とである調の混在、二重否定、表記ゆれ、長すぎる文などをチェックしています。相対リンクや見出しへのリンク切れはlycheeでチェックしています。

機密情報の混入を見つける:secretlint

APIキーなどがコードや設定と一緒にコミットされないよう、secretlintでチェックしています。対象のルールに一致する機密情報を、コミット前とCIで検出します。

チェックを開発の流れで動かす

ここまで紹介したlintは、基本的にhkを使い、コミットフックで実行しています。人が書いたコードもAIエージェントが書いたコードも、コミットする際に同じチェックを受けます。

Pull RequestのCIでも同じチェックを実行しています。多くのチェックは手元とCIで同じmiseのタスクを呼び出し、ツールのバージョンもmiseで固定します。実行条件をそろえるためです。hkの起動をgitconfigで共有する方法は、次の記事で紹介しました。

hkでは、チェックするcheckと修正するfixを分けて設定できます。コミット前には整形し、自動で直せる指摘をその場で直します。以下は、実際のhk.pklから文章のチェックに関する設定を抜き出し、説明用に簡略化した例です。

amends "package://github.com/jdx/hk/releases/download/v2.5.0/hk@2.5.0#/Config.pkl"

local stylecheck = new Mapping {
  ["md:content"] {
    // Markdownファイルを対象にする。
    glob = List("*.md")

    // {{files}}には、対象のファイル名が渡される。
    // checkではファイルを変更せず、文章をチェックする。
    check = "./bin/mise run md:textlint -- {{files}}"

    // fixでは自動修正する。このタスクは修正後の再チェックも行う。
    fix = "./bin/mise run md:textlint -- --fix {{files}}"
  }
}

hooks {
  ["pre-commit"] {
    // コミット前はfixを使い、自動で直せる指摘を修正する。
    fix = true
    steps = stylecheck
  }
}

CIではリポジトリ全体やネットワークが必要なチェックも扱います。生成物については、CIのCode Generationのワークフローが生成し直し、差分があればPull Requestのブランチへコミットする構成です。

チェックのメッセージには、違反の内容だけでなく直し方も書いています。たとえば、スタイリングにはデザイントークンを使うとガイドラインで決めています。marginTop: "12px"のように値を直接書くと、massiv/stylex-token-valuesは次のメッセージを出します。

`marginTop: "12px"` is not a design token. Use spacingVars["--spacing-3"], which has the same value.

同じ値のトークンがメッセージに書かれているので、人とAIエージェントのどちらも、そのまま置き換えられます。例外が必要な場合は、無効にするルール名と理由をコメントに残します。

使っているツールの一覧

この記事で紹介した主なツールを対象ごとにまとめます。自作のチェックは除いています。

対象 ツール
実行の仕組み hk、mise
TypeScript・JavaScript oxlint、oxfmt、fallow、TypeScript
Go golangci-lint、gofumpt、gci
Python Ruff、ty
Protobuf Buf、api-linter
SQL sqlfluff、sqlc
Shell ShellCheck、shfmt
YAML・TOML・Pkl yamllint、yamlfmt、Taplo、Pkl
GitHub Actions jactionlint
Terraform TFLint、terraform fmt
Dockerfile Hadolint、dockerfile-pin
Markdown・文章 markdownlint-cli2、textlint、lychee
機密情報 secretlint

おわりに

lintでチェックすることは、これまでも大事でした。AIを使うことで、チェックの設定や自作ルールを追加することも楽になりました。同時に、AIにコードを書いてもらうなら、その結果を自動でチェックできるようにしておく利点も大きくなっています。

チェックを増やす手間が減り、増やす理由も強くなったので、書き方を決める段階から、機械的にチェックする方法も考えています。みなさんも、ガイドラインに書いていることや、レビューで繰り返している指摘をlintにできないか考えてみてください。

記事一覧へ