Claude Media
Claude Codeでjsx-a11yの違反を直す — 本物の問題と誤検知の振り分け

Claude Codeでjsx-a11yの違反を直す — 本物の問題と誤検知の振り分け

eslint-plugin-jsx-a11yの指摘をJSONで渡し、実装で直せるものと意味が変わるので人が決めるものにルール単位で分けます。PostToolUse hookで編集のたびに再実行する設定例つきです。

eslint-plugin-jsx-a11yは、JSXの静的な構造からアクセシビリティ上の問題を見つけるESLintプラグインです。onClickだけを持つdiv、altのない画像、入力欄に結びついていないlabelといった書き方を、ルール名つきで指摘します。

指摘の数が多いプロジェクトでClaude Codeに「全部直して」と頼むと、危ない結果になりがちです。警告を黙らせるだけの修正(alt=""の一括付与など)が混ざり、利用者にとっての意味が変わるからです。この記事では、lintの出力をJSONで渡し、ルール単位で「実装で直す」「人が決める」を分けて直させ、編集のたびにPostToolUse hookで再実行する流れを組みます。

jsx-a11yが見ているのは「静的に読めるJSX」だけ

READMEの説明では、このプラグインはJSXの静的評価でアクセシビリティの問題を拾います。静的コードの誤りしか捕まえられないため、描画後のDOMの検査には@axe-core/reactとの併用が勧められています。支援技術を使った確認の代わりにもなりません。

この性質が、振り分けの前提になります。静的に読めない書き方は、lintの側が判断を誤ります。たとえば<CustomButton onClick={f} />のように、独自コンポーネントが内部でbuttonを描画する場合です。lintはCustomButtonが何を出すか知らないので、button相当として扱いません。逆に、見かけ上は問題がなくても、実行時のコントラストや読み上げ順は静的lintの対象外です。

描画後のDOMを検査する側は、PlaywrightとaxeでPRのアクセシビリティ検査を落とす手順で扱っています。この記事は、その手前の静的lintで編集中に拾える範囲に絞ります。

前提: lintが動く状態にしておく

プラグインの導入はREADME記載のとおり、ESLintと一緒に開発依存へ入れます。

npm install eslint eslint-plugin-jsx-a11y --save-dev

flat config(eslint.config.js)では、プラグインがflatConfigs.recommendedとflatConfigs.strictの2つの共有設定を提供しています。README掲載の書き方に沿うと、次の形です。

import jsxA11y from 'eslint-plugin-jsx-a11y';
 
export default [
  jsxA11y.flatConfigs.recommended,
  // ほかの設定
];

従来の.eslintrc形式ではplugin:jsx-a11y/recommendedをextendsに書きます。flat configへの移行がまだなら、先にESLintのflat config移行をClaude Codeに任せる手順を済ませると、以降の設定が1つにまとまります。

lintの出力をJSONで渡す

Claude Codeに渡す指摘は、人が読むstylish形式でなくJSONが向いています。ESLintは--format json(-f json)で結果をJSONにでき、各ファイルのmessages配列にruleId、severity、message、line、columnが入ります。

npx eslint --format json src \
  | jq -c '[.[] | select(.messages | length > 0)
    | {file: .filePath, messages: [.messages[]
    | {rule: .ruleId, line, message}]}]' \
  > a11y-report.json

jsonフォーマッターの結果は、ファイルごとのオブジェクトの配列です。メタデータ付きのjson-with-metadataを使うと、同じ結果がresultsプロパティの下に入ります。上のjqは前者を前提にしています。

ここでruleをそのまま残すのが要点です。次の節の振り分けは、ルール名を手がかりに行うからです。件数が多いときはsrc/componentsのように範囲を区切って渡すと、1回の修正で扱う変更が小さく保てます。

ルールごとに「直す」と「決める」を分ける

同じjsx-a11yの指摘でも、直し方が一意に決まるものと、画面の意図を知らないと決められないものが混ざっています。公式のルール別ドキュメントにある解決方法を読むと、次のように分かれます。

ルール公式ドキュメントの直し方扱い
label-has-associated-control公式ドキュメントの直し方inputをlabelで包む、またはhtmlForとIDで結ぶ扱い実装で直す
anchor-is-valid公式ドキュメントの直し方動作だけのaはbuttonにする。遷移なら有効なhrefを与える扱い実装で直す(遷移先は人が確認)
click-events-have-key-events公式ドキュメントの直し方onClickにonKeyUp・onKeyDown・onKeyPressのいずれかを添える扱い実装で直す(button化が先)
no-static-element-interactions公式ドキュメントの直し方要素にroleを与える。divをbuttonなどの意味を持つ要素に置き換える扱い実装で直す(役割は人が確認)
alt-text公式ドキュメントの直し方意味のある代替テキスト。装飾なら空のalt扱い人が決める
no-autofocus公式ドキュメントの直し方autoFocusを使わない、またはfalseにする扱い人が決める

