プルリクエストのレビュー中に、ある関数の @param が実際の引数と食い違っているのを見つけました。引数は数ヶ月前に options オブジェクトへまとめ直されていたのに、JSDocは古い平坦な引数のままだったのです。
厄介だったのは、そのドキュメントが手書きではなくAI生成だったことでした。文面は整っていて、@example まで付いていて、いかにも正しそうに見えます。だからこそ、別の開発者はそれを信じて古い形式で呼び出し、実行時まで気づけませんでした。
手書きのドキュメントも陳腐化します。ただ、手書きのものは最初から半信半疑で読まれます。AI生成のドキュメントは網羅的で体裁が整っているぶん、実態とズレても疑われにくい。
個人開発では、コードを書くのも、ドキュメントを直すのも、後でそれを信じて呼び出すのも同じ一人です。それでも私自身、AIが整えた @param を無意識に信用して、古い引数形式のまま実装を進めてしまったことが何度かありました。この記事は、その「自信を持って古くなっていくドキュメント」を計測して塞ぐまでの運用メモです。
再生成ではなく、計測から入る理由
この問題に最初にぶつかったとき、反射的に考えた対処は「定期的に全ファイルのJSDocを再生成する」でした。しかしこれはうまくいきません。
再生成は、レビュー済みの説明文まで毎回書き換えます。人間が手を入れた注意書きや、AIが生成したあと磨いた @example が、次の再生成で別の表現に流されてしまう。差分は巨大になり、レビュアーは「本当に変わったのはどこか」を見失います。ドキュメントの品質は上がるどころか、変更履歴の信頼性ごと下がっていきました。
必要だったのは、全部を書き直すことではなく、「コードとドキュメントが食い違っている箇所だけ」を名指しすることでした。そのためには、まず食い違いを数値として観測できるようにしなければなりません。
ドリフトの定義:シグネチャのハッシュを紐づける
ドキュメントが陳腐化する典型は、関数の外形(引数名・型・投げる例外)が変わったのに、その関数のドキュメントブロックが更新されないケースです。
そこで、公開シンボルごとに「ドキュメントが最後に触られた時点でのシグネチャ」をハッシュとして保存します。次にコードを走査したとき、現在のシグネチャのハッシュが保存値と食い違い、かつドキュメントブロック自体は変わっていなければ、それはドリフトです。
シグネチャに含めるのは、ドキュメントが説明する対象に限ります。引数名、引数の型、戻り値の型、そして関数本体で throw している例外。関数の中身のロジックが変わっただけではドリフトと見なしません。@param や @returns が説明しているのは外形であって、実装の詳細ではないからです。
検出スクリプト:ts-morph で外形を取り出す
TypeScript の構文を正確に読むために、正規表現ではなく ts-morph(TypeScript Compiler API のラッパー)を使います。正規表現でシグネチャを抜こうとすると、ジェネリクスやユニオン型、複数行の引数定義で必ず破綻するためです。
// scripts/doc-drift.ts
// 公開関数のシグネチャをハッシュ化し、保存済みマニフェストと比較してドリフトを検出する
import { Project, Node, FunctionDeclaration } from "ts-morph";
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const MANIFEST = "doc-manifest.json";
// 外形(引数名・型・戻り値・throwする型)だけを安定した文字列に変換する
function signatureOf(fn: FunctionDeclaration): string {
const params = fn.getParameters().map((p) => {
const name = p.getName();
const type = p.getType().getText(p); // 解決済みの型テキスト
const optional = p.isOptional() ? "?" : "";
return `${name}${optional}:${type}`;
});
const ret = fn.getReturnType().getText(fn);
// 本体で明示的に throw している式の型を収集する
const throwsSet = new Set<string>();
fn.forEachDescendant((node) => {
if (Node.isThrowStatement(node)) {
const expr = node.getExpression();
throwsSet.add(expr ? expr.getType().getText(node) : "unknown");
}
});
const throws = [...throwsSet].sort().join(",");
// 引数順は意味を持つのでソートしない。throwは順不同なのでソートする
return `(${params.join(";")})=>${ret}|throws:${throws}`;
}
// JSDocブロックの生テキスト。ここが変わっていれば「ドキュメントは更新された」と見なす
function docTextOf(fn: FunctionDeclaration): string {
return fn.getJsDocs().map((d) => d.getInnerText()).join("\n").trim();
}
function hash(s: string): string {
return createHash("sha1").update(s).digest("hex").slice(0, 12);
}
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const manifest: Record<string, { sig: string; doc: string }> =
existsSync(MANIFEST) ? JSON.parse(readFileSync(MANIFEST, "utf8")) : {};
type Finding = { id: string; kind: "drift" | "undocumented" | "new" };
const findings: Finding[] = [];
const nextManifest: typeof manifest = {};
for (const sf of project.getSourceFiles("src/**/*.ts")) {
for (const fn of sf.getFunctions()) {
if (!fn.isExported() || !fn.getName()) continue; // 公開関数のみ対象
const id = `${sf.getFilePath().replace(process.cwd(), "")}#${fn.getName()}`;
const sig = hash(signatureOf(fn));
const doc = hash(docTextOf(fn));
const prev = manifest[id];
if (docTextOf(fn) === "") {
findings.push({ id, kind: "undocumented" });
} else if (!prev) {
findings.push({ id, kind: "new" });
} else if (prev.sig !== sig && prev.doc === doc) {
// 外形は変わったのにドキュメントは据え置き = ドリフト
findings.push({ id, kind: "drift" });
}
nextManifest[id] = { sig, doc };
}
}
// --update 時のみマニフェストを書き戻す(ドキュメントを直した後に実行する)
if (process.argv.includes("--update")) {
writeFileSync(MANIFEST, JSON.stringify(nextManifest, null, 2));
}
const drift = findings.filter((f) => f.kind === "drift");
const documented = Object.keys(nextManifest).length;
const rate = documented ? ((drift.length / documented) * 100).toFixed(1) : "0.0";
console.log(`documented public fns: ${documented}`);
console.log(`drift: ${drift.length} (drift rate: ${rate}%)`);
for (const f of drift) console.log(` DRIFT ${f.id}`);
process.exit(drift.length > 0 ? 1 : 0);
このスクリプトの肝は、シグネチャとドキュメント本文を別々にハッシュ化している点です。「外形が変わった」かつ「ドキュメントは据え置き」という二つの条件が同時に成り立つときだけをドリフトと呼ぶことで、単なる実装変更やドキュメントの意図的な書き換えを誤検出しません。
getType().getText() で解決済みの型テキストを使っているのは、型エイリアスの中身が変わったケースも拾うためです。type Status = 'a' | 'b' に 'c' が足されれば、その型を引数に取る関数のシグネチャハッシュも変わり、ドリフトとして表面化します。
この二段ハッシュ方式にたどり着くまでには、いくつか落とし穴も踏みました。最初は本番運用のコードで、実装を少しリファクタしただけの関数まで大量にドリフト判定され、警告が埋もれてしまったのです。原因は、本体のロジック変更まで拾ってしまう素朴なハッシュにありました。外形(引数・戻り値・throw する型)だけをハッシュ対象に絞ることで、この誤検出を回避できます。何を計測対象に含めないか、が実装の肝でした。
ドリフト率を時系列で見る
一度の実行で出る「ドリフト率」は、その瞬間のスナップショットにすぎません。運用で効いてくるのは、この数値を時系列で追うことです。
| 週 | 公開関数 | ドリフト | ドリフト率 | 状況 |
| 導入時 | 214 | — | 基準化 | マニフェストを初期化 |
| 2週目 | 221 | 9 | 4.1% | 型の一括変更で表面化 |
| 4週目 | 233 | 2 | 0.9% | ゲート導入後 |
| 8週目 | 250 | 1 | 0.4% | 定常状態 |
導入直後にドリフト率が跳ねるのは正常です。それまで放置されていた食い違いが一度に可視化されるためで、これは負債の棚卸しにあたります。重要なのは、ゲートを入れたあとで率が下がって定常化していくこと。もし週を追うごとに上がり続けるなら、それはドキュメントを直す文化ではなく、ドリフトを見て見ぬふりする文化が根付きつつあるという信号です。
CIゲート:新しいドリフトだけを止める
ドリフト率を計測できるようになったら、次はCIで新規のドリフトを止めます。ただし、既存の負債で全ビルドを赤くしても誰も直しません。止めるべきは「このPRで新たに生まれたドリフト」だけです。
#!/usr/bin/env bash
# ci/check-doc-drift.sh
set -euo pipefail
# main のマニフェストを基準に、このブランチで増えたドリフトだけを見る
git show origin/main:doc-manifest.json > /tmp/base-manifest.json 2>/dev/null || echo '{}' > /tmp/base-manifest.json
npx tsx scripts/doc-drift.ts > /tmp/drift.txt || true
DRIFT_COUNT=$(grep -c '^ DRIFT' /tmp/drift.txt || true)
echo "current drift findings: ${DRIFT_COUNT}"
# ベースラインのドリフト件数(許容済みの負債)
BASE=$(cat .doc-drift-baseline 2>/dev/null || echo 0)
if [ "${DRIFT_COUNT}" -gt "${BASE}" ]; then
echo "❌ 新しいドキュメントドリフトが ${DRIFT_COUNT} 件(許容: ${BASE} 件)"
grep '^ DRIFT' /tmp/drift.txt
echo "ドリフトした関数のJSDocを更新し、doc-drift.ts --update を実行してください"
exit 1
fi
echo "✅ 新規ドリフトなし"
ベースライン方式にしているのは、負債の返済を段階的にするためです。.doc-drift-baseline を少しずつ下げていけば、既存の食い違いに締め切りを設けつつ、新しい食い違いは即座に止められます。一度に全部を直す必要はありません。
ドリフトした箇所だけをAntigravityに直させる
ここでようやく、AIによる生成が再登場します。ただし冒頭で述べたとおり、ファイル全体の再生成はしません。ドリフト検出が出力した関数のリストを、そのままAntigravityへの指示に流し込みます。
以下の関数はシグネチャが変わったのにJSDocが古いままです。
各関数について、現在の実装に一致するよう @param・@returns・@throws のみを更新してください。
制約:
- 説明の散文(概要文)は既存の表現を保持し、外形の記述だけを直す
- @example は現在の引数形式で動作するか確認し、動かないものだけ修正する
- 対象外の関数には一切触れない
対象:
src/lib/orders.ts#fetchUserOrders
src/lib/billing.ts#reconcileInvoice
「散文は保持し、外形の記述だけを直す」という制約が、差分を小さく保つ鍵です。こう指示すると、AIはドキュメント全体を書き直さず、@param の型や @throws の記述といった、実際にズレた行だけをピンポイントで直してくれます。レビュアーが見るべき差分は数行に収まり、「本当に変わったのはどこか」が一目で分かります。
修正が終わったら doc-drift.ts --update を実行してマニフェストを現在の状態に固定し、次回以降の基準を更新します。
READMEとCHANGELOGは別の壊れ方をする
関数のJSDocはシグネチャで陳腐化を測れますが、READMEやCHANGELOGは壊れ方が違います。こちらは「本文が参照している実体が、まだ存在するか」で測ります。
READMEでよく陳腐化するのは、セットアップ手順のコマンド、環境変数のキー、APIエンドポイントのパスです。これらは実体と照合できます。
// scripts/readme-drift.ts の要点
// README中の ENV キーが .env.example に存在するかを照合する
import { readFileSync } from "node:fs";
const readme = readFileSync("README.md", "utf8");
const envExample = readFileSync(".env.example", "utf8");
const declaredKeys = new Set(
envExample.split("\n").map((l) => l.split("=")[0].trim()).filter(Boolean)
);
// READMEが `BACKTICK` で囲って言及している大文字_付き識別子を環境変数候補として抽出
const mentioned = [...readme.matchAll(/`([A-Z][A-Z0-9_]{3,})`/g)].map((m) => m[1]);
const stale = mentioned.filter((k) => k.endsWith("_KEY") || k.endsWith("_URL") || k.endsWith("_ID"))
.filter((k) => !declaredKeys.has(k));
if (stale.length) {
console.log("READMEが参照するが .env.example に無い環境変数:", stale.join(", "));
process.exit(1);
}
この照合が拾うのは、たとえば環境変数を STRIPE_SECRET から STRIPE_SECRET_KEY に改名したのに、READMEの手順が旧名のままになっているケースです。AIにREADMEを生成させたときは整合していても、その後のコード変更で静かにズレていきます。CHANGELOGについては、参照しているバージョンタグが実在するか、記載されたコマンドが package.json の scripts に存在するかを同じ発想で照合できます。
振り返って
AIにドキュメントを書かせること自体は、もう難しくありません。難しいのは、一度書いたドキュメントが正しくあり続けることを保証することです。そして生成が容易になったぶん、ドキュメントの量は増え、陳腐化の総量もまた増えます。
だからこそ、「きれいに生成する」より「食い違いを計測する」ほうへ重心を移しました。手順にすると次の四つです。
- シグネチャのハッシュで外形の変化を捉える
- ドリフト率で全体の健全性を時系列で見る
- CIゲートで新規の食い違いだけを止める
- ドリフトした箇所だけをAIに直させ、散文は保持する
この四つが噛み合うと、ドキュメントは「信じてよいもの」に戻っていきます。導入の順番としては、まず計測だけを回してドリフト率を眺め、数字が落ち着く見込みが立ってからゲートを入れるのを私はお勧めします。いきなりCIを赤くすると、既存の負債に押し流されて運用が続かないためです。私は段階的な導入を推奨します。
まず試すなら、doc-drift.ts を手元のプロジェクトで一度走らせて、初回のドリフト率を見てみてください。その数字が、あなたのコードベースが抱えている「自信を持って古くなっているドキュメント」の総量です。実装の一助になれば幸いです。