AGENTS.mdに公式スキーマはない。JSONスキーマファイルも型定義も標準化されたセクション名もない。OpenAI自身のドキュメントは、このフォーマットを散文で説明している:「AGENTS.mdファイルはCodexがプロジェクトを理解するのを助けます。」それだけだ。
スキーマ強制がないのは採用の観点では問題ない——新しいフォーマットを覚えなくても誰でもAGENTS.mdを書ける。しかしAIエージェントが予期せぬ挙動をする場合にしか現れないサイレント障害のクラスを生み出す:コマンドブロックの欠落、曖昧な指示、存在しないファイルへの参照、階層構造で互いに隠蔽し合うセクション。
このガイドでは、実用的なAGENTS.mdバリデーターをゼロから構築する。設計目標はシンプルだ:構造的・内容的な問題をAIエージェントに届く前に早期に検知する。全コードはNode.js/TypeScriptで外部APIなしで動作する。
散文ドキュメントの「バリデーション」とは何か
AGENTS.mdは構造化データではない。JSONパーサーで解析してスキーマとフィールドを比較することはできない。できるのは、ドキュメント構造(見出し・コードブロック・リストアイテム)とその構造内のコンテンツに対してルールセットを適用することだ。
バリデーションには3つのレベルがある:
- 構造的: ファイルに期待されるセクションがあるか?コードブロックは正しく閉じられているか?Commandsセクションは?Code Styleセクションは?
- 意味的: コマンド参照は内部で一貫しているか?参照パスはディスク上に存在するか?同じファイル内で矛盾する指示はないか?
- 品質: 不自然に短いセクション(プレースホルダーの可能性)はないか?TODOやFIXMEはないか?使っていないと主張するツールのツール固有機能を参照していないか?
実用的なバリデーターはレベル1と2を完全に処理し、レベル3はオプションの警告として扱う。
パーサーの構築
最小限のMarkdown ASTから始める。必要なのは見出し・コードブロック・リストアイテム・段落テキストだけだ:
// src/parser.ts
export type NodeType = 'heading' | 'code' | 'list-item' | 'paragraph' | 'blank';
export interface MarkdownNode {
type: NodeType;
level?: number; // 見出しの場合: 1-6
lang?: string; // コードブロックの場合: bash, typescript など
content: string;
lineNumber: number;
}
export function parse(content: string): MarkdownNode[] {
const lines = content.split('\n');
const nodes: MarkdownNode[] = [];
let inCodeBlock = false;
let codeLang = '';
let codeLines: string[] = [];
let codeStart = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!inCodeBlock && line.startsWith('```')) {
inCodeBlock = true;
codeLang = line.slice(3).trim();
codeLines = [];
codeStart = i + 1;
continue;
}
if (inCodeBlock && line.startsWith('```')) {
nodes.push({
type: 'code',
lang: codeLang,
content: codeLines.join('\n'),
lineNumber: codeStart,
});
inCodeBlock = false;
codeLines = [];
continue;
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
nodes.push({
type: 'heading',
level: headingMatch[1].length,
content: headingMatch[2].trim(),
lineNumber: i + 1,
});
continue;
}
if (line.match(/^[-*+]\s+/) || line.match(/^\d+\.\s+/)) {
nodes.push({
type: 'list-item',
content: line.replace(/^[-*+\d.]+\s+/, '').trim(),
lineNumber: i + 1,
});
continue;
}
if (line.trim() === '') {
nodes.push({ type: 'blank', content: '', lineNumber: i + 1 });
} else {
nodes.push({ type: 'paragraph', content: line.trim(), lineNumber: i + 1 });
}
}
return nodes;
}
ルールの定義
各ルールは、パース済みノードを受け取り0個以上の診断を返す関数だ:
// src/rules.ts
import { MarkdownNode } from './parser';
import * as fs from 'fs';
import * as path from 'path';
export type Severity = 'error' | 'warning' | 'info';
export interface Diagnostic {
rule: string;
severity: Severity;
message: string;
lineNumber?: number;
}
export type Rule = (
nodes: MarkdownNode[],
filePath: string
) => Diagnostic[];
// ルール: Commandsセクションが必須
export const requireCommandsSection: Rule = (nodes) => {
const hasCommands = nodes.some(
(n) => n.type === 'heading' && /commands?/i.test(n.content)
);
if (!hasCommands) {
return [{
rule: 'require-commands-section',
severity: 'error',
message: 'AGENTS.mdにCommandsセクションがありません。ビルド・テスト・リントコマンドを含む## Commandsを追加してください。',
}];
}
return [];
};
// ルール: コードブロックが閉じられている必要がある
export const noUnclosedCodeBlocks: Rule = (nodes, filePath) => {
const content = fs.readFileSync(filePath, 'utf-8');
const backtickMatches = content.match(/^```/gm) || [];
if (backtickMatches.length % 2 !== 0) {
return [{
rule: 'no-unclosed-code-blocks',
severity: 'error',
message: `未閉じのコードブロックが検知されました。トリプルバッククォートの数: ${backtickMatches.length}(偶数が期待値)`,
}];
}
return [];
};
// ルール: 参照ファイルパスは存在すべき
export const referencedPathsShouldExist: Rule = (nodes, filePath) => {
const repoRoot = path.dirname(filePath);
const diagnostics: Diagnostic[] = [];
for (const node of nodes) {
if (node.type !== 'list-item' && node.type !== 'paragraph') continue;
// バッククォートで囲まれたパスらしき文字列を抽出
const pathMatches = node.content.match(/`([^`]*\/[^`]*)`/g) || [];
for (const match of pathMatches) {
const p = match.slice(1, -1);
if (p.includes('$') || p.includes('*') || p.includes(' ')) continue;
if (p.startsWith('http')) continue;
const fullPath = path.resolve(repoRoot, p);
if (!fs.existsSync(fullPath)) {
diagnostics.push({
rule: 'referenced-paths-should-exist',
severity: 'warning',
message: `参照パスが存在しません: \`${p}\``,
lineNumber: node.lineNumber,
});
}
}
}
return diagnostics;
};
// ルール: Commandsセクションにはバッククォートで書かれたコマンドが必要
export const commandsSectionHasCommands: Rule = (nodes) => {
let inCommandsSection = false;
let commandCount = 0;
for (const node of nodes) {
if (node.type === 'heading') {
inCommandsSection = /commands?/i.test(node.content);
}
if (inCommandsSection && node.type === 'list-item') {
if (node.content.includes('`')) commandCount++;
}
}
if (commandCount === 0) {
return [{
rule: 'commands-section-has-commands',
severity: 'error',
message: 'Commandsセクションはありますがバッククォートで囲まれたコマンドがありません。コマンドをバッククォートで囲んでください: `pnpm test`',
}];
}
return [];
};
// ルール: TODO/FIXMEの警告
export const noTodoFixme: Rule = (nodes) => {
const diagnostics: Diagnostic[] = [];
for (const node of nodes) {
if (/\b(TODO|FIXME)\b/i.test(node.content)) {
diagnostics.push({
rule: 'no-todo-fixme',
severity: 'warning',
message: `${node.lineNumber}行目にTODO/FIXMEがあります——エージェントが読む前に解決または削除してください。`,
lineNumber: node.lineNumber,
});
}
}
return diagnostics;
};
// ルール: 内容密度チェック(3項目以上のセクションがない場合は警告)
export const contentDensityCheck: Rule = (nodes) => {
let itemsInSection = 0;
let hasSubstantialSection = false;
for (const node of nodes) {
if (node.type === 'heading' && node.level && node.level <= 2) {
if (itemsInSection >= 3) hasSubstantialSection = true;
itemsInSection = 0;
}
if (node.type === 'list-item') itemsInSection++;
}
if (itemsInSection >= 3) hasSubstantialSection = true;
if (!hasSubstantialSection) {
return [{
rule: 'content-density-check',
severity: 'warning',
message: 'AGENTS.mdの内容が薄そうです。3項目以上のリストを持つセクションがありません。より具体的な指示を追加することを検討してください。',
}];
}
return [];
};
export const DEFAULT_RULES: Rule[] = [
requireCommandsSection,
noUnclosedCodeBlocks,
referencedPathsShouldExist,
commandsSectionHasCommands,
noTodoFixme,
contentDensityCheck,
];
バリデーターCLI
// src/cli.ts
import { parse } from './parser';
import { DEFAULT_RULES, Diagnostic } from './rules';
import * as fs from 'fs';
import * as path from 'path';
function validate(filePath: string): Diagnostic[] {
const content = fs.readFileSync(filePath, 'utf-8');
const nodes = parse(content);
const diagnostics: Diagnostic[] = [];
for (const rule of DEFAULT_RULES) {
diagnostics.push(...rule(nodes, filePath));
}
return diagnostics;
}
function main() {
const args = process.argv.slice(2);
const targetPath = args[0] || 'AGENTS.md';
const filePath = path.resolve(process.cwd(), targetPath);
if (!fs.existsSync(filePath)) {
console.error(`ファイルが見つかりません: ${filePath}`);
process.exit(1);
}
const diagnostics = validate(filePath);
if (diagnostics.length === 0) {
console.log(`✓ ${path.basename(filePath)}: 問題なし`);
process.exit(0);
}
const errors = diagnostics.filter((d) => d.severity === 'error');
const warnings = diagnostics.filter((d) => d.severity === 'warning');
for (const d of diagnostics) {
const loc = d.lineNumber ? `:${d.lineNumber}` : '';
const prefix = d.severity === 'error' ? 'error' : 'warn';
console.log(`${filePath}${loc}: ${prefix}: [${d.rule}] ${d.message}`);
}
console.log(`\n${errors.length}件のエラー、${warnings.length}件の警告`);
process.exit(errors.length > 0 ? 1 : 0);
}
main();
package.jsonに組み込む:
{
"scripts": {
"validate:agents": "tsx src/cli.ts",
"validate:agents:all": "find . -name 'AGENTS.md' -not -path '*/node_modules/*' | xargs -I{} tsx src/cli.ts {}"
}
}
カスタムルールの追加
ルールインターフェースは意図的にシンプルだ。コアに触れずにドメイン固有のルールを追加できる:
// カスタムルール: 保護パスを列挙したBoundariesセクションを要求
const requireBoundariesSection: Rule = (nodes) => {
const hasBoundaries = nodes.some(
(n) => n.type === 'heading' && /boundar(y|ies)/i.test(n.content)
);
if (!hasBoundaries) {
return [{
rule: 'require-boundaries-section',
severity: 'warning',
message: 'エージェントが変更してはならないディレクトリを列挙する## Boundariesセクションの追加を検討してください。',
}];
}
return [];
};
// カスタムルール: パッケージマネージャーの混在を検知
const noPackageManagerMixing: Rule = (nodes) => {
const content = nodes.map(n => n.content).join('\n');
const usesNpm = /\bnpm (install|run|exec)\b/.test(content);
const usesPnpm = /\bpnpm\b/.test(content);
const usesYarn = /\byarn\b/.test(content);
const managers = [usesNpm && 'npm', usesPnpm && 'pnpm', usesYarn && 'yarn'].filter(Boolean);
if (managers.length > 1) {
return [{
rule: 'no-package-manager-mixing',
severity: 'warning',
message: `複数のパッケージマネージャーが参照されています: ${managers.join(', ')}。AIエージェントが誤ったものを使う可能性があります。`,
}];
}
return [];
};
モノレポ全体での検証実行
#!/bin/bash
# scripts/validate-all-agents-md.sh
ERRORS=0
WARNINGS=0
while IFS= read -r -d '' file; do
echo "チェック中: $file"
output=$(node dist/cli.js "$file" 2>&1)
exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "$output"
ERRORS=$((ERRORS + 1))
elif echo "$output" | grep -q "warn:"; then
echo "$output"
WARNINGS=$((WARNINGS + 1))
fi
done < <(find . -name "AGENTS.md" -not -path "*/node_modules/*" -print0)
echo ""
echo "結果: エラーあり $ERRORS ファイル、警告あり $WARNINGS ファイル"
[ $ERRORS -eq 0 ]
様々な年齢のAGENTS.mdファイルを持つ15パッケージのモノレポでこれを実行した実測結果:3ファイルにコピーペーストミスによる未閉じコードブロック、7ファイルに移動・リネームされたパスへの参照、2ファイルに初期セットアップからのTODOマーカー。どれもCIの失敗を引き起こさなかった——エージェントが予期しない出力を生成するまで見えなかった。
このバリデーターで検知できないこと
バリデーターは構造と機械的な正確さを担当する。指示が明確かどうか、微妙な意味的な方法で互いに矛盾していないか、AIエージェントが実際に従うかどうかは判定しない。それにはエージェントを実行して動作を観察する必要がある——これがAGENTS.mdファイルの品質保証のもう一方の半分だ。