最後の2行は、直した結果が利用者の体験を変えます。alt-textは、画像が何を伝えているかを知らないと文面を書けません。公式ドキュメントも、ボタン内のアイコンなど装飾的な用途はalt=""、内容を伝える画像は意味のある文という使い分けを示しています。lintを通すだけなら、全部alt=""にすれば足ります。これが警告を黙らせるだけの修正の典型です。

no-autofocusも同じです。ルールの説明には、自動フォーカスが視覚のある利用者にもない利用者にも使いにくさを生むとあります。ただ、モーダルを開いた直後に入力欄へ移すといった挙動は、画面設計の判断を含みます。消せばlintは通りますが、操作の流れが変わります。

「実装で直す」側でも、役割は人が確認する

no-static-element-interactionsの解説は、divにonClickを付けたいときの直し方を2通り示します。1つはrole="button"とtabindex、キー操作を足す方法。もう1つは、そもそもbuttonのような意味を持つ要素に置き換える方法です。

解説はさらに、roleを付けても挙動は付かないと注意しています。フォーカスできることとキー操作への対応は、実装する側の責任です。そのためroleだけを足す修正は、lintは通っても使えないボタンを残します。divをbuttonに置き換えるほうが、手数も少なく挙動も揃います。

anchor-is-validの解説も同じ方向です。href="#"やhrefなしのaにonClickだけを付ける書き方は、buttonに置き換えるよう案内しています。見た目はスタイルで寄せられるというのが公式の考え方です。この置き換えはClaude Codeに任せやすい一方、遷移なのか動作なのかをコードから読み取れないときは、人に確認を取らせます。

誤検知は直さずに設定で吸収する

指摘のうち、実装が正しいのにlintが誤る場合があります。典型は独自コンポーネントです。READMEのsettingsのjsx-a11yには、コンポーネント名と実際に描画される要素の対応を書くcomponentsがあります。

{
  settings: {
    'jsx-a11y': {
      components: {
        CustomButton: 'button',
        CityInput: 'input',
      },
    },
  },
}

READMEには、ほかにpolymorphicPropName(asなど)や、forにhtmlForも受け付けさせるattributesの設定例もあります。label-has-associated-controlが独自のLabelコンポーネントで鳴り続ける場合は、コードを曲げる前にこの対応づけを疑います。

Claude Codeには、誤検知と見えても次の順で扱わせます。

  1. まずsettingsのcomponentsで対応づけられないか調べる
  2. 対応づけで消えない場合に限り、行単位の無効化コメントを提案する(理由を1行添える)
  3. 無効化するかどうかは人が決める

ESLintはインラインのコメントで警告を無効化できますが、誤検知かどうかの判断自体を黙って済ませると、本物の問題を隠します。無効化は「Claude Codeが決めない操作」に置くのが安全です。

CLAUDE.mdに振り分けの規則を書く

上の表をそのまま、プロジェクトのCLAUDE.mdに置きます。Claude Codeは毎回これを読んでから修正に入ります。

## jsx-a11yの違反の扱い
- 対象: `npx eslint -f json` の結果のうち `jsx-a11y/` で始まる rule
- 実装で直す: label-has-associated-control, anchor-is-valid,
  click-events-have-key-events, no-static-element-interactions
  - div + onClick は button に置き換える。role だけ足して済ませない
- 人が決める: alt-text, no-autofocus
  - 文面や挙動を決めず、該当行と選択肢を報告して止まる
- 誤検知が疑われるとき: settings の components で解決できるか先に調べる
- eslint-disable は自分で入れない。入れたい場合は理由つきで提案する
- 同じ違反が2回続けて消えないときは、修正を重ねず状況を報告する

最後の1行が、次の節の自動再実行で効いてきます。直せない違反を抱えたまま修正を繰り返す動きを止めるためです。

PostToolUse hookで編集のたびに再実行する

PostToolUseはツールが正常に完了した直後に動くhookです。tool_inputに渡された引数、tool_responseに結果が入り、Edit|Writeのようにツール名で絞れます。

ファイルを書いた直後にそのファイルだけをlintし、違反があれば終了コード2で返します。公式のhooksリファレンスによると、PostToolUseのhookが終了コード2で終わるとstderrがClaudeに渡されます(ツールはすでに実行済みです)。終了コード0で終わった場合、stderrはClaudeに見えません。違反を伝えたいときに2を使う理由はここにあります。

mkdir -p .claude/hooks
touch .claude/hooks/a11y-lint.sh
chmod +x .claude/hooks/a11y-lint.sh

