ドキュメントや技術ブログの内部リンク切れ(HTTP 404)を防ぐため、CI パイプラインで高速なリンクチェッカー(lychee 等)を動かして検査を回す運用が実践されています1。URL の誤字やファイル移動に伴うリンク切れは、ビルド時や PR のチェック時に機械的に検出できるため、リンク切れが本番環境へ流出するリスクは大幅に低減されました。

しかし、「リンク先は実在するが、本来指すべきではない別の記事へ飛んでいる」という 生きている誤リンク は、HTTP 200 OK を返すためリンクチェッカーを完全に素通りします。リンク先が存在している以上、ステータスコードを監視するツールからは正常なアクセスに見えるからです。

私の運用するブログでも、まさにこの盲点による障害が発生していました。4 つの連載目次において、同日に複数話が公開された回へのリンク計 42 本が、すべてその日の午前7時7分5秒の記事(070705)のパーマリンクを指していたのです。

さらに深刻だったのは、連載目次の一括改善をタスク化する作業において、もうひとつの罠を踏んだことでした。作業対象となる連載の一覧を手作業の列挙と head -12 で切り出して作成したため、13 番目に定義されていた連載(16 件の誤リンクを抱えていた code-archaeologist)が改善対象の一覧からサイレントに漏脱したのです。作業一覧の内側ではテストも完了報告も 100% 成功していたため、漏れは一覧の外に隠れ、別セッションで見直されるまで気づかれませんでした。

定義文として整理すると、生きている誤リンク(宛先不整合リンク)とは、HTTP ステータスコードとしては 200 OK を返してリンク切れチェッカーをすり抜けるものの、リンク文字列が指す本来の意図とは異なる実在記事やページ先頭へ読者を誘導してしまうリンクのこと です。

ここからは、「一覧を手作業で作ると漏れは一覧の外に隠れる」という作業構造の失敗を解剖し、設定の正本(Single Source of Truth / SSoT)からの機械的導出と、結果の空間に対する「リンク文字列とタイトルの完全一致検査」による決定論的な検証手法について、実務での失敗と一次計測ログに基づいて解説します。

200 OK で素通りする「生きている誤リンク」の盲点

