こんにちは。Simploop開発チームです。
私たちは、40歳以上の方のための出会いと交流のサービス「トモリエ」を開発・運営しています。今回は、AIとともに開発を進めるために整えている仕組みについて紹介します。
トモリエの開発対象は、Web、モバイルアプリ、API、管理画面などにまたがります。同じ機能の名前を揃えることも、既存のコードを改善することも、変更した箇所だけを見ていれば済むとは限りません。
そこで私たちは、AIが判断するための情報と、その変更を検証する仕組みを整備しています。本記事では、この取り組みをハーネスエンジニアリングとして紹介します。
AIに伝えることと変更を確かめること
AIと開発するうえで、仕様やルールを参照できる状態にしておくことは欠かせません。ただし、守ってほしいルールを書くだけでは、変更の結果がルールに沿っているかまでは確認できません。
例えば、Webとアプリで機能名を統一する場合です。共通の定数を使うように指示しても、新しい画面に文字列を直接書けば、そこだけ共通定義から外れてしまいます。
トモリエでは、こうした問題に対して、次の二つを組み合わせています。
- 仕様や判断の根拠を、人とAIが参照できる形で残す
- 検査できるルールをコードにし、変更時に確認する
これらは、人が実装するときにも役立つ仕組みです。誰が変更しても、同じ基準で確かめられることを重視しています。
以下では、TypeScriptとGitHub Actionsを扱う開発者に向けて、検査の実装と設計上の判断を掘り下げます。コード例は実装の要点を説明用に簡略化したもので、実際の検査では対象ファイルの収集、例外処理、診断の表示も行っています。
実践1 仕様と判断の根拠を参照できる形で残す
トモリエのリポジトリには、AGENTS.md や CLAUDE.md、領域ごとのドキュメントを置いています。
そこには手順や禁止事項に加え、その判断が必要になった理由や、詳細を確認すべき参照先も記載しています。背景が分からないルールは、変更の際に必要性を判断しづらくなります。理由が残っていれば、今もその条件が当てはまるかを検討できます。
一方で、すべての情報を一つのファイルに追加し続けると、読む量が増え、更新も難しくなります。そこで、条件付きのルールを別ファイルに分ける整理も行っています。
さらに、ルールファイルを対象にした静的検査を用意しました。例えば、次のような状態を検知します。
- ルールの適用先を示すパターンが、実在するファイルに一つも一致しない
- 設定の書式が壊れている
- 参照一覧と、実在するルールファイルが一致していない
- ルールファイルがGitの無視対象になっている
適用先のパターンが生きているかを調べる
ルールを分割すると、本文だけでなく「どのファイルに適用するか」という設定も保守対象になります。私たちの検査では、git ls-files で追跡対象のファイルを取得し、ルールファイルの paths に書いたglobと照合しています。
例えばディレクトリを移動したとき、古いパスを指すルールが残っていても、Markdown自体は問題なく読めます。そこで、一つのファイルにも一致しないパターンを dead_glob として報告します。対象が空になった設定を、正常な設定として扱わないためです。
追跡済みのファイルでも無視設定を検査する
もう一つの工夫は、Gitの無視設定の確認です。次のコマンドを使っています。
git check-ignore --no-index -q -- .claude/rules/example.md
ここで example.md は検査対象のルールファイルを表す例です。--no-index を指定することで、追跡済みのファイルについても無視設定を評価します。通常の git check-ignore では追跡済みファイルが対象から外れるため、コミット後に .gitignore を変更したケースを見落とし得ます。Gitのコマンド仕様に沿って、終了コードも区別しています。
0:無視設定に一致するため、違反として扱う1:無視設定に一致しないため、この検査は通過する- それ以外:コマンドの実行異常として扱う
「非ゼロなら無視されていない」とまとめると、コマンド自体が失敗した場合も正常と判断してしまいます。検査できなかった状態を成功に変換しない、という方針です。
この検査が確かめるのはファイルや設定の整合性です。AIが実際にルールを読み、理解して従ったことまで保証するものではありません。
実践2 Webとアプリでサービスの言葉を揃える
ユーザーにとって、機能名は操作の手がかりになります。同じ機能がWebとアプリで別の名前になっていれば、迷う原因になります。
トモリエでは、「つぶやき」「掲示板」などの機能名を共通定義に集約しています。
ここで考えたのは、集約した後の変更をどう守るかでした。既存の箇所を共通定義に置き換えても、新しい実装に同じ文字列が直接書かれれば、再び独立したコピーが生まれます。
そのため、対象の機能名が直接書かれていないかを検査する専用のlintを用意しました。lintは、コードの書き方を静的に調べる仕組みです。
この検査ではTypeScriptの構文解析を使い、文字列リテラルやJSXのテキストを確認しています。単純な文字検索では、機能名について説明したコメントまで違反として拾ってしまうためです。
また、機能名との完全一致を対象にしています。「つぶやき」というラベルと、「つぶやきを投稿」という文章では役割が異なります。検知したい書き方を絞ることで、不要な指摘が増えるのを避けています。
ASTで検出対象を絞り込む
TypeScript Compiler APIでソースコードをAST(抽象構文木)に変換し、文字列リテラルとJSXテキストのノードをたどります。以下は、その中心部分を取り出した例です。
import ts from 'typescript';
interface Violation {
line: number;
label: string;
}
export function findLabels(sourceText: string): Violation[] {
const source = ts.createSourceFile(
'example.tsx', sourceText,
ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX,
);
const labels = new Set(['つぶやき', '掲示板']);
const violations: Violation[] = [];
function visit(node: ts.Node): void {
if (ts.isStringLiteralLike(node) || ts.isJsxText(node)) {
const label = node.text.trim();
if (labels.has(label)) {
const { line } = source.getLineAndCharacterOfPosition(
node.getStart(source),
);
violations.push({ line: line + 1, label });
}
}
ts.forEachChild(node, visit);
}
visit(source);
return violations;
}
ts.forEachChild による再帰走査は、TypeScript公式のCompiler API解説でも紹介されている方法です。ここでは型情報を必要としないため、プロジェクト全体の型検査を組み立てず、ファイルごとに構文を確認しています。
この例では、<span>掲示板</span> や title="掲示板" を検出します。一方、コメント中の「掲示板」や、<span>掲示板に投稿する</span> は検出しません。前者は対象のノードではなく、後者は完全一致しないためです。
実装では、対象語を一つも含まないファイルはASTを生成する前に除外しています。文字列検索は候補を絞るために使い、違反かどうかの判定は構文に基づいて行う、という役割分担です。
また、共通定義のファイル自体は直書き検査から外します。その代わり、定数名と値の対応を別に検査しています。「つぶやき」と「掲示板」の両方が存在していても、割り当てが逆なら問題だからです。
例外リストにも寿命を持たせる
同じ語を別の意味で使う場合に備え、例外はファイル全体ではなく、file・line・constantName の組み合わせで指定する設計にしています。例外には理由も記録します。
ファイル名と行番号だけでは、その行が別の機能名に変わっても例外が適用され続けます。定数名も含めることで、同じ位置にある別の違反を区別します。
さらに、検出結果に対応しなくなった例外は staleAllowances として報告します。例外で違反を除外する処理と、古い例外を検出する処理には同じ照合関数を使っています。二つの処理で条件がずれると、同じ項目を「例外として許可するが、古い例外でもある」と判定しかねないためです。
行番号を使う方式では、コードの移動に応じて例外の見直しが必要になります。この保守負担を受け入れ、例外が無関係なコードに適用され続けることを防ぐ設計です。
確認できるのは、対象範囲にある、定義した語句の直書きです。言い換えや表記揺れなど、サービス内の言葉の問題をすべて自動で見つけられるわけではありません。
文字列の結合や、式を含むテンプレートリテラルを評価して最終的な表示内容を求める処理も行っていません。型検査やブラウザでの表示確認を置き換えるものではなく、起こりやすい直書きの再発を狙った検査です。
実践3 改善済みの範囲を後戻りさせない
既存コードの改善は、一度ですべてを終えられるとは限りません。
例えば、ログの出力方法を揃える場合です。トモリエのAPIでは、構造化ログへ移行済みの一部のファイルを対象に、直接の console.* 呼び出しを禁止する検査を設けています。
改善が済んだ範囲から、その状態を維持する。このような仕組みを「ラチェット」として使っています。
ただし、検査対象のファイルを一覧から削除すれば、プロダクトのコードを変更しなくても検出能力を落とせてしまいます。
そこで、対象ファイルの集合を照合するテストも用意しています。対象が減った場合に気づけるようにするためです。専用のlint設定や、警告を許容しない実行オプションも確認しています。
違反件数だけでなく対象の集合を固定する
対象数だけを確認すると、一つのファイルを外して別のファイルを追加した場合に気づけません。そこで、package.json の検査コマンドに列挙されたパスを取り出し、期待する集合と完全一致するかを確かめています。
以下は、実際のテストと同じ照合方法を使った例です。ファイル名は説明用に置き換えています。
import { expect, test } from 'bun:test';
const expectedFiles = ['src/routes/example.ts'];
const lintCommand =
'eslint --no-eslintrc --config .eslintrc.no-console.json ' +
'--max-warnings=0 src/routes/example.ts';
test('検査対象と実行条件を維持する', () => {
// 実際にはpackage.jsonのスクリプトを読み込む
const listedFiles = lintCommand.split(/\s+/).filter(
(token) => token.startsWith('src/') && token.endsWith('.ts'),
);
expect(listedFiles.sort()).toEqual([...expectedFiles].sort());
expect(lintCommand).toContain('--max-warnings=0');
expect(lintCommand).toContain('--no-eslintrc');
expect(lintCommand).toContain('.eslintrc.no-console.json');
});
対象を増やす変更でも期待値の更新は必要です。その差分によって、どこまで移行を終え、どこを今後も守るかをレビューできます。
専用設定では no-console を error にしています。既存の緩い設定を継承したり、失敗を許容するCIステップに載せたりすると、禁止したつもりのルールが検査失敗につながらないためです。ここで示したオプションは、トモリエで使っている従来のESLint設定方式に対応したもので、別の設定方式へ移す際は実行条件も見直す必要があります。
テストが失敗したときには、期待値を現在の状態に合わせるだけでは不十分です。対象を追加したのか、それとも守っていた範囲を外したのか。差分の意味を確認する必要があります。
実践4 必要なCIが走る条件も検査する
検査を作っていても、必要な変更で実行されなければ役に立ちません。
トモリエでは、別の作業ブランチを取り込み先とするプルリクエストも扱います。その際、CIの起動対象を特定の取り込み先ブランチ名に限定していると、条件に合わないプルリクエストで検査が起動しない問題がありました。
この経験から、プルリクエスト向けのワークフローにブランチ名による制限が再び入らないよう、設定を静的に検査しています。ジョブやステップの条件式に、同様の制限が書かれていないかも確認します。
YAMLの構造を調べて条件の移動も検知する
ワークフロー全体を文字列検索すると、コメント中の説明まで拾ってしまいます。私たちの検査ではYAMLを解析し、pull_request.branches や branches-ignore、if の文字列値を調べています。
branches を削除しても、ジョブの if に取り込み先ブランチの条件を書けば、同じ制限を再現できてしまいます。そのため、if を再帰的に収集し、ジョブとステップの両方を対象にしています。処理をcomposite actionへ移すケースに備え、.github/actions/ 配下も検査します。
YAMLの読み込み時にはマージキーを解決します。アンカーとエイリアスを通じて設定を共有している場合も、展開後の構造を確認するためです。解析できないファイルは、違反ゼロとして通しません。
これはトモリエで採用しているルールです。特定ブランチへのpushで動くデプロイなどには正当な用途があるため、push.branches は禁止していません。また、シェルスクリプトの中で環境変数から取り込み先を判定するような書き方は、この検査の対象外です。
テストの依存ファイルと起動条件を対応させる
変更ファイルのパスによる起動条件にも注意が必要です。
例えば、あるテストが共通定義のファイルを読み込むなら、その共通定義だけを変更したプルリクエストでもテストが走る必要があります。テスト本体の置き場所だけを起動条件にしていると、依存先の変更を見逃します。
例えば、次は考え方を示すために簡略化した起動条件です。
on:
pull_request:
paths:
- 'scripts/**'
- 'packages/shared/src/constants/**'
- '.github/workflows/**'
- '.github/actions/**'
検査スクリプトを変更したときに加え、その入力である共通定義や、検査を実行する設定を変更したときにも起動させます。これは一部分の例であり、実際には各テストが読み込むファイルに合わせて対象を設定しています。
さらに一部のテストでは、ソースから導出した依存ファイルを、ワークフローの paths にあるglobと照合しています。push と pull_request の両方を確認し、片方だけを更新したケースも検知する構成です。
チェックが成功したかと同時に、必要なチェックが実行される構成になっているかを確かめる取り組みです。ただし、静的検査で扱っていない書き方や、実行時の挙動まで網羅できるものではありません。
検査できなかった状態を成功にしない
検査の設計では、違反を見つけるロジックと同じくらい、入力を正しく読めたかが重要です。
ファイル走査であらゆる例外を握り潰すと、権限エラーで一件も読めなかった場合も「違反なし」になってしまいます。共通の走査処理では、存在しないパスなど一部のエラーだけをスキップし、それ以外は失敗として伝えるようにしています。
また、文言のlintでは、CLIが成功を報告する前に走査件数の下限を確認します。パスの変更によって検査対象がほぼ消えた状態を、正常と報告しないためです。ただし、合計件数の下限だけでは、一部のディレクトリが抜けたことまでは保証できません。対象集合の完全性とは別の確認として扱っています。
検査自体のテストにも、違反がある入力とない入力を両方用意しています。機能名のlintなら、直接書いたラベルは検出すること、コメントや複合語は検出しないこと、別の定数に対する例外は適用しないことなどです。「現在のリポジトリで違反がゼロ」だけでは、検出器が何も返さなくなった場合も通ってしまいます。
検査する仕組みも保守する
専用の検査を作ると、その検査自体にも保守が必要になります。
実際、文言を検査する複数のlintに似たファイル走査処理があり、シンボリックリンクの扱いに差が生じていました。そこで、共通化できる走査処理を切り出しています。
検査する内容が違う部分は個別に残し、同じ責務の部分を共通化する。通常のプロダクトコードと同じように、検査コードも見直す必要があります。
また、CIで失敗が表示されることと、マージが禁止されることは別です。必須チェックに設定されていない検査では、結果を見て判断する責任がレビュアーに残ります。
検査の存在だけで安心せず、何を検知できるのか、実行される条件は何か、失敗するとどうなるのかまで把握することが大切です。
利用者の声に応えて開発を続けるために
今回紹介した仕組みは、それぞれ具体的な問題を検知するためのものです。今後は、検査が問題を見つけたケースだけでなく、不要な指摘が出たケースや、保守の負担も確認しながら改善していきたいと考えています。
利用者の声に応えてサービスを改善していくには、変更を続けられる開発環境が必要です。私たちは、仕様や判断の根拠を残し、検査できることを少しずつ仕組みに変えながら、その環境を整えています。
AIとともに開発を進める中で得た経験を、これからもSimploopの実践として発信していきます。