やってみた 2026年9月6日

エージェントが自分で回す検査は何を通すのか - diagram-designの自己チェックと本体ゲートに同じ改変を8種類食わせた

XECIN PythonHTML静的解析品質ゲート

GitHubのトレンドで cathrynlavery/diagram-design が7番目に上がっていました。説明は「39 editorial diagram types for Claude Code, Codex, Factory Droid, Pi, and Agent Skills-compatible hosts. Self-contained HTML + SVG.」。図版のスキルです。

私はPMの側の人間なので、正直なところ図の見栄えそのものにはあまり反応しませんでした。目に留まったのはリポジトリの中身のほうで、scripts/ に Python のリンタと検証スクリプトが20本以上並んでいて、しかもスキルに同梱されて配られるチェッカーは別に1本ある、という構成になっていたことです。

つまりこのリポジトリは、自分たちのCIで回す完全版のゲートと、利用者側のエージェントが自分の生成物に回す縮小版のゲートを、意識して分けて配っている。現場では「レビュー観点はフルセットで定義したのに、実際に回っているチェックはその一部」という状態が事故のもとになりがちです。ここではその「削った側」がコードとして読める形で置いてある。何を残して何を削ったのか、実際に動かして測ってみたくなりました。

フレームワークは目的ではなく道具で、テーラリングは必ずどこかを削ります。削ったところに何が漏れるかを知らずに使うのが、いちばん危ない使い方だと考えています。

試した環境

ホストを汚したくなかったので、使い捨てのコンテナ1つで完結させました。python:3.12-bookworm(Python 3.12.13 / Debian GNU/Linux 12)に --memory=4g --cpus=2 の制限を付けて起動し、その中だけで clone と実行をしています。

セットアップらしいセットアップは、ありませんでした。pip install は1回も打っていません。lint-render.py だけが playwright を要求しますが、それ以外の検証スクリプトと同梱チェッカーは標準ライブラリだけで動きます。

# clone(コンテナ内)
$ git clone --depth 1 https://github.com/cathrynlavery/diagram-design.git
CLONE_MS=1517
HEAD=4451eadc484d76aa860edf3289c16fcd082dcdbf 2026-09-02 19:53:29 -0700
REPO_BYTES=16965965
FILES=512
# .claude-plugin/plugin.json → "version": "2.6.12" / "license": "MIT"

