SvelteKitのリポジトリを10ほど管理していて、開発にはAnthropicのClaude Codeを活用している。AIコーディングアシスタントは、特にプログラミング経験者にとっては高速かつ有能だが、速いからこそ厄介な問題がある。気をつけないと、見つけにくい形で一貫して間違えるのだ。
「コードが実行しない」という問題ではない。それより .safeParse()ではなく.parse()を使う。D1のSQLに.bind()ではなくテンプレートリテラルで値を埋め込む。データベースの書き込み結果を確認しない。load関数に4段階のネストされた非同期ロジックを詰め込む。コードは動くしTypeScriptも通る。PRのdiffでも問題ないように見える。
問題は、CLAUDE.md(やその他のコーディングエージェント向けガイドファイル)に「常にsafeParse()を使え」「SQLを文字列補間するな」と書いても、それは制約ではなく提案に過ぎないということだ。AIはそれを読んで、従うこともあれば従わないこともある。忘れてもコンパイルエラーは出ない。赤い波線も表示されない。指示が非決定的なら、遵守も非決定的になる。
この記事は、遵守を決定的にした方法について書く。
原則:間違ったコードをコンパイル不能にする
発想は工学的なシステム設計から来ている。油圧工学では「バックプレッシャー」は流れを制御するために加える抵抗のことだ。バックプレッシャーがなければシステムは氾濫する。ソフトウェアでの等価物は、悪いパターンを「推奨しない」のではなく「構造的に不可能にする」ことだ。
CLAUDE.mdの「常にsafeParse()を使え」という指示は、パイプに貼った「溢れさせるな」という張り紙だ。Zodスキーマに対する.parse()をフラグするlintルールは、不正な流れを自動的に拒否する圧力弁だ。張り紙は無視できるが、弁は無視できない。
目標は、すべての「常に」と「決して」をドキュメントから機械的なもの(型、lintルール、テスト、構造パターンチェック)に移行すること。CLAUDE.mdに残すべきは文脈と意図、つまり「なぜ」であって「何を」ではない。
検証ピラミッド
3つのレイヤーがあり、それぞれ徐々に微妙な問題を検出する。すべてをチェーンしたnpm run verifyコマンドが、コード生成のたびに実行される。
%%{init: {'flowchart': {'nodeSpacing': 20, 'rankSpacing': 30, 'padding': 6, 'curve': 'basis'}}}%%
flowchart TD
L1["レイヤー1:型<br/>strict tsconfig・Zodスキーマ・判別共用体"]
L2["レイヤー2:リンター<br/>oxlint ~50ms → ESLint Svelteルール → ast-grep 構造解析"]
L3["レイヤー3:テスト<br/>vitest ユニット・Playwright E2E"]
D["npm run verify"]
L1 --> L2 --> L3 --> D
style L1 fill:#2980b9,color:#fff
style L2 fill:#3498db,color:#fff
style L3 fill:#7fb3d8,color:#fff
style D fill:#1e8449,color:#fff
レイヤー1はTypeScriptの型システム。strict: trueとnoUncheckedIndexedAccessを有効化。Zodスキーマで境界のデータを検証し、判別共用体でユーザー状態の網羅的な処理を強制する。コンパイラが勝手にやってくれるので、コストはゼロだ。
レイヤー2はリンティング。3つのパスに分かれている。CLAUDE.mdの「常に/決して」ステートメントの大半が、機械化されてここに着地する。
レイヤー3はテスト。ユーティリティのユニットテスト、D1動作のコントラクトテスト、重要フローのE2E。重要だが、この記事の焦点ではない。
なぜリンティングを3パスにするのか
各ツールには異なる強みがある。1つですべてをカバーしようとすると、パターンの見落としか速度低下のどちらかになる。
oxlintはRustベースのリンターで、約200のJavaScript/TypeScript汎用ルールを約50ミリ秒でチェックする。正確性、疑わしいパターン、パフォーマンスのアンチパターン。空港の金属探知機のようなもので、高速に明らかな問題を検出する。
ESLint + Svelteプラグインは、.svelteファイルをスクリプト・テンプレート・スタイルブロック全体として理解する。<script>で宣言した変数が{#each items}で使われていれば未使用とは判定しない。Svelte 5のルーンのセマンティクスも検証する。そしてカスタムのバックプレッシャールールをホストする。
| ルール | 検出内容 |
|---|---|
no-raw-html |
sanitizeHtml()なしの{@html expr} |
no-binding-leak |
load関数からのplatform.env.*のリターン |
no-schema-parse |
Zodスキーマの.safeParse()ではなく.parse() |
no-silent-catch |
エラーを握りつぶす空のcatch {}ブロック |
この4つのルールは、以前CLAUDE.mdにしか書かれていなかった「常に」と「決して」を正確にエンコードしている。赤い波線が出るようになった。
ast-grepは最新の追加であり、最も興味深い。tree-sitterパーサーを使い、ルール固有のビジターロジックではなく、コードの構造でマッチングする。ルールは宣言的なYAML:
id: n-plus-one-query-map
language: TypeScript
severity: warning
message: >-
Potential N+1 query: database call inside .map().
Use db.batch() or WHERE IN instead.
rule:
pattern: $ARR.map($$ARGS)
has:
pattern: $DB.prepare($$SQL)
stopBy: end
oxlintにもESLintにも表現できないパターンを検出する。配列イテレーション内にネストされたデータベースクエリだ。SvelteKitのload関数からCloudflare D1にアクセスする場面では、パフォーマンスの惨事になる。バッチクエリ1回で済むところを、アイテムごとに1往復。AIはこのパターンを頻繁に生成する。小さなデータセットでは正常に動くからだ。
ast-grepのルールセット全体は、Cloudflare Workersで最も重要なD1/SQLアンチパターンをカバーする。
| ルール | 検出内容 |
|---|---|
sql-injection-d1 |
db.prepare()内のテンプレートリテラル |
sql-injection-concat |
db.prepare()内の文字列結合 |
n-plus-one-query-* |
.map()、.forEach()、for...of内のDBコール |
unbounded-query-all |
SQLにLIMITのない.all() |
unchecked-db-run |
結果を確認しないfire-and-forgetの.run() |
empty-catch-block |
エラーの無言の握りつぶし |
どれも、oxlintとESLintの両方を何の警告もなくパスした、AI生成コード内の実際のミスに対応している。
「最新情報」問題
機械的な強制は既知のパターンに対応する。だがSvelteとCloudflareは常に新機能をリリースしている。SvelteKitだけで5.0以降50回以上のリリースがあった。AIは先週リリースされた機能を知らない。lint設定も同様だ。
そこで補完システムを構築した。各リポジトリを上流リリースと照合する「最新情報」監査だ。
%%{init: {'flowchart': {'nodeSpacing': 25, 'rankSpacing': 35, 'padding': 6, 'curve': 'basis'}}}%%
flowchart TD
A["Svelte / SvelteKit<br/>リリース"] --> B["パターンフィード<br/>svelte.cogley.jp"]
A2["Cloudflare<br/>チェンジログRSS"] --> D
B --> D{"audit-whatsnew.sh"}
D --> E["10リポジトリをスキャン"]
E -->|"レガシーパターン<br/>発見"| F["アクション可能な<br/>レポート"]
E -->|"フィード未収録の<br/>新機能"| G["パターンフィード<br/>を更新"]
G --> B
style B fill:#ff3e00,color:#fff
style D fill:#1a5276,color:#fff
style F fill:#d4ac0d,color:#000
style G fill:#27ae60,color:#fff
2つの方向で機能する。
ダウンストリーム: シェルスクリプトがSvelteKitパターンフィード(grep対応の検索シグネチャを持つJSON Feed 1.1エンドポイント、svelte.cogley.jpで公開)とCloudflareのチェンジログRSSを取得する。Cloudflareのエントリは、各リポジトリが実際に使用しているプロダクトのみにフィルタリングする(wrangler.jsoncのバインディングを読み取る)。次にソースコードからフィードのレガシーパターンを検索する。出力は具体的なレポートだ。「リポジトリxにwritable()ストアを使っているファイルが3つある。Svelte 5.29以降$stateクラスで置換可能。」
アップストリーム: 監査が最近のリリースからの機能でパターンフィードにまだないものを発見すると、「フィードギャップ」としてフラグする。監査がフィードの遅れを教えてくれるので、フィードは常に最新に保たれる。
監査は10リポジトリ全体で約10秒で完了する。シェルスクリプトであり、AIコールではない。知性はフィードのsearch_signaturesにあり、スクリプトはただgrepするだけだ。
配布:1リポジトリですべてを統治する
リント設定、ast-grepルール、監査スクリプト、Claudeコマンドとルール、GitHub Actionsワークフロー、これらすべてが1つの.githubリポジトリに集約されている。TypeScriptの同期スクリプトが10のコンシューマーリポジトリに配布する。
esolia.github(信頼の源、GitHub Orgのプロファイルリポジトリ)
├── scripts/ast-grep-rules/*.yml → scripts/shared/ast-grep-rules/
├── scripts/audit-whatsnew.sh → scripts/shared/audit-whatsnew.sh
├── .claude/shared-rules/*.md → .claude/rules/
├── .claude/shared-commands/*.md → .claude/commands/
└── .github/shared-caller-workflows → .github/workflows/
sync-all.shを1回実行すればすべてが更新される。コンシューマーリポジトリは自前のリント設定やルールファイルを管理しない。継承するだけだ。マルチリポジトリ構成で最も多い障害モードはドリフトだ。1つのリポジトリだけ修正が適用され、他は取り残される。同期がこれを防ぐ。
実際に検出したもの
このシステムのデプロイ以降、ast-grepルールだけでフラグされたもの:
- 「検索フィルタを追加して」と頼んだ際にAIが生成したload関数内のテンプレートリテラルSQLインジェクション
{#each}ブロックのサーバーサイドレンダリングで、AIが親切にアイテムごとに個別にawaitしたN+1クエリパターン- 開発環境では完璧に動作(5行)し、本番環境ではタイムアウトしたであろう(50,000行)無制限の
.all()クエリ - AIがD1コールをtry/catchで囲みながら、エラーのハンドリングを忘れた空のcatchブロック
どれもTypeScriptエラーは出なかった。oxlintにもESLintにも引っかからなかった。構造レイヤーがなければ、すべて次のgit pushで本番に出荷されていた。
残されたギャップ
このシステムは完全ではない。バックプレッシャーガイドには未実装のルールが2つある。no-raw-db-prepare(クエリヘルパーによるテナント分離の強制)とno-plain-error-throw(分類されたエラーの強制)だ。コードベースの構造変更(テナントコンテキスト型、エラー分類モジュール)が先に必要になる。
パターンは常に同じだ。まず仕組みを作り(型、ヘルパー、抽象化)、次にそれを必須にする強制を追加する。仕組みなき強制は、より煩わしい形のドキュメンテーションに過ぎない。
まとめ
AIコーディングアシスタントを複数のリポジトリで運用するなら:
CLAUDE.mdやその他のガイドファイルの「常に」「決して」を棚卸しする。 型制約、lintルール、構造チェックで表現できるなら、それを実装して散文を削除する。リンターそれぞれには異なる強みがある。 oxlintは高速だが浅い。ESLintはフレームワークを理解する。ast-grepは構造をマッチする。3つ合わせて50ms + 2s + 200msでも、人間のレビュー1回より速い。
上流リリースをプログラマティックに追跡する。 AIは知らない機能は使えない。検索シグネチャを持つ「最新情報」フィードで、「何が新しい?」を曖昧な依頼から決定的なスキャンに変える。
一元化して配布する。 10リポジトリでリント設定を個別管理すれば、ドリフトの余地が10箇所。1リポジトリで管理して同期すれば、ドリフトはゼロ。
AIはレビューが追いつかない速さでコードを書く。答えはAIを遅くすることでも、レビュアーを増やすことでもない。型システムが不正な型を拒否するのと同じように、コードベースが不正なパターンを自動的に、即座に、無感情に拒否するようにすることだ。
ここで紹介した仕組みはSvelteKit + Cloudflare Workers固有のものだが、原則(書かれた指示よりも機械的強制を優先する)はあらゆるAI支援コードベースに適用できる。Svelteのパターンフィードは公開しているので、ご自分のツールからも参照できる。
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.