0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CLAUDE.md を32万字書いていた。機械が検査できるのは93行、止められるのは13行だった

0
Posted at

3行で

  • 手元の git リポジトリ 96 本を走査した。エージェント向けの規約(CLAUDE.md / AGENTS.md など)を持っているのは 33 本、34%。総量 323,833 字
  • そのうち実行可能なコマンドの形になっているのは 93 個。さらに、違反を止められる場所(CI・pre-commit・husky)でも実行されているのは 13 個、14.0%
  • そして .claude/ の hooks を使っているリポジトリは 33 本中 0 本。エージェントが動いている最中に効く唯一の仕組みが、まったく使われていなかった。

前提

前に「拘束力の階層」という話を書いた。同じ内容を書く場所によって、守られるかどうかが変わる。

書く場所 拘束力
コミットメッセージ 無し
生成スクリプトの定数 そのコードを通る書き込みだけ
README 読まれれば
CLAUDE.md / AGENTS.md 読まれれば
終了コード 1 で落ちる検査 有り

あのときは1つのリポジトリの事故から書いた。今回は手元の全部を数えて、この階層が実際にどう分布しているかを見る。

走査結果

git リポジトリ 96 本 / 規約ファイルあり 33 本 = 34%
  規約の総量 323,833 字(中央値 3,290 / 最大 66,160)
  断定表現(必ず/禁止/MUST/NEVER)130 箇所
  条件付き表現(必要に応じて/推奨/if needed)18 箇所

止める仕組みを持っているか
  GitHub Actions     22/33 =   67%
  husky               9/33 =   27%
  pre-commit          3/33 =    9%
  .claude の hooks     0/33 =    0%

規約に書かれた実行可能なコマンド 93 個
  違反を止められる場所でも実行されている 13 個 = 14.0%
  どこからも実行されない                 80 個

規約に書かれたファイルパス 207 個
  同名ファイルすら存在しない 24 個 = 11.6%

32万字と、93行

まず量。323,833 字。中央値 3,290 字なので、大半は数千字の常識的な文書で、突出して長いものが1本(66,160 字)ある。

このうち、機械が実行できる形になっているのは 93 個のコマンドだけだ。残りは全部、散文で書かれた指示になる。「必ず」「禁止」「MUST」「NEVER」といった断定表現が 130 箇所あるが、断定していることと強制できることは別だ。

そして 93 個のうち、CI・pre-commit・husky のいずれかでも実行されているのは 13 個(14.0%)。80 個は、どこからも実行されない。

リポジトリ別に見ると、一様に低い。

  リポジトリA                        18 /  1
  リポジトリB                        14 /  2
  リポジトリC                        12 /  6
  リポジトリD                        11 /  3
  リポジトリA(別パスの同名)          11 /  1
  リポジトリE                        10 /  0
  リポジトリF                         9 /  0

(リポジトリ名は伏せた。数字はそのまま。5行目が1行目と同名なのは、
同じリポジトリが複数のパスに置かれているため。)

最も良いもので 12 個中 6 個。18 個書いて 1 個というものもある。「規約はよく書けているが強制が甘い1本」ではなく、全部が同じ形をしている。

一度、数え間違えた

最初の集計では 29.0% と出ていた。倍以上ある。

原因は、コマンドを「先頭2語」で識別していたことだった。npm run devnpm run checknpm run migrate も、鍵は全部 npm run になる。CI に npm run check が1行あるだけで、**規約に書かれた npm run 系のコマンド全部が「強制されている」**と数えられていた。

サブコマンドを取るランナーは1語余分に見る必要がある。

def cmd_key(cmd):
    t = cmd.split()
    if len(t) >= 3 and t[1] in ("run", "exec", "run-script"):
        return " ".join(t[:3])
    return " ".join(t[:2]) if len(t) >= 2 else ""

直したら 29.0% → 14.0%

この間違いの方向が、記事の主題と同じなのが少し面白い。粗い一致は「守られている」側に倒れる。 検査を甘くすると、違反は検出されずに「合格」として出てくる。

.claude/hooks が 0 本

一番はっきりしたのがこれだった。

  GitHub Actions     22/33 =   67%
  .claude の hooks     0/33 =    0%