スクリプトは次のような形が考えられます。hookにはstdinでJSONが渡され、編集したファイルのパスはtool_input.file_pathで取れます。

#!/bin/bash
f=$(jq -r '.tool_input.file_path // empty')
case "$f" in
  *.jsx|*.tsx) ;;
  *) exit 0 ;;
esac
 
report=$(npx eslint --format json "$f" 2>/dev/null \
  | jq -c '[.[].messages[]
    | select((.ruleId // "") | startswith("jsx-a11y/"))
    | {rule: .ruleId, line, message}]')
 
if [ -n "$report" ] && [ "$report" != "[]" ]; then
  echo "jsx-a11y: $f $report" >&2
  exit 2
fi
exit 0

.claude/settings.jsonには、Edit|Writeで絞って登録します。.claude/settings.jsonはリポジトリにコミットでき、チームで共有できます。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/a11y-lint.sh",
            "timeout": 60
          }
        ]
      }
    ]
  }
}

timeoutは秒単位で、command型の既定は600秒です。編集のたびに動くので、1ファイルだけをlintする構成にして、数秒で終わる長さに保ちます。

この構成が拾えない編集

PostToolUseのEdit|Writeにマッチするhookは、Bashコマンドや、Claude Code外のプロセスが同じファイルを書き換えた場合には動きません。sed -iで一括置換させたり、フォーマッターを走らせたりした変更は、hookを通りません。ファイル単位の変更を見たいときは、公式リファレンスが挙げるFileChangedイベントが選択肢です。

そのため、作業の最後にプロジェクト全体を1回lintし直す手順を、CLAUDE.mdの末尾に添えておくと取りこぼしを減らせます。

人が決めるルールに「要人手」の印を付ける

先のスクリプトは、alt-textのような人が決めるルールも、実装で直すルールと同じように終了コード2で返します。このままだと、Claude Codeがaltの文面を勝手に書く余地が残ります。CLAUDE.mdの規則に加えて、hookのメッセージでも人に戻す違反を見分けられるようにします。

if [ -n "$report" ] ...; thenの中で、echoの直前に次の4行を入れ、echoの行を$markつきに替えます。

mark=""
if echo "$report" | grep -Eq 'jsx-a11y/(alt-text|no-autofocus)'; then
  mark="要人手(文面や挙動は決めず、該当行と選択肢を報告する): "
fi
echo "jsx-a11y: ${mark}$f $report" >&2

終了コードは変えません。印を付けるだけなので、止まるかどうかはClaudeの判断とCLAUDE.mdの規則に委ねます。hookで機械的に打ち切る処理ではありません。

--fixで済むものと済まないもの

eslint --fixで自動修正を試したくなりますが、jsx-a11yでは期待しすぎないほうがよい場面があります。READMEのルール表の凡例には、「エディターの提案で手動修正できる」を示す電球の記号があり、自動修正を示す記号は載っていません。interactive-supports-focusのように、ルールのドキュメントが「editor suggestionsで手動修正可能」と書いているものもあります。

つまり、このプラグインの修正は、Claude Codeが差分を読んで書き換える作業になります。--fixを先に走らせても害はありませんが、残った指摘は結局コードの意味を読む必要があります。

運用で気をつけること

  • 範囲を切る: src全体を1回で直させると差分が大きくなる。ディレクトリ単位で指摘を渡し、レビューしやすい大きさに保つ
  • 直した結果を見る: lintが通ることと、使えるUIになることは別。キーボードだけで操作する確認は人が行うか、実行時の検査に回す
  • 重い検査を別に置く: 型や循環依存など、編集のたびに回すと重い検査は別hookに分ける。構成の考え方はmarkdownlint-cli2を編集のたびに回す設定と同じ
  • 誤検知のラベル貼りに注意: 検査ツールの指摘を仕分けて閉じる作業では、理由を残す運用が要る。進め方はCodeQLの誤検知アラートを仕分ける手順が参考になる

まとめ

jsx-a11yの違反は、labelの関連づけやdivのbutton化のように実装だけで直るものと、altの文面やautoFocusの扱いのように画面の意図で決まるものに分かれます。JSONでruleごと渡し、CLAUDE.mdに振り分けを書き、PostToolUse hookで編集のたびに再実行すると、前者は自動で収束し、後者は人に戻ります。

lintが全部通っても、アクセシビリティが満たされたとは言えません。静的に読める範囲の検査であることを前提に、描画後の検査や実機での確認と組み合わせる構成が現実的です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. github.com/jsx-eslint/eslint-plugin-jsx-a11y
  2. code.claude.com/docs/en/hooks
  3. eslint.org/docs/latest/use/command-line-interface
  4. eslint.org/docs/latest/use/formatters
この記事を共有:XはてブLinkedIn