先週、自社サイトの記事レビューで「この表、罫線とヘッダの背景色が付いていません」という指摘をもらった。
指摘そのものは正しい。すぐ直して、PRに「対応しました」と書いて閉じた。所要5分。気持ちよく終わった話のはずだった。
引っかかったのは、その文面に見覚えがあったことだ。前にも同じことを言われた気がする。しかも一度ではない気がする。
それで、履歴を数えてみた。
同じ指摘を15回、毎回1本ずつ直していた
自社サイトのリポジトリで、コミットメッセージに「罫線」を含むものを拾って、記事ファイルを触っているものだけに絞る。そのうえで、1コミットが何ファイルを変更したかを数える。
# 「罫線」に言及した是正コミットが、1回あたり何ファイルを直したか
git log --no-merges --pretty=%H --grep="罫線" | while read h; do
n=$(git show --name-only --pretty= "$h" | grep -c 'src/content/news/')
[ "$n" -gt 0 ] && echo "$n files"
done | sort | uniq -c
出力は 15 1 files の1行だけだった。「1ファイルだけ直したコミット」が15回、2ファイル以上を直したコミットは0回、という意味だ。
最初が2026年4月14日、最後が7月27日。104日かけて同じ種類の指摘を15回受け、15回とも目の前の1本だけを直していたことになる。
月ごとに並べると、途切れずに続いていたのが分かる。
| 時期 | 是正コミット数 | 1コミットの変更ファイル数 | 備考 |
|---|---|---|---|
| 4月 | 1 | 1 | 初回。レビュー指摘への対応 |
| 5月 | 6 | 1 | 週1ペースで同じ指摘 |
| 6月前半 | 4 | 1 | 6月4日に執筆ルールへ明文化 |
| 6月後半〜7月 | 4 | 1 | 明文化後も再発 |
6月4日に、記事執筆の手順書へ「表形式で記載する場合は、罫線とヘッダ背景色を付けること」という一文を足している。ルールとして書いた。それでもそのあと7回、同じ是正コミットが積まれた。
文章で書いたルールは、読まれなければ存在しないのと同じだったりします。
ついでに、是正っぽいコミット全体でも同じ傾向が出ていないかを見た。マージを除く530コミットのうち、メッセージに修正・不具合・漏れ・fix などを含むものが68件。そのうち35件、つまり51%が1ファイルだけの変更だった。うちの是正は、半分が「その場」で終わっている。
テストは目的ではなく手段だ、とよく言うが、是正も同じで、目的は目の前の1本を直すことではなく、同じ状態の場所を無くすことにある。そこがずれていた。
最初の検査は、数を大きく間違えた
では「同じ状態の場所」はいくつ残っているのか。記事のソース(MDX)に正規表現を当てて、素のMarkdown記法の表を探すことにした。
結果は8件。それなりに残っている、と思った。
思ったのだが、手で中身を開いたら1件目からおかしい。指摘されたことのない記事なのに引っかかっている。該当箇所を見たら、コードブロックの中だった。記事の題材として「Markdownの表はこう書く」という例をコードブロックに貼ってある。それを私の正規表現が本文の表として数えていた。
コードフェンスを潰してから当て直すと、こうなる。
import re, glob, io
MD_TABLE = re.compile(r'^\|.*\|\s*$\n^\|[\s:|-]+\|\s*$', re.M)
FENCE = re.compile(r'`{3}.*?`{3}', re.S) # フェンスは `{3} で表記
naive, files, tables = 0, 0, 0
for path in glob.glob('src/content/news/*.mdx'):
body = io.open(path, encoding='utf-8').read().split('---', 2)[-1]
if MD_TABLE.search(body):
naive += 1 # 最初に書いた版
n = len(MD_TABLE.findall(FENCE.sub('', body))) # コードブロックを除いた版
if n:
files += 1
tables += n
print(naive, '件') # => 8 件
print(files, '記事', tables, '表') # => 4 記事 5 表
8件が4記事5表になった。半分は私の数え間違いだった。
測れないものは改善できない、というのが私の信条なのだが、その前に「測る場所を間違えると数が半分になる」という当たり前のところで転んだ。正直なところ、8件のまま「8記事に是正漏れがあります」と報告しなくてよかったと思っている。
ソースではなく、公開されたHTMLに当てる
数え間違いの反省から、検査を当てる場所をビルド後の出力に変えた。読者が見るのは公開されたHTMLであって、MDXのソースではない。罫線が出ているかどうかも、最終的にはHTMLとCSSで決まる。
# dist の HTML から、セルにインライン罫線の無い表を拾う
import re, glob, io
def read(p):
return io.open(p, encoding='utf-8', errors='replace').read()
bad = []
for page in sorted(glob.glob('dist/**/index.html', recursive=True)):
for table in re.findall(r'<table\b.*?</table>', read(page), re.S):
cells = re.findall(r'<(?:th|td)\b([^>]*)>', table)
if cells and not any('border' in c for c in cells):
bad.append(page)
print(len(bad), '件')
サイト全体(当時116ページ)をビルドして当てた結果は27件。内訳はテックブログの記事が5件、WordPressレスキューの記事が19件、LPが3件だった。
レスキュー記事19件のほうが多い。これは焦った。焦って実物を開いたら、罫線はきれいに出ていた。
つまり誤検知だ。レスキュー記事は本文にスタイルを書いていない代わりに、記事を描画するコンポーネント側のCSSでセルに罫線を当てていた。LPの3件も、ページ側のCSSにクラス単位の指定があった。インラインのstyle属性しか見ない検査は、CSSで当たっている分を全部「未対応」として数える。
この検査は「セルにstyle属性があるか」しか見ていないので、最終的な見え方を判定できていない。改善の余地がある、というより、このままだと現場では使えない。
そのページに効くCSSまで見て、数え直す
直した版がこれだ。ページが読み込んでいるCSSを開いて、セルに罫線を当てる規則があるかどうかまで見る。
def css_covers_table(page_html):
"""そのページが読み込む CSS に、表のセルへ罫線を当てる規則があるか"""
for href in re.findall(r'href="(/_astro/[^"]+\.css)"', page_html):
css = read('dist' + href)
for rule in re.finditer(r'([^{}]+)\{([^}]*)\}', css):
sel, decl = rule.group(1), rule.group(2)
if re.search(r'\b(table|th|td)\b', sel) and 'border' in decl:
if 'border-collapse' in decl and 'border:' not in decl:
continue # collapse だけでは線は出ない
return True
return False
# インライン罫線が無く、かつ CSS でも当たっていない表だけを残すと
# 罫線が当たっていない表: 5 件 / 4 ページ
# dist/news/2026-04-28-ai-employee-redmine-queue/index.html
# dist/news/2026-06-19-aws-lp-lead-infra-cors-pii/index.html
# dist/news/2026-07-06-ght-alibaba-zvec/index.html
# dist/news/2026-07-08-ght-iofficeai-officecli/index.html
border-collapse だけの規則を弾いているのは、これを罫線ありと数えると全ページが緑になるからだ。線を引く指定と、線の重なり方を決める指定は別物で、前者が無ければ表は枠なしで出る。ここを分けずに書いた最初の版は、全ページ合格という都合のいい答えを返してきた。
15回直して、4記事が残っていた。しかも残っている4記事のうち3記事は、ルールを明文化したあとに書かれたものだ。
隣のコレクションは、同じ問題を1か所で終わらせていた
ここまでで一番の収穫は、誤検知のほうだった。
同じ「表に罫線を」という要求に対して、うちのリポジトリは2つの違うやり方を同時に走らせていた。
| 観点 | テックブログ側(暫定対策の積み上げ) | レスキュー記事側(恒久対策) |
|---|---|---|
| 罫線を担保している場所 | 記事本文のstyle属性 | 描画コンポーネントのCSS 1か所 |
| 担保にかかった修正回数 | 15回(104日) | 1回 |
| 本文に書かれた重複スタイル | 55記事・1,270セル・81,132バイト | 0 |
| 現在の未対応 | 4記事5表 | 0(9記事19表すべて担保) |
| 新しい記事を書いたとき | 書き手が毎回スタイルを書く | 何もしなくても罫線が出る |
レスキュー記事のほうは、記事の追加に強い。書き手が忘れても壊れないからだ。テックブログのほうは、書き手が毎回正しく書き続けることを前提にしていて、104日で15回失敗した。
同じ会社の、同じリポジトリの中で起きていたことなので、腕の差ではない。最初にどちらを選んだかの差で、そのあと誰も選び直さなかっただけだ。
罫線の指定は、記事55本の本文に1,270セル分、合計81,132バイト書かれている。重複したCSSを文章として配送しているようなもので、いますぐ害が出るわけではないが、記事が増えるほど増え続ける前提の構造になっているのが気持ち悪い。
指摘を受けたときに、いま私が回している手順
今回のことで、レビュー指摘への対応を4ステップに決めた。指摘を1件もらうたびに機械的に通す。
- 直す前に数える。 指摘された状態が他に何件あるかを、まずコマンドで数える。0件ならその場で直して終わり。
- 件数で対策の種類を決める。 1〜2件なら個別修正でよい。3件以上、あるいは「これから書くものでも起きる」なら、個別修正は暫定対策と位置づけて、恒久対策(CSS・テンプレート・検査)を別チケットに起こす。
- 検査は出力に当てる。 ソースではなくビルド後のHTMLに当てる。ソースに当てると、コードブロックの中を数えるような間違いが起きる。
- 受け入れ基準に書く。 「対応しました」ではなく「残件0件」を、機械が判定できる形で書く。
4つ目が今回の肝で、これまでの受け入れ基準は「レビュー指摘に対応していること」だった。これは対応者の自己申告でしか判定できない。いま書き直した基準はこうだ。
# CI ゲートとして置く形。残件があれば非ゼロで落ちる
npx astro build
python tools/check_tables.py
# 罫線が当たっていない表: 0 件 / 0 ページ → exit 0
# 1件でも残っていれば exit 1
検査スクリプトの終了コードを受け入れ基準にしておくと、「直したつもり」と「直っている」の差が出なくなる。動いた、あるいは指摘に返信した、というのは品質OKの根拠にはならない。
ここは好みが分かれるところですが、私はこの手のゲートを最初から必須(error)では入れない。まず警告として走らせて、既存の残件が0になってから必須に切り替える。残件があるまま必須にすると、無関係なPRが赤くなって、そのうち誰も見なくなる。
振り返って
今回いちばん効いたのは、指摘の内容を分析したことではなく、同じ指摘が何回あったかを数えたことだった。1回なら個別対応でいい。15回なら、それは記事の問題ではなく、仕組みの問題として扱うべきだった。回数は、暫定対策と恒久対策を分ける一番わかりやすい判断材料になる。
残っている4記事5表は、個別に直すのではなく、描画コンポーネント側にレスキュー記事と同じCSSを足す方向で別チケットにした。そうすれば本文に散らばった1,270セル分の指定も、そのうち消せる。同じ問題の解き方が、同じリポジトリの中に既にあったわけで、今思えば隣を見ていれば104日もかからなかった。
検査スクリプトのほうは、まだ手元で走らせているだけだ。次はこれをCIの警告として載せて、残件が0になったところで必須に上げる。そこまでやって初めて、今回の15回は回収できたことになると思っている。