はじめに
前任者から引き継いだプロジェクトで、ドキュメントのAPI一覧を信じて作業したら、そのエンドポイントはもう消えていた。そういう経験がある方に向けた記事です。
API一覧とテーブル定義について、コードとのズレをプルリクエストのたびにCIで検出し、ズレがあったときだけLLMにドキュメントの修正案を書かせる構成を、動くサンプルで紹介します。最後に、導入するときのチェックリストを付けています。設定だけ知りたい方は「3.」と「導入チェックリスト」だけ拾ってもらえれば十分です。
先に結論を書くと、この仕組みが効くのは、コード側に機械が読める「正」がある部分だけです。READMEのセットアップ手順や設計の意図は対象外です。その線引きは次の節で書きます。
前提条件 この仕組みが効く範囲
次の3つがそろっていることを前提にしています。
- API: OpenAPIをコードから生成できる(FastAPI、springdoc-openapi、NestJSのSwaggerModuleなど)。手書きのOpenAPIしかない場合は、それとコードのズレは別の問題として残ります
- DB: マイグレーションを空のDBに当てれば、今のスキーマが再現できる
- ドキュメント: API一覧とテーブル定義が、列の決まったMarkdownの表で書かれている
Excelのテーブル定義書で管理している場合は、まずMarkdownかCSVに書き出す仕組みが要ります。ここが一番重いので、導入前に確認してください。
READMEや設計の意図は、コードから「正」を取り出せないので対象にしていません。Zennの記事にも書きましたが、READMEの修正案をLLMに書かせると、変数が増えたことには気づいても、なぜ増えたのかまでは書けず、表面的な案にとどまりました。
環境
macOS、Python 3.12.13、FastAPI 0.142.2、anthropic(Python SDK)1.11.0、PostgreSQL 16(Docker)、actionlint 1.7.12。2026年10月7日に手元で動作を確認しました。GitHub Actions上のYAMLはactionlintで検査済みですが、記事の結果はローカルで同じ手順を実行したものです。
この記事をざっくり図解
サンプルの構成
app/main.py # FastAPIのアプリ(OpenAPIの元)
migrations/001_create_users.sql
migrations/002_add_display_name.sql
docs/api.md # API一覧
docs/db/users.md # テーブル定義(テーブルごとに1ファイル)
scripts/export_openapi.py
scripts/export_schema.sql
scripts/check_doc_drift.py
scripts/draft_doc_fix.py
.github/workflows/doc-drift.yml
わざとズレを3つ入れてあります。コードには GET /users/{user_id}/invoices があるのにAPI一覧にない。API一覧には DELETE /users/{user_id} があるのにコードにはない。マイグレーションで display_name 列を足したのに、テーブル定義に書かれていない。
ドキュメントは、たとえば次の形です。列の順番を決めておくことが、機械的に比べるための条件になります。
# users テーブル定義
| カラム | 型 | NULL許可 | 説明 |
|---|---|---|---|
| id | integer | × | 主キー |
| email | text | × | ログインに使うメールアドレス |
| created_at | timestamp with time zone | × | 登録日時 |
1. ズレの判定は、LLMではなくスクリプトでやる
Zennの記事では、ズレの判定と修正案の両方をLLMに出させる形を書きました。このサンプルでは、API一覧とテーブル定義については判定をスクリプトに任せ、LLMには修正案だけを書かせています。
理由は2つです。一つは、CIの合否が実行のたびに変わると困ることです。同じコードで、通ったり落ちたりするチェックは、すぐに誰も見なくなります。もう一つは費用で、ズレがないプルリクエストでは、LLMを1回も呼ばずに済みます。API一覧とテーブル定義は構造が決まっているので、集合の差を取るだけで判定できます。
コード側の情報は、OpenAPIとPostgreSQLの information_schema から取り出します。
import json
import sys
sys.path.insert(0, ".")
from app.main import app # noqa: E402
json.dump(app.openapi(), sys.stdout, ensure_ascii=False, indent=2)
SELECT json_agg(
json_build_object(
'table', table_name,
'column', column_name,
'type', data_type,
'nullable', is_nullable = 'YES'
) ORDER BY table_name, ordinal_position
)
FROM information_schema.columns
WHERE table_schema = 'public';
突き合わせるスクリプトです。
"""コード側の定義(OpenAPI / DBスキーマ)と、Markdownのドキュメントのズレを検出する。
使い方:
python scripts/check_doc_drift.py build/openapi.json build/schema.json --report build/drift.json
ズレがあれば終了コード1を返す。LLMは使わない(判定は毎回同じ結果になる)。
"""
import argparse
import json
import pathlib
import sys
API_DOC = pathlib.Path("docs/api.md")
DB_DOC_DIR = pathlib.Path("docs/db")
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
IGNORE_TABLES = {"schema_migrations", "alembic_version"}
# ドキュメントで使ってよい型の別名。information_schema の data_type に寄せる
TYPE_ALIASES = {
"int": "integer",
"varchar": "character varying",
"timestamptz": "timestamp with time zone",
"bool": "boolean",
}
NULLABLE_MARKS = {"○", "yes", "y", "true"}
def read_md_table(path: pathlib.Path) -> list[list[str]]:
"""Markdownの最初の表を、見出し行と区切り行を除いた行のリストで返す。"""
rows = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line.startswith("|"):
if rows:
break # 最初の表だけ読む
continue
cells = [c.strip() for c in line.strip("|").split("|")]
rows.append(cells)
return rows[2:] # 見出し行と |---| 行を飛ばす
def check_api(openapi: dict) -> dict:
in_code = {
(method.upper(), path)
for path, ops in openapi.get("paths", {}).items()
for method in ops
if method in HTTP_METHODS
}
in_doc = {(r[0].upper(), r[1]) for r in read_md_table(API_DOC)}
return {
"missing_in_doc": sorted(f"{m} {p}" for m, p in in_code - in_doc),
"missing_in_code": sorted(f"{m} {p}" for m, p in in_doc - in_code),
}
def check_db(columns: list[dict]) -> list[dict]:
drifts = []
tables: dict[str, dict[str, dict]] = {}
for c in columns:
if c["table"] not in IGNORE_TABLES:
tables.setdefault(c["table"], {})[c["column"]] = c
for table, code_cols in sorted(tables.items()):
doc_path = DB_DOC_DIR / f"{table}.md"
if not doc_path.exists():
drifts.append({"table": table, "kind": "doc_file_missing", "doc": str(doc_path)})
continue
doc_cols = {}
for r in read_md_table(doc_path):
name, typ, nullable = r[0], r[1].lower(), r[2].lower()
doc_cols[name] = {
"type": TYPE_ALIASES.get(typ, typ),
"nullable": nullable in NULLABLE_MARKS,
}
for name in code_cols.keys() - doc_cols.keys():
drifts.append({"table": table, "kind": "missing_in_doc", "column": name,
"code": code_cols[name]})
for name in doc_cols.keys() - code_cols.keys():
drifts.append({"table": table, "kind": "missing_in_code", "column": name})
for name in code_cols.keys() & doc_cols.keys():
code, doc = code_cols[name], doc_cols[name]
if code["type"] != doc["type"]:
drifts.append({"table": table, "kind": "type_mismatch", "column": name,
"code": code["type"], "doc": doc["type"]})
if code["nullable"] != doc["nullable"]:
drifts.append({"table": table, "kind": "nullable_mismatch", "column": name,
"code": code["nullable"], "doc": doc["nullable"]})
return drifts
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("openapi")
parser.add_argument("schema")
parser.add_argument("--report", required=True)
args = parser.parse_args()
openapi = json.loads(pathlib.Path(args.openapi).read_text(encoding="utf-8"))
columns = json.loads(pathlib.Path(args.schema).read_text(encoding="utf-8")) or []
report = {"api": check_api(openapi), "db": check_db(columns)}
pathlib.Path(args.report).write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
has_drift = bool(report["api"]["missing_in_doc"] or report["api"]["missing_in_code"]
or report["db"])
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1 if has_drift else 0
if __name__ == "__main__":
sys.exit(main())
型の表記はチームごとに揺れるので、TYPE_ALIASES で information_schema の表記に寄せています。NULL許可の欄は、日本のテーブル定義書でよく使う「○」と「×」を受け付けるようにしました。
2. ズレがあったときだけ、LLMに修正案を書かせる
"""検出したズレをもとに、ドキュメントの修正案をLLMに書かせ、PRコメント用のMarkdownを出力する。
CIの合否はこのスクリプトでは決めない。APIキーがない、入力が大きすぎる、
応答が途中で止まった、といった場合は、ズレの一覧だけを出力して終わる。
"""
import json
import os
import pathlib
import sys
import anthropic
MODEL = os.environ.get("DOC_SYNC_MODEL", "claude-opus-5-5")
MAX_INPUT_CHARS = 30_000 # これを超えたらLLMに渡さない(黙って切り詰めない)
SYSTEM = """あなたは設計ドキュメントの更新案を書く担当です。
入力は、機械的に検出したズレの一覧(JSON)と、現在のドキュメントです。
ズレを解消するための、Markdownの表の行の追加・変更・削除案だけを出してください。
・ズレの一覧にない箇所は変更しないこと
・説明欄など、コードから判断できない内容は推測せず「(要記入)」と書くこと
・ファイルごとに、変更後の表をそのまま貼れる形で示すこと"""
def summarize(report: dict) -> str:
lines = []
for e in report["api"]["missing_in_doc"]:
lines.append(f"- API: ドキュメントにない `{e}`")
for e in report["api"]["missing_in_code"]:
lines.append(f"- API: コードにない `{e}`(削除済み?)")
for d in report["db"]:
col = f".{d['column']}" if "column" in d else ""
lines.append(f"- DB: `{d['table']}{col}` {d['kind']}")
return "\n".join(lines)
def related_docs(report: dict) -> dict[str, str]:
paths = set()
if report["api"]["missing_in_doc"] or report["api"]["missing_in_code"]:
paths.add("docs/api.md")
for d in report["db"]:
paths.add(f"docs/db/{d['table']}.md")
return {p: pathlib.Path(p).read_text(encoding="utf-8")
for p in sorted(paths) if pathlib.Path(p).exists()}
def draft(report: dict, docs: dict[str, str]) -> str | None:
if not os.environ.get("ANTHROPIC_API_KEY"):
return None # フォークからのPRなど、シークレットが渡らない場合
body = "## ズレの一覧\n" + json.dumps(report, ensure_ascii=False, indent=2)
for path, text in docs.items():
body += f"\n\n## 現在のドキュメント: {path}\n{text}"
if len(body) > MAX_INPUT_CHARS:
return None
client = anthropic.Anthropic()
resp = client.messages.create(
model=MODEL,
max_tokens=4000,
output_config={"effort": "low"},
system=SYSTEM,
messages=[{"role": "user", "content": body}],
)
if resp.stop_reason != "end_turn":
return None # max_tokens や refusal で止まった案は出さない
return "".join(b.text for b in resp.content if b.type == "text")
def main() -> None:
report = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
out = ["### ドキュメントとコードのズレ(自動検出)", "", summarize(report), ""]
try:
suggestion = draft(report, related_docs(report))
except anthropic.APIError as e:
suggestion = None
print(f"LLM呼び出しに失敗: {e}", file=sys.stderr)
if suggestion:
out += ["<details><summary>修正案(LLMが作成。採用前に必ず確認してください)</summary>",
"", suggestion, "", "</details>"]
else:
out.append("修正案は作成していません。上の一覧をもとに、ドキュメントを更新してください。")
print("\n".join(out))
if __name__ == "__main__":
main()
書いていて気をつけたのは、次の点です。
説明欄を推測させない。 新しい列の「説明」は、コードからは分かりません。何も指示しないと、列名から説明文をそれらしく作ってしまいます。それがドキュメントに残ると、あとから来た人には推測か事実か区別できません。だから「(要記入)」と書かせて、人が埋める箇所として残します。
入力を黙って切り詰めない。 長すぎるときは、途中で切ってLLMに渡すのではなく、修正案を作らずにズレの一覧だけを出します。半分だけ読んだ修正案は、ないほうがましだと考えました。
途中で止まった応答は使わない。 stop_reason が end_turn 以外(出力上限に達した、など)のときは、修正案を捨てています。
モデルは環境変数で差し替える。 表の行を書き足すだけの作業なので、effort は low にしています。
3. GitHub Actionsに組み込む
name: doc-drift
on:
pull_request:
paths:
- "app/**"
- "migrations/**"
- "docs/api.md"
- "docs/db/**"
permissions:
contents: read
pull-requests: write
jobs:
drift:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: OpenAPIを書き出す
run: |
mkdir -p build
python scripts/export_openapi.py > build/openapi.json
- name: マイグレーションを当てて、スキーマを書き出す
run: |
for f in migrations/*.sql; do
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f "$f"
done
psql "$DATABASE_URL" -At -f scripts/export_schema.sql > build/schema.json
- name: ズレを検出する
id: drift
continue-on-error: true
run: python scripts/check_doc_drift.py build/openapi.json build/schema.json --report build/drift.json
- name: 修正案を作ってPRにコメントする
if: steps.drift.outcome == 'failure'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GH_TOKEN: ${{ github.token }}
run: |
python scripts/draft_doc_fix.py build/drift.json > build/comment.md
gh pr comment "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --body-file build/comment.md
- name: ズレがあればジョブを失敗にする
if: steps.drift.outcome == 'failure'
run: exit 1
continue-on-error: true を付けたステップは、失敗しても steps.drift.outcome が failure になるだけで、後続のステップは動きます。コメントを付けたあと、最後のステップで改めてジョブを失敗にしています。これで「ズレがある間はマージできない」と「何がずれているかはコメントで分かる」を両立させています。
paths には、コードとドキュメントの両方を入れています。ドキュメントだけを直したプルリクエストでも、直した結果が合っているかを確認するためです。
ubuntu-24.04 のランナーにはPostgreSQLのクライアント(psql)が入っているので、DBのドライバをPythonに入れずに済みます。
4. 実行結果
ズレを入れたサンプルで実行すると、check_doc_drift.py は終了コード1で終わり、次のレポートを書き出します。
{
"api": {
"missing_in_doc": [
"GET /users/{user_id}/invoices"
],
"missing_in_code": [
"DELETE /users/{user_id}"
]
},
"db": [
{
"table": "users",
"kind": "missing_in_doc",
"column": "display_name",
"code": {
"table": "users",
"column": "display_name",
"type": "character varying",
"nullable": true
}
}
]
}
APIキーがない状態で draft_doc_fix.py を動かすと、コメントはズレの一覧だけになります。
### ドキュメントとコードのズレ(自動検出)
- API: ドキュメントにない `GET /users/{user_id}/invoices`
- API: コードにない `DELETE /users/{user_id}`(削除済み?)
- DB: `users.display_name` missing_in_doc
修正案は作成していません。上の一覧をもとに、ドキュメントを更新してください。
APIキーがある場合は、この下に、折りたたみで修正案が付きます。
導入チェックリスト
- OpenAPIはコードから生成されているか(手書きなら、まずそこを直す)
- マイグレーションを空のDBに当てて、今のスキーマが再現できるか
- API一覧とテーブル定義は、列の順番が決まったMarkdownの表になっているか(Excelなら書き出しの仕組みを先に作る)
- テーブル定義のファイル名とテーブル名を一致させたか
-
型の表記ゆれを
TYPE_ALIASESに登録したか - コードやドキュメントの一部を外部のLLM APIに送ってよいか、契約やNDAで確認したか
- APIキーはリポジトリのシークレットに置き、ログに出していないか
- 最初の1〜2週間は、ジョブを失敗にせず、コメントだけにしたか(既存のズレが多いと、全部のプルリクエストが止まる)
- 既存のズレを一度まとめて直す日を決めたか
- 修正案をそのまま採用せず、「(要記入)」を人が埋める運用にしたか
最初からジョブを失敗にすると、既存のズレのせいで関係のないプルリクエストまで止まります。最後のステップをしばらく外しておき、ズレを一掃してから戻すのが無難です。
注意点と限界
フォークからのプルリクエストでは、シークレットが渡りません。 その場合は修正案なしのコメントになり、GITHUB_TOKEN も読み取り専用になるので、コメント自体が付かないことがあります。OSSで使う場合は、別の構成が要ります。
比べているのは、エンドポイントの有無、列の有無、型、NULL許可だけです。 リクエストやレスポンスの項目、varchar(100) の長さ、デフォルト値、インデックスは見ていません。実際、このサンプルでも display_name の長さ100は character varying としか出てきません。比べたい項目が増えたら、export_schema.sql で character_maximum_length などを取り出して足してください。
プッシュのたびにコメントが増えます。 gh pr comment は新しいコメントを付けるので、ズレを直すまでプッシュを繰り返すと、同じ内容が並びます。気になる場合は、前回のコメントを更新する形にします。
LLMの修正案は間違えることがあります。 ズレの検出は毎回同じ結果になりますが、修正案は実行のたびに表現が変わります。採用するかどうかは、必ずレビューで決めてください。
コードを外部に送ることになります。 お客様のプロジェクトで使う場合、外部のLLM APIにコードやドキュメントを送ってよいかは、契約次第です。私たちも、送る前に確認するようにしています。
引き継ぎで何が変わったか
Zennの記事で書いたとおり、この種の仕組みを3か月ほど運用したあと、別のプロジェクトで担当者交代がありました。以前は、ドキュメントのほぼ全ページについて、コードで裏を取っていました。そのときは、API一覧とテーブル定義は信じてよく、READMEなど文脈の要る部分だけを前任者に聞けば済みました。
引き継ぎにかかった日数を測ってはいないので、数字は出せません。変わったのは、全部を疑ってかかる状態から、疑う場所を決められる状態になったことです。
最後に
ドキュメントのうち、コードから「正」を取り出せる部分は、思っているより多くあります。まずはAPI一覧かテーブル定義のどちらか一つで、判定をスクリプト、修正案をLLMに分ける形から試してみてください。