「プレビューの打ち切り位置を決めているのはどのファイルですか」。運用しているサイトのリポジトリで、Claude Code にそう尋ねたときのことです。
返事が来る前に、セッションはアプリケーションコードの探索を始めていました。答え自体は正しく、2ファイルを指して的確でした。ただ、そこに至るまでに読まれた量が、私の想像していた「ちょっと探す」の範囲を超えていました。
個人開発では、この手の摩擦を指摘してくれる人がいません。気になったので、探索の作法を変える前に、リポジトリ側を数えることにしました。読ませる範囲を決めるには、まず何がどれだけ重いのかを知らないと始まらないからです。
結果として一番驚いたのは、ビルドで作り直せる生成物1ファイルが、手で書いた70本のソースコード全部よりも重かったことでした。
セッションを始める前に、リポジトリ側を数える
計測対象は claudelab.net のリポジトリです。日本語806本・英語806本の記事を MDX で抱えた Next.js のサイトで、git 管理下のファイルは1,796件あります。
最初に書いたのは、領域ごとの「読ませたときの重さ」を出すだけの小さなスクリプトです。トークナイザを厳密に再現する必要はありません。領域同士を比べて意思決定できれば十分なので、日本語は1文字を約1トークン、それ以外は約3.6文字を1トークンとして概算しています。
#!/usr/bin/env python3
"""ctxweight.py - git 管理下のファイルを「読ませたときの重さ」で並べる。
使い方:
python3 ctxweight.py # リポジトリ全体
python3 ctxweight.py src # 特定ディレクトリのみ
"""
import collections
import subprocess
import sys
PRICE_PER_MTOK = 2.0 # 入力単価(USD / 1M tokens)。自分が使うモデルの値に置き換える
def estimate_tokens(text: str) -> int:
"""CJK は約1文字=1トークン、それ以外は約3.6文字=1トークンとして概算する。
正確な値はモデルのトークナイザ依存なので、絶対値ではなく
「領域Aは領域Bの何倍か」を判断するための相対指標として使う。
"""
cjk = sum(1 for ch in text if ' ' <= ch <= '鿿' or '' <= ch <= '')
return int(cjk + (len(text) - cjk) / 3.6)
def tracked_files(roots):
# git ls-files を使うと .gitignore 済みのファイルを自動的に除ける
out = subprocess.check_output(['git', 'ls-files', '-z'] + list(roots)).decode()
return [f for f in out.split('\0') if f]
def group(path: str) -> str:
parts = path.split('/')
return '/'.join(parts[:2]) if len(parts) > 1 else '(root)'
def main():
agg = collections.defaultdict(lambda: [0, 0]) # [tokens, files]
per_file = []
for path in tracked_files(sys.argv[1:]):
try:
text = open(path, encoding='utf-8').read()
except (UnicodeDecodeError, OSError):
continue # 画像などのバイナリは対象外。読めないものは黙って飛ばす
tokens = estimate_tokens(text)
agg[group(path)][0] += tokens
agg[group(path)][1] += 1
per_file.append((tokens, path))
total = sum(v[0] for v in agg.values()) or 1
print(f"{'領域':<28}{'推定tok':>12}{'files':>7}{'占有率':>8}{'$/回':>9}")
for key, (tokens, files) in sorted(agg.items(), key=lambda x: -x[1][0])[:15]:
print(f"{key:<28}{tokens:>12,}{files:>7,}"
f"{tokens / total * 100:>7.1f}%{tokens / 1e6 * PRICE_PER_MTOK:>9.3f}")
print(f"{'合計':<28}{total:>12,}"
f"{sum(v[1] for v in agg.values()):>7,}{100.0:>7.1f}%"
f"{total / 1e6 * PRICE_PER_MTOK:>9.3f}")
print("\n-- 単体で重いファイル --")
for tokens, path in sorted(per_file, reverse=True)[:5]:
print(f"{tokens:>12,} {path}")
if __name__ == '__main__':
main()git ls-files を使っているのは、.gitignore 済みのファイルを自分で除外しなくて済むからです。node_modules や .next を数えても意味がありませんし、除外リストを手で保守すると必ずずれます。
リポジトリ全体に当てると、こうなりました。
| 領域 | 推定トークン | ファイル数 | 占有率 |
|---|---|---|---|
| content/articles | 7,666,927 | 1,612 | 91.0% |
| src/generated | 289,603 | 2 | 3.4% |
| ルート直下 | 162,418 | 15 | 1.9% |
| content/blog | 157,421 | 70 | 1.9% |
| src/app | 80,278 | 36 | 1.0% |
| src/components | 37,332 | 22 | 0.4% |
| 合計 | 8,422,323 | 1,796 | 100% |
記事本文が9割を占めるのは、コンテンツサイトなので当然です。予想どおりでした。
想定と食い違ったのは、その次の行でした。
生成物1ファイルが、手で書いたコード全部の2.2倍だった
src だけに絞ると、内訳の歪みがはっきりします。
| 領域 | 推定トークン | ファイル数 | src 内の占有率 |
|---|---|---|---|
| src/generated | 289,603 | 2 | 68.9% |
| src/app | 80,278 | 36 | 19.1% |
| src/components | 37,332 | 22 | 8.9% |
| src/config | 8,115 | 2 | 1.9% |
| src/lib ほか | 5,029 | 10 | 1.2% |
手で書いたソースコード70本を合計しても130,754トークンです。対して src/generated はたった2ファイルで289,603トークン、うち articles.json 単体が284,072トークンを占めています。比にして2.21倍。
このファイルは MDX から機械的に組み立てられるメタデータの集約で、ビルドのたびに作り直されます。つまり、読ませても新しい情報は一切増えません。元データはすべて content/ 側にあります。
それでも src を対象にした探索では、真っ先に候補に挙がる位置にいます。ファイル名が articles.json で、パスが src の下にあるからです。名前とパスが「重要そう」に見えるファイルほど、実は導出物であることがあります。
入力単価に置き換えると、この2ファイルは1回読むだけで約0.58ドルです。手書きコード全部を読んでも0.26ドルなので、情報量ゼロの側が2倍以上高い、という状態でした。
なお単価は動きます。Sonnet 5 の入力単価は2026年8月10日付で1Mトークンあたり2ドルが恒久価格になり、9月1日に予定されていた引き上げは行われないことになりました。ただしこの手の数字は前提が変わるので、スクリプト側の PRICE_PER_MTOK は必ず自分で確認した値に置き換えてください。