CI は 3 分の 2 のリポジトリにある。使い方も分かっている。にもかかわらず、エージェント向けの hooks を設定しているリポジトリは1本も無い。

この差が意味するのは、タイミングだ。

エージェントがファイルを書く
  → コミットする
    → プッシュする
      → CI が動く   ← ここで初めて止まる

CI が止めるのはプッシュの後で、そのときエージェントのセッションはとっくに次へ進んでいる。あるいは終わっている。人間が結果を見て、次のセッションで直すことになる。

.claude/hooksツール呼び出しの直前・直後に走る。ファイルが書かれた瞬間に検査して、落とせる。エージェントのループの中にある唯一の門がここなのに、誰も置いていない。

私自身も置いていなかった。CI は当たり前に書くのに、こちらは思いつかなかった。「エージェントに読ませる文書」は書くが、「エージェントを止める仕組み」は書いていないという非対称が、33 本すべてに出ている。

外れた仮説: 規約は腐っていなかった

走査する前、私は「規約は書かれた時点で止まっていて、コードだけが動いているから、参照先が壊れているはずだ」と考えていた。それを測るつもりだった。

規約に書かれたファイルパス 207 個
  同名ファイルすら存在しない 24 個 = 11.6%

11.6%。しかも残った 24 個を1つずつ見たら、大半は壊れていなかった。

  • tools/your_tool.py src/sites/your-site.ts — 雛形の説明で、実在しなくて正しい。
  • data/news_YYYY-MM-DD.json — 日付の入るファイル名。
  • ~/.hermes/config.yaml — リポジトリの外にあるパス。

これを除くと、本当に壊れているものはほとんど残らない。

**規約は腐っていない。**参照先は生きている。書いてあることは正しい。ただ、そのうち 86% は実行されない

問題は鮮度ではなく形式だった。「正しいことが書いてある文書」と「違反を止める仕組み」は別のもので、前者をいくら丁寧に保っても後者にはならない。

それで何をするか

1. 規約の各項目に「これはどこで落ちるか」を書く。 落ちる場所が無い項目は、良くて助言、悪くて願望になる。私は自分の CLAUDE.md にこう書いた。

## 記事を書いたら必ず検証を通す

    python3 scripts/check_article.py src/content/blog/<新しい記事>.md

**「必要に応じて」ではなく、毎回実行する。** 条件付きにすると実行されない。

書いた直後は満足したが、今回の走査でこのリポジトリは 1 個中 0 個だった。この記述自体は何も止めていない。 scripts/check_article.py は存在し、正しく動き、終了コード 1 で落ちる。だが呼ばれなければ落ちようがない。

2. 条件付きの表現を消す。 「必要に応じて」「可能であれば」「推奨」は、実行されないことを最初から許可している。私のコーパスでは 18 箇所しかなかったので、ここは全体としては問題になっていない。断定表現のほうが 130 箇所と圧倒的に多い。問題は言い回しの弱さではなく、強い言い回しが強制に繋がっていないことだった。

3. .claude/hooks を1つでも置く。 CI で書いている検査のうち、ファイル1本を見れば判定できるものは、そのまま hooks に移せる。書き込みの直後に落ちれば、エージェントはその場で直す。次のセッションを待たなくていい。

33 本中 0 本ということは、ここが一番伸びしろがある。

自分の環境で走査する

#!/usr/bin/env python3
"""ローカルの git リポジトリを走査し、エージェント向け規約の「拘束力」を測る。

    python3 rules_enforcement.py [走査の起点 ...] [--exclude 正規表現]

規約に書かれていることが、違反を止められる場所でも実行されているかを見る。
--exclude は業務リポジトリを外すために使う(パスに対する正規表現)。
"""
import sys, os, re, glob, statistics, collections

RULE_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules",
              ".github/copilot-instructions.md"]
# 規約の本文に現れる、実行可能なコマンド行
CMD = re.compile(r"(?m)^\s*(?:\$\s*)?((?:npm|pnpm|yarn|npx|python3?|pytest|make|uv"
                 r"|go|cargo|ruff|eslint|tsc|bash|sh|black|mypy|flake8|prettier"
                 r"|vitest|jest)\s+[^\n`]{2,80})")