# 同梱チェッカーを、同梱の例155枚すべてに当てる
$ for f in skills/diagram-design/assets/*.html; do python3 skills/diagram-design/scripts/self_check.py "$f"; done
NG: skills/diagram-design/assets/icons.html
NG: skills/diagram-design/assets/index.html
self_check OK=153 NG=2 TOTAL_MS=3897

# 落ちた2枚の理由(index.html は図ではなくギャラリーページ)
$ python3 skills/diagram-design/scripts/self_check.py skills/diagram-design/assets/index.html
FAIL skills/diagram-design/assets/index.html
  - <iframe> is not allowed in a diagram file
  - diagram file needs at least one accessible (non-aria-hidden) SVG
  - script 1 must carry only the canonical data-diagram-controls attribute
  - expected exactly one data-motion-root; found 0
rc=1

# リポジトリ側のゲート(こちらがCIの本体)
$ python3 scripts/lint-skin.py --all --baseline
Summary: 153 file(s) checked, 20 skipped, 0 finding(s).      rc=0 MS=580
$ python3 scripts/verify-geometry.py --all
Summary: 155 file(s) checked, 0 finding(s).                   rc=0 MS=36
$ python3 scripts/verify-treemap.py --all
OK treemap: 3 file(s), area matches labels and every label/marker fits its cell   rc=0 MS=25
$ python3 scripts/verify-sankey.py --all
OK sankey: 3 file(s), flow conserved at every node, stages carry equal totals, ribbons hold their width, variants share one geometry   rc=0 MS=36

素の状態はきれいに緑でした。155枚のHTMLに対して同梱チェッカーが3.9秒、リポジトリ側のリンタが0.58秒。1ファイル単体なら self_check.py が22〜26ms、lint-skin.py が36〜38ms(各5回計測)です。この速さなら、生成のたびに回しても運用のリズムを壊しません。

これがpristineな1枚。トレンドで見かけた図そのものです。

diagram-designに同梱されたtreemapの例。左の大きなオレンジの矩形がAsia、右上にAfrica・Europe、右下にNorth America・South Americaが面積比で並び、Asiaのラベルには「4.78B · 59% of world」と書かれている

最初に踏んだのは、名前の話でした

本題に入る前に1回転びました。例のファイルを作業用にコピーして名前を変えたら、リポジトリ側のリンタだけが落ちたんです。中身は1バイトも変えていません。

$ cp skills/diagram-design/assets/example-treemap.html /work/rename/my-diagram.html
$ cmp skills/diagram-design/assets/example-treemap.html /work/rename/my-diagram.html && echo "bytes identical: yes"
bytes identical: yes

$ python3 skills/diagram-design/scripts/self_check.py /work/rename/my-diagram.html
OK /work/rename/my-diagram.html
self_check rc=0

$ python3 scripts/lint-skin.py /work/rename/my-diagram.html
/work/rename/my-diagram.html:31: a11y: accessible-name IDs must match file slug "my-diagram": expected "my-diagram-title" / "my-diagram-desc"
Summary: 1 file(s) checked, 0 skipped, 1 finding(s).
lint-skin rc=1

SVGのアクセシブル名を指す aria-labelledby のIDと、ファイル名のスラッグを一致させろ、というルールでした。リポジトリに例を追加する立場なら妥当な規約です。一方で、利用者が生成物をコピーして名前を付け替える運用では、これは毎回当たります。

理屈はそうなんですが、私は最初これを自分の改変が壊した何かだと思い込んで、しばらく別の場所を疑いました。今思えば、比較実験を始める前に対照群(何も変えていないコピー)を1本置いていれば、5分で切り分けられた話です。実際そのあと、ファイル名を維持したままディレクトリだけ分ける方式に組み直したら、対照群は全チェッカーで緑になりました。

教科書どおりに言えば「変更を1点に絞れ」で、これは何度も言われてきたことなんですが、道具のほうがファイル名という思わぬ入力を見ていると、その1点が自分の意図とずれます。

本題: 同じ8種類の改変を、全チェッカーに食わせる

やったことは単純です。同梱の例を1枚コピーして1か所だけ壊し、self_check.py(エージェントが自分で回す縮小版)、lint-skin.py(リポジトリ側のリンタ)、verify-geometry.py、そして図の種類ごとの量的ゲート(verify-treemap.py / verify-sankey.py)を全部当てて、終了コードを並べました。1ケース1ディレクトリ・1プロセスで、ファイル名は元のまま維持しています。

改変の内訳は、意味を壊すもの(数字と絵が食い違う)と、安全性・アクセシビリティの契約を壊すものの2系統です。

# 00/06 は対照群(バイト列そのまま)。以下は主要な出力の抜粋。

# 01 treemapのラベルだけ書き換え(59% → 72%。面積は動かさない)
verify-treemap: rc=1  example-treemap.html:51: cell 532x380 is labelled 72% but declares 59.05%
                      — the label and data-share must state the same fact
self_check:     rc=0  OK

# 02 面積だけ縮める(width 532 → 430。ラベルもdata-shareも59%のまま)
verify-treemap: rc=1  example-treemap.html:51: cell 430x380 draws 53.70% of the area but declares 59.05%
                      — -9.1% relative. Area is the only encoding; resize the cell
self_check:     rc=0  OK

# 07 サンキーの流量ラベルを1つ書き換え(4,000 min → 6,000 min)
verify-sankey:  rc=1  example-sankey.html:97: node '6,000 min' draws 80.00px for 6000,
                      which is -33.3% off the scale the rest of the figure uses.
                      Width is the only encoding; resize the node
self_check:     rc=0  OK

# 08 棒グラフの値ラベルを1つ書き換え(110 → 150。棒の高さは110のまま)
self_check:     rc=0  OK
lint-skin:      rc=0  Summary: 1 file(s) checked, 0 skipped, 0 finding(s).
geometry:       rc=0  Summary: 1 file(s) checked, 0 finding(s).
# scripts/ に verify-bar.py は存在しない(verify-* は beeswarm bubble bump docs-sync doctor
# drawio-import dumbbell geometry mermaid-import motion plugin-package polar ridgeline
# sankey screenshot-freshness semantic-motion sequence-oauth skin-polarity slopegraph treemap)

結果を1枚の表にすると、境界がはっきり出ます。空欄は「そのケースでは0件(緑)」です。

改変内容self_check(同梱)lint-skingeometry種類別の量的ゲート
00 対照群(treemapそのまま)rc=0rc=0rc=0rc=0
01 ラベルだけ嘘(59%→72%)rc=0rc=0rc=0rc=1(2件)
02 面積だけ嘘(幅532→430)rc=0rc=0rc=0rc=1(7件)
03 アクセシブル名のIDを1文字壊すrc=1rc=1rc=0rc=0
04 外部画像を1枚差し込むrc=1rc=1rc=0rc=0
05 onclick属性を1つ足すrc=1rc=1rc=0rc=0
06 対照群(sankeyそのまま)rc=0rc=0rc=0rc=0
07 サンキーの流量ラベルを1つ嘘にrc=0rc=0rc=0rc=1(2件)
08 棒グラフの値ラベルを1つ嘘にrc=0rc=0rc=0該当ゲートなし

線がきれいに引けています。同梱の self_check.py が止めるのは、そのファイルが安全か・支援技術から読めるかという契約の側です。数字と絵が食い違う改変(01・02・07)は、3件とも終了コード0で通りました。

これは隠れた欠陥ではなくて、ソースにそう書いてあります。self_check.py の冒頭には「This is a distilled subset of the repository gates (lint-skin.py, verify-motion.py), which remain the authority for contributions to the repository itself.」とあり、SKILL.md(39,996バイト)の最終チェックリストにも「From the installed skill directory, did python3 scripts/self_check.py <file> pass? (Accessible-SVG contract, single-file safety, motion basics; ships with the skill.)」と、対象範囲が3つ明記されています。作者が意図して削った結果です。

削った理由も想像はつきます。量的ゲートはグラフの種類ごとに1本ずつ書く必要があり、実際 verify-*.py は20本あってサンキー用のテストだけで4.3秒かかります。これを利用者側に全部持たせたら、20msで終わる手軽さは消える。テーラリングとしては筋が通っていると思います。

問題は、通ったという事実がどこまでを保証しているかが、受け取る側に伝わっているかどうかです。

面積が9%小さくなっても、自己チェックは緑でした

02のケース、つまりAsiaの矩形の幅を532から430に縮めたファイルを、実際にブラウザで開いてみました。ラベルは「4.78B · 59% of world」のままです。

幅を縮めたtreemap。Asiaの矩形が右に大きな余白を残して小さくなっているが、ラベルは「4.78B · 59% of world」のまま変わっていない

右に不自然な余白ができているので、人が見れば気づきます。ただ、この状態で self_check.py は OK を返す。リポジトリ側の verify-treemap.py を通して初めて「draws 53.70% of the area but declares 59.05% — -9.1% relative. Area is the only encoding」と、相対誤差付きで指摘が出ます。この検証器が相対誤差で判定しているのは重要で、絶対誤差だと小さいセルの間違いほど通ってしまう。実装を読むと、そこは意識して選ばれていました。

もっと分かりやすいのが08の棒グラフです。値ラベルだけ150に書き換えて、棒の高さは110相当のまま残しました。

棒グラフ。S5の棒だけがオレンジで強調され、その上のラベルは150と表示されているが、棒の頭は縦軸の100と120の間、110の位置にある

ラベルは150、棒の頭は120の目盛りより下。人間の目には一発で分かる矛盾ですが、self_check.py も lint-skin.py も verify-geometry.py も、全部rc=0でした。棒グラフには量的ゲートが用意されていません。treemapとサンキーが「面積・幅が唯一の符号化だから」検証されているのに対し、棒グラフは高さのほかに目盛り線という手がかりがある、という違いなのかもしれません。ここは推測で、リポジトリにその判断を書いた記述は見つけられませんでした。

なお、これらの検証器そのものにも敵対的テストが同梱されていて、私が触った範囲では全部通りました。チェッカーを疑うより先に自分の改変を疑ってよい、という安心材料にはなります。

$ python3 scripts/test-self-check.py
All self-check tests passed                       rc=0 ms=73
$ python3 scripts/test-verify-treemap.py
OK verify-treemap: both polarities behave         rc=0 ms=898
$ python3 scripts/test-verify-sankey.py
All sankey checker cases passed.                  rc=0 ms=4290
$ python3 scripts/test-verify-geometry.py
All label geometry tests passed.                  rc=0 ms=29

both polarities behave という表現が示すとおり、これらは「壊したものを落とす」だけでなく「正しいものを落とさない」方向も見ています。ゲートを作る側としては、ここまで持っているかどうかが分かれ目だと考えています。

今回やっていないこと

正直に書いておくと、私が測ったのは静的な検査だけです。lint-render.py はヘッドレスChromiumで実際にレンダリングして版面を見るスクリプトですが、playwrightを入れていないので今回は動かしていません。したがって「レンダリング後にしか分からない崩れ」を誰が拾うかは、この記事の範囲外です。

図を生成する本来の経路、つまりエージェントにSKILL.mdを読ませて新しい図を書かせる部分も試していません。今回触ったのは、同梱されている155枚の完成品と、それを検査する側だけです。

試してみての所感

私の関心はもともと図そのものではなく、「自動化されたゲートが何を保証していると言えるか」でした。その意味で、この構成はむしろ良い教材だったと思っています。

チェッカーが2層に分かれていて、外向きに配るほうが明示的に縮小版であることをコードにもドキュメントにも書いてある。現場でよくある「レビュー観点のリストはあるが、実際に自動で見ているのはどれか誰も答えられない」状態と比べれば、はるかに扱いやすい。20msで回るチェックが手元にあるのは、生成のたびに叩ける速さとして実用的です。

そのうえで、私がこれを使うなら受け入れ基準はこう書きます。self_check.py が通ったことは「安全に配布できる単一ファイルで、支援技術から読める」ことの確認であって、「図が示している数字が正しい」ことの確認ではない。数字の正しさは、treemapとサンキーならリポジトリ側の量的ゲートまで回して初めて言えるし、棒グラフや折れ線なら現時点では人が見るしかない。

ここは好みが分かれるところですが、自動チェックの守備範囲を1枚の表にしておくと、レビューで何を見るかの会話が短くなります。今回作った8ケースの表は、そのまま自分の手元の受け入れ基準の下敷きにするつもりです。