従来のリンク検証ツール(markdown-link-check のようなステータスコード検査ツールや、lychee のようにフラグメント #見出し の存在確認まで行える高機能ツール)であっても、検査対象はあくまで URL の到達性やページ内要素の実在確認に留まります。

たとえば、lychee のステータスコード許容範囲(デフォルト: 100..=103,200..=299)2 や、markdown-link-check の生存判定基準(200 OK、または aliveStatusCodes の設定例 [200, 206])3 を見ても明らかなように、HTTP リクエストが成功ステータスを返している限り、チェッカーはそれを正常と判定します。リンク先が実在する記事である限り、アンカーテキストがどれほど別の記事の内容を名指ししていても、ツールが警告を発することはありません。

実際に私のブログのリポジトリで発見された誤リンクは、以下の 4 つの連載目次ファイルにおいて、計 42 件に達していました。

  • コード考古学者 (code-archaeologist): 16 件
  • コードシェフ (code-chef): 10 件
  • コードメカニック (code-mechanic): 8 件
  • コードテイマー (code-tamer): 8 件
  • 合計: 4 ファイル・42 件

これらの連載では、同じ日に複数話が連続して公開された回が存在していました。しかし、目次ページのリンクを生成・編集する過程で、URL の時刻部分がすべてその日の 070705(午前7時7分5秒に公開された最初の回)に固定されてしまっていました。目次上のリンク文字列は「第2話」「第3話」と正しく表記されているにもかかわらず、クリックするとすべて「第1話」へ飛ばされる状態が続いていたのです。

リンク検証における失敗モードは、HTTP 応答の有無だけでなく、文脈と宛先の整合性によって性質が根本から分かれます。

観点通常のリンク切れ (Broken Link)生きている誤リンク (Wrong Target Link)
HTTP ステータス404 Not Found / 500 系エラー200 OK(実在するページ)
CI ツールの検知lychee 等で容易に自動検知可能ステータスコード検査では 100% 素通りする
読者への影響閲覧不能(エラー画面が表示される)別の回や別記事へ飛ばされ、混乱・離脱を招く
検知に必要な情報URL の到達性(ファイルやルートの存在)リンク文字列と遷移先コンテンツの意味的整合性

このように、HTTP 200 を返す誤リンクは従来の CI ゲートの射程外にあり、到達性とは別の検証軸を用意しなければ、人間が偶然気づくまで永続化してしまいます。

一覧を手作業や head で切ると、漏れは一覧の外に隠れる

この誤リンクの問題と並行して進めていた「連載目次の一括改善作業」において、私たちはもうひとつの構造的な作業ミスを起こしました。

各連載の目次構成を改善するタスクを連載ごとに起票するにあたり、作業担当のエージェントは対象となる連載の一覧を作成しました。その際、エージェントは連載の名前を手作業で 12 個列挙し、さらに設定ファイルの先頭 12 行(head -12)を参照して対象リストを組み立ててしまいました。

その結果、設定ファイルの 13 番目に定義されていた連載 code-archaeologist(コード考古学者)が、作業対象の一覧からサイレントに漏脱しました。前章で挙げた 42 件の誤リンクのうち最多の 16 件を抱えていたにもかかわらず、手作業で作った一覧からは丸ごと抜け落ちていたのです。

ここに、作業リスト作成における 構造的な罠 があります。

作業対象の一覧を手動や head で不完全に作ってしまうと、その一覧の内側で作業している限り、テストも進捗報告も 100% 成功して見えます。「リストアップされた 12 個の連載目次について改善タスクの起票と点検を完了しました」という報告が上がれば、レビューする側も作業が完了したと誤認してしまいます。漏れは作成された一覧の外側にあるため、一覧の内側をどれだけ精査しても、決して見つけることはできません。

この現象は、AI エージェントに「一括修正して」「全ファイルをリファクタリングして」と指示した場合にも頻繁に発生します。エージェントが出力の肥大化を抑えようとしたり、目についた範囲だけでリストを組み立てたりする過程で、暗黙のうちに head や手書き列挙によって範囲を絞り込み、末尾の要素や例外的な配置のファイルを一覧から除外してしまう事象がしばしば観測されます。

誤リンク自体はリポジトリ全記事への一括検査(次章で詳述)によって検出・修正できましたが、改善タスクの一覧から漏れた 13 番目の連載 code-archaeologist の目次改善は手付かずのまま取り残され、後続の別セッションで連載全体の動線を見直していた際にようやく発見・回収されました。

当時の作業振り返りログには、以下の教訓が記録されています。

「全部が対象の作業で、一覧を正本から導出せずに作ると、検査も報告も一覧の内側では全件成功に見える。漏れは一覧の外にあるので、一覧を見直しても見つからない」

対策1: 作業対象は設定の正本(SSoT)から機械的に導出する

作業対象の一覧を、人間やエージェントの手作業で作ってはなりません。各データ要素を唯一の場所で管理・編集することで複製の更新忘れ等による不整合を防ぎやすくする「単一情報源(Single Source of Truth / SSoT)」の設計プラクティス4 に従い、システム内の正本からスクリプトで機械的に導出する規律を徹底します。

これは、ソフトウェア工学における DRY(Don’t Repeat Yourself)原則の本質でもあります。Andy Hunt と Dave Thomas は『達人プログラマー』において、DRY 原則を「システム内のすべての知識は、単一の、曖昧さのない、権威ある表現を持たなければならない」と定義しました5。Kent Beck が提唱したシンプルデザインの原則でも、重複の排除(Beck の表現では Once and only Once、他所で DRY や Single Point Of Truth とも呼ばれる概念)が掲げられています6。

私たちのブログ運用では、サイト全体の設定を司る設定ファイル config/_default/params.toml の [series_nav] セクションが、連載定義の唯一の正本でした。

作業対象を抽出する際は、手作業のメモや不完全なスニペットを介在させるのではなく、この正本ファイルからシェルスクリプトやワンライナーを用いて直接キー一覧を抽出します。

# config/_default/params.toml の [series_nav] セクションからキーを機械的に抽出
awk '/^\[series_nav\]/{flag=1; next} /^\[/{flag=0} flag && /^[^# \t]/ {split($1, a, "="); gsub(/[ \t]/, "", a[1]); print a[1]}' config/_default/params.toml

このコマンドを実際に実行すると、一次検証ログに記録されているとおり、以下の 14 件(13 連載+企画 1 件)が完全に出力されます。

code-doctor
code-detective
code-carpenter
code-dispatcher
code-gardener
code-mechanic
code-navigator
code-puppeteer
code-tamer
code-wizard
code-chef
code-bartender
code-archaeologist
perl-advent-calendar-2025

12 番目の code-bartender の後ろに、手作業リストから抜け落ちていた 13 番目の code-archaeologist が確実に現れます。

設定ファイルを唯一の正本として直接パースすることで、後から追加された連載や末尾の要素が確実に抽出対象に入ります。そして、照合における最も重要な原則は、「正本の件数 = 処理対象件数 + 除外件数(正当な理由付き)」 を機械的に突合することです。件数の一致を確認するステップを挟むことで、一覧の外に抜け落ちるサイレントな漏れを構造的に排除できます。

対策2: リンク文字列と遷移先タイトルの一致検査

対象一覧を漏れなく抽出して修正を行っても、将来にわたって生きている誤リンクの混入を恒久的に防ぐには、CI での自動検査が不可欠です。

予防策(注意深い手作業や、プロンプトでの「間違えないで」という指示)は「原因の空間」に働きかけるアプローチであるため、どれだけ注意を重ねてもヒューマンエラーやエージェントの気まぐれによる漏れが生じます。これに対し、出力側の検査は成果物そのものが持つプロパティ、すなわち「結果の空間」の性質を検査するため、決定論的に不整合を遮断できます。

Web アクセシビリティや主要なドキュメントスタイルガイドにおいても、リンク文字列とリンク先タイトルを整合させることは推奨されています。

たとえば、W3C の WCAG 2.1 達成基準 2.4.4 の解説文書では、「リンク文字列とページのタイトルを一致させるか、非常に似たものにすることは、読者の認知的連続性を保つための優れたプラクティスである」と記載されています7。また、Google Developer Documentation Style Guide の相互参照ガイドラインにおいても、「リンクテキストには、遷移先ページの正確なタイトルか、それを表す具体的なフレーズを使用すること」と案内されています8。

この設計思想に基づき、ブログのリンク検査スクリプト(tools/checks/linkcheck.pl)に「内部リンク文字列と別記事タイトルの一致検査」を追加しました。

検査ロジックの骨子は、内部リンクのアンカーテキストを抽出し、リポジトリ内の全記事($POSTS_DIR 配下)のフロントマターを走査して構築したタイトル索引 title_index()(タイトルから記事パスを引く by_title と、記事パスからタイトルを引く by_rel のハッシュリファレンス)と突き合わせるというシンプルなものです。もしリンク文字列が「リンク先とは異なる別の記事のタイトル」と完全一致していた場合は、宛先不整合のエラーを出力して exit 2 で終了します。

また、「リファレンス」「目次」「はじめに」のような短い一般単語が、たまたま過去の記事タイトルと重複して誤検知(偽陽性)を引き起こすのを防ぐため、10 文字未満のリンク文字列は検査対象から除外する という制約を設けました。

# lang:perl:tools/checks/linkcheck.pl
# (4) 内部リンク文言が別記事の title と完全一致していないか検査
while ($line =~ m{(?:\[([^\[\]]*))?\]\(\s*(/\d{4}/\d{2}/\d{2}/[^)\s"#]+)}g) {
    my ($label, $link) = ($1, $2);
    (my $rel = $link) =~ s{^/}{};
    $rel =~ s{/$}{};
    unless (-f "$POSTS_DIR/$rel.md") {
        push @e, "$path:$ln: 内部リンク先が見つかりません: $link";
        next;
    }
    next unless defined $label && length($label) >= 10;
    my $titles = title_index();
    my $owner  = $titles->{by_title}{$label};
    next unless defined $owner && $owner ne $rel;
    push @e, "$path:$ln: リンク文言は別の記事のタイトルです: $link → /$owner/ ($label)"
        unless ($titles->{by_rel}{$rel} // '') eq $label;
}

この検査スクリプトを、修正前のブログ記事(全 1532 記事)に対して実行し、出力されたエラー行をファイル別に集計した結果は以下のとおりです。

     10 content/post/2026/06/03/060705.md
     16 content/post/2026/06/20/060705.md
      8 content/post/2026/06/28/060705.md
      8 content/post/2026/07/02/060705.md
perl tools/checks/linkcheck.pl $(git ls-files 'content/post/*.md') 2>&1  0.18s user 0.12s system 92% cpu 0.327 total

実行結果は、4 つの連載目次に潜んでいた 42 件の誤リンク(シェフ 10 件、考古学者 16 件、メカニック 8 件、テイマー 8 件)のみを正確に検出し、それ以外の誤検知(偽陽性)は 0 件 でした。

さらに特筆すべきは、全 1532 記事を全件走査するのに要した時間が、わずか 0.327 秒 だった点です。この圧倒的な軽さにより、CI パイプラインでの実行はもちろん、エディタの保存時フックやエージェントのツール実行後フック(PostToolUse)に組み込んでも、開発の快適性を一切損ないません。現在では、記事編集のたびに自動的にこの検査が走り、生きている誤リンクの混入を即座にブロックしています。

メリットの裏にあるトレードオフと適用限界

タイトル一致検査による結果空間の検査は非常に強力ですが、万能の銀の弾丸ではなく、いくつかの明確なトレードオフと適用限界が存在します。

第 1 に、10 文字未満の短い文言に対する取りこぼし です。「第1話」「前回の記事」といった短いアンカーテキストは、一般語との衝突による誤検知を避けるために検査対象外としているため、この検査では宛先誤りを検知できません。

第 2 に、自由文によるアンカーテキストの限界 です。リンクテキストが記事タイトルそのものではなく、「こちらの解説を参照してください」のような文脈依存のフレーズである場合、タイトルとの照合自体が成立しません。markdownlint のルール MD059(descriptive-link-text)9 などでも非記述的なリンクテキストが静的解析の対象とされているように、文脈依存のアンカーテキストにおける宛先誤りはタイトル一致検査のスコープ外となります。

第 3 に、パーマリンク構造への依存 です。上記の Perl 実装における正規表現は、日付ベースの URL 体系(/\d{4}/\d{2}/\d{2}/...)を前提としています。スラッグ直下の階層や別体系の URL を採用しているドキュメントサイトに導入する場合は、パス抽出ロジックを環境に合わせて調整する必要があります。

第 4 に、記事タイトルの一意性(ユニーク性)という前提 です。タイトルをキーとするハッシュマップ($titles->{by_title}{$label})で逆引きを行っているため、システム内に「全く同名のタイトルを持つ別記事」が存在しない命名運用が前提となります。同名記事が許容されるシステムでは、タイトルだけでなくカテゴリや日付を組み合わせた複合キーによる管理が必要です。

ドキュメントツールの歴史を振り返ると、こうしたリンクとタイトルの不整合を防ぐための別のアプローチも存在してきました。たとえば Sphinx の :doc: ロールは、明示的なリンク文言を指定しない限り、リンク先ドキュメントのタイトルを自動抽出して展開する仕組みを持っています10。また、Obsidian のコミュニティプラグインである Title As Link Text も、ノートのフロントマターの title や見出しを検出してリンクテキストを自動設定します11。

しかし、一般的な静的サイトジェネレーターやプレーンな Markdown を用いた運用では、記法レベルでの自動同期機構が備わっていないケースが大半です。そのような環境において、今回実装したような「結果の空間に対するタイトル一致リント」は、最も導入コストが低く、かつ決定論的な安全をもたらす現実的な防波堤となります。

「そこまで厳密に検査する必要があるのか? 目視レビューで気づかないか?」という反論もあるかもしれません。しかし、現実に 42 本の誤リンクは何重もの目視レビューをすり抜けて公開され、手作業一覧の 12 件の内側では全員が「全件完了」と信じ込んでいました。人間の目視や AI の自己点検は、本質的に「自分が見ている一覧の内側」しか捉えられません。だからこそ、機械的な正本導出と結果空間での検査が必要なのです。

前提が変われば結論はどう変わるか

もし将来、ドキュメント管理システムが完全にスキーマ駆動・AST(抽象構文木)ベースに移行し、リンクがすべて記事のメタデータから自動生成される構造になれば、今回のような文字列一致リントの役割は小さくなるでしょう。

しかし、人間が自由に Markdown を記述し、あるいは AI エージェントが自律的にファイルを読み書きしてドキュメントを保守する現代の運用においては、「手作業による一覧作成の禁止(SSoT 導出)」と「結果の空間での機械検査」という 2 つの柱は、極めて費用対効果の高い品質保証であり続けます。

特に、AI エージェントが開発現場に深く入り込むほど、エージェントが「自己都合で切り取ったスコープ」の外側で何が起きているかを監視する外部ハーネスの価値は増大します。エージェントが「完了しました」と報告したとき、その作業対象が本当に正本と一致しているかを外部から検証する仕組みこそが、自律型開発パイプラインの安全を担保する鍵となります。

よくある質問(FAQ)

Q. なぜ通常のリンクチェッカー(lychee 等)ではこの誤リンクを見つけられなかったのですか?

A. 従来のリンクチェッカーは HTTP ステータスコード(404 や 500 等)の検査に特化しており、リンク先が存在する別記事(200 OK)である場合は正常と判定するためです。リンクテキストと遷移先コンテンツの意味的整合性は、ステータスコード検査のスコープ外となっています。

Q. リンク文字列とタイトルの完全一致検査で、誤検知(偽陽性)は起きないのですか?

A. 一般語との偶然の一致を防ぐため、「10 文字以上」という制約を設けることで偽陽性を防いでいます。実際、1532 記事に適用した検証では誤検知は 0 件で、42 件の真の誤リンクのみを正確に検出しました。

Q. 一括置換やリファクタリングで「一覧の漏れ」を防ぐ最も確実な方法は何ですか?

A. 設定ファイルや Git 管理下の追跡ファイル(SSoT)からスクリプトで機械的に対象を抽出し、「正本の総件数 = 処理対象件数 + 除外件数(理由付き)」を突き合わせる運用を徹底することです。手作業での列挙や head による切り捨てを排除することが根本対策になります。

まとめ

ここまでに解説した知見と実践のポイントは、以下の 4 点に集約されます。

  • 200 OK は宛先の正しさを保証しない: リンクチェッカーが成功を返しても、実在する別記事へ誘導する「生きている誤リンク」は完全に素通りする。
  • 手作業の一覧作成は漏れを一覧の外に隠す: 対象一覧を手作業や head で切り出すと、一覧の内側ではテストも進捗も 100% 成功に見えるため、サイレントな作業漏れを誘発する。
  • 対象一覧は設定の正本(SSoT)から機械的に導出する: 設定ファイルや追跡ファイルから直接対象を抽出し、「正本の総件数 = 処理対象件数 + 除外件数(理由付き)」を必ず機械照合する。
  • 結果の空間に対するタイトル一致検査で決定論的に閉じる: 予防の注意深さに頼るのではなく、成果物に対して「リンク文字列とタイトルの完全一致検査(10 文字以上)」を実施することで、生きている誤リンクを確実に検知・ブロックする。

ドキュメント保守の自動化、AI エージェントを用いたリファクタリングパイプラインの品質ゲート設計、CI/CD による決定論的検証の実装について、ぜひ コンタクトフォーム よりお気軽にご相談ください。

Footnotes

  1. 増え続けるドキュメントのリンク切れを lychee で CI チェックするようにした話 e-dash tech blog (2026/10/01)。lychee による内部リンク・見出しアンカー(#)CI運用の実例とステータス検査の限界。 ↩

  2. lychee CLI Options lychee documentation。--accept のデフォルト範囲(100..=103,200..=299)およびステータスコード検査の仕様。 ↩

  3. markdown-link-check tcort, README。aliveStatusCodes([200, 206])による生存判定仕様。 ↩

  4. Single source of truth Wikipedia (SSOT / SPOT)。単一情報源(SSOT)のアーキテクチャ定義および不整合を防ぎやすくする利点の解説。 ↩

  5. Don’t repeat yourself Wikipedia (DRY), Andy Hunt & Dave Thomas The Pragmatic Programmer (1999) 引用。DRY原則の原典定義(Every piece of knowledge must have a single, unambiguous, authoritative representation within a system)。 ↩

  6. bliki: Beck Design Rules Martin Fowler, Bliki。Kent Beck のシンプルデザイン原則における重複排除(Has no duplicated logic / Once and only Once)と関連概念(Single Point Of Truth)。 ↩

  7. Understanding WCAG 2.1: Link Purpose (In Context) W3C, Success Criterion 2.4.4 (Level A)。Having the link and the title agree, or be very similar, is good practice...(リンク文言とタイトルの整合性推奨)。 ↩

  8. Google developer documentation style guide: Cross-references Google LLC。match the link text to the page title(リンクテキストをページタイトルと一致させるガイドライン)。 ↩

  9. markdownlint MD059 David Anson, doc/md059.md。MD059(descriptive-link-text)による非記述的リンクテキストの静的解析仕様。 ↩

  10. Cross-references — Sphinx documentation Sphinx team。:doc: ロールによるリンク先ドキュメントタイトルの自動導出機能。 ↩

  11. obsidian-title-as-link-text Lex Toumbourou, README。ノートタイトルをリンク文言へ自動同期するプラグイン実装例。 ↩