# 違反を止められる場所。package.json の scripts は「定義」であって門ではない。
BLOCKING = [".github/workflows/*.y*ml", ".pre-commit-config.yaml",
            ".husky/*", ".claude/settings.json", ".claude/hooks/*"]
MUST = re.compile(r"必ず|してください|するな|禁止|MUST|NEVER|ALWAYS")
COND = re.compile(r"必要に応じて|可能であれば|なるべく|できるだけ|推奨"
                  r"|as needed|if needed|when appropriate|where possible")
INLINE = re.compile(r"`([^`\n]{5,90})`")
EXT = re.compile(r"\.(py|js|ts|tsx|jsx|md|json|ya?ml|toml|sh|php|rb|go|rs|sql|vue|html|css)$")


def cmd_key(cmd):
    """コマンドを識別する最小の語数。

    `npm run` までしか見ないと `npm run dev` も `npm run check` も
    同じ鍵になり、片方が CI にあるだけで全部「強制されている」ことになる。
    サブコマンドを取るランナーは1語余分に見る。
    """
    t = cmd.split()
    if len(t) >= 3 and t[1] in ("run", "exec", "run-script"):
        return " ".join(t[:3])
    return " ".join(t[:2]) if len(t) >= 2 else ""


def read_all(*patterns):
    out = ""
    for pat in patterns:
        for p in glob.glob(pat, recursive=True):
            if os.path.isfile(p):
                out += open(p, encoding="utf-8", errors="replace").read() + "\n"
    return out


SKIP = {"node_modules", ".venv", "venv", "vendor", "dist", "build",
        "Library", "Applications", ".Trash", ".npm", ".cache", "site-packages"}


def find_repos(roots, exclude=None, depth=5):
    """.git を探す。見つけたらその下は降りない(サブモジュールは追わない)。"""
    seen = []
    for root in roots:
        base = os.path.expanduser(root)
        for dp, dn, _ in os.walk(base):
            if dp[len(base):].count(os.sep) >= depth:
                dn[:] = []
                continue
            if ".git" in dn:
                dn[:] = []
                if not (exclude and exclude.search(dp)):
                    seen.append(dp)
                continue
            dn[:] = [d for d in dn if d not in SKIP and not d.startswith("Library")]
    return sorted(set(seen))


def basenames(repo):
    """リポジトリ内の全ファイル名。パスが移動しただけかを見分けるのに使う。"""
    names = set()
    for dp, dn, fn in os.walk(repo):
        dn[:] = [d for d in dn if d not in
                 (".git", "node_modules", ".venv", "vendor", "dist", "build")]
        names.update(fn)
    return names


