console.log が本番のバンドルに乗っているのを、リリース後にブラウザのコンソールで見つけました。
AGENTS.md には「console.log を本番コードに残さない」と書いてあります。書いたのは私です。ファイルは1つ、monorepo でもなく、ルートに置いた47行。読み込み順序の衝突を疑う余地はありませんでした。
エージェントはそのファイルを読んでいて、それでも守っていなかった。
最初に思ったのは「モデルの機嫌」でした。しかし機嫌のせいにすると、打てる手が何もなくなります。そこで、守られたかどうかを私の記憶で判定するのをやめて、機械で数えることにしました。
数え始めてから分かったのは、守られないルールには規則性があるということです。
「守られている気がする」で運用していた
それまでの私の運用は、こうでした。エージェントに実装を任せる。返ってきた差分を読む。気になったところを指摘する。指摘が多い日は「今日は調子が悪い」と思う。
この運用の問題は、分母がないことです。
10個のルールのうち何個が守られたのか、先週と比べて改善したのか悪化したのか、47行のうちどの行が効いているのか。何も分からないまま、私は AGENTS.md に行を足し続けていました。守られないルールを見つけるたびに、より強い言葉で、より詳しく書き直す。ファイルは3月に12行だったものが、7月には47行になっていました。
そして遵守率は、体感で下がっていました。体感で、というのが問題です。
ルールには2種類ある
計測しようとして最初に詰まったのが、「このルールは守られたか」を機械が答えられない、という点でした。
たとえば「関数は最大30行とし、それを超える場合は分割する」は数えられます。一方で「コードの意図が伝わる命名にする」は数えられません。前者は述語に落ちますが、後者は落ちません。
この線引きが、後で効いてきます。とりあえず落ちるものだけを対象にしました。47行のうち、検証可能な述語に分解できたのは19個でした。残りは「良いコードを書く」の言い換えでした。
半分以上が、機械で確かめられないルールだったわけです。この時点で、私は自分のファイルに何を書いていたのか怪しくなってきました。
ルールを述語に分解する
19個のルールを、それぞれ「差分に対して真偽を返す関数」に書き直しました。
エージェントが変更したファイルの内容を受け取り、違反していれば違反箇所を返す。それだけの関数です。難しいことはしていません。
# tools/adherence/rules.py
# AGENTS.md の各ルールに 1 対 1 で対応する検証関数を並べる。
# ここに書けないルールは AGENTS.md にも書かない、という方針の受け皿にする。
import re
from dataclasses import dataclass
from typing import Callable
@dataclass
class Violation:
rule_id: str
file: str
line: int
excerpt: str
@dataclass
class Rule:
id: str # AGENTS.md 側にも同じ ID を書いて対応を明示する
description: str
applies_to: str # ファイルパスの正規表現
check: Callable[[str, str], list]
def no_console_log(path: str, content: str) -> list:
out = []
for i, line in enumerate(content.splitlines(), start=1):
# コメント行の説明文まで拾わないよう、行頭の呼び出しに限定する
if re.search(r'(?<!//\s)\bconsole\.(log|debug)\s*\(', line):
out.append(Violation("R-001", path, i, line.strip()))
return out
def function_length_limit(path: str, content: str, limit: int = 30) -> list:
out = []
lines = content.splitlines()
start, name, depth = None, None, 0
for i, line in enumerate(lines, start=1):
m = re.match(r'\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)', line)
if m and start is None:
start, name, depth = i, m.group(1), 0
if start is not None:
depth += line.count("{") - line.count("}")
if depth <= 0 and i > start:
if (i - start) > limit:
out.append(Violation("R-004", path, start,
f"{name}: {i - start} 行 (上限 {limit})"))
start, name = None, None
return out
def no_any_type(path: str, content: str) -> list:
out = []
for i, line in enumerate(content.splitlines(), start=1):
# 意図的な any は `// allow-any:` の理由コメントで明示する運用にしている
if re.search(r':\s*any\b', line) and "allow-any:" not in line:
out.append(Violation("R-002", path, i, line.strip()))
return out
RULES = [
Rule("R-001", "console.log を本番コードに残さない",
r"^src/(?!.*\.test\.).*\.(ts|tsx)$", no_console_log),
Rule("R-002", "any 型を使わない",
r"^src/.*\.(ts|tsx)$", no_any_type),
Rule("R-004", "関数は 30 行以内",
r"^src/.*\.(ts|tsx)$", function_length_limit),
# ... 19 個ぶん
]書いていて気づいたのは、no_console_log の正規表現に「コメント行を除外する」という但し書きが必要になったことです。
これは些細な実装都合に見えて、実は重要でした。私が AGENTS.md に書いていたルールは、自分で厳密に定義したことが一度もなかったのです。「console.log を残さない」の「残さない」がテストコードを含むのか、コメントアウトされたものを含むのか、決めていませんでした。決めていないルールを、エージェントが私の意図どおりに解釈してくれるはずがありません。
述語に落とす作業は、それ自体がルールの棚卸しになりました。
差分に対して回す
検証関数ができたら、エージェントの作業後に走らせます。git の差分から対象ファイルを拾い、該当するルールだけを当てる形にしました。
# tools/adherence/measure.py
import json
import re
import subprocess
from datetime import datetime, timezone
from rules import RULES
def changed_files(base: str = "HEAD~1") -> list:
out = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=d", base, "HEAD"],
capture_output=True, text=True, check=True,
)
return [p for p in out.stdout.splitlines() if p]
def read_at_head(path: str) -> str:
out = subprocess.run(["git", "show", f"HEAD:{path}"],
capture_output=True, text=True)
return out.stdout if out.returncode == 0 else ""
def measure(base: str = "HEAD~1") -> dict:
files = changed_files(base)
per_rule = {r.id: {"applicable": 0, "violated": 0,
"description": r.description} for r in RULES}
violations = []
for path in files:
content = read_at_head(path)
if not content:
continue
for rule in RULES:
if not re.match(rule.applies_to, path):
continue
# 「適用対象になった回数」を分母にする。
# 対象外のファイルまで分母に入れると遵守率が甘く出る。
per_rule[rule.id]["applicable"] += 1
found = rule.check(path, content)
if found:
per_rule[rule.id]["violated"] += 1
violations.extend(found)
for rid, s in per_rule.items():
s["adherence"] = (
None if s["applicable"] == 0
else round(1 - s["violated"] / s["applicable"], 3)
)
scored = [s for s in per_rule.values() if s["adherence"] is not None]
overall = (round(sum(s["adherence"] for s in scored) / len(scored), 3)
if scored else None)
return {
"measured_at": datetime.now(timezone.utc).isoformat(),
"commit": subprocess.run(["git", "rev-parse", "HEAD"],
capture_output=True, text=True).stdout.strip(),
"files_changed": len(files),
"overall_adherence": overall,
"per_rule": per_rule,
"violations": [v.__dict__ for v in violations],
}
if __name__ == "__main__":
result = measure()
with open("adherence.jsonl", "a") as f:
f.write(json.dumps(result, ensure_ascii=False) + "\n")
print(f"overall={result['overall_adherence']} "
f"violations={len(result['violations'])}")分母の取り方には少し悩みました。
最初は「全ファイル数」を分母にしていましたが、これだと .md ファイルばかり触った日の遵守率が不当に高く出ます。ルールが適用対象になった回数だけを数える形に変えて、ようやく日ごとの比較に耐える数字になりました。
計測は毎回のコミット後に走らせ、1行の JSON として追記していきます。これで推移が追えます。