先週、社内のコーポレートサイトに載せた記事で、内部リンクが1本切れていた。
公開してから気づいたわけではなく、レビューで人間が目視で見つけた。ありがたい話ではあるのだが、私はそこで少し引っかかった。型チェックもビルドも通っていて、CIは全部緑だった。それでもリンクは切れていた。つまり、いま自社のCIが担保しているのは「壊れて落ちるもの」だけで、「壊れてはいないが間違っているもの」は人間の目に丸投げになっている。
品質は工程で作り込む、というのが私の信条で、レビュー前に機械で弾けるものを人間に探させているのは工程設計としてよくない。というわけで、コンテンツ側のCIに静的検査のゲートを足してみた話をする。
いまのゲートが守っている範囲
うちのサイトはAstroで組んでいて、PRを出すとプレビュー環境向けの検証ジョブが走る。中身はこうだ。
# preview.yml の validate ジョブ(抜粋)
- name: Type check
run: npm run astro check
- name: Build
run: npm run build
env:
ASTRO_BASE_PATH: /pr-${{ github.event.number }}
astro check が型を見て、build が記事フロントマターのスキーマ(タイトルは100文字以内、summaryは200文字以内、といったZodの制約)を見る。ここで守れているのは「構造」だ。フロントマターの型が違う、必須項目が抜けている、といったものは確実に落ちる。
逆に言うと、本文がどれだけ間違っていてもここは通る。リンク先が404でも、社名の表記が揺れていても、二重否定で読みにくくても、型としては正しいので緑になる。動いた=品質OK ではない、というのはこういうところに出る。
守備範囲を整理するとこうなる。
| 検査 | 守るもの | 手段 | 重大度 |
|---|---|---|---|
| 型チェック | コンポーネント・データの型整合 | astro check | error(既存) |
| スキーマ検証 | フロントマターの構造・文字数制限 | build(Zod) | error(既存) |
| リンク切れ検査 | 内部リンクが実在するか | ビルド後のdistを走査 | error(今回追加) |
| 文章校正 | 表記ゆれ・冗長表現 | textlint | warn(今回追加) |
追加する2つを、リンク切れは error、表記ゆれは warn に分けた。この線引きの理由は後で書く。
リンク切れ検査は「ビルド後」に当てる
最初に手を付けたのがリンク切れだ。ここで一つ設計判断がある。MDXのソースに対してリンクを検査するか、ビルドして出力されたHTMLに対して検査するか。
うちはビルド後に当てることにした。理由はプレビュー環境のパスにある。PRごとに /pr-42/ のようなサブパス配下にデプロイされるので、記事の中で /news/xxx/ のように絶対パスでリンクを書くと、プレビュー上では /pr-42/news/xxx/ にいるべきものが /news/xxx/ を指して404になる。この手のずれはMDXソースを眺めても分からない。ビルドが BASE_PATH を効かせて吐いたHTMLを見て初めて分かる。
検査ステップはこうした。
- name: Build for check
run: npm run build
- name: Check internal links
run: |
npx linkinator ./dist \
--recurse \
--silent \
--skip "^https?://"
--skip "^https?://" で外部リンクを一旦除外している。ここが今回いちばんハマったところだ。
正直なところ、最初は外部リンクも一緒に検査していた。そうしたら、参照している他社サイトの一部がレート制限やタイムアウトを返してきて、記事の中身とはまったく関係ないところでCIが赤くなった。同じPRを再実行すると今度は緑になる。落ちたり通ったりするゲートは、E2Eのflakyと同じで誰も信用しなくなる。
なので外部リンクは内部リンクと同じ土俵に載せないことにした。存在確認したい外部リンクだけ別ジョブに切り出して、しかも warn 扱いにしている。CIを止める権利があるのは、自分たちのリポジトリの中で完結して判定できるものだけ、というのが今回学んだ線引きだ。
textlintは「盛りすぎない」ところから
表記ゆれの方はtextlintを入れた。日本語の技術文書向けのプリセットと、社内用語の辞書を組み合わせる構成にしている。
// .textlintrc.json
{
"rules": {
"preset-ja-technical-writing": {
// 一文の長さや二重否定など、素の指摘は活かす
"sentence-length": { "max": 120 },
"no-doubled-joshi": true
},
"prh": {
"rulePaths": ["prh-xecin.yml"]
}
}
}
prh は社名やサービス名、製品名の表記を辞書で統一するためのルールだ。「Github」を「GitHub」に、「Cloudfront」を「CloudFront」に、といった揺れを機械的に拾える。この辞書は現場で見つけた揺れを足していく育て方をしている。
ここで一度失敗した。プリセットの指摘を全部 error にして既存記事にかけたら、数百件の警告が出た。過去記事は今の基準で書かれていないので当然なのだが、これを全部直さないとCIが通らない、という状態はレビューを重くするだけで品質を上げない。テストは目的ではなく手段で、通すこと自体が目的化したら本末転倒だ。
そこで2つ手を打った。ひとつは textlint を warn(CIは止めない、指摘だけ出す)にしたこと。もうひとつは、検査対象を「そのPRで変更された記事だけ」に絞ったことだ。
- name: Lint changed articles
run: |
files=$(git diff --name-only origin/main... -- 'src/content/**/*.mdx')
if [ -n "$files" ]; then
npx textlint $files || true # ← warn 運用。落とさない
fi
末尾の || true は正直まだ改善の余地がある。いまは textlint の終了コードを握りつぶして warn 扱いにしているが、本当は「リンク切れ級の致命的な指摘(例えば prh の確定辞書に反する社名ミス)は error、文体の好みに寄る指摘は warn」というふうにルール単位で重大度を分けたい。textlint はルールごとに severity を指定できるので、そこはこれから詰める。ここは好みが分かれるところですが、文章の校正を全部 error にすると書き手が萎縮するので、私は入口は warn 寄りで始める派だ。
入れてみて分かったこと
この構成で既存記事を通してみたら、内部リンク切れが3件見つかった。いずれも過去に別記事へ張ったリンクで、リンク先のslugを後から変えたときに追随できていなかったものだ。人間のレビューをすり抜けて本番に出ていたわけで、機械で拾える価値はあった。
CIの時間は、検証ジョブ全体で30秒ほど増えた。npm ci とビルドが支配的なので、検査の追加分は体感で気にならない範囲に収まっている。
ゲートの並べ方は、速くて落ちやすいものを前に置くのが基本だと考えている。型チェック → textlint(変更分のみ)→ ビルド → リンク切れ検査、の順にして、構造的に壊れているものは早い段階で落とす。遅くて重い検査を先頭に置くと、直せば分かるミスのフィードバックが遅れる。
受け入れ基準の形にしておくとこうなる。PRがマージ可能と言えるのは、(1) 型とスキーマが error ゼロ、(2) 変更した記事の内部リンクが全て実在、(3) textlint の error 級指摘(確定辞書違反)がゼロ、の3つを満たしたときだ。文体の warn は残っていてもよい。ここを曖昧にすると「CIは緑だけど何を保証しているのか誰も言えない」状態に戻ってしまう。
これから
今回やったのはコンテンツの静的検査までで、まだ穴はある。画像の代替テキストが空になっていないか、見出しレベルが飛んでいないか、といったアクセシビリティ寄りの検査はゲートに載せられていない。この辺りも機械で拾えるものは拾って、人間のレビューは「文章として面白いか」「読者にとって正しいか」という、機械に振れない部分に集中させたい。
静的検査ゲートは、レビュアーの負担を減らすためというより、レビュアーを本来の仕事に戻すために足すものだと思っている。もっと良い検査の分け方があったら、ぜひ教えてほしい。