def main():
    argv = sys.argv[1:]
    exclude = None
    if "--exclude" in argv:
        i = argv.index("--exclude")
        exclude = re.compile(argv[i + 1], re.I)
        argv = argv[:i] + argv[i + 2:]
    roots = argv or ["~"]
    repos = find_repos(roots, exclude)
    if not repos:
        print("git リポジトリが見つからない。")
        return 1

    withrules, sizes = [], []
    must = cond = 0
    cmd_total = cmd_blocked = 0
    path_total = path_missing = 0
    mech = collections.Counter()
    rows = []

    for repo in repos:
        text = read_all(*[os.path.join(repo, f) for f in RULE_FILES])
        if not text.strip():
            continue
        withrules.append(repo)
        sizes.append(len(text))
        must += len(MUST.findall(text))
        cond += len(COND.findall(text))

        blocking = read_all(*[os.path.join(repo, p) for p in BLOCKING])
        for name, pat in [("GitHub Actions", ".github/workflows/*.y*ml"),
                          ("pre-commit", ".pre-commit-config.yaml"),
                          ("husky", ".husky/*"),
                          (".claude の hooks", ".claude/settings.json")]:
            if glob.glob(os.path.join(repo, pat)):
                if name != ".claude の hooks":
                    mech[name] += 1
                else:
                    s = read_all(os.path.join(repo, pat))
                    if "hooks" in s:
                        mech[name] += 1

        cmds = {" ".join(m.split())[:60] for m in CMD.findall(text)}
        hit = sum(1 for c in cmds if cmd_key(c) and cmd_key(c) in blocking)
        cmd_total += len(cmds)
        cmd_blocked += hit

        names = basenames(repo)
        paths = {s for s in INLINE.findall(text)
                 if "/" in s and EXT.search(s) and " " not in s
                 and not s.startswith(("http", "@")) and "*" not in s and "{" not in s}
        missing = [p for p in paths
                   if not os.path.exists(os.path.join(repo, p.lstrip("./")))
                   and os.path.basename(p) not in names]
        path_total += len(paths)
        path_missing += len(missing)
        if cmds or paths:
            rows.append((os.path.basename(repo), len(cmds), hit,
                         len(paths), len(missing)))

    n = len(repos)
    m = len(withrules)
    print(f"git リポジトリ {n} 本 / 規約ファイルあり {m} 本 = {m / n * 100:.0f}%")
    if not m:
        return 0
    print(f"  規約の総量 {sum(sizes):,}"
          f"(中央値 {statistics.median(sizes):,.0f} / 最大 {max(sizes):,}")
    print(f"  断定表現(必ず/禁止/MUST/NEVER){must} 箇所")
    print(f"  条件付き表現(必要に応じて/推奨/if needed){cond} 箇所")

    print("\n止める仕組みを持っているか")
    for name in ("GitHub Actions", "husky", "pre-commit", ".claude の hooks"):
        print(f"  {name:<18}{mech[name]:>3}/{m} = {mech[name] / m * 100:>4.0f}%")

    if cmd_total:
        print(f"\n規約に書かれた実行可能なコマンド {cmd_total}")
        print(f"  違反を止められる場所でも実行されている {cmd_blocked}"
              f" = {cmd_blocked / cmd_total * 100:.1f}%")
        print(f"  どこからも実行されない                 {cmd_total - cmd_blocked}")
    if path_total:
        print(f"\n規約に書かれたファイルパス {path_total}")
        print(f"  同名ファイルすら存在しない {path_missing}"
              f" = {path_missing / path_total * 100:.1f}%")

    if rows:
        print("\nリポジトリ別(コマンド数 / 強制されている数 / パス数 / 壊れている数)")
        for name, c, h, p, mp in sorted(rows, key=lambda x: -x[1])[:15]:
            print(f"  {name:<32}{c:>4} /{h:>3}   {p:>4} /{mp:>3}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

--exclude は業務用リポジトリを外すためのもの。走査は .git を見つけたらその下に降りないので、サブモジュールは数えない。

この記事の限界

  • 私1人の環境である。96 本のうち多くは実験用や使い捨てで、本番運用しているものは一部。運用中のリポジトリだけに絞れば強制率は上がるはずだが、そこは分けていない。
  • 「違反を止められる場所」の判定が粗い。 CI に npm run check という行があることと、それが必須のステータスチェックになっていることは別だ。ローカルからは required status の設定は見えないので、14.0% ですら上限の可能性がある。
  • コマンドの抽出は正規表現で、規約に散文で書かれた指示(「テストを通してから出す」)は数に入らない。実行可能な形になっているものだけを数えている。
  • package.json の scripts を「止める場所」に含めていない。定義は門ではないと判断したが、prepublish のようなフックとして機能しているものは取りこぼす。

まとめ

  • 96 本中 33 本(34%)が規約を持ち、総量は 323,833 字
  • そのうち実行可能なコマンドは 93 個。違反を止められる場所でも実行されるのは 13 個(14.0%)
  • **.claude/hooks は 33 本中 0 本。**エージェントのループの中で効く唯一の門が空いている。
  • 規約は腐っていなかった。参照先は生きていた。問題は鮮度ではなく、86% が実行されない形で書かれていること。
  • 検査を粗くすると「守られている」側に倒れる。私の最初の集計は、それで倍の数字を出した。

書いた文書が守られているかを知りたいなら、その文書に書いてあるコマンドが、どこかで実行されているかを数える。数えられないなら、それは強制されていない。


関連

「86% が実行されない形で書かれている」の続きです。では実行される形とは何か、どの層に何を置けば止まるのかを、構成パターン別に書いた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?